From d19df49fcf29976ea5ea416eb4236201808e4a5b Mon Sep 17 00:00:00 2001 From: Aldo Zarate Date: Sat, 31 Oct 2026 19:01:28 -0600 Subject: [PATCH] HATCH and LWPOLYLINE in a tilted plane are read as seen from above (#1886) Their boundary and vertices are in the object's plane (OCS). Only a mirrored plane (0,0,-1) was handled, by flipping x; a tilted plane whose normal points down was taken for a mirror too, and one pointing up kept its plane coordinates, so each face landed turned about the origin by its own angle. A tilted one is now mapped to the WCS and drawn as seen from above at its mean height: bulged edges are flattened (they are elliptical there), and a hatch's pattern direction and origin follow. Its extrusion becomes Z, since its points are plan coordinates now. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/dxf_read/common.rs | 32 ++++++++++++++++++++- crates/io/src/dxf_read/entities.rs | 46 ++++++++++++++++++++++++++---- crates/io/src/tests.rs | 41 ++++++++++++++++++++++++++ docs/file-format-parity.md | 5 ++-- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index e46f3c09d..37f53beb2 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -1,4 +1,4 @@ -use super::{Color, Common, Lineweight, Rgb, Tag, Transparency, Vec2, Vec3}; +use super::{Color, Common, Lineweight, PolyVertex, Rgb, Tag, Transparency, Vec2, Vec3}; /// Value lookup helpers over an entity's tags. pub(super) struct T<'a>(pub(super) &'a [Tag]); @@ -105,3 +105,33 @@ pub(super) fn ocs(c: &Common, p: Vec3) -> Vec3 { pub(super) fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } } + +/// Whether an extrusion tilts an object's plane off the XY plane (neither the Z axis nor the Z +/// axis turned over, as MIRROR leaves it, allowing for noise in x and y like INSERT does). +pub(super) fn tilted(n: Vec3) -> bool { + n.is_finite() && n.x.hypot(n.y) > 1e-3 * n.z.abs() +} + +/// A run of OCS vertices at `elevation` in the plane of `n` as seen from above in the WCS. A +/// tilted arc is an elliptical arc there, so bulged edges are flattened (within a thousandth of +/// the run's size); straight runs keep their vertices. Also gives the mean WCS Z, the elevation +/// the flattened run takes. +pub(super) fn plan_of_tilted(n: Vec3, elevation: f64, vertices: &[PolyVertex], closed: bool) -> (Vec, f64) { + let m = cadcraft_geom::Mat4::ocs(n); + let pts: Vec = if vertices.iter().all(|v| v.bulge == 0.0) { + vertices.iter().map(|v| v.p).collect() + } else { + let pl = cadcraft_geom::Polyline { vertices: vertices.to_vec(), closed }; + let b = pl.bounds(); + let size = (b.max - b.min).len(); + let tol = if size.is_finite() && size > 0.0 { size * 1e-3 } else { 1e-6 }; + let mut pts = pl.tessellate(tol); + if closed && pts.len() > 1 && pts.first() == pts.last() { + pts.pop(); + } + pts + }; + let world: Vec = pts.iter().map(|p| m.apply(Vec3::new(p.x, p.y, elevation))).collect(); + let z = if world.is_empty() { elevation } else { world.iter().map(|p| p.z).sum::() / world.len() as f64 }; + (world.iter().map(|p| PolyVertex::new(p.xy())).collect(), z) +} diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 12abae7da..92e6aef4c 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -1,4 +1,4 @@ -use super::common::{T, common, ocs}; +use super::common::{T, common, ocs, plan_of_tilted, tilted}; use super::hatch::hatch; use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from}; use super::{ @@ -81,13 +81,17 @@ fn lwpolyline(tags: &[Tag], c: &Common, t: &T) -> Option { } verts.extend(cur); let flags = t.i(70).unwrap_or(0); - if c.extrusion.z < 0.0 { + let mut elevation = t.fd(38, 0.0); + if tilted(c.extrusion) { + // In a tilted plane: drawn as seen from above, at the vertices' mean height. + (verts, elevation) = plan_of_tilted(c.extrusion, elevation, &verts, flags & 1 != 0); + } else if c.extrusion.z < 0.0 { for v in &mut verts { v.p.x = -v.p.x; v.bulge = -v.bulge; } } - let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation: t.fd(38, 0.0), plinegen: flags & 128 != 0 }; + let pl = LwPolyline { vertices: verts, closed: flags & 1 != 0, const_width: t.fd(43, 0.0), elevation, plinegen: flags & 128 != 0 }; // A region CADCraft wrote as polylines (`dxf_region`). crate::dxf_region::from_polyline(tags, pl) } @@ -121,14 +125,21 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef return None; } let t = T(tags); - let c = common(&t); + let mut c = common(&t); let k = match kind { "LINE" => EntityKind::Line(Line { a: t.p(10), b: t.p(11) }), "POINT" => EntityKind::Point(Point { p: t.p(10), angle: t.fd(50, 0.0).to_radians() }), "CIRCLE" => EntityKind::Circle(Circle { center: ocs(&c, t.p(10)), radius: t.fd(40, 1.0).abs() }), "ARC" => EntityKind::Arc(arc(&c, &t)), "ELLIPSE" => EntityKind::Ellipse(ellipse(&c, &t)), - "LWPOLYLINE" => lwpolyline(tags, &c, &t)?, + "LWPOLYLINE" => { + let k = lwpolyline(tags, &c, &t)?; + if tilted(c.extrusion) { + // Its vertices are now WCS, seen from above (`lwpolyline`). + c.extrusion = Vec3::Z; + } + k + } "SPLINE" => EntityKind::Spline(spline(&t)), "HELIX" => EntityKind::Helix(helix(tags)), "RAY" => EntityKind::Ray(RayLine { base: t.p(10), dir: t.p(11) }), @@ -227,7 +238,30 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), "HATCH" => { let mut k = hatch(tags, &t)?; - if c.extrusion.z < 0.0 + if tilted(c.extrusion) + && let EntityKind::Hatch(h) = &mut k + { + // In a tilted plane (a terrain face, a slope): the boundary as seen from above, + // at its mean height, and the pattern direction and origin projected the same way. + let n = c.extrusion; + let m = cadcraft_geom::Mat4::ocs(n); + let (mut sum, mut count) = (0.0, 0usize); + for l in &mut h.loops { + let (v, z) = plan_of_tilted(n, h.elevation, &l.vertices, true); + sum += z * v.len() as f64; + count += v.len(); + l.vertices = v; + } + let dir = m.apply(Vec3::new(h.angle.cos(), h.angle.sin(), 0.0)).xy(); + if dir.len() > 1e-9 { + h.angle = cadcraft_geom::norm_angle(dir.y.atan2(dir.x)); + } + h.origin = m.apply(Vec3::new(h.origin.x, h.origin.y, h.elevation)).xy(); + if count > 0 { + h.elevation = sum / count as f64; + } + c.extrusion = Vec3::Z; + } else if c.extrusion.z < 0.0 && let EntityKind::Hatch(h) = &mut k { // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 977a7c184..f81122eda 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2109,6 +2109,47 @@ fn mirrored_hatch_extrusion_flips_boundary() { } } +/// Issue #1886: a HATCH in a tilted plane (a terrain face) is drawn as seen from above, not turned +/// about the origin. With extrusion (0, 0.5, -√3/2) the OCS axes are x = (-1,0,0) and +/// y = (0, √3/2, 1/2): the square (1,0)..(3,2) lies over (-3,0)..(-1,√3), from z 0 up to z 1. +/// Before, the negative Z counted as a plain mirror and only x was flipped. +#[test] +fn tilted_hatch_is_drawn_as_seen_from_above() { + let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0.5\n230\n-0.8660254037844386\n2\nSOLID\n70\n1\n71\n0\n91\n1\n\ + 92\n2\n72\n0\n73\n1\n93\n4\n10\n1\n20\n0\n10\n3\n20\n0\n10\n3\n20\n2\n10\n1\n20\n2\n97\n0\n75\n0\n76\n1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "h.dxf").unwrap(); + let b = d.extents(&Space::Model); + for (got, want) in [(b.min.x, -3.0), (b.min.y, 0.0), (b.max.x, -1.0), (b.max.y, 3f64.sqrt())] { + assert!((got - want).abs() < 1e-9, "extents {b:?}"); + } + let e = d.model.iter().next().unwrap(); + assert_eq!(e.common.extrusion, Vec3::Z, "its points are plan coordinates now"); + let EntityKind::Hatch(h) = &e.kind else { panic!("not a hatch") }; + assert!((h.elevation - 0.5).abs() < 1e-9, "the mean height of its corners: {}", h.elevation); +} + +/// Issue #1886: a LWPOLYLINE circle (two half-circle bulges) in the same tilted plane is the +/// ellipse it looks like from above, flattened; a plain one keeps its bulges. +#[test] +fn tilted_lwpolyline_is_drawn_as_seen_from_above() { + let circle = |n: &str| { + format!("0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n8\n0\n90\n2\n70\n1\n{n}10\n1\n20\n0\n42\n1\n10\n-1\n20\n0\n42\n1\n0\nENDSEC\n0\nEOF\n") + }; + let d = read(circle("210\n0\n220\n0.5\n230\n-0.8660254037844386\n").as_bytes(), "p.dxf").unwrap(); + let b = d.extents(&Space::Model); + let ry = 3f64.sqrt() / 2.0; + for (got, want) in [(b.min.x, -1.0), (b.min.y, -ry), (b.max.x, 1.0), (b.max.y, ry)] { + assert!((got - want).abs() < 5e-3, "extents {b:?}"); + } + let e = d.model.iter().next().unwrap(); + assert_eq!(e.common.extrusion, Vec3::Z); + let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None }); + assert!(p.vertices.len() > 8 && p.vertices.iter().all(|v| v.bulge == 0.0), "{} vertices", p.vertices.len()); + let plain = read(circle("").as_bytes(), "p.dxf").unwrap(); + let p = first(&plain, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None }); + assert_eq!(p.vertices.iter().map(|v| v.bulge).collect::>(), vec![1.0, 1.0]); +} + /// The model extents of a one-loop solid HATCH whose edge boundary is `edges` (group 72 onwards). fn hatch_edge_extents(edges: &str) -> Bounds2 { let n = edges.matches("72\n").count(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d85415123..d6e2b9680 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -73,7 +73,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | Content | Read | Write | Notes | |---|---|---|---| -| LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | +| LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes; a LWPOLYLINE in a tilted plane is read as seen from above (arcs flattened, #1886) | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | | TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement; a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) | @@ -81,7 +81,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | | LEADER | yes | yes | | | MULTILEADER | yes (#459) | yes, native MULTILEADER with MLEADERSTYLE (#459) | several landings per multileader are folded into one; per-object overrides and annotative scale contexts not kept | -| HATCH pattern / solid | yes | yes; associative hatches keep their boundary objects in blocks too, and the boundary carries the hatch reactor (stretched dynamic blocks drag the fill in AutoCAD) | | +| HATCH pattern / solid | yes | yes; associative hatches keep their boundary objects in blocks too, and the boundary carries the hatch reactor (stretched dynamic blocks drag the fill in AutoCAD); a hatch in a tilted plane is read as seen from above (#1886) | | | HATCH gradient | yes | yes (#162, fixes #80) | | | SOLID, TRACE, 3DFACE | yes | yes | | | ACAD_TABLE | yes | yes (entity + block) | | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | HATCH and LWPOLYLINE in a tilted plane (extrusion off the Z axis) are read as seen from above at their mean height, instead of keeping plane coordinates that turned them about the origin (#1886) | | 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) | | 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use |