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:?}"); +}