From be8ae81c409fbe8bc0c3fee14e0bcac6b083f4b6 Mon Sep 17 00:00:00 2001 From: STE1200 <319441566+STE1200@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:34:12 +0200 Subject: [PATCH] DXF read: map mirrored OCS (extrusion 0,0,-1) of 2D entities to WCS (#277) AutoCAD and BricsCAD store a mirrored block reference, and many 2D entities drawn in a mirrored UCS, with extrusion (0,0,-1). Their points and angles are then in an OCS whose x axis runs the other way. The reader only honoured that for CIRCLE, ARC, LWPOLYLINE and SOLID/TRACE, so mirrored blocks, hatches and texts landed on the wrong side of the y axis (e.g. profile blocks at x -57618 instead of +57618). - INSERT: insertion point through the OCS, negative x scale, opposite rotation and negative column spacing. Only for extrusions parallel to -Z (x/y noise up to 1e-3); a tilted block plane needs a 3D transform that Insert cannot hold and is left as read. - HATCH and R12 2D POLYLINE: boundary x and bulges mirrored like LWPOLYLINE; the hatch pattern angle mirrors too. - TEXT/ATTDEF/ATTRIB and MTEXT: points through the OCS; drawn readable (MIRRTEXT = 0) with the mirrored footprint, so the direction turns and left/right justification or attachment swap. - ELLIPSE: centre and major axis are WCS, but the minor axis is extrusion x major, so the parameter range is negated. - DIMENSION: the text midpoint (11) is the one OCS point. Checked against an independent DXF library on private drawings: every INSERT transform (plain, mirrored, tilted) matches, as do the mirrored arcs, ellipses, polylines and hatches. The remaining extents outliers in those drawings come from references to empty blocks, which still add their insertion point to the extents. Co-authored-by: Claude Fable 5.1 --- crates/io/src/dxf_read.rs | 154 +++++++++++++++++++++++++++++++------- crates/io/src/tests.rs | 89 ++++++++++++++++++++++ 2 files changed, 214 insertions(+), 29 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 16c0a18c1..f1c7cbf4c 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -83,11 +83,11 @@ fn common(t: &T) -> Common { /// Apply the OCS → WCS transform for 2D entities with a non-default extrusion. fn ocs(c: &Common, p: Vec3) -> Vec3 { - if (c.extrusion.z - 1.0).abs() < 1e-12 && c.extrusion.x.abs() < 1e-12 && c.extrusion.y.abs() < 1e-12 { - p - } else { - cadcraft_geom::Mat4::ocs(c.extrusion).apply(p) - } + ocs_n(c.extrusion, p) +} + +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) } } fn text_from(t: &T) -> Text { @@ -106,7 +106,7 @@ fn text_from(t: &T) -> Text { _ => VAlign::Baseline, }; let align = if t.f(11).is_some() { Some(t.p(11)) } else { None }; - Text { + let mut text = Text { insert: t.p(10), align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, height: t.fd(40, 0.2).max(1e-9), @@ -117,6 +117,40 @@ fn text_from(t: &T) -> Text { style: t.s(7).unwrap_or_else(|| "Standard".into()), halign: h, valign: v, + }; + let n = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); + if n.z < 0.0 { + mirror_text(&mut text, align.map(|a| ocs_n(n, a)), ocs_n(n, t.p(10))); + } + text +} + +/// TEXT / ATTRIB / ATTDEF with extrusion (0,0,-1): the points are OCS and the text runs from its +/// insertion point towards -x in the WCS, with mirrored glyphs. CADCraft draws text readable +/// (MIRRTEXT = 0), so it keeps the mirrored footprint: the direction turns to -rotation and +/// left/right justification swap ends (start ↔ end point). +fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { + t.rotation = cadcraft_geom::norm_angle(-t.rotation); + let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; + match t.halign { + HAlign::Left => { + t.halign = HAlign::Right; + t.insert = insert; + t.align_pt = Some(insert); + } + HAlign::Right => { + t.halign = HAlign::Left; + t.insert = align.unwrap_or(insert); + t.align_pt = baseline_left(t, t.insert); + } + HAlign::Aligned | HAlign::Fit => { + t.insert = align.unwrap_or(insert); + t.align_pt = Some(insert); + } + HAlign::Center | HAlign::Middle => { + t.insert = insert; + t.align_pt = align; + } } } @@ -143,13 +177,24 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { } EntityKind::Arc(a) } - "ELLIPSE" => EntityKind::Ellipse(Ellipse { - center: t.p(10), - major: t.p(11), - ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), - start: t.fd(41, 0.0), - end: t.fd(42, std::f64::consts::TAU), - }), + "ELLIPSE" => { + let mut e = Ellipse { + center: t.p(10), + major: t.p(11), + ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), + start: t.fd(41, 0.0), + end: t.fd(42, std::f64::consts::TAU), + }; + if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { + // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) + // it points the other way, so parameter t lands where -t does in the XY plane. A full + // ellipse is symmetric and stays as it is. + let (s, e2) = (-e.end, -e.start); + e.start = cadcraft_geom::norm_angle(s); + e.end = cadcraft_geom::norm_angle(e2); + } + EntityKind::Ellipse(e) + } "LWPOLYLINE" => { let mut verts = Vec::new(); let mut cur: Option = None; @@ -224,15 +269,27 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { contents.push_str(&tg.str()); } contents.push_str(&t.s(1).unwrap_or_default()); - let rotation = match (t.f(11), t.f(21)) { + let mut rotation = match (t.f(11), t.f(21)) { (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), _ => t.fd(50, 0.0).to_radians(), }; + let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; + if c.extrusion.z < 0.0 { + // Insertion point and direction are WCS, but the line height runs along extrusion × + // direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with + // the same footprint, the text turns round and left/right attachment swap. + rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI); + attach = match attach { + 1 | 4 | 7 => attach + 2, + 3 | 6 | 9 => attach - 2, + a => a, + }; + } EntityKind::MText(MText { insert: t.p(10), height: t.fd(40, 0.2).max(1e-9), width: t.fd(41, 0.0).max(0.0), - attach: t.i(71).unwrap_or(1).clamp(1, 9) as u8, + attach, rotation, style: t.s(7).unwrap_or_else(|| "Standard".into()), contents, @@ -246,17 +303,37 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { constant: t.i(70).unwrap_or(0) & 2 != 0, prompt: t.s(3).unwrap_or_default(), }), - "INSERT" => EntityKind::Insert(Insert { - block: t.s(2).unwrap_or_default(), - insert: t.p(10), - scale: Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)), - rotation: t.fd(50, 0.0).to_radians(), - attribs: Vec::new(), - cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, - rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, - col_spacing: t.fd(44, 0.0), - row_spacing: t.fd(45, 0.0), - }), + "INSERT" => { + // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, + // rotation and column spacing are in that OCS, where x runs the other way. In the WCS that + // is the mirrored point, a negative x scale, the opposite rotation and a negative column + // spacing. Noise of up to 1e-3 in the extrusion's x/y moves the projection by ~1e-7 of the + // size, so it still counts as mirrored. A truly tilted extrusion (block plane not parallel + // to XY) needs the full 3D block transform, which Insert cannot hold; it is left as read. + let n = c.extrusion; + let mirrored = n.z < 0.0 && n.x.hypot(n.y) <= 1e-3 * n.z.abs(); + let mut p = t.p(10); + let mut scale = Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)); + let mut rotation = t.fd(50, 0.0).to_radians(); + let mut col_spacing = t.fd(44, 0.0); + if mirrored { + p = ocs(&c, p); + scale.x = -scale.x; + rotation = -rotation; + col_spacing = -col_spacing; + } + EntityKind::Insert(Insert { + block: t.s(2).unwrap_or_default(), + insert: p, + scale, + rotation, + attribs: Vec::new(), + cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, + rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, + col_spacing, + row_spacing: t.fd(45, 0.0), + }) + } "DIMENSION" => { let ty = t.i(70).unwrap_or(0) & 0x0f; let kind = match ty { @@ -273,7 +350,8 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { EntityKind::Dimension(Dimension { kind, defpt: t.p(10), - text_mid: t.p(11), + // The text midpoint is the one OCS point of a DIMENSION. + text_mid: if c.extrusion.z < 0.0 { ocs(&c, t.p(11)) } else { t.p(11) }, p13: t.p(13), p14: t.p(14), p15: t.p(15), @@ -300,7 +378,20 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } } "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" => hatch(tags, &t)?, + "HATCH" => { + let mut k = hatch(tags, &t)?; + if c.extrusion.z < 0.0 + && let EntityKind::Hatch(h) = &mut k + { + // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. + for v in h.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + h.angle = cadcraft_geom::norm_angle(std::f64::consts::PI - h.angle); + } + k + } "ACAD_TABLE" => EntityKind::Table(acad_table(tags)), "VIEWPORT" => EntityKind::Viewport(Viewport { center: t.p(10), @@ -713,8 +804,13 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> } else if flags & 8 != 0 { EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) } else { + // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. + let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; EntityKind::LwPolyline(LwPolyline { - vertices: pts.iter().map(|p| PolyVertex { p: p.0.xy(), bulge: p.1, start_width: p.2, end_width: p.3 }).collect(), + vertices: pts + .iter() + .map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }) + .collect(), closed: flags & 1 != 0, const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), elevation: t.p(10).z, diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 8e1677968..20cd30eca 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1656,3 +1656,92 @@ fn autocad_header_dim_variables_are_not_overrides() { let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1); assert_eq!(read_dxf(text.as_bytes()).unwrap().dim_overrides().get("decimals"), Some(&serde_json::json!(5))); } + +#[test] +fn mirrored_insert_extrusion_maps_to_negative_x_scale() { + // A block reference mirrored in AutoCAD is stored with extrusion (0,0,-1); its insertion point, + // scale and rotation are in that OCS, where x runs the other way: OCS (-10,5) is WCS (10,5). + let text = "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nB\n10\n0\n20\n0\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n0\nENDBLK\n0\nENDSEC\n\ + 0\nSECTION\n2\nENTITIES\n0\nINSERT\n8\n0\n2\nB\n10\n-10\n20\n5\n50\n90\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "m.dxf").unwrap(); + let ins = first(&d, |k| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None }); + assert!((ins.insert.x - 10.0).abs() < 1e-9 && (ins.insert.y - 5.0).abs() < 1e-9, "{:?}", ins.insert); + assert_eq!((ins.scale.x, ins.scale.y), (-1.0, 1.0)); + assert!((ins.rotation + std::f64::consts::FRAC_PI_2).abs() < 1e-12, "{}", ins.rotation); + // In the OCS the line (0,0)-(2,0) turned by 90° runs from (-10,5) to (-10,7); mirrored into the WCS + // that is (10,5)-(10,7). The same follows from T(10,5)·R(-90°)·S(-1,1): (2,0) → (-2,0) → (0,2) → (10,7). + let b = d.extents(&Space::Model); + for (got, want) in [(b.min.x, 10.0), (b.min.y, 5.0), (b.max.x, 10.0), (b.max.y, 7.0)] { + assert!((got - want).abs() < 1e-9, "extents {b:?}"); + } + // Written back in the WCS (negative x scale, no extrusion), it reads back in the same place. + let b2 = roundtrip(&d).extents(&Space::Model); + assert!((b2.min - b.min).len() < 1e-9 && (b2.max - b.max).len() < 1e-9, "{b2:?} vs {b:?}"); +} + +#[test] +fn mirrored_ellipse_extrusion_reverses_parameters() { + // Extrusion (0,0,-1) flips the minor axis (extrusion × major): parameters 0..90° of the ellipse + // centred at the origin with major (2,0) and ratio 0.5 run from (2,0) to (0,-1), not (0,1). + let text = "0\nSECTION\n2\nENTITIES\n0\nELLIPSE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n40\n0.5\n41\n0\n42\n1.5707963267948966\n\ + 210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "e.dxf").unwrap(); + let b = d.extents(&Space::Model); + for (got, want) in [(b.min.x, 0.0), (b.min.y, -1.0), (b.max.x, 2.0), (b.max.y, 0.0)] { + assert!((got - want).abs() < 1e-6, "extents {b:?}"); + } +} + +#[test] +fn mirrored_2d_polyline_extrusion_flips_x_and_bulge() { + // R12-style 2D POLYLINE vertices are OCS like LWPOLYLINE: with extrusion (0,0,-1), OCS (1,0) is WCS + // (-1,0) and a counter-clockwise bulge turns clockwise. + let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n0\n210\n0\n220\n0\n230\n-1\n\ + 0\nVERTEX\n8\n0\n10\n1\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n3\n20\n0\n0\nSEQEND\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "p.dxf").unwrap(); + let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None }); + let got: Vec<(f64, f64, f64)> = p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(); + assert_eq!(got, vec![(-1.0, 0.0, -1.0), (-3.0, 0.0, 0.0)]); +} + +#[test] +fn mirrored_hatch_extrusion_flips_boundary() { + // HATCH boundaries are OCS: the square (1,0)..(3,2) under extrusion (0,0,-1) lies at (-3,0)..(-1,2). + let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n-1\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, 2.0)] { + assert!((got - want).abs() < 1e-9, "extents {b:?}"); + } +} + +#[test] +fn mirrored_text_extrusion_keeps_its_footprint() { + // TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with + // mirrored glyphs). Drawn readable, it becomes right-justified at (10,5) with the same footprint. + let text = "0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n-10\n20\n5\n40\n1\n1\nAB\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "t.dxf").unwrap(); + let t = first(&d, |k| if let EntityKind::Text(t) = k { Some(t.clone()) } else { None }); + assert_eq!(t.halign, HAlign::Right); + assert!(t.rotation.abs() < 1e-12, "{}", t.rotation); + let a = t.align_pt.expect("right-justified text has an alignment point"); + assert!((a.x - 10.0).abs() < 1e-9 && (a.y - 5.0).abs() < 1e-9, "{a:?}"); + let list = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); + assert!((list.bounds.max.x - 10.0).abs() < 1e-6 && list.bounds.min.x < 9.5, "drawn left of (10,5): {:?}", list.bounds); +} + +#[test] +fn mirrored_mtext_extrusion_keeps_its_footprint() { + // MTEXT at (0,0) with direction (1,0), top-left attachment and extrusion (0,0,-1): the line height + // runs along extrusion × direction = (0,-1), so the text box lies above the insertion point + // (glyphs upside down). Drawn readable: turned round, top-right attachment, same box. + let text = + "0\nSECTION\n2\nENTITIES\n0\nMTEXT\n8\n0\n10\n0\n20\n0\n40\n1\n71\n1\n1\nAB\n11\n1\n21\n0\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "mt.dxf").unwrap(); + let t = first(&d, |k| if let EntityKind::MText(t) = k { Some(t.clone()) } else { None }); + assert_eq!(t.attach, 3); + assert!((t.rotation - std::f64::consts::PI).abs() < 1e-12, "{}", t.rotation); + let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds; + assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}"); +}