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 <noreply@anthropic.com>
This commit is contained in:
Aldo Zarate
2026-10-31 19:01:28 -06:00
co-authored by Claude Opus 5.5
parent 5caaf73e3b
commit d19df49fcf
4 changed files with 115 additions and 9 deletions
+31 -1
View File
@@ -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<PolyVertex>, f64) {
let m = cadcraft_geom::Mat4::ocs(n);
let pts: Vec<Vec2> = 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<Vec3> = 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::<f64>() / world.len() as f64 };
(world.iter().map(|p| PolyVertex::new(p.xy())).collect(), z)
}
+40 -6
View File
@@ -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<EntityKind> {
}
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.
+41
View File
@@ -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<_>>(), 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();
+3 -2
View File
@@ -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 |