collective

Browse / github.com/storytold/cadcraft / patch

fix(engine): refresh attribute baselines after value changes

fix(engine): refresh attribute baselines after value changes Changing a justified attribute's value can leave its saved baseline at the old string's position when the width change falls within the DXF writer's deliberate font-substitution tolerance. The alignment anchor renders correctly in CADCraft, but the exported baseline can remain stale. Refresh the derived XY baseline when ATTEDIT or -ATTEDIT actually changes the value. Preserve the alignment anchor, both Z values, no-op imported font placement, and the independent points used by Left/Baseline, Aligned and Fit text. The existing writer then recomputes the baseline; its tolerance and file synta

Status
open
Score
0.00
Adopters
0 other operators
Author
smacdmac101 (imported from pull request #2214, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+233 −9 in 7 files
Review
pending
Shared
2026-10-28
Id
cc2dc5a0df1fe0b8e4bd52c3

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/blocks/attedit.rs crates/engine/tests/it/attribute_value_baseline.rs crates/engine/tests/it/main.rs crates/io/src/dxf_write/common.rs docs/file-format-parity.md xtask/src/parity.rs xtask/src/stats.rs

diff --git a/crates/engine/src/cmd/blocks/attedit.rs b/crates/engine/src/cmd/blocks/attedit.rs--- a/crates/engine/src/cmd/blocks/attedit.rs+++ b/crates/engine/src/cmd/blocks/attedit.rs@@ -3,7 +3,7 @@ //! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.  use cadcraft_doc::color::Color;-use cadcraft_doc::{AttribProps, Common, EntityKind, Handle};+use cadcraft_doc::{AttribProps, Common, EntityKind, HAlign, Handle, Text, VAlign}; use serde_json::{Map, Value, json};  use crate::cmd::curves::KW;@@ -67,7 +67,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {         if let EntityKind::Insert(i) = &mut e.kind {             for a in &mut i.attribs {                 if let Some(v) = value_for(&vals, &a.tag) {-                    a.text.value = v;+                    set_value(&mut a.text, v);                     n += 1;                 }             }@@ -130,13 +130,29 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option             && let Some(a) = ins.attribs.get_mut(k)             && let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)         {-            a.text.value = v;+            set_value(&mut a.text, v);             changed = true;         }     })?;     Ok(changed) } +/// Refresh the derived baseline after a value change, even one within the writer's+/// imported-font tolerance. Equal XY points request recomputation on export; retain+/// both elevations and the independent endpoints used by Aligned/Fit text.+fn set_value(text: &mut Text, value: String) {+    if text.value == value {+        return;+    }+    text.value = value;+    if !matches!((text.halign, text.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _))+        && let Some(anchor) = text.align_pt+    {+        text.insert.x = anchor.x;+        text.insert.y = anchor.y;+    }+}+ /// Change the properties set on attribute `k` of block reference `h`. fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> {     s.doc_mut()?.modify_entity(h, |e| {diff --git a/crates/engine/tests/it/attribute_value_baseline.rs b/crates/engine/tests/it/attribute_value_baseline.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/attribute_value_baseline.rs@@ -0,0 +1,203 @@+//! Small value changes must not export an imported justified attribute's old baseline.+use cadcraft_doc::{Attrib, Drawing, EntityKind, HAlign, Handle, Text, VAlign};+use cadcraft_engine::{Input, Session};+use cadcraft_geom::Vec3;+use serde_json::json;++const BEFORE: &str = "POWER ADAPTER";+const AFTER: &str = "POWER ADAPTER.";++fn fixture() -> (Session, Handle, Handle) {+    let mut s = Session::new();+    for (tag, value, y) in [("T", BEFORE, 15), ("U", "ADPT-01", 0)] {+        s.execute("attdef", &json!({"tag":tag,"default":value,"at":[0,y],"height":3})).unwrap();+    }+    s.execute("line", &json!({"points":[[0,-5],[40,-5]]})).unwrap();+    let handles: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();+    s.execute("block", &json!({"name":"Equipment","handles":handles,"base":[0,0],"keep":"delete"})).unwrap();+    let a = s.execute("insert", &json!({"name":"Equipment","at":[100,200],"scale":2,"rotation":30})).unwrap();+    let b = s.execute("insert", &json!({"name":"Equipment","at":[300,200],"attribs":{"T":"Other"}})).unwrap();+    let a = Handle::parse_hex(a["handle"].as_str().unwrap()).unwrap();+    let b = Handle::parse_hex(b["handle"].as_str().unwrap()).unwrap();+    // Set existing document fields directly: this regression does not require Text Options.+    s.doc_mut()+        .unwrap()+        .modify_entity(a, |e| {+            let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };+            let t = &mut i.attribs[0].text;+            t.insert.z = 7.0;+            t.align_pt = Some(t.insert);+            t.halign = HAlign::Center;+            t.valign = VAlign::Top;+        })+        .unwrap();+    let imported = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();+    let mut s = Session::empty();+    s.open_drawing(imported, "Imported", None);+    (s, a, b)+}++fn attributes(d: &Drawing, h: Handle) -> Vec<Attrib> {+    let EntityKind::Insert(i) = &d.entity(h).unwrap().kind else { panic!("insert") };+    i.attribs.clone()+}++fn baseline(d: &Drawing, t: &Text) -> cadcraft_geom::Vec2 {+    let style = d.text_style(&t.style).unwrap();+    let (h, v) = cadcraft_render::text_align(t.halign, t.valign);+    cadcraft_fonts::baseline_start(+        &cadcraft_fonts::TextFont::resolve(&style.font),+        &t.value,+        &cadcraft_fonts::TextParams {+            insert: t.insert.xy(),+            align_pt: t.align_pt.map(|p| p.xy()),+            height: t.height,+            rotation: t.rotation,+            width_factor: t.width_factor,+            oblique: t.oblique,+            h,+            v,+            backwards: style.backwards,+            upside_down: style.upside_down,+        },+    )+}++#[test]+fn all_attribute_value_paths_refresh_small_justified_export_changes() {+    for path in ["json", "interactive", "global-json", "global-interactive"] {+        let (mut s, a, b) = fixture();+        let before = attributes(s.doc().unwrap(), a);+        let other = attributes(s.doc().unwrap(), b);+        let definition = s.doc().unwrap().block("Equipment").unwrap().clone();+        match path {+            "json" => {+                s.execute("attedit", &json!({"handle":a.hex(),"values":{"t":AFTER}})).unwrap();+            }+            "interactive" => {+                s.start("attedit").unwrap();+                s.input(Input::Pick(vec![a])).unwrap();+                s.input(Input::Text(AFTER.into())).unwrap();+                s.input(Input::Enter).unwrap();+                assert!(s.running.is_none());+            }+            "global-json" => {+                s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER."})).unwrap();+            }+            "global-interactive" => {+                s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER.\n").unwrap();+                assert!(s.running.is_none(), "{}", s.prompt_text());+            }+            _ => panic!("path"),+        }+        let edited = attributes(s.doc().unwrap(), a);+        assert_eq!(edited[0].text.value, AFTER, "{path}");+        assert_eq!(edited[0].text.align_pt, before[0].text.align_pt);+        assert_eq!(edited[0].text.insert.z, 7.0);+        assert_eq!(edited[1], before[1]);+        assert_eq!(attributes(s.doc().unwrap(), b), other);+        assert_eq!(s.doc().unwrap().block("Equipment").unwrap(), &definition);+        let expected = baseline(s.doc().unwrap(), &edited[0].text);+        assert!(expected.dist(before[0].text.insert.xy()) > 0.01, "fixture must change width");+        for name in ["edited.dxf", "edited.dwg"] {+            let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap();+            let restored = attributes(&reopened, a);+            assert!(+                restored[0].text.insert.xy().dist(expected) < 1e-8,+                "{path} {name}: stale {:?}, expected {expected:?}",+                restored[0].text.insert.xy()+            );+            assert_eq!(restored[0].text.align_pt, before[0].text.align_pt);+            assert_eq!(restored[0].text.value, AFTER);+            assert_eq!(restored[0].text.insert.z, 7.0);+        }+        s.undo().unwrap();+        assert_eq!(attributes(s.doc().unwrap(), a), before, "{path} undo");+        s.redo().unwrap();+        assert_eq!(attributes(s.doc().unwrap(), a), edited, "{path} redo");+    }+}++#[test]+fn equal_attribute_values_preserve_an_imported_font_baseline() {+    let (mut s, a, _) = fixture();+    // A small imported-font offset must survive a no-op edit (writer tolerance is deliberate).+    s.doc_mut()+        .unwrap()+        .modify_entity(a, |e| {+            let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };+            i.attribs[0].text.insert.x += 0.1;+        })+        .unwrap();+    let before = attributes(s.doc().unwrap(), a);+    s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":BEFORE}})).unwrap();+    assert_eq!(attributes(s.doc().unwrap(), a), before);+    s.start("attedit").unwrap();+    s.input(Input::Pick(vec![a])).unwrap();+    s.input(Input::Enter).unwrap();+    s.input(Input::Enter).unwrap();+    assert_eq!(attributes(s.doc().unwrap(), a), before);+    s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER"})).unwrap();+    assert_eq!(attributes(s.doc().unwrap(), a), before, "global JSON no-op");+    s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER\n").unwrap();+    assert!(s.running.is_none(), "{}", s.prompt_text());+    assert_eq!(attributes(s.doc().unwrap(), a), before, "global interactive no-op");+}++/// Recomputing a justified baseline must not replace its independent insertion elevation.+#[test]+fn value_edits_preserve_distinct_insertion_and_alignment_elevations_on_export() {+    let (mut s, a, _) = fixture();+    s.doc_mut()+        .unwrap()+        .modify_entity(a, |e| {+            let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };+            i.attribs[0].text.insert.z = 9.0;+        })+        .unwrap();+    let anchor = attributes(s.doc().unwrap(), a)[0].text.align_pt;+    s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap();+    for name in ["elevations.dxf", "elevations.dwg"] {+        let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap();+        let restored = attributes(&reopened, a);+        assert_eq!(restored[0].text.insert.z, 9.0, "{name}: insertion elevation");+        assert_eq!(restored[0].text.align_pt.map(|p| p.xy()), anchor.map(|p| p.xy()), "{name}: alignment XY");+        if name.ends_with(".dxf") {+            assert_eq!(restored[0].text.align_pt, anchor, "DXF retains independent alignment elevation");+        } else {+            // The DWG bridge uses one text elevation for both points.+            assert_eq!(restored[0].text.align_pt.unwrap().z, 9.0);+        }+    }+}++#[test]+fn value_edits_preserve_left_baseline_and_two_point_placements() {+    for horizontal in [HAlign::Left, HAlign::Aligned, HAlign::Fit] {+        let (mut s, a, _) = fixture();+        s.doc_mut()+            .unwrap()+            .modify_entity(a, |e| {+                let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") };+                let t = &mut i.attribs[0].text;+                t.halign = horizontal;+                t.valign = VAlign::Baseline;+                t.insert = Vec3::new(10.0, 20.0, 7.0);+                t.align_pt = (horizontal != HAlign::Left).then_some(Vec3::new(40.0, 30.0, 9.0));+            })+            .unwrap();+        let before = attributes(s.doc().unwrap(), a);+        s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap();+        let mut expected = before;+        expected[0].text.value = AFTER.into();+        assert_eq!(attributes(s.doc().unwrap(), a), expected);+        if horizontal != HAlign::Left {+            let reopened = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap();+            let restored = attributes(&reopened, a);+            assert_eq!(restored[0].text.insert, expected[0].text.insert);+            assert_eq!(restored[0].text.align_pt, expected[0].text.align_pt);+            assert_eq!(restored[0].text.halign, horizontal);+            assert_eq!(restored[0].text.value, AFTER);+        }+    }+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -10,6 +10,7 @@ mod attedit_prompts; mod attrib_modes; mod attrib_props; mod attribute_tools;+mod attribute_value_baseline; mod block_keep; mod block_redefine; mod blockreplace;diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs--- a/crates/io/src/dxf_write/common.rs+++ b/crates/io/src/dxf_write/common.rs@@ -234,7 +234,8 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 {         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 }+    // Justification recalculates the baseline in XY; group 10 keeps its own elevation.+    if q.is_finite() { Vec3::new(q.x, q.y, t.insert.z) } else { t.insert } }  pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) {diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (attribute value edits refresh exported baselines) · **Target:** Autodesk AutoCAD 2027  Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds@@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | 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) | ATTDEF prompt kept (#137) |+| 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 | | 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) | | 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; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | 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 |@@ -140,6 +140,7 @@ our output in AutoCAD black-box.  | Date | Change | Summary | |---|---|---|+| 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-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs--- a/xtask/src/parity.rs+++ b/xtask/src/parity.rs@@ -136,7 +136,9 @@ mod tests {         assert_eq!(day(20_736), "2026-10-10");         assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01");         // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).-        assert_eq!(day(115_740_740), "318857-05-20");+        // Checked on the day count: Windows' SystemTime ends in the year 30828, so building that+        // SystemTime panics there.+        assert_eq!(civil_from_days(115_740_740), (318_857, 5, 20));         // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.         if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) {             assert_eq!(date(far), "292277026596-12-04");diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs--- a/xtask/src/stats.rs+++ b/xtask/src/stats.rs@@ -134,7 +134,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {  #[cfg(test)] mod tests {-    use super::{Counts, count_tests, walk};+    use super::{Counts, count_tests};      #[test]     fn counts_attributes_and_proptest_blocks() {@@ -155,7 +155,7 @@ mod tests {         // A directory symlink pointing back at `real` would be re-scanned if followed.         symlink(&real, base.join("alias")).unwrap();         let mut c = Counts::default();-        walk(&base, &mut c).unwrap();+        super::walk(&base, &mut c).unwrap();         assert_eq!(c.files, 1, "the aliased directory was followed and counted twice");         std::fs::remove_dir_all(&base).ok();     }