From f0e4fef0030a1643716fffe01b5a8771d21980b0 Mon Sep 17 00:00:00 2001 From: aldozar Date: Mon, 19 Oct 2026 00:28:47 -0600 Subject: [PATCH] AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours (#1062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AutoCAD interop: justified text, DIMPOST, current dimension style, *D block colours What AutoCAD takes from CADCraft's DWG/DXF as it is: - Justified TEXT/ATTRIB/ATTDEF (#1050): group 10 is now where the baseline starts, from the alignment point and the text's width, not the alignment point itself; AutoCAD drew centred and right-aligned text from DWG starting at its alignment point. A stored point close to CADCraft's own (AutoCAD's, exact for its fonts) is kept. - DIMPOST (#1053) is written even when empty: the DWG bridge defaulted a missing one to "<>", and AutoCAD then dropped the "R" of radius dimensions when it regenerated them. - The current dimension style (#1051): the DXF header now carries its DIM* variables (AutoCAD draws new and updated dimensions from them); the DWG bridge points $DIMSTYLE at the style by name (its handle was renumbered, so the writer fell back to Standard), and reading a DWG gives the current dimension and text styles their names back. - *D blocks (#1052): lines, arrows, extension lines and text take the style's DIMCLRD/DIMCLRE/DIMCLRT instead of ByBlock. #1054 (group 42 of a rotated linear dimension) was already fixed on main by #855; acad_interop.rs keeps it covered. Co-Authored-By: Claude Opus 5.5 * Test: angular dimensions store their angle in radians (group 42) Three-point (60°, 174°) and two-line (120°) angular dimensions, read back from the DWG as DXF. Already right on main since #855; this guards it. Co-Authored-By: Claude Opus 5.5 * Justified text made here: Left Middle and Left Bottom write their baseline start too text_insert kept a stored insertion point that was within 0.1 x an estimated width + 0.5 x height of CADCraft's baseline start, taking it for AutoCAD's own. The width was estimated from the alignment point's offset, which for Left Middle is only h/2 (vertical), so a text made here, whose stored point is the alignment point, passed and kept it: AutoCAD drew it half a height high (Left Bottom: a third). Reported with a test by @lee-cyrille-maskell. A stored point equal to the alignment point is now always stale. Otherwise the offset is split along and across the baseline: along it the same slack as before (a tenth of the text's width plus half its height, as fonts and their substitutes differ most in width), across it a fifth of the height. Multiline attribute values keep the distance-only check, since their lines are placed by the embedded MTEXT. On 12 drawings made in AutoCAD (1,055 justified texts) the insertion points written are the same as before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/dwg/src/lib.rs | 26 +++ crates/fonts/src/lib.rs | 39 +++++ crates/io/src/dxf_ext.rs | 2 + crates/io/src/dxf_write.rs | 134 ++++++++++++++- crates/io/src/tests.rs | 5 +- crates/io/tests/acad_interop.rs | 296 ++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 5 + 7 files changed, 496 insertions(+), 11 deletions(-) create mode 100644 crates/io/tests/acad_interop.rs diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index d8ec839d..7600d63e 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -85,6 +85,7 @@ mod native { } let notes = read_notes(&outcome.stats); let mut doc = outcome.document; + current_style_names(&mut doc); table_merges(&mut doc); table_style_rows(&mut doc, false); let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false }; @@ -385,6 +386,30 @@ mod native { convert(dxf, Some(v)) } + /// The current dimension and text styles' names from their handles: a DWG stores the + /// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG + /// lost its current dimension style (#1051). + fn current_style_names(doc: &mut acadrust::CadDocument) { + let h = doc.header.current_dimstyle_handle; + if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) { + doc.header.current_dimstyle_name = n; + } + let h = doc.header.current_text_style_handle; + if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) { + doc.header.current_text_style_name = n; + } + } + + /// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the + /// handle read with it needn't be the style's in the document, in which case the DWG writer + /// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051). + fn current_dimstyle(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_dimstyle_name.clone(); + if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) { + doc.header.current_dimstyle_handle = h; + } + } + fn convert(dxf: &[u8], version: Option) -> Result, String> { let data = dxf.to_vec(); let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) @@ -400,6 +425,7 @@ mod native { } table_style_rows(&mut doc, true); drop_unwritable(&mut doc); + current_dimstyle(&mut doc); std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|e| format!("DWG write: {e}")) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 9ccc69ca..3f86f817 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -490,6 +490,45 @@ impl TextParams { } } +/// Where a single-line TEXT's baseline starts (the left end of its first character's baseline), +/// in world coordinates: what DXF/DWG store as a justified text's insertion point (group 10), +/// computed from its alignment point (group 11) and its width in `font`. +pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 { + let (origin, local, rot, mirror) = frame(font, s, p); + origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot) +} + +/// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline +/// start from that point (along the text), its rotation and its mirroring (backwards, upside +/// down). Aligned and fit text are placed by their insertion point. +fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) { + let height = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 }; + let rot = if p.rotation.is_finite() { p.rotation } else { 0.0 }; + if matches!(p.h, Align::Aligned | Align::Fit) { + let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2)); + return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0)); + } + let width = shape_line(font, s, height, p.width_factor, p.oblique).width; + let origin = match (p.h, p.v) { + (Align::Left, VAlign::Baseline) => p.insert, + _ => p.align_pt.unwrap_or(p.insert), + }; + let dx = match p.h { + Align::Left | Align::Aligned | Align::Fit => 0.0, + Align::Center | Align::Middle => -width / 2.0, + Align::Right => -width, + }; + let dy = match (p.h, p.v) { + (Align::Middle, _) => -height / 2.0, + (_, VAlign::Baseline) => 0.0, + (_, VAlign::Bottom) => height / 3.0, + (_, VAlign::Middle) => -height / 2.0, + (_, VAlign::Top) => -height, + }; + let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); + (origin, Vec2::new(dx, dy), rot, mirror) +} + /// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its /// bounding box. pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index cb915345..d699ecbd 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -140,6 +140,8 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[ /// DIMSAH: separate arrow blocks in use. pub(crate) const DIMSAH: i32 = 173; +/// DIMPOST's group in DIMSTYLE records. +pub(crate) const DIMPOST: i32 = 3; /// Variables newer than R2000: DIMFXL, DIMJOGANG, DIMARCSYM, DIMFXLON, DIMLTYPE, DIMLTEX1, /// DIMLTEX2. Files of [`NEWER_SINCE`] or later get them as DIMSTYLE groups; R2000 files get them diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index cd4d2c17..3fd43215 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -291,6 +291,50 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("PSLTSCALE", 70), ]; +/// The current dimension style's variables as header variables (`$DIMTXT`, `$DIMDEC`…), after +/// `$DIMSTYLE`: AutoCAD draws new and updated dimensions with the header's DIM* values, which +/// otherwise stay its defaults (0.18 text, 4 decimals) whatever the current style says (#1051). +/// `$DIMSCALE` is a header variable of its own. +fn dim_header_vars(w: &mut W, d: &Drawing) { + let name = d.header.str("DIMSTYLE", "Standard"); + let Some(style) = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&name)) else { return }; + let v = serde_json::to_value(style).unwrap_or_default(); + let none = |_: &str| None; + for (var, field) in cadcraft_doc::DIMVARS { + if *var == "DIMSCALE" { + continue; + } + let (Some(value), Some((_, _, kind))) = (v.get(*field), dxf_ext::DIM_CODES.iter().find(|(f, _, _)| f == field)) else { continue }; + match kind { + // Names, not handles, in the header. + K::TextStyle | K::Block => { + let Some(n) = value.as_str() else { continue }; + if *kind == K::TextStyle && n.trim().is_empty() { + continue; + } + w.s(9, format!("${var}")); + w.s(if *kind == K::TextStyle { 7 } else { 1 }, n); + } + K::Linetype => {} + _ => match dxf_ext::encode(*kind, value, &none, &none, &none) { + Some(DimVal::Real(x)) => { + w.s(9, format!("${var}")); + w.f(40, x); + } + Some(DimVal::Int(i)) if i16::try_from(i).is_ok() => { + w.s(9, format!("${var}")); + w.i(70, i); + } + Some(DimVal::Str(t)) => { + w.s(9, format!("${var}")); + w.s(1, t); + } + _ => {} + }, + } + } +} + fn header_vars(w: &mut W, d: &Drawing) { for (name, code) in HEADER_VARS { let Some(v) = d.header.get(name) else { continue }; @@ -329,6 +373,7 @@ fn header_vars(w: &mut W, d: &Drawing) { } } } + dim_header_vars(w, d); } /// An entity's group 440 (DXF Reference): ByBlock is `0x01000000`, a fixed transparency its alpha @@ -458,8 +503,61 @@ fn attrib_flags(a: &Attrib) -> i64 { /// A TEXT's groups, or an attribute's (`attrib`, in a file of version `target`) with its /// subclass: the subclass version (`280 0`, 2010 and later), prompt, tag, flags and alignment, /// then Lock position (`280`, 2007 and later; readers take the 280 after the tag as it). -fn text_tags(w: &mut W, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { - w.p(10, t.insert); +/// The insertion point (group 10) to write for `t`. A justified text is placed by its alignment +/// point (group 11), and its insertion point is the start of its baseline, which AutoCAD reads +/// from DWG as is (it recomputes it only for DXF): writing the alignment point there drew centred +/// and right-aligned text from the wrong end in AutoCAD (#1050). +/// +/// The stored point is kept when it is close to where CADCraft's font puts the baseline start: +/// then it is AutoCAD's own (exact for its fonts). It is replaced when it is clearly stale: the +/// alignment point itself (text made here) or a text whose length changed since. Close means +/// along the baseline within a tenth of the text's width plus half its height (AutoCAD's fonts, +/// or the ones CADCraft substitutes for them, differ most in width, and most on short texts) and +/// across it within a fifth of its height: Left Middle and Left Bottom text made here, whose +/// alignment point is only half or a third of a height from the baseline start, must not pass +/// for AutoCAD's. +fn text_insert(d: &Drawing, t: &Text) -> Vec3 { + let computed = computed_text_insert(d, t); + if t.align_pt.is_some_and(|a| a.xy() == t.insert.xy()) { + return computed; + } + let h = t.height.abs(); + let off = (t.insert.xy() - computed.xy()).rotate(-t.rotation); + let close = if t.value.contains('\n') { + // A multiline attribute's lines are placed by its embedded MTEXT: a one-line baseline + // start says little about them, so only the distance counts. + let width = t.align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy())); + off.len() <= 0.1 * width + 0.5 * h + } else { + let width = cadcraft_fonts::line_width(&t.value, h, t.width_factor.abs()); + off.x.abs() <= 0.1 * width + 0.5 * h && off.y.abs() <= 0.2 * h + }; + if close { t.insert } else { computed } +} + +fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { + let justified = !matches!((t.halign, t.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _)); + let Some(a) = t.align_pt.filter(|_| justified) else { return t.insert }; + let ts = d.text_style(&t.style).cloned().unwrap_or_default(); + let (h, v) = cadcraft_render::text_align(t.halign, t.valign); + let p = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(a.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: ts.backwards, + upside_down: ts.upside_down, + }; + let q = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::resolve(&ts.font), &t.value, &p); + if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } +} + +fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { + w.p(10, text_insert(d, t)); w.f(40, t.height); w.s(1, &t.value); if t.rotation != 0.0 { @@ -692,7 +790,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.s(0, "TEXT"); common(w, e, owner, paper, "AcDbText"); thickness(w, e); - text_tags(w, t, None, None); + text_tags(w, d, t, None, None); } // Closed polylines, one per loop (`dxf_region`): REGION's own data is ACIS. EntityKind::Region(r) => { @@ -712,7 +810,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx EntityKind::AttDef(a) => { w.s(0, "ATTDEF"); common(w, e, owner, paper, "AcDbText"); - text_tags(w, &a.text, Some((a, cx.target)), Some(&a.prompt)); + text_tags(w, d, &a.text, Some((a, cx.target)), Some(&a.prompt)); if cx.target >= Version::R2007 { multiline_tags(w, a, true); } @@ -776,7 +874,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.i(60, 1); } w.s(100, "AcDbText"); - text_tags(w, &a.text, Some((a, cx.target)), None); + text_tags(w, d, &a.text, Some((a, cx.target)), None); if cx.target >= Version::R2007 { multiline_tags(w, a, false); } @@ -1285,8 +1383,16 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); let g = cadcraft_render::dimension_in(d, dm); let mut out = Vec::new(); - let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() }; - for l in &g.lines { + // Each part in its style colour (DIMCLRD, DIMCLRE, DIMCLRT), ByBlock when the style says so, + // as AutoCAD writes them: readers that draw the block drew every part ByBlock, i.e. in the + // dimension's own colour (#1052). + let in_color = |color: Color| Common { layer: layer.into(), color, ..Common::default() }; + let (dim_c, ext_c, text_c) = (in_color(style.dim_line_color), in_color(style.ext_line_color), in_color(style.text_color)); + for (i, l) in g.lines.iter().enumerate() { + let c = match g.line_roles.get(i) { + Some(cadcraft_render::LineRole::Ext) => &ext_c, + _ => &dim_c, + }; for seg in l.windows(2) { if let [a, b] = seg { out.push(Entity { handle: Handle(0), common: c.clone(), kind: EntityKind::Line(Line { a: a.to3(0.0), b: b.to3(0.0) }) }); @@ -1297,7 +1403,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { if let [a, b, cc] = t.as_slice() { out.push(Entity { handle: Handle(0), - common: c.clone(), + common: dim_c.clone(), kind: EntityKind::Solid(Solid { corners: [a.to3(0.0), b.to3(0.0), cc.to3(0.0), cc.to3(0.0)] }), }); } @@ -1305,7 +1411,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { if !g.value.is_empty() { out.push(Entity { handle: Handle(0), - common: c, + common: text_c, kind: EntityKind::MText(MText { insert: g.text_pos.to3(0.0), height: g.text_height, @@ -2057,6 +2163,16 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; for (field, code, kind) in dxf_ext::DIM_CODES { let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue }; + // DIMPOST is written even when empty: a reader that defaults a missing one to "<>" + // (the DWG bridge's) would otherwise give radius and diameter dimensions no "R" or + // "Ø" in AutoCAD (#1053). + if *code == dxf_ext::DIMPOST + && let DimVal::Str(t) = &enc + && t.is_empty() + { + w.s(*code, ""); + continue; + } if !dxf_ext::NEWER_CODES.contains(code) { w.dimval(*code, enc, false); continue; diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index a7a8c265..1fa53c6e 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1880,8 +1880,9 @@ fn autocad_header_dim_variables_are_not_overrides() { assert_eq!(back.header.str("DIMDSEP", ""), ","); assert!(back.header.get("DIMTXT").is_some() && back.header.get("DIMCLRD").is_some()); assert_eq!(back.dim_overrides(), serde_json::Map::new()); - // A variable that really differs from the style is an override. - let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1); + // A variable that really differs from the style is an override. CADCraft writes the current + // style's variables in the header too (#1051), so both copies change. + let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1).replacen("$DIMDEC\r\n 70\r\n3\r\n", "$DIMDEC\r\n 70\r\n5\r\n", 1); assert_eq!(read_dxf(text.as_bytes()).unwrap().dim_overrides().get("decimals"), Some(&serde_json::json!(5))); } diff --git a/crates/io/tests/acad_interop.rs b/crates/io/tests/acad_interop.rs new file mode 100644 index 00000000..387f2333 --- /dev/null +++ b/crates/io/tests/acad_interop.rs @@ -0,0 +1,296 @@ +//! What AutoCAD reads from CADCraft's DWG and DXF as it is, without recomputing it: justified +//! text's insertion point (#1050), DIMPOST (#1053), the current dimension style and its header +//! variables (#1051), the colours of a dimension's `*D` block (#1052) and the stored measurement +//! of a rotated linear dimension (#1054) and of angular dimensions. + +use cadcraft_color::Color; +use cadcraft_doc::{Common, DimKind, DimStyle, Dimension, Drawing, EntityKind, HAlign, Space, Text, VAlign}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_dxf, write_with}; + +fn dwg_roundtrip(d: &Drawing) -> Drawing { + let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap(); + read(&dwg, "case.dwg").unwrap() +} + +/// `(code, value)` pairs of a DXF, values trimmed. +fn tags(dxf: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = dxf.lines().collect(); + lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim().to_string()))).collect() +} + +fn text(value: &str, at: Vec3, h: HAlign, v: VAlign) -> EntityKind { + EntityKind::Text(Text { + insert: at, + align_pt: Some(at), + height: 5.0, + value: value.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: h, + valign: v, + }) +} + +#[test] +fn justified_text_is_written_with_its_baseline_start_as_insertion_point() { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), text("MIDDLE CENTER", Vec3::new(100.0, 50.0, 0.0), HAlign::Center, VAlign::Middle)).unwrap(); + d.add(&Space::Model, Common::default(), text("BOTTOM RIGHT", Vec3::new(100.0, 30.0, 0.0), HAlign::Right, VAlign::Bottom)).unwrap(); + let width = |s: &str| cadcraft_fonts::line_width(s, 5.0, 1.0); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + // DXF: group 10 is where the baseline starts, group 11 the alignment point. + let t = tags(&write_dxf(&d)); + let texts: Vec = t.iter().enumerate().filter(|(_, (c, v))| *c == 0 && v == "TEXT").map(|(i, _)| i).collect(); + let group = |start: usize, code: i32| -> f64 { t[start..].iter().find(|(c, _)| *c == code).unwrap().1.parse().unwrap() }; + assert_eq!(texts.len(), 2); + assert!(near(group(texts[0], 10), 100.0 - width("MIDDLE CENTER") / 2.0), "{}", group(texts[0], 10)); + assert!(near(group(texts[0], 11), 100.0)); + assert!(near(group(texts[1], 10), 100.0 - width("BOTTOM RIGHT")), "{}", group(texts[1], 10)); + + // DWG: the same, through the bridge. + let back = dwg_roundtrip(&d); + let starts: Vec<(f64, f64)> = back + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Text(t) => Some((t.insert.x, t.align_pt.map_or(f64::NAN, |a| a.x))), + _ => None, + }) + .collect(); + assert_eq!(starts.len(), 2); + assert!(near(starts[0].0, 100.0 - width("MIDDLE CENTER") / 2.0) && near(starts[0].1, 100.0), "{starts:?}"); + assert!(near(starts[1].0, 100.0 - width("BOTTOM RIGHT")) && near(starts[1].1, 100.0), "{starts:?}"); +} + +/// A metric drawing whose current style is S25 (text 0.25, 2 decimals, DIMSCALE 25, red lines, +/// green text), with one dimension. +fn s25(kind: DimKind, p13: Vec3, p14: Vec3, defpt: Vec3) -> Drawing { + let mut d = Drawing::new_metric(); + d.dim_styles.push(DimStyle { + name: "S25".into(), + scale: 25.0, + text_height: 0.25, + decimals: 2, + dim_line_color: Color::from_aci(1), + ext_line_color: Color::from_aci(1), + text_color: Color::from_aci(3), + ..DimStyle::default() + }); + d.header.set_str("DIMSTYLE", "S25"); + let dm = Dimension { + kind, + defpt, + text_mid: Vec3::ZERO, + p13, + p14, + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "S25".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + d +} + +fn linear(d_rotation: f64) -> Drawing { + s25(DimKind::Linear { rotation: d_rotation }, Vec3::ZERO, Vec3::new(100.0, 0.0, 0.0), Vec3::new(50.0, -20.0, 0.0)) +} + +#[test] +fn the_current_dimension_style_and_its_variables_reach_autocad() { + let d = linear(0.0); + let t = tags(&write_dxf(&d)); + let var = |name: &str| t.windows(2).find(|w| w[0] == (9, name.to_string())).map(|w| w[1].1.clone()); + assert_eq!(var("$DIMSTYLE").as_deref(), Some("S25")); + assert_eq!(var("$DIMTXT").and_then(|v| v.parse::().ok()), Some(0.25)); + assert_eq!(var("$DIMDEC").as_deref(), Some("2")); + assert_eq!(var("$DIMCLRT").as_deref(), Some("3")); + // DIMPOST is written even when empty (#1053). + let back = dwg_roundtrip(&d); + assert_eq!(back.header.str("DIMSTYLE", ""), "S25", "the DWG keeps the current style"); + for s in &back.dim_styles { + assert_eq!(s.post, "", "{}: DIMPOST stays empty, not \"<>\"", s.name); + } +} + +#[test] +fn a_dimension_block_takes_the_style_colours() { + let d = linear(0.0); + let t = tags(&write_dxf(&d)); + let blocks = t.iter().position(|(c, v)| *c == 2 && v == "BLOCKS").unwrap(); + let start = blocks + t[blocks..].iter().position(|(c, v)| *c == 2 && v.starts_with("*D")).unwrap(); + let end = start + t[start..].iter().position(|(c, v)| *c == 0 && v == "ENDBLK").unwrap(); + let mut colors: Vec<(String, i64)> = Vec::new(); + let mut kind = String::new(); + for (c, v) in &t[start..end] { + match c { + 0 => kind = v.clone(), + 62 => colors.push((kind.clone(), v.parse().unwrap())), + _ => {} + } + } + assert!(colors.iter().any(|(k, c)| k == "LINE" && *c == 1), "{colors:?}"); + assert!(colors.iter().any(|(k, c)| k == "MTEXT" && *c == 3), "{colors:?}"); + assert!(!colors.iter().any(|(_, c)| *c == 0), "nothing ByBlock: {colors:?}"); +} + +#[test] +fn a_rotated_linear_dimension_stores_its_projected_length() { + let d = s25( + DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 }, + Vec3::new(-5.0, 0.0, 0.0), + Vec3::new(0.0, 210.0, 0.0), + Vec3::new(-40.0, 105.0, 0.0), + ); + let dwg = write_with(&d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap(); + let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + let t = tags(&dxf); + let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap(); + let m: f64 = t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap(); + assert!((m - 210.0).abs() < 1e-9, "group 42 = {m}"); +} + +/// Group 10 and the value of every TEXT in a DXF, in file order. +fn text_starts(dxf: &str) -> Vec<(String, cadcraft_geom::Vec2)> { + let t = tags(dxf); + let mut out = Vec::new(); + for (i, (c, v)) in t.iter().enumerate() { + if *c == 0 && v == "TEXT" { + let end = t[i + 1..].iter().position(|(c, _)| *c == 0).map_or(t.len(), |k| i + 1 + k); + let g = |code: i32| t[i + 1..end].iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()); + let num = |code: i32| g(code).and_then(|v| v.parse::().ok()).unwrap_or(f64::NAN); + out.push((g(1).unwrap_or_default(), cadcraft_geom::Vec2::new(num(10), num(20)))); + } + } + out +} + +/// Justified TEXT made in CADCraft (insertion point = alignment point) is written with its +/// baseline start in group 10, for every justification and rotated too: placed baseline-left at +/// the written group 10, each text covers what CADCraft draws for the justified one. Left Middle +/// and Left Bottom were kept at the alignment point, half or a third of a height off, because +/// that point passed for AutoCAD's own (test by @lee-cyrille-maskell, from their fix for #1050). +#[test] +fn justified_text_made_here_writes_its_baseline_start_for_every_justification() { + use HAlign::{Center, Left, Right}; + use VAlign::{Baseline, Bottom, Top}; + let just = [ + (Left, Baseline), + (Center, Baseline), + (Right, Baseline), + (HAlign::Middle, Baseline), + (Left, Top), + (Center, Top), + (Right, Top), + (Left, VAlign::Middle), + (Center, VAlign::Middle), + (Right, VAlign::Middle), + (Left, Bottom), + (Center, Bottom), + (Right, Bottom), + ]; + let mut d = Drawing::new_imperial(); + let mut made = Vec::new(); + for (i, &(h, v)) in just.iter().enumerate() { + for rotation in [0.0, 0.5] { + let at = Vec3::new(100.0, 400.0 - 22.0 * i as f64, 0.0); + let left = h == Left && v == Baseline; + let t = Text { + insert: at, + align_pt: if left { None } else { Some(at) }, + height: 5.0, + value: format!("{h:?} {v:?} {rotation} Hxg"), + rotation, + width_factor: 0.8, + oblique: 0.0, + style: "Standard".into(), + halign: h, + valign: v, + }; + d.add(&Space::Model, Common::default(), EntityKind::Text(t.clone())).unwrap(); + made.push(t); + } + } + let dxf = write_dxf(&d); + let starts = text_starts(&dxf); + assert_eq!(starts.len(), made.len()); + let near = |a: cadcraft_geom::Vec2, b: cadcraft_geom::Vec2| (a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6; + for (t, (value, p)) in made.iter().zip(&starts) { + assert_eq!(&t.value, value); + let (_, want) = cadcraft_render::place_text_entity(&d, t, &t.value); + let start = Text { insert: Vec3::new(p.x, p.y, 0.0), align_pt: None, halign: Left, valign: Baseline, ..t.clone() }; + let (_, got) = cadcraft_render::place_text_entity(&d, &start, &start.value); + assert!(near(got.min, want.min) && near(got.max, want.max), "{value}: drawn {want:?}, from group 10 {got:?}"); + } + // Read back and saved again, group 10 stays put. + let back = read(dxf.as_bytes(), "t.dxf").unwrap(); + assert_eq!(text_starts(&write_dxf(&back)), starts); +} + +/// Group 42 of a dimension in the DXF AutoCAD reads from `d`'s DWG. +fn stored_measurement(d: &Drawing) -> f64 { + let dwg = write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap(); + let dxf = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + let t = tags(&dxf); + let dim = t.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap(); + t[dim..].iter().find(|(c, _)| *c == 42).unwrap().1.parse().unwrap() +} + +fn polar(deg: f64, r: f64) -> Vec3 { + let a = deg.to_radians(); + Vec3::new(r * a.cos(), r * a.sin(), 0.0) +} + +/// Angular dimensions store their angle too, in radians as AutoCAD does (0.0 in 0.6.2). +#[test] +fn an_angular_dimension_stores_its_angle_in_radians() { + // Three points: vertex (p15) at the origin, legs through p13 and p14, the arc through defpt. + for deg in [60.0, 174.0] { + let d = s25(DimKind::Angular3P, polar(0.0, 20.0), polar(deg, 20.0), polar(deg / 2.0, 15.0)); + let m = stored_measurement(&d); + assert!((m - f64::to_radians(deg)).abs() < 1e-9, "{deg}°: group 42 = {m}"); + } + // Two lines, 120° apart: p13-p14 and defpt-p15, the arc through p16. + let mut d = s25(DimKind::Angular, polar(0.0, 5.0), polar(0.0, 20.0), polar(120.0, 5.0)); + let h = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).unwrap().handle; + d.modify_entity(h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.p15 = polar(120.0, 20.0); + dm.p16 = polar(60.0, 15.0); + } + }) + .unwrap(); + let m = stored_measurement(&d); + assert!((m - f64::to_radians(120.0)).abs() < 1e-9, "two lines: group 42 = {m}"); +} + +/// A justified text read from AutoCAD keeps AutoCAD's insertion point (exact for its own fonts) +/// when it is near CADCraft's; only a stale one (the alignment point, or a text that has since +/// changed length) is replaced. +#[test] +fn an_insertion_point_from_autocad_is_kept() { + let mut d = Drawing::new_metric(); + let at = Vec3::new(100.0, 50.0, 0.0); + let ours = 100.0 - cadcraft_fonts::line_width("MIDDLE CENTER", 5.0, 1.0) / 2.0; + let mut k = text("MIDDLE CENTER", at, HAlign::Center, VAlign::Middle); + if let EntityKind::Text(t) = &mut k { + // AutoCAD's font is a little narrower than CADCraft's. + t.insert = Vec3::new(ours + 2.5, 47.5, 0.0); + } + d.add(&Space::Model, Common::default(), k).unwrap(); + let t = tags(&write_dxf(&d)); + let i = t.iter().position(|(c, v)| *c == 0 && v == "TEXT").unwrap(); + let x: f64 = t[i..].iter().find(|(c, _)| *c == 10).unwrap().1.parse().unwrap(); + assert!((x - (ours + 2.5)).abs() < 1e-9, "kept: {x}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8dadabe8..18264e9e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1445,6 +1445,11 @@ fn table(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::Table, rgb: Ink, lw: f32) table::draw(b, ctx, t, rgb, lw); } +/// A TEXT's justification as the fonts' alignment (used where text is placed outside a build). +pub fn text_align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) { + align(h, v) +} + fn align(h: cadcraft_doc::HAlign, v: cadcraft_doc::VAlign) -> (cadcraft_fonts::Align, cadcraft_fonts::VAlign) { use cadcraft_fonts::{Align as A, VAlign as V}; let ha = match h {