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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Books
2026-11-01 14:38:31 -05:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent f0b46e34dc
commit ccbc4fa854
5 changed files with 288 additions and 19 deletions
+45 -15
View File
@@ -846,7 +846,7 @@ fn unknown_handles(d: &Drawing) -> HashSet<String> {
}
/// Put the drawing's foreign data back into a written file's tags (see the module comment).
pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing) {
pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle, Vec<String>>) {
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<Tag>, 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<Tag>, 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<Foreign
out
}
/// The reactor handles a written record gains: the kept foreign ones (remapped) unless the record
/// is an unknown entity that already carries its own, then `extra` (handles of the written file,
/// such as a DIMASSOC), without repeating any the record already names. They go into the one
/// `{ACAD_REACTORS` group the record has, or a new one, so no reactor is lost to a second group.
fn reactors_to_add(map: &Map, h: &Head, x: Option<&ForeignExtra>, unknown: Option<&[String]>) -> Vec<String> {
let extra = unknown.unwrap_or_default();
let mut have: HashSet<String> = 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<Tag>) {
fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: Option<&[String]>, root: bool, out: &mut Vec<Tag>) {
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<String> = 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<String> = 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 {
+2 -2
View File
@@ -43,8 +43,8 @@ struct W {
pstyles: HashMap<String, String>,
/// Upper-case layer name → its LAYER table record handle (group 331/341 of viewports).
layers: HashMap<String, String>,
/// 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<Handle, Vec<String>>,
}
+15 -2
View File
@@ -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);
@@ -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);
}
+179
View File
@@ -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<String> {
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<usize> = (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<String> = 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<String> = 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<i32> = 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<String> = 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<String> = reactors.iter().map(|r| r.to_ascii_uppercase()).collect();
let mut want: Vec<String> = 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());
}