collective

Browse / github.com/storytold/cadcraft / patch

Make automatic DIMBREAK gaps follow edited geometry

Make automatic DIMBREAK gaps follow edited geometry Automatic DIMBREAK gaps currently stay at their original coordinates when a crossing object moves. This makes the gaps follow their source objects and dimension edits: moving a crossing line from x=4 to x=6 moves its gap to x=6 in the same command and undo step. Gaps disappear when the crossing vanishes and return when the objects cross again. - Retain source handles and gap size, recomputing after dimension associativity and geometry updates; preserve manual gaps and clear the relationship with Remove. - Preserve relationships through DXF/DWG using CADCraft xdata, including dimensions with n

Status
open
Score
0.00
Adopters
0 other operators
Author
campbellcody (imported from pull request #2143, open)
Base
1fcf654ea8414ea2ef052ca2b3b77411f2026f78
Size
+882 −168 in 36 files
Review
pending
Shared
2026-10-28
Id
310185aae9eae95a8769962d

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/constraints/src/auto.rs crates/doc/src/entity.rs crates/doc/src/tests.rs crates/engine/src/assoc.rs crates/engine/src/audit.rs crates/engine/src/cmd/annotate_dimedit.rs crates/engine/src/cmd/array.rs crates/engine/src/cmd/blocks/insert_file.rs crates/engine/src/cmd/blocks/xref/load.rs crates/engine/src/cmd/clipboard.rs crates/engine/src/cmd/exchange.rs crates/engine/src/cmd/file/compare/export.rs crates/engine/src/cmd/inquiry.rs crates/engine/src/cmd/modify.rs crates/engine/src/dimbreak.rs crates/engine/src/lib.rs crates/engine/src/mtext_edit.rs crates/engine/src/snap.rs crates/engine/tests/it/dimbreak_assoc.rs crates/engine/tests/it/main.rs crates/geom/src/nurbs.rs crates/io/src/dxf_ext.rs crates/io/src/dxf_raw.rs crates/io/src/dxf_read.rs crates/io/src/dxf_write.rs crates/io/tests/it/dim_marks_roundtrip.rs crates/render/src/clip.rs crates/render/src/text.rs crates/ui-egui/src/cmdline.rs crates/ui-egui/src/grip_dyn.rs crates/ui-egui/src/hatchdlg.rs docs/gaps.md docs/roadmap.md docs/target-app-parity.md xtask/src/layers.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (associative DIMBREAK follows edited geometry and survives saves) · **Target:** Autodesk AutoCAD 2027  CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a@@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)  | Date | Entry | |---|---|+| 2026-10-25 | Automatic DIMBREAK gaps follow moved crossing objects and edited dimensions, disappear when crossings vanish, and return when the objects cross again. Manual gaps are preserved; source links survive DXF/DWG, and geometry edits and break updates share one undo step. | | 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) | | 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. | | 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |@@ -136,6 +137,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Associative DIMBREAK; F7 narrowed, readiness estimates unchanged | | 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) | | 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove | | 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |diff --git a/crates/constraints/src/auto.rs b/crates/constraints/src/auto.rs--- a/crates/constraints/src/auto.rs+++ b/crates/constraints/src/auto.rs@@ -52,15 +52,11 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo {                     g.curves.push((GeomRef::whole(h), ga.center, a.radius));                 }             }-            EntityKind::Circle(c) => {-                if c.center.is_finite() && c.radius.is_finite() {-                    g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));-                }+            EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => {+                g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));             }-            EntityKind::Point(p) => {-                if p.p.is_finite() {-                    g.points.push((GeomRef::whole(h), p.p.xy()));-                }+            EntityKind::Point(p) if p.p.is_finite() => {+                g.points.push((GeomRef::whole(h), p.p.xy()));             }             EntityKind::LwPolyline(p) => {                 let n = p.vertices.len();diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs--- a/crates/doc/src/entity.rs+++ b/crates/doc/src/entity.rs@@ -588,6 +588,9 @@ pub struct DimMarks {     /// Gaps in the dimension and extension lines (DIMBREAK).     #[serde(skip_serializing_if = "Vec::is_empty")]     pub breaks: Vec<DimBreak>,+    /// Objects followed by DIMBREAK Auto, kept even while none crosses the dimension.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub auto_break: Option<AutoDimBreak>,     /// Inspection frame, label and rate (DIMINSPECT).     #[serde(skip_serializing_if = "Option::is_none")]     pub inspect: Option<Inspection>,@@ -614,6 +617,9 @@ impl DimMarks {             t3(&mut b.at);             b.size *= s;         }+        if let Some(a) = &mut self.auto_break {+            a.size *= s;+        }     } } @@ -634,10 +640,33 @@ pub struct JoggedRadius { pub struct DimBreak {     pub at: Vec3,     pub size: f64,-    /// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.+    /// Made by DIMBREAK Auto rather than picked by hand.     pub auto: bool, } +/// The sources and gap size of DIMBREAK Auto. Computed gaps live in [`DimMarks::breaks`];+/// this relationship survives temporarily missing intersections and file round trips.+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct AutoDimBreak {+    pub objects: Vec<Handle>,+    pub size: f64,+}++impl AutoDimBreak {+    /// Remap source handles using `remap`, leaving unmapped handles unchanged.+    pub fn remap_handles(&mut self, mut remap: impl FnMut(Handle) -> Option<Handle>) {+        for source in &mut self.objects {+            if let Some(new_h) = remap(*source) {+                *source = new_h;+            }+        }+    }+}++/// Most source objects retained by one automatic dimension break (including file input).+pub const MAX_DIM_BREAK_OBJECTS: usize = 20_000;+ /// An inspection dimension (DIMINSPECT). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs--- a/crates/doc/src/tests.rs+++ b/crates/doc/src/tests.rs@@ -581,3 +581,14 @@ fn space_names_its_tab() {     assert_eq!(p.layout(), Some("Sheet A"));     assert_eq!(p.tab_name(), "Sheet A"); }++#[test]+fn auto_dim_break_remap_handles() {+    let mut a = AutoDimBreak { objects: vec![Handle(1), Handle(2), Handle(3)], size: 0.5 };+    a.remap_handles(|h| match h.0 {+        1 => Some(Handle(10)),+        2 => Some(Handle(20)),+        _ => None,+    });+    assert_eq!(a.objects, vec![Handle(10), Handle(20), Handle(3)]);+}diff --git a/crates/engine/src/assoc.rs b/crates/engine/src/assoc.rs--- a/crates/engine/src/assoc.rs+++ b/crates/engine/src/assoc.rs@@ -343,6 +343,7 @@ pub fn after_command(s: &mut crate::Session, before: Option<&Arc<Drawing>>, uid:     {         update(&mut st.doc, before);         notes = crate::cmd::hatch::refill_associative(&mut st.doc, before);+        crate::dimbreak::update(&mut st.doc, before);     }     notes.into_iter().for_each(|n| s.echo(n)); }diff --git a/crates/engine/src/audit.rs b/crates/engine/src/audit.rs--- a/crates/engine/src/audit.rs+++ b/crates/engine/src/audit.rs@@ -460,17 +460,13 @@ fn references(d: &mut Drawing, out: &mut Vec<Issue>) {                         m.style = "Standard".into();                     }                 }-                EntityKind::Table(t) => {-                    if !has_name(&d.table_styles, &t.style, |s| &s.name) {-                        note("table style", &t.style);-                        t.style = "Standard".into();-                    }+                EntityKind::Table(t) if !has_name(&d.table_styles, &t.style, |s| &s.name) => {+                    note("table style", &t.style);+                    t.style = "Standard".into();                 }-                EntityKind::Leader(l) => {-                    if !ok_dim(d, &l.style) {-                        note("dimension style", &l.style);-                        l.style = "Standard".into();-                    }+                EntityKind::Leader(l) if !ok_dim(d, &l.style) => {+                    note("dimension style", &l.style);+                    l.style = "Standard".into();                 }                 EntityKind::Dimension(dim) => {                     if !ok_dim(d, &dim.style) {diff --git a/crates/engine/src/cmd/annotate_dimedit.rs b/crates/engine/src/cmd/annotate_dimedit.rs--- a/crates/engine/src/cmd/annotate_dimedit.rs+++ b/crates/engine/src/cmd/annotate_dimedit.rs@@ -1,18 +1,14 @@ //! Dimension edits: DIMEDIT (Home/New/Rotate/Oblique), DIMJOGGED, DIMJOGLINE, DIMBREAK and //! DIMINSPECT. Each has a JSON form; the prompt sequences run the same JSON form. -use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection, JoggedRadius, Prim};-use cadcraft_geom::{Segment, Vec2};+use cadcraft_doc::{AutoDimBreak, DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection, JoggedRadius, MAX_DIM_BREAK_OBJECTS};+use cadcraft_geom::Vec2; use serde_json::{Value, json};  use super::machines::{SelOutcome, SelectPhase}; use super::*; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; -/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.-const MAX_BREAK_OBJECTS: usize = 20_000;-const MAX_AUTO_BREAKS: usize = 1000;- pub(super) fn specs() -> Vec<CommandSpec> {     vec![         CommandSpec::new("dimedit", "Dimension Edit", run_dimedit)@@ -35,7 +31,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {             .interactive(|_| Ok(Box::new(JogLineM::default()))),         CommandSpec::new("dimbreak", "Dimension Break", run_dimbreak)             .menu(&["Dimension", "Dimension Break"])-            .params("{handles?, mode?: auto|manual|remove (default auto), objects?: [hex] (auto: the objects that break; default all crossing objects), first?/second?: [x,y] (manual), size?: number (default 0.125 × DIMSCALE)}")+            .params("{handles?, mode?: auto|manual|remove (default auto), objects?: [hex] (auto: source objects followed through edits; default current non-text/non-dimension objects in this space), first?/second?: [x,y] (manual), size?: number (default 0.125 × DIMSCALE)}")             .interactive(|_| Ok(Box::new(DimBreakM::default()))),         CommandSpec::new("diminspect", "Inspection", run_diminspect)             .menu(&["Dimension", "Inspection"])@@ -452,40 +448,6 @@ impl Interactive for JogLineM {  // ---------------- DIMBREAK ---------------- -/// Where the dimension's drawn lines cross the objects: (point, direction of the line there).-fn crossings(dm_lines: &[Vec<Vec2>], prims: &[Prim]) -> Vec<Vec2> {-    let mut out = Vec::new();-    for l in dm_lines {-        for w in l.windows(2) {-            let seg = Segment::Line(cadcraft_geom::Line::new(w[0], w[1]));-            for p in prims {-                let pts = match p {-                    Prim::Seg(o) => cadcraft_geom::intersect_segments(&seg, o),-                    Prim::Circle(c) => cadcraft_geom::line_circle(&cadcraft_geom::Line::new(w[0], w[1]), c)-                        .into_iter()-                        .filter(|(_, t)| (-1e-9..=1.0 + 1e-9).contains(t))-                        .map(|(q, _)| q)-                        .collect(),-                    Prim::Infinite { base, dir, ray } => cadcraft_geom::line_line_infinite(w[0], w[1], *base, *base + *dir)-                        .filter(|(_, t, u)| (-1e-9..=1.0 + 1e-9).contains(t) && (!ray || *u >= -1e-9))-                        .map(|(q, _, _)| vec![q])-                        .unwrap_or_default(),-                    _ => Vec::new(),-                };-                for q in pts {-                    if out.len() >= MAX_AUTO_BREAKS {-                        return out;-                    }-                    if !out.iter().any(|o: &Vec2| o.dist(q) < 1e-9) {-                        out.push(q);-                    }-                }-            }-        }-    }-    out-}- fn run_dimbreak(s: &mut Session, p: &Value) -> Result<Value> {     let hs = dims_of(s, &targets(s, p)?);     if hs.is_empty() {@@ -500,8 +462,9 @@ fn run_dimbreak(s: &mut Session, p: &Value) -> Result<Value> {     match mode.as_str() {         "remove" => {             let n = edit_dims(s, &hs, &|dm| {-                let had = !dm.marks.breaks.is_empty();+                let had = !dm.marks.breaks.is_empty() || dm.marks.auto_break.is_some();                 dm.marks.breaks.clear();+                dm.marks.auto_break = None;                 had             })?;             Ok(json!({ "changed": n }))@@ -520,35 +483,37 @@ fn run_dimbreak(s: &mut Session, p: &Value) -> Result<Value> {         }         "auto" => {             // The breaking objects: those given, else every object (not a dimension or text).-            let given: Option<Vec<Handle>> = match p.get("objects") {+            let given: Option<std::collections::HashSet<Handle>> = match p.get("objects") {                 None | Some(Value::Null) => None,-                Some(_) => Some(targets(s, &json!({ "handles": p.get("objects") }))?),+                Some(_) => Some(targets(s, &json!({ "handles": p.get("objects") }))?.into_iter().collect()),             };-            let space = s.space();-            let store = d.space(&space).ok_or_else(|| bad("dimbreak", "no current space"))?;-            let others: Vec<(Handle, Vec<Prim>)> = store-                .iter()-                .filter(|e| match &given {-                    Some(g) => g.contains(&e.handle),-                    None => !matches!(e.kind, EntityKind::Dimension(_) | EntityKind::Text(_) | EntityKind::MText(_)),-                })-                .take(MAX_BREAK_OBJECTS)-                .map(|e| (e.handle, e.kind.prims()))-                .collect();             let mut plan = Vec::new();             for h in &hs {                 let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) else { continue };-                let mut plain = dm.clone();-                plain.marks.breaks.clear();-                let g = cadcraft_render::dimension_in(d, &plain);-                let prims: Vec<Prim> = others.iter().filter(|(o, _)| o != h).flat_map(|(_, p)| p.iter().cloned()).collect();-                plan.push((*h, crossings(&g.lines, &prims)));+                let space = d.space_of(*h).ok_or_else(|| bad("dimbreak", "the dimension is in no space"))?;+                let store = d.space(&space).ok_or_else(|| bad("dimbreak", "no dimension space"))?;+                let objects: Vec<Handle> = store+                    .iter()+                    .filter(|e| match &given {+                        Some(g) => g.contains(&e.handle),+                        None => !matches!(e.kind, EntityKind::Dimension(_) | EntityKind::Text(_) | EntityKind::MText(_)),+                    })+                    .take(MAX_DIM_BREAK_OBJECTS + 1)+                    .map(|e| e.handle)+                    .collect();+                if objects.len() > MAX_DIM_BREAK_OBJECTS {+                    return Err(bad("dimbreak", format!("at most {MAX_DIM_BREAK_OBJECTS} source objects; select fewer objects")));+                }+                let auto = AutoDimBreak { objects: objects.iter().copied().filter(|o| o != h).collect(), size };+                let breaks = crate::dimbreak::automatic(d, &space, dm, &auto);+                plan.push((*h, auto, breaks));             }             let mut n = 0;-            for (h, pts) in plan {+            for (h, auto, breaks) in plan {                 n += edit_dims(s, &[h], &|dm| {                     dm.marks.breaks.retain(|b| !b.auto);-                    dm.marks.breaks.extend(pts.iter().map(|q| DimBreak { at: v3(*q), size, auto: true }));+                    dm.marks.breaks.extend(breaks.iter().cloned());+                    dm.marks.auto_break = Some(auto.clone());                     true                 })?;             }diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs--- a/crates/engine/src/cmd/array.rs+++ b/crates/engine/src/cmd/array.rs@@ -125,9 +125,21 @@ pub(crate) fn make_assoc(s: &mut Session, objs: &[Handle], base: Vec2, mut p: Ar     let src_name = d.anonymous_block_name("*U");     let mut src = Block::new(&src_name);     let to_local = Mat3::translate(-base);+    let mut orig_to_src = HashMap::new();+    let mut src_ents = Vec::with_capacity(ents.len());     for mut e in ents {+        let orig = e.handle;         e.handle = d.new_handle();+        orig_to_src.insert(orig, e.handle);         e.kind.transform(&to_local);+        src_ents.push(e);+    }+    for mut e in src_ents {+        if let EntityKind::Dimension(dm) = &mut e.kind+            && let Some(a) = &mut dm.marks.auto_break+        {+            a.remap_handles(|h| orig_to_src.get(&h).copied());+        }         src.entities.push(e);     }     d.blocks.insert(src_name.clone(), Arc::new(src));diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs--- a/crates/engine/src/cmd/blocks/insert_file.rs+++ b/crates/engine/src/cmd/blocks/insert_file.rs@@ -194,6 +194,7 @@ fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Ent         }         EntityKind::Dimension(dm) => {             dm.assoc.clear();+            dm.marks.auto_break = None;             if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {                 dm.block = Some(n.clone());             }diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs--- a/crates/engine/src/cmd/blocks/xref/load.rs+++ b/crates/engine/src/cmd/blocks/xref/load.rs@@ -406,6 +406,7 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S         rename_refs(&mut e, &dep);         if let EntityKind::Dimension(dm) = &mut e.kind {             dm.assoc.clear();+            dm.marks.auto_break = None;         }         e     };diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs--- a/crates/engine/src/cmd/clipboard.rs+++ b/crates/engine/src/cmd/clipboard.rs@@ -349,6 +349,7 @@ fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Ent         }         EntityKind::Dimension(dm) => {             dm.assoc.clear();+            dm.marks.auto_break = None;             if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {                 dm.block = Some(n.clone());             }diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs--- a/crates/engine/src/cmd/exchange.rs+++ b/crates/engine/src/cmd/exchange.rs@@ -268,6 +268,7 @@ fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Ent         }         EntityKind::Dimension(dm) => {             dm.assoc.clear();+            dm.marks.auto_break = None;             if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {                 dm.block = Some(n.clone());             }diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs--- a/crates/engine/src/cmd/file/compare/export.rs+++ b/crates/engine/src/cmd/file/compare/export.rs@@ -271,7 +271,10 @@ fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &R /// have new handles, so the links would name objects the snapshot does not have. fn detach(k: &mut EntityKind) {     match k {-        EntityKind::Dimension(dm) => dm.assoc.clear(),+        EntityKind::Dimension(dm) => {+            dm.assoc.clear();+            dm.marks.auto_break = None;+        }         EntityKind::Hatch(h) => {             h.associative = false;             h.loops.iter_mut().for_each(|l| l.sources.clear());diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs--- a/crates/engine/src/cmd/inquiry.rs+++ b/crates/engine/src/cmd/inquiry.rs@@ -786,12 +786,11 @@ impl Interactive for DistM {                     let model = model_points(&self.sources);                     s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));                 }-                Input::Keyword(k) if k == "Undo" => {-                    if pts.len() > 1 {-                        pts.pop();-                        self.sources.pop();-                    }+                Input::Keyword(k) if k == "Undo" && pts.len() > 1 => {+                    pts.pop();+                    self.sources.pop();                 }+                Input::Keyword(k) if k == "Undo" => {}                 Input::Keyword(k) if k == "Length" => self.length = true,                 Input::Keyword(k) if k == "Total" => return self.total(s),                 Input::Keyword(k) => s.echo(format!("{k}: not available yet")),diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs--- a/crates/engine/src/cmd/modify.rs+++ b/crates/engine/src/cmd/modify.rs@@ -102,6 +102,7 @@ pub fn specs() -> Vec<CommandSpec> { pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {     let d = s.doc_mut()?;     let mut out = Vec::new();+    let mut copies = std::collections::HashMap::new();     for h in hs {         let Some(sp) = d.space_of(*h) else { continue };         if copy {@@ -112,6 +113,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:             if let Some(st) = d.space_mut(&sp) {                 st.push(ne);                 out.push(nh);+                copies.insert(*h, nh);             }         } else {             let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);@@ -122,6 +124,19 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy:             out.push(*h);         }     }+    // A copied dimension follows the copied breaking objects when they were copied together;+    // sources left behind still refer to the originals in this drawing.+    for h in copies.values() {+        if d.entity(*h).is_some_and(|e| matches!(&e.kind, EntityKind::Dimension(dm) if dm.marks.auto_break.is_some())) {+            d.modify_entity(*h, |e| {+                if let EntityKind::Dimension(dm) = &mut e.kind+                    && let Some(a) = &mut dm.marks.auto_break+                {+                    a.remap_handles(|h| copies.get(&h).copied());+                }+            })?;+        }+    }     Ok(out) } @@ -1480,15 +1495,48 @@ pub(crate) fn explode_with(         let own_pieces = !matches!(e.kind, EntityKind::Insert(_));         let d = s.doc_mut()?;         d.remove_entity(*h);+        let mut item_remap: std::collections::HashMap<Handle, Handle> = std::collections::HashMap::new();+        let mut item_entities: Vec<Entity> = Vec::new();++        let push_item =+            |entities: &mut Vec<Entity>, remap: &std::collections::HashMap<Handle, Handle>, d: &mut cadcraft_doc::Drawing, out: &mut Vec<Handle>| {+                for mut e in entities.drain(..) {+                    if let EntityKind::Dimension(dm) = &mut e.kind+                        && let Some(a) = &mut dm.marks.auto_break+                    {+                        a.remap_handles(|h| remap.get(&h).copied());+                    }+                    out.push(e.handle);+                    if let Some(st) = d.space_mut(&space) {+                        st.push(e);+                    }+                }+            };+         for mut p in parts {             if own_pieces {                 p.common.xdata.clear();+                p.handle = d.new_handle();+                out.push(p.handle);+                if let Some(st) = d.space_mut(&space) {+                    st.push(p);+                }+                continue;+            }+            let src_h = p.handle;+            if src_h.0 != 0 && item_remap.contains_key(&src_h) {+                push_item(&mut item_entities, &item_remap, d, &mut out);+                item_remap.clear();             }-            p.handle = d.new_handle();-            out.push(p.handle);-            if let Some(st) = d.space_mut(&space) {-                st.push(p);+            let new_h = d.new_handle();+            p.handle = new_h;+            if src_h.0 != 0 {+                item_remap.insert(src_h, new_h);             }+            item_entities.push(p);+        }+        if !item_entities.is_empty() {+            push_item(&mut item_entities, &item_remap, d, &mut out);         }     }     s.set_selection(Vec::new());diff --git a/crates/engine/src/dimbreak.rs b/crates/engine/src/dimbreak.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/dimbreak.rs@@ -0,0 +1,107 @@+//! Automatic dimension breaks follow their source objects after geometry and dimension+//! associativity have settled. The update belongs to the editing command's undo step.++use std::sync::Arc;++use cadcraft_doc::{AutoDimBreak, DimBreak, Dimension, Drawing, EntityKind, Handle, MAX_DIM_BREAK_OBJECTS, Prim, Space};+use cadcraft_geom::{Line, Segment, Vec2, Vec3};++/// Bound both the output and the intersection work of one dimension, including hostile files.+const MAX_AUTO_BREAKS: usize = 1000;+const MAX_INTERSECTION_TESTS: usize = 2_000_000;++/// Gaps made by the selected source objects in this dimension's own space. Manual gaps do+/// not hide intersections from Auto; neither do automatic gaps from the previous edit.+pub(crate) fn automatic(d: &Drawing, space: &Space, dm: &Dimension, auto: &AutoDimBreak) -> Vec<DimBreak> {+    if !auto.size.is_finite() || auto.size <= 0.0 {+        return Vec::new();+    }+    let Some(store) = d.space(space) else { return Vec::new() };+    let mut plain = dm.clone();+    plain.marks.breaks.clear();+    plain.marks.auto_break = None;+    let geometry = cadcraft_render::dimension_in(d, &plain);+    let mut points: Vec<Vec2> = Vec::new();+    let mut tests = 0usize;+    'objects: for h in auto.objects.iter().take(MAX_DIM_BREAK_OBJECTS) {+        let Some(e) = store.get(*h) else { continue };+        for p in e.kind.prims() {+            for line in &geometry.lines {+                for w in line.windows(2) {+                    if tests >= MAX_INTERSECTION_TESTS || points.len() >= MAX_AUTO_BREAKS {+                        break 'objects;+                    }+                    tests += 1;+                    let (Some(a), Some(b)) = (w.first().copied(), w.get(1).copied()) else { continue };+                    let seg = Line::new(a, b);+                    let hits = match &p {+                        Prim::Seg(other) => cadcraft_geom::intersect_segments(&Segment::Line(seg), other),+                        Prim::Circle(c) => cadcraft_geom::line_circle(&seg, c)+                            .into_iter()+                            .filter(|(_, t)| (-1e-9..=1.0 + 1e-9).contains(t))+                            .map(|(q, _)| q)+                            .collect(),+                        Prim::Infinite { base, dir, ray } => cadcraft_geom::line_line_infinite(a, b, *base, *base + *dir)+                            .filter(|(_, t, u)| (-1e-9..=1.0 + 1e-9).contains(t) && (!ray || *u >= -1e-9))+                            .map(|(q, _, _)| vec![q])+                            .unwrap_or_default(),+                        _ => Vec::new(),+                    };+                    for q in hits {+                        if q.is_finite() && !points.iter().any(|o| o.dist(q) < 1e-9) {+                            points.push(q);+                            if points.len() >= MAX_AUTO_BREAKS {+                                break 'objects;+                            }+                        }+                    }+                }+            }+        }+    }+    points.into_iter().map(|p| DimBreak { at: Vec3::new(p.x, p.y, 0.0), size: auto.size, auto: true }).collect()+}++/// Refresh only relationships whose dimension, style or source geometry changed. In+/// particular, unrelated edits do not rebuild every dimension's rendered geometry.+pub(crate) fn update(after: &mut Arc<Drawing>, before: &Drawing) {+    let d: &Drawing = after;+    let styles_changed = before.dim_styles != d.dim_styles+        || before.text_styles != d.text_styles+        || before.header.f64("DIMSCALE", 1.0) != d.header.f64("DIMSCALE", 1.0);+    let stores = std::iter::once((Space::Model, &d.model)).chain(d.layouts.iter().map(|l| (Space::Paper(l.name.clone()), &l.entities)));+    let mut changes: Vec<(Handle, Dimension)> = Vec::new();+    for (space, store) in stores {+        let old_store = before.space(&space);+        if !styles_changed && old_store.is_some_and(|old| old.same_as(store)) {+            continue;+        }+        for e in store.iter() {+            let EntityKind::Dimension(dm) = &e.kind else { continue };+            let Some(auto) = &dm.marks.auto_break else { continue };+            let touched = styles_changed+                || old_store.and_then(|s| s.get(e.handle)).is_none_or(|old| !Arc::ptr_eq(old, e) && old.kind != e.kind)+                || auto.objects.iter().take(MAX_DIM_BREAK_OBJECTS).any(|h| match (old_store.and_then(|s| s.get(*h)), store.get(*h)) {+                    (Some(a), Some(b)) => !Arc::ptr_eq(a, b) && a.kind != b.kind,+                    (None, None) => false,+                    _ => true,+                });+            if !touched {+                continue;+            }+            let mut next = dm.clone();+            next.marks.breaks.retain(|b| !b.auto);+            next.marks.breaks.extend(automatic(d, &space, dm, auto));+            if next.marks.breaks != dm.marks.breaks {+                next.block = None;+                changes.push((e.handle, next));+            }+        }+    }+    if !changes.is_empty() {+        let d = Arc::make_mut(after);+        for (h, next) in changes {+            let _ = d.modify_entity(h, |e| e.kind = EntityKind::Dimension(next));+        }+    }+}diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -17,6 +17,7 @@ pub mod blockedit; #[cfg(not(target_arch = "wasm32"))] pub mod capped_read; pub mod cmd;+mod dimbreak; pub mod doc_path; mod finite; pub mod grip_dims;diff --git a/crates/engine/src/mtext_edit.rs b/crates/engine/src/mtext_edit.rs--- a/crates/engine/src/mtext_edit.rs+++ b/crates/engine/src/mtext_edit.rs@@ -135,13 +135,11 @@ impl Formatted {                     let code = chars.get(i + 1).copied();                     i += 2;                     match code {-                        None => {}-                        Some('P') => {-                            if f.paras.len() < MAX_PARAS {-                                f.push_char('\n', &cur);-                                f.paras.push(props.clone());-                            }+                        Some('P') if f.paras.len() < MAX_PARAS => {+                            f.push_char('\n', &cur);+                            f.paras.push(props.clone());                         }+                        None | Some('P') => {}                         Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),                         Some('L') => cur.underline = true,                         Some('l') => cur.underline = false,diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs--- a/crates/engine/src/snap.rs+++ b/crates/engine/src/snap.rs@@ -254,7 +254,7 @@ pub(crate) fn osnap_with_neighborhood(         let model_cursor = view.to_model(cursor);         let model_aperture = aperture / view.scale;         let model_base = base.map(|p| view.to_model(p));-        if !model_cursor.is_finite() || !(model_aperture.is_finite() && model_aperture > 0.0) || model_base.is_some_and(|p| !p.is_finite()) {+        if !(model_cursor.is_finite() && model_aperture.is_finite() && model_aperture > 0.0) || model_base.is_some_and(|p| !p.is_finite()) {             continue;         }         let hit = osnap_in(diff --git a/crates/engine/tests/it/dimbreak_assoc.rs b/crates/engine/tests/it/dimbreak_assoc.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/dimbreak_assoc.rs@@ -0,0 +1,382 @@+//! DIMBREAK Auto tracks objects, including edits with no remaining intersection. Gaps are+//! updated by the same command and undo step as the objects, through JSON and prompts.++use std::sync::Arc;++use cadcraft_engine::doc::{AutoDimBreak, DimBreak, Dimension, EntityKind, Handle, Space};+use cadcraft_engine::geom::{Vec2, Vec3};+use cadcraft_engine::render::LineRole;+use cadcraft_engine::{Input, Session};+use serde_json::json;++fn linear(s: &mut Session) -> Handle {+    let r = s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 3] })).unwrap();+    Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()+}++fn line(s: &mut Session, x: f64) -> Handle {+    s.execute("line", &json!({ "points": [[x, -1], [x, 6]] })).unwrap();+    s.doc().unwrap().space(&s.space()).unwrap().last().unwrap().handle+}++fn dim(s: &Session, h: Handle) -> &Dimension {+    match &s.doc().unwrap().entity(h).unwrap().kind {+        EntityKind::Dimension(dm) => dm,+        k => panic!("expected dimension, got {k:?}"),+    }+}++fn move_by(s: &mut Session, h: Handle, dx: f64, dy: f64) {+    s.execute("move", &json!({ "handles": [h.hex()], "delta": [dx, dy] })).unwrap();+}++fn auto(s: &mut Session, h: Handle, source: Handle) {+    s.execute("dimbreak", &json!({ "handles": [h.hex()], "objects": [source.hex()], "size": 0.5 })).unwrap();+}++fn assert_gap(s: &Session, h: Handle, x: f64, y: f64) {+    let dm = dim(s, h);+    let gaps: Vec<_> = dm.marks.breaks.iter().filter(|b| b.auto).collect();+    assert_eq!(gaps.len(), 1, "{:?}", dm.marks);+    assert!(gaps[0].at.xy().dist(Vec2::new(x, y)) < 1e-9, "{:?}", gaps[0]);+    let geometry = cadcraft_engine::render::dimension_in(s.doc().unwrap(), dm);+    assert!(!geometry.lines_of(LineRole::Dim).any(|l| {+        l.windows(2).any(|w| (w[0].y - y).abs() < 1e-9 && (w[1].y - y).abs() < 1e-9 && w[0].x.min(w[1].x) < x && x < w[0].x.max(w[1].x))+    }));+}++#[test]+fn source_moves_and_erasure_share_the_commands_undo_steps() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    assert_gap(&s, h, 4.0, 3.0);+    move_by(&mut s, source, 2.0, 0.0);+    assert_gap(&s, h, 6.0, 3.0);+    s.execute("undo", &json!({})).unwrap();+    assert_gap(&s, h, 4.0, 3.0);+    s.execute("redo", &json!({})).unwrap();+    assert_gap(&s, h, 6.0, 3.0);+    s.execute("erase", &json!({ "handles": [source.hex()] })).unwrap();+    assert!(dim(&s, h).marks.breaks.is_empty());+    s.execute("undo", &json!({})).unwrap();+    assert_gap(&s, h, 6.0, 3.0);+    s.execute("redo", &json!({})).unwrap();+    assert!(dim(&s, h).marks.breaks.is_empty());+}++#[test]+fn missing_crossings_return_and_manual_gaps_stay_put() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    s.execute("dimbreak", &json!({ "handles": [h.hex()], "mode": "manual", "first": [7, 3], "second": [8, 3] })).unwrap();+    move_by(&mut s, source, 20.0, 0.0);+    let dm = dim(&s, h);+    assert_eq!(dm.marks.breaks.len(), 1);+    assert!(!dm.marks.breaks[0].auto);+    assert_eq!(dm.marks.breaks[0].at, Vec3::new(7.5, 3.0, 0.0));+    move_by(&mut s, source, -18.0, 0.0);+    assert_gap(&s, h, 6.0, 3.0);+    assert_eq!(dim(&s, h).marks.breaks.len(), 2);+    // Remove also removes the relationship, so later moves cannot recreate gaps.+    s.execute("dimbreak", &json!({ "handles": [h.hex()], "mode": "remove" })).unwrap();+    assert!(dim(&s, h).marks.auto_break.is_none());+    move_by(&mut s, source, -2.0, 0.0);+    assert!(dim(&s, h).marks.breaks.is_empty());+}++#[test]+fn dimension_edits_and_measured_object_associativity_refresh_breaks() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    move_by(&mut s, h, 0.0, 1.0);+    assert_gap(&s, h, 4.0, 4.0);+    move_by(&mut s, h, 20.0, 0.0);+    assert!(dim(&s, h).marks.breaks.is_empty());+    move_by(&mut s, h, -20.0, 0.0);+    assert_gap(&s, h, 4.0, 4.0);++    let mut s = Session::new();+    s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();+    let measured = s.doc().unwrap().model.last().unwrap().handle;+    let r = s.execute("dimlinear", &json!({ "object": measured.hex(), "at": [5, 3] })).unwrap();+    let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    move_by(&mut s, measured, 0.0, 1.0);+    assert_gap(&s, h, 4.0, 4.0);+}++#[test]+fn prompt_edits_follow_only_the_selected_source() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    let other = line(&mut s, 8.0);+    s.start("dimbreak").unwrap();+    s.input(Input::Pick(vec![h])).unwrap();+    s.input(Input::Pick(vec![source])).unwrap();+    s.input(Input::Enter).unwrap();+    assert!(s.running.is_none());+    assert_gap(&s, h, 4.0, 3.0);+    s.start("move").unwrap();+    s.input(Input::Pick(vec![source])).unwrap();+    s.input(Input::Enter).unwrap();+    s.input(Input::Point(Vec2::ZERO)).unwrap();+    s.input(Input::Point(Vec2::new(2.0, 0.0))).unwrap();+    assert!(s.running.is_none());+    assert_gap(&s, h, 6.0, 3.0);+    let before = s.doc().unwrap().entity(h).unwrap().clone();+    move_by(&mut s, other, -1.0, 0.0);+    assert!(Arc::ptr_eq(&before, s.doc().unwrap().entity(h).unwrap()), "unrelated edits leave the dimension alone");+    s.execute("undo", &json!({})).unwrap();+    s.execute("undo", &json!({})).unwrap();+    assert_gap(&s, h, 4.0, 3.0);+}++#[test]+fn relationships_survive_dxf_even_without_a_current_gap() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 24.0);+    auto(&mut s, h, source);+    assert!(dim(&s, h).marks.breaks.is_empty());+    let text = cadcraft_io::write_dxf(s.doc().unwrap());+    let d = cadcraft_io::read_dxf(text.as_bytes()).unwrap();+    s.state_mut().unwrap().doc = Arc::new(d);+    assert_eq!(dim(&s, h).marks.auto_break, Some(AutoDimBreak { objects: vec![source], size: 0.5 }));+    move_by(&mut s, source, -20.0, 0.0);+    assert_gap(&s, h, 4.0, 3.0);+    let text = cadcraft_io::write_dxf(s.doc().unwrap());+    s.state_mut().unwrap().doc = Arc::new(cadcraft_io::read_dxf(text.as_bytes()).unwrap());+    move_by(&mut s, source, 2.0, 0.0);+    assert_gap(&s, h, 6.0, 3.0);+    assert!(dim(&s, h).block.is_none(), "the saved dimension block must be regenerated");+}++#[cfg(not(target_arch = "wasm32"))]+#[test]+fn relationships_survive_dwg_and_continue_following() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    let bytes = cadcraft_io::write(s.doc().unwrap(), "breaks.dwg").unwrap();+    s.state_mut().unwrap().doc = Arc::new(cadcraft_io::read(&bytes, "breaks.dwg").unwrap());+    assert_eq!(dim(&s, h).marks.auto_break, Some(AutoDimBreak { objects: vec![source], size: 0.5 }));+    move_by(&mut s, source, 2.0, 0.0);+    assert_gap(&s, h, 6.0, 3.0);+}++#[test]+fn scaling_and_copying_keep_the_gap_size_and_source_relationships() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    s.execute("scale", &json!({ "handles": [h.hex(), source.hex()], "base": [0, 0], "factor": 2 })).unwrap();+    assert_gap(&s, h, 8.0, 6.0);+    assert_eq!(dim(&s, h).marks.auto_break.as_ref().unwrap().size, 1.0);+    assert_eq!(dim(&s, h).marks.breaks[0].size, 1.0);+    let r = s.execute("copy", &json!({ "handles": [h.hex(), source.hex()], "delta": [30, 0] })).unwrap();+    let copied_dim = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();+    let copied_source = Handle::parse_hex(r["handles"][1].as_str().unwrap()).unwrap();+    assert_eq!(dim(&s, copied_dim).marks.auto_break.as_ref().unwrap().objects, vec![copied_source]);+    assert_gap(&s, copied_dim, 38.0, 6.0);+    move_by(&mut s, copied_source, 1.0, 0.0);+    assert_gap(&s, copied_dim, 39.0, 6.0);+    assert_gap(&s, h, 8.0, 6.0);+}++#[test]+fn pasted_dimensions_cannot_follow_an_unrelated_handle_in_another_drawing() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    s.execute("copyclip", &json!({ "handles": [h.hex()] })).unwrap();+    let mut other = Session::new();+    linear(&mut other);+    let unrelated = line(&mut other, 7.0);+    assert_eq!(unrelated, source, "independent drawings reuse handle numbers");+    let b = s.open_drawing((*other.doc().unwrap()).clone(), "other", None);+    s.active = b;+    let r = s.execute("pasteorig", &json!({})).unwrap();+    let pasted = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();+    assert!(dim(&s, pasted).marks.auto_break.is_none());+    let before = dim(&s, pasted).marks.breaks.clone();+    move_by(&mut s, unrelated, 1.0, 0.0);+    assert_eq!(dim(&s, pasted).marks.breaks, before);+}++#[test]+fn default_auto_follows_existing_objects_and_remove_clears_an_invisible_relationship() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 24.0);+    s.execute("dimbreak", &json!({ "handles": [h.hex()] })).unwrap();+    assert!(dim(&s, h).marks.breaks.is_empty());+    move_by(&mut s, source, -20.0, 0.0);+    assert_gap(&s, h, 4.0, 3.0);+    let unrelated = line(&mut s, 8.0);+    move_by(&mut s, unrelated, -1.0, 0.0);+    assert_gap(&s, h, 4.0, 3.0);+    move_by(&mut s, source, 20.0, 0.0);+    assert!(dim(&s, h).marks.breaks.is_empty());+    assert_eq!(s.execute("dimbreak", &json!({ "handles": [h.hex()], "mode": "remove" })).unwrap()["changed"], 1);+    move_by(&mut s, source, -20.0, 0.0);+    assert!(dim(&s, h).marks.breaks.is_empty());+}++#[test]+fn extension_line_breaks_follow_the_crossing_object() {+    let mut s = Session::new();+    let h = linear(&mut s);+    s.execute("line", &json!({ "points": [[8, 1], [12, 1]] })).unwrap();+    let source = s.doc().unwrap().model.last().unwrap().handle;+    auto(&mut s, h, source);+    assert_eq!(dim(&s, h).marks.breaks[0].at, Vec3::new(10.0, 1.0, 0.0));+    move_by(&mut s, source, 0.0, 1.0);+    assert_eq!(dim(&s, h).marks.breaks[0].at, Vec3::new(10.0, 2.0, 0.0));+    let g = cadcraft_engine::render::dimension_in(s.doc().unwrap(), dim(&s, h));+    assert!(!g.lines_of(LineRole::Ext).any(|l| {+        l.windows(2).any(|w| (w[0].x - 10.0).abs() < 1e-9 && (w[1].x - 10.0).abs() < 1e-9 && w[0].y.min(w[1].y) < 2.0 && 2.0 < w[0].y.max(w[1].y))+    }));+}++#[test]+fn model_and_paper_dimensions_only_follow_objects_in_their_own_space() {+    let mut s = Session::new();+    let model_dim = linear(&mut s);+    let model_line = line(&mut s, 4.0);+    s.state_mut().unwrap().space = Space::Paper("Layout1".into());+    let paper_dim = linear(&mut s);+    let paper_line = line(&mut s, 8.0);+    s.execute("dimbreak", &json!({ "handles": [model_dim.hex(), paper_dim.hex()] })).unwrap();+    assert_gap(&s, model_dim, 4.0, 3.0);+    assert_gap(&s, paper_dim, 8.0, 3.0);+    move_by(&mut s, paper_line, -2.0, 0.0);+    assert_gap(&s, paper_dim, 6.0, 3.0);+    assert_gap(&s, model_dim, 4.0, 3.0);+    s.state_mut().unwrap().space = Space::Model;+    move_by(&mut s, model_line, -1.0, 0.0);+    assert_gap(&s, model_dim, 3.0, 3.0);+    assert_gap(&s, paper_dim, 6.0, 3.0);+}++#[test]+fn too_many_sources_are_refused_without_changing_the_existing_breaks() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    let d = s.doc_mut().unwrap();+    let template = (**d.entity(source).unwrap()).clone();+    for _ in 0..cadcraft_engine::doc::MAX_DIM_BREAK_OBJECTS {+        let mut e = template.clone();+        e.handle = d.new_handle();+        d.model.push(e);+    }+    let before = s.state().unwrap().doc.clone();+    let err = s.execute("dimbreak", &json!({ "handles": [h.hex()] })).unwrap_err();+    assert!(err.to_string().contains("source objects"), "{err}");+    assert!(Arc::ptr_eq(&before, &s.state().unwrap().doc));+    assert_gap(&s, h, 4.0, 3.0);+    s.execute("dimbreak", &json!({ "handles": [h.hex()], "objects": [source.hex()] })).unwrap();+    assert_gap(&s, h, 4.0, 3.0);+}++#[test]+fn sources_in_other_spaces_and_legacy_gaps_do_not_get_followed() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    let d = s.doc_mut().unwrap();+    let e = d.model.remove(source).unwrap();+    d.layouts.first_mut().unwrap().entities.push((*e).clone());+    // A change to the dimension forces refresh after the source left its space.+    move_by(&mut s, h, 0.0, 1.0);+    assert!(dim(&s, h).marks.breaks.is_empty());+    assert!(s.doc().unwrap().space(&Space::Model).unwrap().get(source).is_none());+    s.doc_mut()+        .unwrap()+        .modify_entity(h, |e| {+            if let EntityKind::Dimension(dm) = &mut e.kind {+                dm.marks.auto_break = None;+                dm.marks.breaks = vec![DimBreak { at: Vec3::new(4.0, 4.0, 0.0), size: 0.5, auto: true }];+            }+        })+        .unwrap();+    // Old files have auto-marked points but no relationship. Keep those points as before.+    s.execute("setvar", &json!({ "name": "DIMSCALE", "value": 2 })).unwrap();+    assert_eq!(dim(&s, h).marks.breaks.len(), 1);+}++#[test]+fn array_and_explode_remap_auto_break_handles() {+    let mut s = Session::new();+    let h = linear(&mut s);+    let source = line(&mut s, 4.0);+    auto(&mut s, h, source);+    assert_gap(&s, h, 4.0, 3.0);++    // Stage 1: Make an associative array: 1 row, 2 cols, colSpacing 20.+    let arr = s.execute("arrayrect", &json!({ "handles": [h.hex(), source.hex()], "rows": 1, "cols": 2, "colSpacing": 20.0 })).unwrap();+    assert_eq!(arr["associative"], true);+    let arr_h = Handle::parse_hex(arr["handle"].as_str().unwrap()).unwrap();++    // Verify stage 1: in the source block, the dimension's auto_break object was remapped+    // to the cloned line handle inside the source block.+    let src_line_handle = {+        let d = s.doc().unwrap();+        let p = d.array_params(d.entity(arr_h).unwrap()).unwrap();+        let src_blk = d.block(&p.source).unwrap();+        let src_line = src_blk.entities.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap();+        let src_dim = src_blk+            .entities+            .iter()+            .find_map(|e| match &e.kind {+                EntityKind::Dimension(dm) => Some(dm),+                _ => None,+            })+            .unwrap();+        assert_ne!(src_line.handle, source);+        assert_eq!(src_dim.marks.auto_break.as_ref().unwrap().objects, vec![src_line.handle]);+        src_line.handle+    };++    // Stage 2: Explode the array into top-level entities.+    let exp = s.execute("explode", &json!({ "handles": [arr_h.hex()] })).unwrap();+    let new_hs: Vec<Handle> = exp["handles"].as_array().unwrap().iter().map(|v| Handle::parse_hex(v.as_str().unwrap()).unwrap()).collect();+    assert_eq!(new_hs.len(), 4); // 2 items * 2 entities++    // Filter into dimensions and lines in model space.+    let (dim_hs, line_hs) = {+        let d = s.doc().unwrap();+        let dims: Vec<Handle> = new_hs.iter().copied().filter(|h| matches!(&d.entity(*h).unwrap().kind, EntityKind::Dimension(_))).collect();+        let lines: Vec<Handle> = new_hs.iter().copied().filter(|h| matches!(&d.entity(*h).unwrap().kind, EntityKind::Line(_))).collect();+        (dims, lines)+    };+    assert_eq!(dim_hs.len(), 2);+    assert_eq!(line_hs.len(), 2);++    for (dim_h, line_h) in dim_hs.iter().zip(line_hs.iter()) {+        let dm = dim(&s, *dim_h);+        assert_ne!(dm.marks.auto_break.as_ref().unwrap().objects, vec![source]);+        assert_ne!(dm.marks.auto_break.as_ref().unwrap().objects, vec![src_line_handle]);+        assert_eq!(dm.marks.auto_break.as_ref().unwrap().objects, vec![*line_h]);+    }++    // Both exploded dimensions have their gaps active.+    let gap0_x = dim(&s, dim_hs[0]).marks.breaks[0].at.x;+    let gap1_x = dim(&s, dim_hs[1]).marks.breaks[0].at.x;+    assert!((gap0_x - 4.0).abs() < 1e-9 || (gap0_x - 24.0).abs() < 1e-9);+    assert!((gap1_x - 4.0).abs() < 1e-9 || (gap1_x - 24.0).abs() < 1e-9);+    assert!((gap0_x - gap1_x).abs() > 10.0);+}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@@ -31,6 +31,7 @@ mod dim_properties; mod dim_radial_inside; mod dim_radial_outside; mod dim_vars;+mod dimbreak_assoc; mod dimtedit_prompts; mod drafting_spacing; mod draw_prompts;diff --git a/crates/geom/src/nurbs.rs b/crates/geom/src/nurbs.rs--- a/crates/geom/src/nurbs.rs+++ b/crates/geom/src/nurbs.rs@@ -487,7 +487,7 @@ impl Spline {      /// Arc length between parameters `a` and `b` (adaptive Gauss–Legendre over each knot span).     pub fn length_between(&self, a: f64, b: f64) -> f64 {-        if !self.is_valid() || !(a.is_finite() && b.is_finite()) {+        if !(self.is_valid() && a.is_finite() && b.is_finite()) {             return 0.0;         }         let (lo, hi) = self.domain();diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs--- a/crates/io/src/dxf_ext.rs+++ b/crates/io/src/dxf_ext.rs@@ -41,6 +41,8 @@ pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000;  /// Caps for hostile input. pub(crate) const MAX_XDATA_ITEMS: usize = 4096;+/// Maximum encoded byte size for an extended data record (AutoCAD limits xdata to 16 KB).+pub(crate) const MAX_XDATA_BYTES: usize = 16_384; pub(crate) const MAX_PAYLOAD: usize = 64 << 20; pub(crate) const MAX_CONSTRAINTS: usize = 1_000_000; pub(crate) const MAX_LAYER_STATES: usize = 100_000;@@ -474,11 +476,27 @@ pub(crate) fn arclen_xdata(center: cadcraft_geom::Vec3) -> Vec<Tag> {     vec![Tag::s(1000, "ARCLEN"), Tag::s(1002, "{"), Tag::f(1010, center.x), Tag::f(1020, center.y), Tag::f(1030, center.z), Tag::s(1002, "}")] } +/// Encoded byte length of an extended data tag in AutoCAD binary xdata (2-byte group code+/// followed by the type-specific binary value).+pub(crate) fn encoded_len(t: &Tag) -> usize {+    use cadcraft_dxf::{Kind, kind_of};+    match kind_of(t.code) {+        Kind::Str => 2 + t.str().len() + 1,+        Kind::Real => 2 + 8,+        Kind::Int16 => 2 + 2,+        Kind::Int32 => 2 + 4,+        Kind::Int64 => 2 + 8,+        Kind::Bool => 2 + 1,+        Kind::Hex => 2 + 1 + (t.str().len() / 2),+    }+}+ /// Dimension marks as CADCraft xdata (`1000 MARKS`, `1002 {`, items, `1002 }`): `1000 JOGLINE` /// and a point; `1000 JOGGED`, the centre and the jog point; `1000 BREAK`, a point, `1040` size /// and `1070` auto; `1000 INSPECT`, `1070` shape, `1000` label and `1000` rate. Points are-/// `1010/1020/1030`. The oblique angle is the standard group 52 instead.-pub(crate) fn marks_xdata(m: &cadcraft_doc::DimMarks) -> Vec<Tag> {+/// `1010/1020/1030`. A separate `BREAKAUTO` list holds `1040` gap size and `1005` source+/// handles, including when there are no gaps. The oblique angle is the standard group 52 instead.+pub(crate) fn marks_xdata(m: &cadcraft_doc::DimMarks, reserved: usize) -> Vec<Tag> {     let pt = |v: &mut Vec<Tag>, p: cadcraft_geom::Vec3| v.extend([Tag::f(1010, p.x), Tag::f(1020, p.y), Tag::f(1030, p.z)]);     let mut v = Vec::new();     if let Some(j) = m.jog_line {@@ -498,12 +516,45 @@ pub(crate) fn marks_xdata(m: &cadcraft_doc::DimMarks) -> Vec<Tag> {     if let Some(i) = &m.inspect {         v.extend([Tag::s(1000, "INSPECT"), Tag::i(1070, i64::from(i.shape)), Tag::s(1000, i.label.clone()), Tag::s(1000, i.rate.clone())]);     }-    if v.is_empty() {-        return v;++    // Encoded byte size of other xdata on the entity: any records already in the CADCRAFT block+    // (such as arc-length and associativity), the application header (1001 CADCRAFT),+    // and the MARKS record (1000 MARKS, 1002 {, tags in v, 1002 }) if non-empty.+    let other_len = reserved+        + encoded_len(&Tag::s(1001, APP))+        + if v.is_empty() {+            0+        } else {+            encoded_len(&Tag::s(1000, "MARKS"))+                + encoded_len(&Tag::s(1002, "{"))+                + v.iter().map(encoded_len).sum::<usize>()+                + encoded_len(&Tag::s(1002, "}"))+        };++    // Put the relationship first: an inspection label may itself be "BREAKAUTO".+    let mut out = Vec::new();+    if let Some(a) = &m.auto_break+        && a.objects.len() <= cadcraft_doc::MAX_DIM_BREAK_OBJECTS+    {+        let hex_handles: Vec<String> = a.objects.iter().map(|h| h.hex()).collect();+        let breakauto_fixed_len = encoded_len(&Tag::s(1000, "BREAKAUTO"))+            + encoded_len(&Tag::s(1002, "{"))+            + encoded_len(&Tag::f(1040, a.size))+            + encoded_len(&Tag::s(1002, "}"));+        let breakauto_handles_len: usize = hex_handles.iter().map(|h| encoded_len(&Tag::s(1005, h))).sum();+        let breakauto_len = breakauto_fixed_len + breakauto_handles_len;++        if breakauto_len + other_len <= MAX_XDATA_BYTES {+            out.extend([Tag::s(1000, "BREAKAUTO"), Tag::s(1002, "{"), Tag::f(1040, a.size)]);+            out.extend(hex_handles.into_iter().map(|h| Tag::s(1005, h)));+            out.push(Tag::s(1002, "}"));+        }+    }+    if !v.is_empty() {+        out.extend([Tag::s(1000, "MARKS"), Tag::s(1002, "{")]);+        out.extend(v);+        out.push(Tag::s(1002, "}"));     }-    let mut out = vec![Tag::s(1000, "MARKS"), Tag::s(1002, "{")];-    out.extend(v);-    out.push(Tag::s(1002, "}"));     out } @@ -543,6 +594,17 @@ pub(crate) fn read_marks(tags: &[Tag]) -> cadcraft_doc::DimMarks {             _ => {}         }     }+    let auto = xdata_list(xdata(tags, APP), "BREAKAUTO");+    if let Some(size) = auto.first().filter(|t| t.code == 1040).map(Tag::f64).filter(|x| x.is_finite() && *x > 0.0) {+        let objects = auto+            .iter()+            .skip(1)+            .take(cadcraft_doc::MAX_DIM_BREAK_OBJECTS)+            .filter(|t| t.code == 1005)+            .filter_map(|t| Handle::parse_hex(&t.str()).filter(|h| h.0 != 0))+            .collect();+        m.auto_break = Some(cadcraft_doc::AutoDimBreak { objects, size });+    }     m } diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs--- a/crates/io/src/dxf_raw.rs+++ b/crates/io/src/dxf_raw.rs@@ -135,10 +135,8 @@ fn head(tags: &[Tag]) -> Head {                 h.has_reactors |= group.as_deref() == Some("ACAD_REACTORS");             }             (_, Some("ACAD_REACTORS")) => h.reactors.push(norm(&t.str())),-            (_, Some("ACAD_XDICTIONARY")) => {-                if h.xdict.is_none() {-                    h.xdict = Some(norm(&t.str()));-                }+            (_, Some("ACAD_XDICTIONARY")) if h.xdict.is_none() => {+                h.xdict = Some(norm(&t.str()));             }             (_, Some(_)) => {}             (5 | 105, None) if h.handle.is_none() => h.handle = Some(norm(&t.str())),diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs--- a/crates/io/src/dxf_read.rs+++ b/crates/io/src/dxf_read.rs@@ -1187,42 +1187,40 @@ fn parse_entities(                 };                 kindent = Some((c, k));             }-            "INSERT" => {-                if t.i(66) == Some(1) {-                    let mut attribs = Vec::new();-                    let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default();-                    while let Some((k2, t2)) = recs.get(i) {-                        if k2 == "ATTRIB" {-                            let tt = T(t2);-                            // A multiline attribute has its text as embedded MTEXT after its own groups.-                            let (own, embedded) = split_embedded(t2);-                            let multiline = multiline_attrib(embedded, &common(&tt));-                            let own = T(own);-                            let mut text = text_from(&own);-                            if let Some(m) = &multiline {-                                text.value.clone_from(&m.read_as);-                            }-                            attribs.push(Attrib {-                                tag: own.s(2).unwrap_or_default(),-                                text,-                                invisible: own.i(70).unwrap_or(0) & 1 != 0,-                                constant: false,-                                prompt: String::new(),-                                props: attrib_props(&tt, &ins_layer),-                                multiline,-                                modes: attrib_modes(own.0),-                            });+            "INSERT" if t.i(66) == Some(1) => {+                let mut attribs = Vec::new();+                let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default();+                while let Some((k2, t2)) = recs.get(i) {+                    if k2 == "ATTRIB" {+                        let tt = T(t2);+                        // A multiline attribute has its text as embedded MTEXT after its own groups.+                        let (own, embedded) = split_embedded(t2);+                        let multiline = multiline_attrib(embedded, &common(&tt));+                        let own = T(own);+                        let mut text = text_from(&own);+                        if let Some(m) = &multiline {+                            text.value.clone_from(&m.read_as);+                        }+                        attribs.push(Attrib {+                            tag: own.s(2).unwrap_or_default(),+                            text,+                            invisible: own.i(70).unwrap_or(0) & 1 != 0,+                            constant: false,+                            prompt: String::new(),+                            props: attrib_props(&tt, &ins_layer),+                            multiline,+                            modes: attrib_modes(own.0),+                        });+                        i += 1;+                    } else {+                        if k2 == "SEQEND" {                             i += 1;-                        } else {-                            if k2 == "SEQEND" {-                                i += 1;-                            }-                            break;                         }+                        break;                     }-                    if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() {-                        ins.attribs = attribs;-                    }+                }+                if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() {+                    ins.attribs = attribs;                 }             }             // A VERTEX or ATTRIB with no POLYLINE or INSERT before it; a stray SEQEND holds nothing.diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs--- a/crates/io/src/dxf_write.rs+++ b/crates/io/src/dxf_write.rs@@ -233,7 +233,8 @@ fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {     if !dm.assoc.is_empty() {         ours.extend(dxf_ext::assoc_xdata(&dm.assoc));     }-    ours.extend(dxf_ext::marks_xdata(&dm.marks));+    let reserved: usize = ours.iter().map(dxf_ext::encoded_len).sum();+    ours.extend(dxf_ext::marks_xdata(&dm.marks, reserved));     if !ours.is_empty() {         w.s(1001, dxf_ext::APP);         w.xdata(ours);diff --git a/crates/io/tests/it/dim_marks_roundtrip.rs b/crates/io/tests/it/dim_marks_roundtrip.rs--- a/crates/io/tests/it/dim_marks_roundtrip.rs+++ b/crates/io/tests/it/dim_marks_roundtrip.rs@@ -2,7 +2,9 @@ //! DXF Reference), jogs, breaks and inspection frames as CADCraft xdata. Damaged xdata is //! skipped. -use cadcraft_doc::{Common, DimBreak, DimKind, DimMarks, Dimension, Drawing, EntityKind, Inspection, JoggedRadius, Space};+use cadcraft_doc::{+    AssocSnap, AutoDimBreak, Common, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Drawing, EntityKind, Handle, Inspection, JoggedRadius, Space,+}; use cadcraft_geom::Vec3; use cadcraft_io::{read_dxf, write_dxf}; @@ -47,7 +49,8 @@ fn dimension_marks_roundtrip() {             DimBreak { at: Vec3::new(4.0, 3.0, 0.0), size: 0.125, auto: true },             DimBreak { at: Vec3::new(7.0, 3.0, 0.0), size: 0.5, auto: false },         ],-        inspect: Some(Inspection { shape: 1, label: "QC".into(), rate: "25%".into() }),+        auto_break: Some(AutoDimBreak { objects: vec![Handle(0xABC), Handle(0xDEF)], size: 0.125 }),+        inspect: Some(Inspection { shape: 1, label: "BREAKAUTO".into(), rate: "25%".into() }),         ..Default::default()     };     let aligned = DimMarks { oblique: Some(100f64.to_radians()), ..Default::default() };@@ -92,3 +95,96 @@ fn damaged_marks_are_skipped() {     let got = marks_of(&back);     assert_eq!(got.first(), Some(&DimMarks::default()), "{got:?}"); }++#[test]+fn automatic_break_relationship_without_gaps_roundtrips_and_bad_sizes_are_skipped() {+    let mut d = Drawing::new_imperial();+    let auto = AutoDimBreak { objects: vec![Handle(0xABC)], size: 0.5 };+    let m = DimMarks { auto_break: Some(auto.clone()), ..Default::default() };+    d.add(&Space::Model, Common::default(), dim(DimKind::Linear { rotation: 0.0 }, m)).unwrap();+    let text = write_dxf(&d);+    let back = read_dxf(text.as_bytes()).unwrap();+    assert_eq!(marks_of(&back)[0].auto_break, Some(auto));+    for bad in ["nan", "inf", "0", "-1"] {+        let damaged = text.replacen("BREAKAUTO\r\n1002\r\n{\r\n1040\r\n0.5\r\n", &format!("BREAKAUTO\r\n1002\r\n{{\r\n1040\r\n{bad}\r\n"), 1);+        assert_ne!(damaged, text);+        let back = read_dxf(damaged.as_bytes()).unwrap();+        assert!(marks_of(&back)[0].auto_break.is_none(), "{bad}");+    }+}++#[test]+fn automatic_break_source_input_is_bounded_and_bad_handles_are_skipped() {+    let mut d = Drawing::new_imperial();+    let m = DimMarks { auto_break: Some(AutoDimBreak { objects: vec![Handle(0xABC)], size: 0.5 }), ..Default::default() };+    d.add(&Space::Model, Common::default(), dim(DimKind::Linear { rotation: 0.0 }, m)).unwrap();+    let text = write_dxf(&d);+    let damaged = text.replacen("1005\r\nABC\r\n", "1005\r\nnot-a-handle\r\n1005\r\n0\r\n1005\r\nDEF\r\n", 1);+    let back = read_dxf(damaged.as_bytes()).unwrap();+    assert_eq!(marks_of(&back)[0].auto_break.as_ref().unwrap().objects, vec![Handle(0xDEF)]);+    let oversized = text.replacen("1005\r\nABC\r\n", &"1005\r\nABC\r\n".repeat(cadcraft_doc::MAX_DIM_BREAK_OBJECTS + 10), 1);+    let back = read_dxf(oversized.as_bytes()).unwrap();+    assert_eq!(marks_of(&back)[0].auto_break.as_ref().unwrap().objects.len(), cadcraft_doc::MAX_DIM_BREAK_OBJECTS);+}++#[test]+fn automatic_break_xdata_is_omitted_when_oversized_or_exceeding_byte_limit() {+    let mut d = Drawing::new_imperial();+    // Exceeds MAX_DIM_BREAK_OBJECTS: entire BREAKAUTO record must be omitted.+    let too_many_objects: Vec<Handle> = (0..=cadcraft_doc::MAX_DIM_BREAK_OBJECTS).map(|i| Handle(i as u64 + 1)).collect();+    let m = DimMarks { auto_break: Some(AutoDimBreak { objects: too_many_objects, size: 0.5 }), ..Default::default() };+    d.add(&Space::Model, Common::default(), dim(DimKind::Linear { rotation: 0.0 }, m)).unwrap();+    let text = write_dxf(&d);+    assert!(!text.contains("BREAKAUTO"), "oversized object list must omit BREAKAUTO entirely");+    let back = read_dxf(text.as_bytes()).unwrap();+    assert!(marks_of(&back)[0].auto_break.is_none());++    // Exceeds 16 KB xdata limit even when objects.len() <= MAX_DIM_BREAK_OBJECTS:+    // 3000 handles with 5 hex digits each: 30 + 3000 * (3 + 5) = 24,030 bytes > 16,384 bytes.+    let mut d2 = Drawing::new_imperial();+    let large_xdata_objects: Vec<Handle> = (0x10000..0x10BB8).map(Handle).collect();+    assert!(large_xdata_objects.len() <= cadcraft_doc::MAX_DIM_BREAK_OBJECTS);+    let m2 = DimMarks { auto_break: Some(AutoDimBreak { objects: large_xdata_objects, size: 0.5 }), ..Default::default() };+    d2.add(&Space::Model, Common::default(), dim(DimKind::Linear { rotation: 0.0 }, m2)).unwrap();+    let text2 = write_dxf(&d2);+    assert!(!text2.contains("BREAKAUTO"), "xdata exceeding 16 KB limit must omit BREAKAUTO entirely");+    let back2 = read_dxf(text2.as_bytes()).unwrap();+    assert!(marks_of(&back2)[0].auto_break.is_none());++    // BREAKAUTO alone fits, but BREAKAUTO + MARKS + app header exceeds 16 KB:+    // BREAKAUTO is omitted, MARKS is preserved.+    let mut d3 = Drawing::new_imperial();+    let near_limit_objects: Vec<Handle> = (0x10000..0x107F8).map(Handle).collect();+    let breaks = vec![DimBreak { at: Vec3::ZERO, size: 0.5, auto: false }; 5];+    let m3 = DimMarks { auto_break: Some(AutoDimBreak { objects: near_limit_objects, size: 0.5 }), breaks, ..Default::default() };+    d3.add(&Space::Model, Common::default(), dim(DimKind::Linear { rotation: 0.0 }, m3)).unwrap();+    let text3 = write_dxf(&d3);+    assert!(!text3.contains("BREAKAUTO"), "combined xdata exceeding 16 KB must omit BREAKAUTO");+    assert!(text3.contains("MARKS"), "MARKS must be preserved even when BREAKAUTO is omitted");+    let back3 = read_dxf(text3.as_bytes()).unwrap();+    assert!(marks_of(&back3)[0].auto_break.is_none());+    assert_eq!(marks_of(&back3)[0].breaks.len(), 5);++    // BREAKAUTO alone fits, but BREAKAUTO + ASSOC + app header exceeds 16 KB:+    // BREAKAUTO is omitted, ASSOC is preserved.+    let mut d4 = Drawing::new_imperial();+    let near_limit_objects4: Vec<Handle> = (0x10000..0x107F8).map(Handle).collect();+    let m4 = DimMarks { auto_break: Some(AutoDimBreak { objects: near_limit_objects4, size: 0.5 }), ..Default::default() };+    let mut dm4 = dim(DimKind::Linear { rotation: 0.0 }, m4);+    if let EntityKind::Dimension(dm) = &mut dm4 {+        dm.assoc = vec![+            DimAssoc { point: "defpt".into(), handle: Handle(1), snap: AssocSnap::Start },+            DimAssoc { point: "p13".into(), handle: Handle(2), snap: AssocSnap::End },+            DimAssoc { point: "p14".into(), handle: Handle(3), snap: AssocSnap::Mid },+        ];+    }+    d4.add(&Space::Model, Common::default(), dm4).unwrap();+    let text4 = write_dxf(&d4);+    assert!(!text4.contains("BREAKAUTO"), "assoc xdata consuming budget must omit BREAKAUTO");+    assert!(text4.contains("ASSOC"), "ASSOC must be preserved even when BREAKAUTO is omitted");+    let back4 = read_dxf(text4.as_bytes()).unwrap();+    assert!(marks_of(&back4)[0].auto_break.is_none());+    if let EntityKind::Dimension(dm) = &back4.model.iter().next().unwrap().kind {+        assert_eq!(dm.assoc.len(), 3);+    }+}diff --git a/crates/render/src/clip.rs b/crates/render/src/clip.rs--- a/crates/render/src/clip.rs+++ b/crates/render/src/clip.rs@@ -209,7 +209,7 @@ pub fn boundary_polygon(k: &EntityKind) -> Option<Vec<Vec2>> {         EntityKind::Ellipse(e) => {             let g = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };             let r = g.major.len();-            if !g.is_full() || !(r.is_finite() && r > 0.0 && e.ratio.is_finite() && e.ratio > 0.0) {+            if !(g.is_full() && r.is_finite() && r > 0.0 && e.ratio.is_finite() && e.ratio > 0.0) {                 return None;             }             g.tessellate(r * e.ratio.min(1.0) * 2e-3, &mut pts);diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs--- a/crates/render/src/text.rs+++ b/crates/render/src/text.rs@@ -77,7 +77,7 @@ pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc<Mesh> {     let contours = ttf::glyph_contours(&g.face, g.gid);     let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add);     // Font data is untrusted; don't hand unbounded or invalid paths to the tessellator.-    if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) {+    if points.is_none_or(|n| n > MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) {         return Arc::new(Mesh { contours: Arc::default(), triangles: Vec::new() });     }     let contours = match detail {diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs--- a/crates/ui-egui/src/cmdline.rs+++ b/crates/ui-egui/src/cmdline.rs@@ -322,13 +322,11 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {                         app.cmd.buffer.pop();                     }                 }-                Key::ArrowUp if !modifiers.any() => {-                    if !app.cmd.history.is_empty() {-                        let n = app.cmd.history.len();-                        let pos = app.cmd.history_pos.map(|p| p.saturating_sub(1)).unwrap_or(n - 1);-                        app.cmd.history_pos = Some(pos);-                        app.cmd.buffer = app.cmd.history.get(pos).cloned().unwrap_or_default();-                    }+                Key::ArrowUp if !modifiers.any() && !app.cmd.history.is_empty() => {+                    let n = app.cmd.history.len();+                    let pos = app.cmd.history_pos.map(|p| p.saturating_sub(1)).unwrap_or(n - 1);+                    app.cmd.history_pos = Some(pos);+                    app.cmd.buffer = app.cmd.history.get(pos).cloned().unwrap_or_default();                 }                 Key::ArrowDown if !modifiers.any() => {                     if !options_key(app, 1)diff --git a/crates/ui-egui/src/grip_dyn.rs b/crates/ui-egui/src/grip_dyn.rs--- a/crates/ui-egui/src/grip_dyn.rs+++ b/crates/ui-egui/src/grip_dyn.rs@@ -193,7 +193,7 @@ pub fn draw(app: &mut CadApp, p: &egui::Painter) -> bool {             p.line_segment([cs + off * 0.2, cs + off * 1.1], Stroke::new(1.0, t.text_faint));             a + off + along * (0.25 + 0.25 * offset_rank.min(2.0))         };-        let live = locked.is_none() && !(active && !app.cmd.buffer.is_empty());+        let live = locked.is_none() && (!active || app.cmd.buffer.is_empty());         field(p, at, &text, &font, active, locked.is_some(), live);     }     truediff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs--- a/crates/ui-egui/src/hatchdlg.rs+++ b/crates/ui-egui/src/hatchdlg.rs@@ -449,7 +449,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {         }         ui.separator();         ui.horizontal(|ui| {-            let ready = !st.edit.is_empty() || !(st.points.is_empty() && st.handles.is_empty());+            let ready = !(st.edit.is_empty() && st.points.is_empty() && st.handles.is_empty());             if ui.add_enabled(ready, egui::Button::new(crate::tl!("OK"))).clicked() {                 action = Action::Ok;             }diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (associative DIMBREAK: F7 narrowed) · **Target:** Autodesk AutoCAD 2027  Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -22,6 +22,10 @@ do them first.  ## Features +DIMBREAK Auto follows the source objects present when Auto is run (or the explicit `objects`+selection). New objects require Auto again. Source links and size use CADCraft xdata in+DXF/DWG; older files with gap positions alone keep those gaps until Auto is run again.+ | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---| | F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 |@@ -29,7 +33,7 @@ do them first. | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 |-| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |+| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; automatic DIMBREAK gaps follow their source objects and dimension edits, survive DXF/DWG, and share the editing command’s undo step; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 |@@ -150,6 +154,7 @@ Detail: [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Automatic DIMBREAK retains source handles and gap size, recomputes after geometry and dimension-associativity updates, preserves manual gaps, and survives DXF/DWG save/reopen; Remove clears the relationship. F7 narrowed; percentages and estimates unchanged. | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |diff --git a/docs/roadmap.md b/docs/roadmap.md--- a/docs/roadmap.md+++ b/docs/roadmap.md@@ -1,6 +1,6 @@ # CADCraft roadmap detail -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks: "Use blocks" evidence updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (associative DIMBREAK annotation progress) · **Target:** Autodesk AutoCAD 2027  Forward-looking plan: milestones with status, the current focus, and what comes next with estimates. The summary is [ROADMAP.md](../ROADMAP.md); the work items behind every line are in@@ -53,7 +53,7 @@ remaining gaps are known bugs and edge features (≈ 75% ready for real work). | 2D geometry exact for ellipses and splines; polyline offset correct; relative tolerances | not started | 50–80 | | Precision input at AutoCAD level (tracking, dynamic input, overrides, point modifiers, grips, selection keywords, context menus) | partial | 90–140 | | Every Draw/Modify prompt option works; associative arrays | partial | 40–60 |-| Annotation depth: DIMSTYLE manager and variables, missing DIM commands, in-place MTEXT, fields, MLEADER editing, annotative scaling, tables | partial | 90–130 |+| Annotation depth: DIMSTYLE manager and variables, missing DIM commands, in-place MTEXT, fields, MLEADER editing, annotative scaling, tables | partial; associative DIMBREAK done | 90–130 | | Xrefs, block editor, dynamic blocks, groups, attribute tools | not started | 90–135 | | Plot styles, plot preview, PUBLISH, non-rectangular viewports | partial | 30–45 | | Autosave, recovery, AUDIT/RECOVER, Options dialog, templates, SHX, user `.lin`/`.pat` | partial | 40–60 |@@ -103,6 +103,7 @@ AutoLISP surface, AI features with openly licensed local models, and every remai  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Annotation: automatic DIMBREAK follows source and dimension edits, including save/reopen and undo/redo; milestone percentages and estimates unchanged | | 2026-10-12 | minor | Block Editor for static blocks landed: "Use blocks" evidence updated; status and hours unchanged (REFEDIT and dynamic blocks remain) | | 2026-10-11 | minor | Alpha-gate evidence refreshed for merged work (#459, #525, #528, #160, #186, #564); statuses and hours unchanged | | 2026-10-10 | minor | Merged main: fmt fixed, Ukrainian catalog noted in M13, ready for real work 38% |diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md--- a/docs/target-app-parity.md+++ b/docs/target-app-parity.md@@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (associative DIMBREAK; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027  The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall,@@ -150,7 +150,7 @@ presence: an option that prints "not available yet" doesn't count. | Modify | 15% | 50% | 40–60 | TRIM/EXTEND, OFFSET, ROTATE, PEDIT and SPLINEDIT options done (#397, #558, #559); associative arrays with ARRAYEDIT (#448), no array grips; MATCHPROP picker and Settings (#439) | [gaps](gaps.md#features) | | Geometry precision | 10% | 40% | 70–110 | exact for lines/arcs/circles; ellipse and spline intersections, offsets, closest points tessellated; absolute tolerances; polyline offset doesn't recompute bulges | [geometry-parity](geometry-parity.md) | | Layers and properties | 7% | 70% | 10–15 | Layer Properties Manager with filters, states, VP overrides; QSELECT; Quick Properties; transparency; linetype manager thin; 19 linetypes of ~45 | [gaps](gaps.md#features) |-| Dimensions | 9% | 45% | 25–35 | all core DIM types and associativity; style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT, QDIM modes (#507) and TOLERANCE (#420); no DIMSTYLE manager dialog or smart DIM | [gaps](gaps.md#features) |+| Dimensions | 9% | 45% | 25–35 | all core DIM types and associativity; style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK (automatic gaps follow edits and survive DXF/DWG), DIMEDIT, DIMINSPECT, QDIM modes (#507) and TOLERANCE (#420); no DIMSTYLE manager dialog or smart DIM | [gaps](gaps.md#features) | | Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); editor is a dialog, not in place; no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) | | Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); tables with cell styles, formulas, breaking and CSV data links (#534); no MLEADEREDIT/ALIGN/COLLECT, no FIELD | [gaps](gaps.md#features) | | Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) |@@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Dimensions: associative DIMBREAK follows edited sources and dimensions, with undo/redo and DXF/DWG persistence; percentages and hours unchanged | | 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured | | 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured | | 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured |diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs--- a/xtask/src/layers.rs+++ b/xtask/src/layers.rs@@ -167,11 +167,10 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {                 match classify(&d.name) {                     // Unregistered deps are reported on their own entry.                     None => {}-                    Some(Class::Testkit) => {-                        if d.kind != DepKind::Dev {-                            out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });-                        }+                    Some(Class::Testkit) if d.kind != DepKind::Dev => {+                        out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });                     }+                    Some(Class::Testkit) => {}                     Some(dc) => {                         let to = dc.layer().unwrap_or(u8::MAX);                         if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {