From 7b50d6a5a3ad7789a6dd87d2a0e619dd8e09b59d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 1 Aug 2026 20:31:28 +0300 Subject: [PATCH] fix(solid): normalize quadrilateral edges Accept perimeter and DXF Z point orders while keeping screen fill and interaction on the WCS-aware geometry. Refs #617 --- src/entities/solid.rs | 107 +++++++++++++++++++++---------- src/modules/draw/draw/solid2d.rs | 65 +++++++++++++++---- src/scene/entity.rs | 29 ++++----- src/scene/mod.rs | 13 ++-- src/scene/paper.rs | 7 ++ 5 files changed, 151 insertions(+), 70 deletions(-) diff --git a/src/entities/solid.rs b/src/entities/solid.rs index 597773433..de4c1164f 100644 --- a/src/entities/solid.rs +++ b/src/entities/solid.rs @@ -1,11 +1,10 @@ // SOLID entity — 2D filled quadrilateral (or triangle when p3 == p4). // // Wireframe: the 4 perimeter edges as TruckObject::Lines. -// Filled: two triangles on `fill_tris`, so a SOLID nested in a block (e.g. -// a `_BoxFilled` dimension arrowhead) renders filled — block -// expansion has no separate hatch-model injection. Top-level SOLIDs -// also get a solid-fill HatchModel; the duplicate fill is invisible -// for the opaque solids these are (arrowheads, cell fills). +// Filled: two triangles on `fill_tris`, preserving the entity's full WCS +// plane both at top level and through block expansion. The scene +// keeps a separate 2-D HatchModel only for plot projection; screen +// rendering filters that flattened copy out. // Grips: 4 corner grip points. use acadrust::entities::Solid; @@ -21,55 +20,95 @@ fn dvec3(v: &acadrust::types::Vector3) -> glam::DVec3 { glam::DVec3::new(v.x, v.y, v.z) } +pub(crate) fn wcs_corners(solid: &Solid) -> [[f64; 3]; 4] { + let n = (solid.normal.x, solid.normal.y, solid.normal.z); + let w = |v: &acadrust::types::Vector3| { + let (x, y, z) = crate::scene::view::transform::ocs_point_to_wcs((v.x, v.y, v.z), n); + [x, y, z] + }; + [ + w(&solid.first_corner), + w(&solid.second_corner), + w(&solid.third_corner), + w(&solid.fourth_corner), + ] +} + +/// Return a non-self-intersecting perimeter for either conventional DXF SOLID +/// Z-order or older/perimeter-ordered data. Prefer DXF order when both are +/// valid, but recover legacy solids whose 1-2-4-3 walk forms a bow-tie. +pub(crate) fn perimeter_indices(corners: &[[f64; 3]; 4]) -> [usize; 4] { + let p = |index: usize| glam::DVec3::from_array(corners[index]); + let edge = p(1) - p(0); + let mut normal = edge.cross(p(2) - p(0)); + if normal.length_squared() < 1.0e-20 { + normal = edge.cross(p(3) - p(0)); + } + if normal.length_squared() < 1.0e-20 { + return [0, 1, 3, 2]; + } + let orient = |a: usize, b: usize, c: usize| { + (p(b) - p(a)).cross(p(c) - p(a)).dot(normal) + }; + let segments_cross = |a: usize, b: usize, c: usize, d: usize| { + let ab_c = orient(a, b, c); + let ab_d = orient(a, b, d); + let cd_a = orient(c, d, a); + let cd_b = orient(c, d, b); + ((ab_c > 0.0 && ab_d < 0.0) || (ab_c < 0.0 && ab_d > 0.0)) + && ((cd_a > 0.0 && cd_b < 0.0) || (cd_a < 0.0 && cd_b > 0.0)) + }; + let order_crosses = |order: [usize; 4]| { + segments_cross(order[0], order[1], order[2], order[3]) + || segments_cross(order[1], order[2], order[3], order[0]) + }; + let dxf = [0, 1, 3, 2]; + let perimeter = [0, 1, 2, 3]; + if order_crosses(dxf) && !order_crosses(perimeter) { + perimeter + } else { + dxf + } +} + impl TruckConvertible for Solid { fn to_truck(&self, _document: &acadrust::CadDocument) -> Option { - // SOLID corners are OCS (planar entity) — map through the arbitrary - // axis so mirrored solids (normal 0,0,-1) land on the correct side. - let n = (self.normal.x, self.normal.y, self.normal.z); - let w = |v: &acadrust::types::Vector3| -> [f64; 3] { - let (x, y, z) = crate::scene::view::transform::ocs_point_to_wcs((v.x, v.y, v.z), n); - [x, y, z] - }; - let p0 = w(&self.first_corner); - let p1 = w(&self.second_corner); - let p2 = w(&self.third_corner); - let p3 = w(&self.fourth_corner); - - // DXF SOLID vertex order: 1-2-4-3 (Z-shaped), render as closed quad outline. - // AutoCAD stores corners in "Z" order: p0-p1 are top edge, p2-p3 are bottom edge, - // so the visual quad is p0→p1→p3→p2→p0. + // SOLID corners are OCS. Map them to WCS, then resolve either the DXF + // Z-order or legacy perimeter order before building edges and fill. + let corners = wcs_corners(self); + let order = perimeter_indices(&corners); + let [p0, p1, p2, p3] = order.map(|index| corners[index]); let pts = vec![ p0, p1, [f64::NAN; 3], p1, - p3, - [f64::NAN; 3], - p3, p2, [f64::NAN; 3], p2, + p3, + [f64::NAN; 3], + p3, p0, ]; - let dvp = |p: [f64; 3]| glam::DVec3::new(p[0], p[1], p[2]); - let snap = vec![ - (dvp(p0), SnapHint::Node), - (dvp(p1), SnapHint::Node), - (dvp(p2), SnapHint::Node), - (dvp(p3), SnapHint::Node), - ]; + let dvp = |p: [f64; 3]| glam::DVec3::from_array(p); + let snap = corners + .iter() + .copied() + .map(|point| (dvp(point), SnapHint::Node)) + .collect(); - // Fill the visual quad p0→p1→p3→p2 (Z-order) as two triangles; a - // triangle SOLID (p2 == p3) degenerates the second, which is harmless. - let fill_tris = vec![p0, p1, p3, p0, p3, p2]; + // Fill the resolved perimeter as two triangles. For a triangle the last + // two points coincide and the second triangle degenerates harmlessly. + let fill_tris = vec![p0, p1, p2, p0, p2, p3]; Some(TruckEntity { pick_tris: Vec::new(), object: TruckObject::Lines(pts), snap_pts: snap, tangent_geoms: vec![], - key_vertices: vec![p0, p1, p2, p3], + key_vertices: corners.to_vec(), fill_tris, }) } diff --git a/src/modules/draw/draw/solid2d.rs b/src/modules/draw/draw/solid2d.rs index d896a9ef9..4377bc788 100644 --- a/src/modules/draw/draw/solid2d.rs +++ b/src/modules/draw/draw/solid2d.rs @@ -2,10 +2,10 @@ // // Command: SOLID (reachable as SO / SOLID2D — the bare SOLID verb currently // toggles the shaded display) — pick three or four corner points and commit a -// filled triangle or quadrilateral. Points are taken in the order picked: the -// third point is the corner diagonally opposite the second, so a four-point -// solid is entered in a Z pattern (entering the corners in ring order yields -// the classic bow-tie fill). Enter after the third point commits a triangle. +// filled triangle or quadrilateral. Four points may be picked either around +// the perimeter (the natural order shown by the rubber band) or in AutoCAD's +// traditional Z pattern. The command normalizes both forms to the DXF corner +// order before committing. Enter after the third point commits a triangle. use acadrust::entities::Solid; use acadrust::types::Vector3; @@ -43,6 +43,45 @@ impl Solid2dCommand { fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } + + /// True when walking the four points in pick order produces a bow-tie. + /// SOLID2D currently commits into the default world-XY OCS, so use that + /// same plane when deciding whether the last two points need swapping. + fn pick_order_crosses(points: &[DVec3]) -> bool { + if points.len() != 4 { + return false; + } + let orient = |a: DVec3, b: DVec3, c: DVec3| { + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) + }; + let crosses = |a: DVec3, b: DVec3, c: DVec3, d: DVec3| { + let ab_c = orient(a, b, c); + let ab_d = orient(a, b, d); + let cd_a = orient(c, d, a); + let cd_b = orient(c, d, b); + ((ab_c > 0.0 && ab_d < 0.0) || (ab_c < 0.0 && ab_d > 0.0)) + && ((cd_a > 0.0 && cd_b < 0.0) || (cd_a < 0.0 && cd_b > 0.0)) + }; + crosses(points[0], points[1], points[2], points[3]) + || crosses(points[1], points[2], points[3], points[0]) + } + + /// Convert either perimeter picks (1-2-3-4) or classic SOLID Z picks + /// (1-2-4-3 around the perimeter) into the entity's DXF Z order. + fn solid_from_four_points(points: &[DVec3]) -> Solid { + let perimeter_order = !Self::pick_order_crosses(points); + let (third, fourth) = if perimeter_order { + (points[3], points[2]) + } else { + (points[2], points[3]) + }; + Solid::new( + Self::v3(points[0]), + Self::v3(points[1]), + Self::v3(third), + Self::v3(fourth), + ) + } } impl CadCommand for Solid2dCommand { @@ -72,13 +111,7 @@ impl CadCommand for Solid2dCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { self.points.push(pt); if self.points.len() == 4 { - let p = &self.points; - let solid = Solid::new( - Self::v3(p[0]), - Self::v3(p[1]), - Self::v3(p[2]), - Self::v3(p[3]), - ); + let solid = Self::solid_from_four_points(&self.points); CmdResult::CommitAndExit(EntityType::Solid(solid)) } else { CmdResult::NeedPoint @@ -105,9 +138,13 @@ impl CadCommand for Solid2dCommand { } // Outline the corners picked so far plus the cursor, closed back to the // first point, as a rubber-band hint. - let mut pts: Vec<[f64; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect(); - pts.push([pt.x, pt.y, pt.z]); - pts.push([self.points[0].x, self.points[0].y, self.points[0].z]); + let mut preview = self.points.clone(); + preview.push(pt); + if preview.len() == 4 && Self::pick_order_crosses(&preview) { + preview.swap(2, 3); + } + let mut pts: Vec<[f64; 3]> = preview.iter().map(|p| [p.x, p.y, p.z]).collect(); + pts.push([preview[0].x, preview[0].y, preview[0].z]); Some(WireModel::solid_f64( "rubber_band".to_string(), pts, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 41d03edc0..d604da4a0 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -827,6 +827,13 @@ impl Scene { let Some(entity) = self.document.get_entity(handle) else { return true; }; + // SOLID already renders through its WCS-aware fill triangles. + // Its cached HatchModel is an XY-only plot fallback; sending + // that to the screen too adds a flattened, grip-less copy at + // z=0 for elevated or angled geometry (#617). + if matches!(entity, EntityType::Solid(_)) { + return false; + } let c = entity.common(); if c.invisible || self.entity_temporarily_hidden(handle) @@ -2398,29 +2405,17 @@ impl Scene { } /// Build a solid-fill HatchModel for a DXF Solid entity. - /// DXF SOLID corners are in "Z-order": p0-p1 top, p2-p3 bottom. - /// Visual quad is p0→p1→p3→p2 (closed). + /// Conventional DXF SOLID corners use Z-order; legacy entities may already + /// be in perimeter order. Use the same non-crossing resolver as wire fill. pub(super) fn solid_hatch_model(solid: &DxfSolid, color: [f32; 4]) -> HatchModel { // Keep the corners in f64 until the AABB centre is known, then store // each as a small f32 offset from it — same precision-preserving anchor // `hatch_model_from_dxf` uses. Casting the absolute WCS corner straight // to f32 costs ~0.06 units of resolution at UTM magnitudes (~1e6), so // the quad snapped to a grid and the fill drifted off its outline. - // SOLID corners are stored in OCS (like ARC/LWPOLYLINE) — map through - // the arbitrary axis so a mirrored solid (normal 0,0,-1) fills on the - // correct side of the drawing. - let n = (solid.normal.x, solid.normal.y, solid.normal.z); - let w = |v: &acadrust::types::Vector3| -> [f64; 2] { - let (x, y, _) = - crate::scene::view::transform::ocs_point_to_wcs((v.x, v.y, v.z), n); - [x, y] - }; - let corners: [[f64; 2]; 4] = [ - w(&solid.first_corner), - w(&solid.second_corner), - w(&solid.fourth_corner), - w(&solid.third_corner), - ]; + let wcs = crate::entities::solid::wcs_corners(solid); + let order = crate::entities::solid::perimeter_indices(&wcs); + let corners: [[f64; 2]; 4] = order.map(|index| [wcs[index][0], wcs[index][1]]); let mut min = [f64::INFINITY; 2]; let mut max = [f64::NEG_INFINITY; 2]; for c in &corners { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e70e76cd1..0f8ae8ac7 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5989,13 +5989,16 @@ impl Scene { } fn hatch_visible_for_interaction(&self, handle: Handle) -> bool { - let Some(common) = self - .document - .get_entity(handle) - .map(|entity| entity.common()) - else { + let Some(entity) = self.document.get_entity(handle) else { return false; }; + // Use the SOLID's WCS-aware wire fill for interaction as well as + // drawing. Its cached HatchModel is flattened to XY for plotting and + // must not leave an invisible selection target at z=0 (#617). + if matches!(entity, EntityType::Solid(_)) { + return false; + } + let common = entity.common(); if common.invisible || self.entity_temporarily_hidden(handle) || self.layer_hidden(&common.layer) diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 061659c3f..72838338f 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -392,6 +392,13 @@ impl Scene { let contextual = crate::scene::annotative::entity_for_active_context(&self.document, source); let entity = contextual.as_ref(); + // Paper-space SOLIDs already carry WCS-aware wire fill triangles. + // Keep their cached XY HatchModel out of the sheet set so the same + // entity is not emitted twice (#617). Model fills projected through + // floating viewports still use `plot_hatches_for_block` below. + if matches!(entity, EntityType::Solid(_)) { + continue; + } let c = entity.common(); if c.invisible || self.entity_temporarily_hidden(handle)