From ccbc4fa8541ec71655448eddb2f2e283006836fc Mon Sep 17 00:00:00 2001 From: Books Date: Mon, 2 Nov 2026 06:38:31 +1100 Subject: [PATCH] DXF: link dimension sources to their associations (fixes #2221) (#2589) * DXF: link dimension sources to their associations (#2221) Source objects lacked the persistent reactor back to their DIMASSOC. Register validated source and intersection references once through the existing reactor list, keeping a self-link from duplicating the dimension's own reactor. Synthetic regressions cover missing links, repeated references and self-links. * Parity checklist: regenerated for dimension reactor gate * Parity checklist: regenerated after merging main * DXF: a dimension source keeps its other reactors next to the DIMASSOC (review on #2589) The DIMASSOC reactor made the writer emit an ACAD_REACTORS group, so the raw passthrough skipped the source's retained foreign reactors. They are now merged, deduplicated, into that one group. Test: a line reactor-linked to a kept object keeps both links after an associative dimension is added. * DXF: an unmodelled DIMASSOC source gets the DIMASSOC reactor (review on #2589) Unknown entities are written by the raw passthrough, which never saw the planned reactors. The map is now passed to dxf_raw::finish and merged into the entity's reactor group. Test: an unknown entity with a dimension snapped to it names the DIMASSOC. * Docs: leave the file-format parity document to maintainers (review on #2589) * DXF: pass the unknown-entity reactors as one argument (review on #2589) * DXF: collapse the extension dictionary condition (review on #2589) * Test: pin the reactor merge for dimension sources (review on #2589) * DXF: a kept extension dictionary follows the reactors group (review on #2589) When a written record already had its reactors group, the kept extension dictionary was inserted right after the handle, ahead of it; the DXF reference puts the reactors first. The reactor tests now find each group by name, so either order can no longer pass. * docs: leave the generated parity checklist to maintainers Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_raw.rs | 60 +++++-- crates/io/src/dxf_write.rs | 4 +- crates/io/src/dxf_write/sections.rs | 17 +- crates/io/tests/dimassoc_angular_radial.rs | 47 ++++++ crates/io/tests/it/roundtrip_audit.rs | 179 +++++++++++++++++++++ 5 files changed, 288 insertions(+), 19 deletions(-) diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 9b755f80f..16ea48b6c 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -846,7 +846,7 @@ fn unknown_handles(d: &Drawing) -> HashSet { } /// Put the drawing's foreign data back into a written file's tags (see the module comment). -pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { +pub(crate) fn finish(tags: &mut Vec, d: &Drawing, reactors: &HashMap>) { let f = d.foreign.as_ref(); let unknown = unknown_handles(d); let xd: XdataOf = all_entities(d).filter(|e| !e.common.xdata.is_empty()).map(|e| (e.handle, e.common.xdata.as_slice())).collect(); @@ -993,8 +993,10 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { let (Some(zero), Some(ub)) = (t.first(), t.get(1..)) else { continue }; let ur = Rec { sec: sec.clone(), kind: zero.str(), start: 0, end: 0 }; let x = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| entity_extra(f, &xd, h)); + // Reactors the writer planned for this source (a DIMASSOC, an associative hatch). + let extra = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| reactors.get(&h)).map_or(&[][..], Vec::as_slice); out.push(zero.clone()); - record(&map, &ur, ub, x.as_deref(), true, false, &mut out); + record(&map, &ur, ub, x.as_deref(), Some(extra), false, &mut out); } out.extend(tags.get(r.start).cloned()); let x = extra_for(f, &xd, r, b, &block); @@ -1002,7 +1004,7 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { out.extend(b.iter().cloned()); continue; } - record(&map, r, b, x.as_deref(), false, Some(r.start) == root, &mut out); + record(&map, r, b, x.as_deref(), None, Some(r.start) == root, &mut out); } add_appids(&mut out); let top = max_handle(&out).saturating_add(1); @@ -1076,10 +1078,23 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap, unknown: Option<&[String]>) -> Vec { + let extra = unknown.unwrap_or_default(); + let mut have: HashSet = h.reactors.iter().cloned().collect(); + let kept = x.filter(|_| !(unknown.is_some() && h.has_reactors)).into_iter().flat_map(|x| x.reactors.iter()).filter_map(|r| map.get(&r.hex())); + kept.chain(extra.iter().cloned()).filter(|r| r != "0" && have.insert(norm(r))).collect() +} + /// A written record with its kept extras, remapped references (unknown entities) and kept /// dictionary entries (the named object dictionary). -fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool, root: bool, out: &mut Vec) { +fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: Option<&[String]>, root: bool, out: &mut Vec) { let h = head(b); + let adds = reactors_to_add(map, &h, x, unknown); + let mut in_reactors = false; let entity = matches!(r.sec.as_str(), "ENTITIES" | "BLOCKS") && r.kind != "BLOCK"; let xdata_at = b.iter().position(|t| t.code == 1001).unwrap_or(b.len()); let own_apps: HashSet = b.iter().filter(|t| t.code == 1001).map(|t| up(&t.str())).collect(); @@ -1096,6 +1111,12 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool let rgb = b.iter().take(xdata_at).find(|t| t.code == 420).map(Tag::str); let has_book = b.iter().take(xdata_at).any(|t| t.code == 430); let handle_at = b.iter().position(|t| matches!(t.code, 5 | 105)); + // A kept extension dictionary follows the reactors group, as the DXF reference orders them. + let reactors_end = b.iter().position(|t| t.code == 102 && t.str().trim() == "{ACAD_REACTORS").and_then(|s| { + let rest = b.get(s + 1..)?; + rest.iter().position(|t| t.code == 102 && t.str().trim() == "}").map(|e| s + 1 + e) + }); + let xdict_at = if h.has_reactors { reactors_end.or(handle_at) } else { handle_at }; let mut seen_owner = false; for (i, t) in b.iter().enumerate() { if i == props_at @@ -1114,8 +1135,18 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool { xdata(map, x, &own_apps, out); } + if t.code == 102 { + match t.str().trim() { + "{ACAD_REACTORS" => in_reactors = true, + "}" if in_reactors => { + in_reactors = false; + out.extend(adds.iter().map(|a| Tag::s(330, a.clone()))); + } + _ => {} + } + } let mut t = t.clone(); - if unknown && i < xdata_at && is_pointer(t.code) && t.code != 102 { + if unknown.is_some() && i < xdata_at && is_pointer(t.code) && t.code != 102 { if t.code == 330 && !seen_owner { seen_owner = true; } else { @@ -1123,18 +1154,17 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool } } out.push(t); - if Some(i) == handle_at + if Some(i) == handle_at && !h.has_reactors && !adds.is_empty() { + out.push(Tag::s(102, "{ACAD_REACTORS")); + out.extend(adds.iter().map(|a| Tag::s(330, a.clone()))); + out.push(Tag::s(102, "}")); + } + if Some(i) == xdict_at && let Some(x) = x + && h.xdict.is_none() + && let Some(xd) = x.xdict { - if !h.has_reactors { - let rs: Vec = x.reactors.iter().map(|r| r.hex()).collect(); - map.handle_group(out, "ACAD_REACTORS", 330, &rs); - } - if h.xdict.is_none() - && let Some(xd) = x.xdict - { - map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); - } + map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); } } if let Some(x) = x { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index d6aa337aa..b93471b01 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -43,8 +43,8 @@ struct W { pstyles: HashMap, /// Upper-case layer name → its LAYER table record handle (group 331/341 of viewports). layers: HashMap, - /// Persistent reactors of boundary objects: each source object of an associative hatch → - /// the hatches (groups `{ACAD_REACTORS 330 …}`), from [`hatch_reactors`]. + /// Persistent reactors of source geometry: the associative hatches and DIMASSOC objects + /// that follow it (groups `{ACAD_REACTORS 330 …}`). reactors: HashMap>, } diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index e9f8a9e35..b2eff022d 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -874,7 +874,20 @@ fn plan_dimension_associations(d: &Drawing, all_spaces: &[&EntityStore], w: &mut if let EntityKind::Dimension(dm) = &e.kind { let (flags, refs) = std_assoc_refs(d, dm); if !refs.is_empty() { - cx.assoc.insert(e.handle, AssocObj { xdict: w.h(), handle: w.h(), flags, refs }); + let assoc = AssocObj { xdict: w.h(), handle: w.h(), flags, refs }; + // Source geometry must name the DIMASSOC as well, or other readers do not + // follow later edits. Both objects of an intersection need the reactor. + for source in assoc.refs.iter().flat_map(|r| std::iter::once(r.main).chain(r.other)) { + // common_x already gives the dimension its own DIMASSOC reactor. + if source == e.handle { + continue; + } + let reactors = w.reactors.entry(source).or_default(); + if !reactors.contains(&assoc.handle) { + reactors.push(assoc.handle.clone()); + } + } + cx.assoc.insert(e.handle, assoc); } } } @@ -1117,7 +1130,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, frozen_34 if let Some(t) = w.t.get_mut(seed_pos + 1) { *t = Tag::s(5, seed); } - crate::dxf_raw::finish(&mut w.t, d); + crate::dxf_raw::finish(&mut w.t, d, &w.reactors); crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); add_class_instance_counts(&mut w.t, target); mark_xrefs(&mut w.t, d); diff --git a/crates/io/tests/dimassoc_angular_radial.rs b/crates/io/tests/dimassoc_angular_radial.rs index ec1a11f69..f955ad7b5 100644 --- a/crates/io/tests/dimassoc_angular_radial.rs +++ b/crates/io/tests/dimassoc_angular_radial.rs @@ -288,3 +288,50 @@ fn standard_dimassoc_associates_in_dxf_and_dwg() { let back = read(&dwg, "case.dwg").unwrap(); assert_standard_assoc(&c, &back, "DWG"); } + +/// Every source object names each DIMASSOC that follows it once, before its owner group. +#[test] +fn dimension_sources_carry_their_persistent_reactors() { + let c = case(); + let text = write_dxf(&c.d); + let all = records(&text); + for dimension in [c.hd, c.hr, c.ha, c.hl] { + let assoc = dimassoc(&text, dimension).expect("DIMASSOC"); + let reactor = string(&assoc, 5).expect("DIMASSOC handle"); + for reference in point_refs(&assoc) { + // Both objects of an intersection need the link back. Repeated snaps to one + // object must not write the same DIMASSOC reactor twice. + for source in [string(&reference, 331), string(&reference, 332)].into_iter().flatten() { + let geometry = all.iter().find(|r| string(r, 5) == Some(source)).expect("referenced geometry"); + let start = geometry.iter().position(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").expect("source reactor group"); + let end = + start + 1 + geometry.iter().skip(start + 1).position(|(code, value)| *code == 102 && value == "}").expect("reactor group end"); + assert_eq!(geometry[start + 1..end].iter().filter(|(code, value)| *code == 330 && value == reactor).count(), 1); + assert_eq!(start, 2, "reactors immediately follow the entity handle"); + assert_eq!(geometry.get(end + 1).map(|(code, _)| *code), Some(330), "entity owner follows reactors"); + assert_eq!(geometry.iter().filter(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").count(), 1); + } + } + } +} + +/// Malformed self-links must not duplicate the reactor the dimension already carries. +#[test] +fn a_self_referential_dimension_writes_its_reactor_once() { + let mut c = case(); + assert!(c.d.model.modify(c.hl, |e| { + let EntityKind::Dimension(dm) = &mut e.kind else { panic!("dimension") }; + for reference in &mut dm.assoc { + reference.handle = c.hl; + } + })); + let text = write_dxf(&c.d); + let assoc = dimassoc(&text, c.hl).expect("DIMASSOC"); + let reactor = string(&assoc, 5).expect("DIMASSOC handle"); + let all = records(&text); + let handle = c.hl.hex(); + let dimension = all.iter().find(|r| string(r, 5) == Some(&handle)).expect("dimension record"); + let start = dimension.iter().position(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").expect("reactor group"); + let end = start + 1 + dimension.iter().skip(start + 1).position(|(code, value)| *code == 102 && value == "}").expect("reactor group end"); + assert_eq!(dimension[start + 1..end].iter().filter(|(code, value)| *code == 330 && value == reactor).count(), 1); +} diff --git a/crates/io/tests/it/roundtrip_audit.rs b/crates/io/tests/it/roundtrip_audit.rs index 0b15f3181..c2a326935 100644 --- a/crates/io/tests/it/roundtrip_audit.rs +++ b/crates/io/tests/it/roundtrip_audit.rs @@ -1010,3 +1010,182 @@ fn dwg_keeps_extension_dictionaries_xrecords_and_entity_xdata() { assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf")); assert!(d.ucss.iter().any(|u| u.name == "TILTED")); } + +/// A linear dimension whose two extension origins are snapped to `a` and `b`, so the saved file +/// carries a DIMASSOC naming both objects. +fn add_associated_dimension(d: &mut Drawing, a: cadcraft_doc::Handle, b: cadcraft_doc::Handle) { + use cadcraft_doc::{AssocSnap, Common, DimAssoc, DimKind, Dimension, Space}; + use cadcraft_geom::Vec3; + let at = |point: &str, handle| DimAssoc { point: point.into(), handle, snap: AssocSnap::Mid }; + let dm = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(5.0, -3.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: vec![at("p13", a), at("p14", b)], + marks: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).expect("add dimension"); +} + +/// The handle of the one DIMASSOC object of a written file. +fn dimassoc_handle(o: &Out) -> String { + let assocs = o.of("DIMASSOC"); + assert_eq!(assocs.len(), 1, "one DIMASSOC"); + h(assocs.first().copied().unwrap_or_default()) +} + +/// A source object that already has a reactor to a kept foreign object (the fixture's line 2A +/// names ACME_OBJECT 42) keeps it when a DIMASSOC reactor is added: one group with both (review on #2589). +#[test] +fn a_dimension_source_keeps_its_foreign_reactors_next_to_the_dimassoc() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let o = Out::new(&write_dxf(&d)); + let line = o.of("LINE").into_iter().find(|t| h(t).eq_ignore_ascii_case("2A")).expect("line 2A"); + assert_eq!(line.iter().filter(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").count(), 1, "one reactor group"); + let reactors = group(line, "ACAD_REACTORS"); + assert!(reactors.contains(&dimassoc_handle(&o)), "DIMASSOC reactor: {reactors:?}"); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|(k, _)| k == "ACME_OBJECT")), "foreign reactor kept: {reactors:?}"); + assert!(o.dangling().is_empty(), "{:?}", o.dangling()); +} +/// An unmodelled entity a DIMASSOC refers to gets the reactor too, next to what it had (review on #2589). +#[test] +fn an_unknown_dimension_source_carries_the_dimassoc_reactor() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x30), cadcraft_doc::Handle(0x2A)); + let o = Out::new(&write_dxf(&d)); + let widget = entity_by_text(&o, "ACME_WIDGET", "widget-in-model"); + let reactors = group(widget, "ACAD_REACTORS"); + assert!(reactors.contains(&dimassoc_handle(&o)), "DIMASSOC reactor: {reactors:?}"); + assert_eq!(widget.iter().filter(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").count(), 1); + assert_eq!(owner(widget), h(o.named("BLOCK_RECORD", "*Model_Space")), "owner unchanged"); + assert!(o.dangling().is_empty(), "{:?}", o.dangling()); +} + +/// The handles of the `{ACAD_REACTORS` group of the record of handle `at`, after checking the +/// written text holds exactly one such group, placed as the DXF reference orders a record: +/// handle (5), then the reactors, then the owner (330), then the first subclass marker (100) and +/// any xdata (1001). Each handle appears once and names a DIMASSOC when `assoc_only` is set. +fn one_reactor_group(o: &Out, at: &str, assoc_only: bool) -> Vec { + let (_, rec) = o.handle(at).expect("source record"); + let codes: Vec<(i32, String)> = rec.iter().map(|t| (t.code, t.str())).collect(); + let starts: Vec = (0..codes.len()).filter(|&i| codes[i].0 == 102 && codes[i].1 == "{ACAD_REACTORS").collect(); + assert_eq!(starts.len(), 1, "exactly one reactor group in {codes:?}"); + let start = starts[0]; + let end = start + codes[start..].iter().position(|c| c.0 == 102 && c.1 == "}").expect("group end"); + let handles: Vec = codes[start + 1..end].iter().map(|c| c.1.clone()).collect(); + assert!(codes[start + 1..end].iter().all(|c| c.0 == 330), "only 330 groups inside: {codes:?}"); + let unique: std::collections::HashSet = handles.iter().map(|r| r.to_ascii_uppercase()).collect(); + assert_eq!(unique.len(), handles.len(), "each reactor once: {handles:?}"); + let pos = |code: i32| codes.iter().position(|c| c.0 == code); + assert_eq!(pos(5), Some(0), "handle first"); + let owner_at = end + 1 + codes[end + 1..].iter().position(|c| c.0 == 330).expect("owner"); + assert!(owner_at > end, "owner after the reactors: {codes:?}"); + assert!(end < pos(100).expect("subclass marker"), "reactors before the first subclass marker"); + if let Some(x) = pos(1001) { + assert!(end < x, "reactors before the xdata"); + } + if assoc_only { + for r in &handles { + assert_eq!(o.handle(r).map(|(k, _)| k), Some("DIMASSOC"), "{r} resolves to a DIMASSOC"); + } + } + handles +} + +/// A record that already has a reactors group (line 2A names ACME_OBJECT 42) comes out with one +/// group holding the foreign handle and the DIMASSOC, each once, in the same order after a +/// second save and with the extension dictionary group still between it and the owner. +/// Pinning test for the merge (review on #2589). +#[test] +fn a_record_with_a_reactor_group_gets_one_group_with_every_handle_once() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let text = write_dxf(&d); + let o = Out::new(&text); + let reactors = one_reactor_group(&o, "2A", false); + assert_eq!(reactors.len(), 2, "foreign + DIMASSOC: {reactors:?}"); + let assoc = dimassoc_handle(&o); + assert_eq!(reactors.iter().filter(|r| r.eq_ignore_ascii_case(&assoc)).count(), 1); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|(k, _)| k == "ACME_OBJECT"))); + let (_, line) = o.handle("2A").expect("line"); + let order: Vec = line.iter().map(|t| t.code).collect(); + let start = |name: &str| line.iter().position(|t| t.code == 102 && t.str() == format!("{{{name}")); + let (rx, xd) = (start("ACAD_REACTORS"), start("ACAD_XDICTIONARY")); + assert!(rx.is_some() && xd.is_some() && rx < xd, "reactors group before the extension dictionary group: {order:?}"); + assert_eq!(group(line, "ACAD_XDICTIONARY").len(), 1); + // Our reader accepts it, the association survives, and a second save keeps the same group. + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + assert_eq!(again.of("DIMASSOC").len(), 1); + let second = one_reactor_group(&again, "2A", false); + assert_eq!(second.len(), 2, "{second:?}"); + assert!(second.iter().any(|r| again.handle(r).is_some_and(|(k, _)| k == "DIMASSOC"))); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} + +/// A record with no reactors group (line 2A with its group removed, keeping the extension +/// dictionary and the xdata) gets a new one in the preamble, between the handle and the owner, +/// so before the first subclass marker and the xdata. Pinning test (review on #2589). +#[test] +fn a_record_without_a_reactor_group_gets_a_new_one_in_the_preamble() { + let bare = fixture().replacen("5\n2A\n102\n{ACAD_REACTORS\n330\n42\n102\n}\n", "5\n2A\n", 1); + assert_ne!(bare, fixture(), "the fixture line lost its reactors"); + let mut d = read_dxf(bare.as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let text = write_dxf(&d); + let o = Out::new(&text); + let (_, line) = o.handle("2A").expect("line"); + assert!(line.iter().any(|t| t.code == 1001), "the record keeps its xdata"); + let xdict_at = line.iter().position(|t| t.code == 102 && t.str() == "{ACAD_XDICTIONARY").expect("xdict group"); + let reactors = one_reactor_group(&o, "2A", true); + assert_eq!(reactors, vec![dimassoc_handle(&o)]); + // The preamble is the handle, then the reactors group and the extension dictionary group in + // the DXF reference's order, then the owner: nothing else in between. + let reactors_at = line.iter().position(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").expect("reactor group"); + let owner_at = line.iter().position(|t| t.code == 330 && t.str() != reactors[0]).expect("owner"); + assert!(reactors_at < xdict_at && xdict_at < owner_at, "reactors {reactors_at}, xdict {xdict_at}, owner {owner_at}"); + assert!(line.iter().take(owner_at).all(|t| matches!(t.code, 5 | 102 | 330 | 360)), "only handle and groups before the owner"); + // Reading it back keeps the association and the DIMASSOC still resolves. + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + one_reactor_group(&again, "2A", true); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} + +/// A new line used by two dimensions (so by two DIMASSOC objects) lists both, once each, in one +/// group that sits between its handle and its owner. Pinning test (review on #2589). +#[test] +fn a_source_used_by_two_associations_lists_both_dimassocs_once() { + use cadcraft_doc::{Common, Line, Space}; + use cadcraft_geom::Vec3; + let mut d = Drawing::new_metric(); + let line = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) })).expect("add line"); + add_associated_dimension(&mut d, line, line); + add_associated_dimension(&mut d, line, line); + let text = write_dxf(&d); + let o = Out::new(&text); + let assocs: Vec = o.of("DIMASSOC").into_iter().map(h).collect(); + assert_eq!(assocs.len(), 2, "one DIMASSOC per dimension"); + let reactors = one_reactor_group(&o, &line.hex(), true); + let mut got: Vec = reactors.iter().map(|r| r.to_ascii_uppercase()).collect(); + let mut want: Vec = assocs.iter().map(|r| r.to_ascii_uppercase()).collect(); + got.sort(); + want.sort(); + assert_eq!(got, want, "both associations, once each"); + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + assert_eq!(one_reactor_group(&again, &line.hex(), true).len(), 2); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +}