Browse / github.com/storytold/cadcraft / patch
fix(io): preserve implicit TEXT anchors in DWG output
Decide DWG export semantics for justified TEXT without a second alignment point ## Scope Contract decision / investigation, split from #2238 (B1); regression tests are in #2237. This issue tracks **one finding**, including any shared DXF/DWG manifestations. Decision/investigation, not a claim that None is directly representable in native DWG. Separate from DWG TEXT's established single-elevation limit and #2229's independent font-placement report. ## Observed result `None` becomes `Some(0,0,4.125)` for the Left/Bottom witness. Determine how an internal text with no second point should be exported without moving it, and distinguish this from the established single-elev
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- jonascloud (imported from pull request #2306, open)
- Base
d864a08b013353143086bd8bb568c26cb6256796- Size
- +554 −19 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8203aa2f94db3cc3d967620f
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/tests/text_implicit_alignment.rs crates/io/src/dxf_write.rs crates/io/src/dxf_write/common.rs crates/io/src/dxf_write/sections.rs crates/io/src/lib.rs crates/io/tests/it/main.rs crates/io/tests/it/text_implicit_alignment.rs
new file mode 100644@@ -0,0 +1,128 @@+//! Making an implicit TEXT anchor explicit in DWG must preserve grip editing, property edits,+//! and undo/redo. These exercise the engine API and interactive prompt path, not OS mouse input.++use cadcraft_engine::Session;+use cadcraft_engine::doc::{Common, Drawing, EntityKind, HAlign, Space, Text, VAlign};+use cadcraft_engine::geom::{Vec2, Vec3};+use cadcraft_engine::grips::GripMode;+use cadcraft_io::{SaveOptions, Version};+use serde_json::json;++fn reopened(d: &Drawing, version: Version) -> Drawing {+ let bytes = cadcraft_io::write_with(d, "text.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap();+ cadcraft_io::read(&bytes, "text.dwg").unwrap()+}++fn geometry(d: &Drawing) -> Vec<Vec2> {+ let list = cadcraft_engine::render::build(d, &Space::Model, &Default::default());+ assert!(!list.verts.is_empty());+ list.verts+}++fn same_geometry(a: &Drawing, b: &Drawing) {+ let a = geometry(a);+ let b = geometry(b);+ assert_eq!(a.len(), b.len());+ assert!(a.iter().zip(b).all(|(a, b)| a.dist(b) < 1e-10));+}++#[test]+fn rejected_dwg_save_keeps_existing_file_and_unsaved_drawing() {+ use cadcraft_engine::cmd::file::{IoHooks, set_io};+ use std::io::Write;++ set_io(IoHooks {+ read: |bytes, name| cadcraft_io::read(bytes, name).map_err(|e| e.to_string()),+ write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),+ plot: None,+ });+ let mut s = Session::new();+ let saved = s.state().unwrap().saved.clone();+ let previous_bytes = cadcraft_io::write(s.doc().unwrap(), "previous.dwg").unwrap();+ let path = std::env::temp_dir().join(format!("cadcraft-text-rejected-save-{}.dwg", std::process::id()));+ let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();+ file.write_all(&previous_bytes).unwrap();+ drop(file);+ s.execute("text", &json!({"at": [12, 7], "text": "AB", "height": 3})).unwrap();+ let handle = s.doc().unwrap().model.iter().next().unwrap().handle;+ s.doc_mut()+ .unwrap()+ .modify_entity(handle, |e| {+ let EntityKind::Text(t) = &mut e.kind else { panic!("TEXT") };+ t.halign = HAlign::Fit;+ t.align_pt = None;+ })+ .unwrap();+ let before = s.doc().unwrap().clone();+ let result = s.execute("saveas", &json!({"path": path.to_str().unwrap()}));+ let actual_bytes = std::fs::read(&path).unwrap();+ std::fs::remove_file(&path).unwrap();+ let error = result.unwrap_err();+ assert!(error.to_string().contains("second alignment point"), "{error}");+ assert_eq!(actual_bytes, previous_bytes);+ assert_eq!(s.doc().unwrap().model, before.model);+ assert!(s.state().unwrap().path.is_none());+ assert_eq!(s.state().unwrap().saved.model, saved.model);+}++#[test]+fn reopened_implicit_text_keeps_grips_property_edits_and_undo() {+ for (h, v) in+ [(HAlign::Left, VAlign::Bottom), (HAlign::Center, VAlign::Baseline), (HAlign::Right, VAlign::Top), (HAlign::Middle, VAlign::Baseline)]+ {+ let mut source = Drawing::new_metric();+ let handle = source+ .add(+ &Space::Model,+ Common::default(),+ EntityKind::Text(Text {+ insert: Vec3::new(12.0, 7.0, 5.0),+ align_pt: None,+ height: 3.0,+ value: "AB".into(),+ rotation: 0.7,+ width_factor: 1.25,+ oblique: -0.2,+ style: "Standard".into(),+ halign: h,+ valign: v,+ }),+ )+ .unwrap();+ for version in Version::ALL {+ for interactive in [false, true] {+ let mut edited = Vec::new();+ for d in [source.clone(), reopened(&source, version)] {+ let before = d.clone();+ let mut s = Session::new();+ *s.doc_mut().unwrap() = d;+ assert_eq!(s.grips_of(handle).unwrap(), vec![Vec2::new(12.0, 7.0)]);+ if interactive {+ s.set_selection(vec![handle]);+ s.start_grip_edit(&[(handle, 0)]).unwrap();+ s.cmdline("16,5").unwrap();+ assert!(s.running.is_none(), "{}", s.prompt_text());+ } else {+ s.grip_edit(handle, 0, Vec2::new(16.0, 5.0), GripMode::Stretch).unwrap();+ }+ assert_eq!(s.grips_of(handle).unwrap(), vec![Vec2::new(16.0, 5.0)]);+ let after = s.doc().unwrap().clone();+ assert!(geometry(&before).iter().zip(geometry(&after)).all(|(a, b)| (*a + Vec2::new(4.0, -2.0)).dist(b) < 1e-10));+ s.execute("undo", &json!({})).unwrap();+ assert_eq!(s.doc().unwrap().model, before.model);+ s.execute("redo", &json!({})).unwrap();+ assert_eq!(s.doc().unwrap().model, after.model);+ s.execute("properties.set", &json!({"handles": [handle.hex()], "text": "AB AB AB", "height": 4.5})).unwrap();+ assert_eq!(s.grips_of(handle).unwrap(), vec![Vec2::new(16.0, 5.0)]);+ let EntityKind::Text(t) = &s.doc().unwrap().entity(handle).unwrap().kind else { panic!("TEXT") };+ assert_eq!(t.value, "AB AB AB");+ assert_eq!(t.height, 4.5);+ let back = reopened(s.doc().unwrap(), version);+ same_geometry(s.doc().unwrap(), &back);+ edited.push(back);+ }+ same_geometry(&edited[0], &edited[1]);+ }+ }+ }+}@@ -31,7 +31,10 @@ use ctx::Ctx; use entity::entity; use sections::tags_for; -struct W {+struct W<'a> {+ // DXF tag emission is infallible. The DWG bridge checks this result before encoding;+ // recording the first error here validates only records that are actually emitted.+ dwg_text: Option<&'a mut crate::Result<()>>, t: Vec<Tag>, next: u64, /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects).@@ -43,7 +46,7 @@ struct W { reactors: HashMap<Handle, Vec<String>>, } -impl W {+impl W<'_> { fn s(&mut self, c: i32, v: impl Into<String>) { self.t.push(Tag::s(c, v)); }@@ -107,15 +110,17 @@ pub fn write(d: &Drawing) -> String { /// The drawing's DXF tags, declaring `version` in `$ACADVER`. pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {- tags_for(d, version, version)+ tags_for(d, version, version, None) } /// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG /// version's (MULTILEADER from 2007).-pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {- let mut tags = tags_for(d, Version::R2000, dwg);+pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> crate::Result<String> {+ let mut text_result = Ok(());+ let mut tags = tags_for(d, Version::R2000, dwg, Some(&mut text_result));+ text_result?; bridge_markers(&mut tags);- cadcraft_dxf::write_ascii(&tags)+ Ok(cadcraft_dxf::write_ascii(&tags)) } /// Subclass markers the DWG bridge knows by another spelling (it matches them exactly):@@ -136,6 +141,7 @@ fn bridge_markers(tags: &mut [Tag]) { /// out. AutoLISP's `entget` is built on it. pub fn entity_groups(d: &Drawing, e: &Entity) -> (String, Vec<Tag>) { let mut w = W {+ dwg_text: None, t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new(),@@ -13,7 +13,7 @@ pub(super) fn annotative_xdata(w: &mut W) { /// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension. pub(super) fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {- let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() };+ let mut ov = W { dwg_text: None, t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; let mut sah = false; for (k, v) in &dm.overrides { let Some(field) = DimStyle::field_name(k) else { continue };@@ -187,7 +187,7 @@ fn attrib_flags(a: &Attrib) -> i64 { /// The insertion point (group 10) to write for `t`. A justified text is placed by its alignment /// point (group 11), and its insertion point is the start of its baseline, which AutoCAD reads /// from DWG as is (it recomputes it only for DXF): writing the alignment point there drew centred-/// and right-aligned text from the wrong end in AutoCAD (#1050).+/// and right-aligned text from the wrong end in AutoCAD. /// /// The stored point is kept when it is close to where CADCraft's font puts the baseline start: /// then it is AutoCAD's own (exact for its fonts). It is replaced when it is clearly stale: the@@ -197,9 +197,9 @@ fn attrib_flags(a: &Attrib) -> i64 { /// across it within a fifth of its height: Left Middle and Left Bottom text made here, whose /// alignment point is only half or a third of a height from the baseline start, must not pass /// for AutoCAD's.-fn text_insert(d: &Drawing, t: &Text) -> Vec3 {- let computed = computed_text_insert(d, t);- if t.align_pt.is_some_and(|a| a.xy() == t.insert.xy()) {+fn text_insert(d: &Drawing, t: &Text, align_pt: Option<Vec3>) -> Vec3 {+ let computed = computed_text_insert(d, t, align_pt);+ if align_pt.is_some_and(|a| a.xy() == t.insert.xy()) { return computed; } let h = t.height.abs();@@ -207,7 +207,7 @@ fn text_insert(d: &Drawing, t: &Text) -> Vec3 { let close = if t.value.contains('\n') { // A multiline attribute's lines are placed by its embedded MTEXT: a one-line baseline // start says little about them, so only the distance counts.- let width = t.align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy()));+ let width = align_pt.map_or(0.0, |a| 2.0 * a.xy().dist(computed.xy())); off.len() <= 0.1 * width + 0.5 * h } else { let width = cadcraft_fonts::line_width(&t.value, h, t.width_factor.abs());@@ -216,9 +216,9 @@ fn text_insert(d: &Drawing, t: &Text) -> Vec3 { if close { t.insert } else { computed } } -fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 {+fn computed_text_insert(d: &Drawing, t: &Text, align_pt: Option<Vec3>) -> Vec3 { let justified = !matches!((t.halign, t.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _));- let Some(a) = t.align_pt.filter(|_| justified) else { return t.insert };+ let Some(a) = align_pt.filter(|_| justified) else { return t.insert }; let ts = d.text_style(&t.style).cloned().unwrap_or_default(); let (h, v) = cadcraft_render::text_align(t.halign, t.valign); let p = cadcraft_fonts::TextParams {@@ -237,8 +237,37 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } } +/// Resolve only an emitted record's DWG anchor; do not mutate the drawing or DXF's optional+/// point. Aligned/Fit need an authored endpoint: inventing one changes subsequent value edits.+fn dwg_alignment_point(t: &Text) -> crate::Result<Option<Vec3>> {+ if t.align_pt.is_some() || (t.halign == HAlign::Left && t.valign == VAlign::Baseline) {+ return Ok(t.align_pt);+ }+ if matches!(t.halign, HAlign::Aligned | HAlign::Fit) {+ return Err(crate::IoError::Format(format!("DWG export: {:?} text requires a second alignment point", t.halign)));+ }+ if !t.insert.x.is_finite() || !t.insert.y.is_finite() || !t.insert.z.is_finite() {+ return Err(crate::IoError::Format("DWG export: implicit text alignment point must be finite".into()));+ }+ Ok(Some(t.insert))+}+ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) {- w.p(10, text_insert(d, t));+ let align_pt = if let Some(result) = w.dwg_text.as_deref_mut() {+ if result.is_err() {+ return;+ }+ match dwg_alignment_point(t) {+ Ok(point) => point,+ Err(error) => {+ *result = Err(error);+ return;+ }+ }+ } else {+ t.align_pt+ };+ w.p(10, text_insert(d, t, align_pt)); w.f(40, t.height); w.s(1, &t.value); if t.rotation != 0.0 {@@ -268,7 +297,7 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri if h != 0 { w.i(72, h); }- if let Some(a) = t.align_pt {+ if let Some(a) = align_pt { w.p(11, a); } match attrib {@@ -10,12 +10,13 @@ use super::*; /// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the /// file ends up in: `version`, or the DWG version the bridge writes) has.-pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {+pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, dwg_text: Option<&mut crate::Result<()>>) -> Vec<Tag> { // An in-place block edit in progress is not written: the drawing as before it. let committed = d.committed(); let d: &Drawing = &committed; // Structural handles start above every entity handle. let mut w = W {+ dwg_text, t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new(),@@ -487,7 +488,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Ta w.i(70, 0); let v = serde_json::to_value(s).unwrap_or_default(); let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default();- let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() };+ let mut newer = W { dwg_text: None, t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; for (field, code, kind) in dxf_ext::DIM_CODES { let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue }; // DIMPOST is written even when empty: a reader that defaults a missing one to "<>"@@ -99,7 +99,7 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8> // gradients, so a drawing from an R2000 DXF doesn't lose them by default. "dwg" if !opts.binary => { let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));- cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)+ cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version)?.as_bytes(), version.code()).map_err(IoError::Format) } e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))), _ => write_framed(d, name, None),@@ -62,6 +62,7 @@ mod svg_arc_budget; mod svg_tiny_extents; mod table_cellstyle_roundtrip; mod tables_objects_roundtrip;+mod text_implicit_alignment; mod text_left_justify; mod tolerance_roundtrip; mod user_lin_pat_roundtrip;new file mode 100644@@ -0,0 +1,370 @@+//! DWG must preserve the placement and edits of TEXT whose ordinary alignment anchor is+//! implicit. Aligned/Fit without a second endpoint must fail explicitly, not gain a new+//! length constraint. DXF keeps its existing optional-point representation.++use cadcraft_doc::{Attrib, Block, Common, Drawing, Entity, EntityKind, HAlign, Insert, Space, Text, VAlign};+use cadcraft_geom::{Mat3, Vec2, Vec3};+use cadcraft_io::{SaveOptions, Version};+use std::sync::Arc;++fn text(h: HAlign, v: VAlign) -> Text {+ Text {+ insert: Vec3::new(12.0, 7.0, 5.0),+ align_pt: None,+ height: 3.0,+ value: "AB".into(),+ rotation: 0.7,+ width_factor: 1.25,+ oblique: -0.2,+ style: "Standard".into(),+ halign: h,+ valign: v,+ }+}++fn drawing(t: Text) -> Drawing {+ let mut d = Drawing::new_metric();+ d.add(&Space::Model, Common::default(), EntityKind::Text(t)).unwrap();+ d+}++fn first_text(d: &Drawing) -> &Text {+ let EntityKind::Text(t) = &d.model.iter().next().unwrap().kind else { panic!("TEXT") };+ t+}++fn reopen(d: &Drawing, name: &str, version: Version, binary: bool) -> Drawing {+ let bytes = cadcraft_io::write_with(d, name, &SaveOptions { version: Some(version), binary }).unwrap();+ let back = cadcraft_io::read(&bytes, name).unwrap();+ assert_eq!(cadcraft_io::save_version(&back), version);+ assert!(back.read_notes.is_empty(), "{:?}", back.read_notes);+ back+}++fn same_geometry(a: &Drawing, b: &Drawing) {+ let a = cadcraft_render::build(a, &Space::Model, &Default::default());+ let b = cadcraft_render::build(b, &Space::Model, &Default::default());+ assert!(!a.verts.is_empty());+ assert_eq!(a.prims.len(), b.prims.len());+ for (pa, pb) in a.prims.iter().zip(&b.prims) {+ assert_eq!(pa.kind, pb.kind);+ assert_eq!(a.points(pa).len(), b.points(pb).len());+ for (a, b) in a.points(pa).iter().zip(b.points(pb)) {+ // Wire angle conversions round slightly; the fixed fixture is only tens of units.+ assert!(a.dist(*b) < 1e-10, "vertex moved: {a:?} -> {b:?}");+ }+ }+}++fn edit(d: &mut Drawing) {+ let handle = d.model.iter().next().unwrap().handle;+ d.model.modify(handle, |e| {+ e.kind.transform(&Mat3::translate(Vec2::new(4.0, -2.0)));+ let EntityKind::Text(t) = &mut e.kind else { panic!("TEXT") };+ t.value = "AB AB AB".into();+ });+}++#[test]+fn dwg_keeps_implicit_anchors_geometry_and_value_edits() {+ for h in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Middle] {+ for v in [VAlign::Baseline, VAlign::Bottom, VAlign::Middle, VAlign::Top] {+ if h == HAlign::Middle && v != VAlign::Baseline {+ continue;+ }+ for flags in 0..4 {+ let mut source = drawing(text(h, v));+ source.text_styles[0].backwards = flags & 1 != 0;+ source.text_styles[0].upside_down = flags & 2 != 0;+ let mut edited_source = source.clone();+ edit(&mut edited_source);+ for version in Version::ALL {+ let mut current = source.clone();+ for _ in 0..2 {+ current = reopen(¤t, "text.dwg", version, false);+ let back = first_text(¤t);+ assert_eq!((back.halign, back.valign), (h, v));+ assert_eq!(back.justify_point(), first_text(&source).justify_point());+ assert_eq!(back.align_pt, if h == HAlign::Left && v == VAlign::Baseline { None } else { Some(first_text(&source).insert) });+ same_geometry(&source, ¤t);+ let mut edited = current.clone();+ edit(&mut edited);+ same_geometry(&edited_source, &edited);+ let edited_back = reopen(&edited, "edited.dwg", version, false);+ assert_eq!(first_text(&edited_back).value, "AB AB AB");+ assert_eq!(first_text(&edited_back).justify_point(), first_text(&edited_source).justify_point());+ same_geometry(&edited_source, &edited_back);+ }+ }+ assert_eq!(first_text(&source).align_pt, None, "saving does not mutate the drawing");+ }+ }+ }+}++#[test]+fn dwg_refuses_missing_two_point_alignment_but_keeps_explicit_endpoints() {+ for h in [HAlign::Aligned, HAlign::Fit] {+ let source = drawing(text(h, VAlign::Baseline));+ for version in Version::ALL {+ let Err(error) = cadcraft_io::write_with(&source, "text.dwg", &SaveOptions { version: Some(version), binary: false }) else {+ panic!("DWG must refuse {h:?} without its second endpoint");+ };+ assert!(error.to_string().contains("second alignment point"), "{error}");+ let mut explicit = text(h, VAlign::Baseline);+ explicit.align_pt = Some(Vec3::new(18.0, 7.0, 5.0));+ let explicit = drawing(explicit);+ let back = reopen(&explicit, "text.dwg", version, false);+ assert_eq!(first_text(&back).align_pt, first_text(&explicit).align_pt);+ same_geometry(&explicit, &back);+ }+ }+}++#[test]+fn dwg_keeps_authored_ordinary_alignment_points() {+ for (h, v) in+ [(HAlign::Left, VAlign::Bottom), (HAlign::Center, VAlign::Baseline), (HAlign::Right, VAlign::Top), (HAlign::Middle, VAlign::Baseline)]+ {+ let mut t = text(h, v);+ t.align_pt = Some(Vec3::new(20.0, 8.0, 5.0));+ let source = drawing(t);+ for version in Version::ALL {+ let back = reopen(&source, "explicit.dwg", version, false);+ assert_eq!(first_text(&back).align_pt, first_text(&source).align_pt);+ same_geometry(&source, &back);+ }+ }+}++#[test]+fn dxf_keeps_absent_alignment_points() {+ for h in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Middle, HAlign::Aligned, HAlign::Fit] {+ let source = drawing(text(h, if h == HAlign::Left { VAlign::Bottom } else { VAlign::Baseline }));+ for version in Version::ALL {+ for binary in [false, true] {+ let back = reopen(&source, "text.dxf", version, binary);+ let actual = first_text(&back);+ let mut expected = first_text(&source).clone();+ // Only degree/radian conversion roundoff is permitted, not point synthesis.+ assert!((actual.rotation - expected.rotation).abs() <= 8.0 * f64::EPSILON);+ assert!((actual.oblique - expected.oblique).abs() <= 8.0 * f64::EPSILON);+ expected.rotation = actual.rotation;+ expected.oblique = actual.oblique;+ assert_eq!(actual, &expected);+ same_geometry(&source, &back);+ }+ }+ }+}++#[test]+fn entity_inspection_keeps_the_authored_optional_point() {+ let source = drawing(text(HAlign::Right, VAlign::Top));+ let entity = source.model.iter().next().unwrap();+ let (kind, tags) = cadcraft_io::entity_groups(&source, entity);+ assert_eq!(kind, "TEXT");+ assert!(!tags.iter().any(|tag| matches!(tag.code, 11 | 21 | 31)));+ assert_eq!(tags.iter().find(|tag| tag.code == 10).unwrap().f64(), 12.0);+ assert_eq!(tags.iter().find(|tag| tag.code == 20).unwrap().f64(), 7.0);+ let back = reopen(&source, "text.dwg", Version::R2018, false);+ assert_eq!(first_text(&back).align_pt, Some(first_text(&source).insert));+ assert_eq!(cadcraft_io::entity_groups(&source, entity), (kind, tags));+}++fn attribute() -> Attrib {+ Attrib {+ tag: "ID".into(),+ // Nonbaseline ATTRIB reading currently drops vertical justification; isolate anchor export here.+ text: text(HAlign::Center, VAlign::Baseline),+ invisible: false,+ constant: false,+ prompt: String::new(),+ props: Default::default(),+ multiline: None,+ modes: Default::default(),+ }+}++fn texts(d: &Drawing) -> Vec<&Text> {+ d.model+ .iter()+ .chain(d.layouts.iter().flat_map(|l| l.entities.iter()))+ .chain(d.blocks.values().flat_map(|b| b.entities.iter()))+ .flat_map(|e| match &e.kind {+ EntityKind::Text(t) => vec![t],+ EntityKind::AttDef(a) => vec![&a.text],+ EntityKind::Insert(i) => i.attribs.iter().map(|a| &a.text).collect(),+ _ => Vec::new(),+ })+ .collect()+}++#[test]+fn invented_endpoint_changes_future_value_edits() {+ for h in [HAlign::Aligned, HAlign::Fit] {+ let mut t = text(h, VAlign::Baseline);+ let mut implicit = drawing(t.clone());+ let width = cadcraft_fonts::text_width(&cadcraft_fonts::TextFont::Stroke, &t.value, t.height, t.width_factor);+ t.align_pt = Some(t.insert + Vec3::new(width * t.rotation.cos(), width * t.rotation.sin(), 0.0));+ let mut invented = reopen(&drawing(t), "invented.dwg", Version::R2018, false);+ same_geometry(&implicit, &invented);+ edit(&mut implicit);+ edit(&mut invented);+ let a = cadcraft_render::build(&implicit, &Space::Model, &Default::default());+ let b = cadcraft_render::build(&invented, &Space::Model, &Default::default());+ assert_eq!(a.verts.len(), b.verts.len());+ let max_shift = a.verts.iter().zip(&b.verts).map(|(a, b)| a.dist(*b)).fold(0.0_f64, f64::max);+ assert!(max_shift > 1.0, "an invented endpoint silently introduces a length constraint");+ }+}++fn insert(block: &str) -> Insert {+ Insert {+ block: block.into(),+ insert: Vec3::ZERO,+ scale: Vec3::new(1.0, 1.0, 1.0),+ rotation: 0.0,+ attribs: vec![],+ cols: 1,+ rows: 1,+ col_spacing: 0.0,+ row_spacing: 0.0,+ }+}++#[test]+fn xref_contents_do_not_block_host_export() {+ let mut d = drawing(text(HAlign::Left, VAlign::Baseline));+ let mut external = Block::new("External");+ external.xref_path = Some("external.dxf".into());+ external.entities.push(Entity::new(d.new_handle(), EntityKind::Text(text(HAlign::Fit, VAlign::Baseline))));+ d.blocks.insert(external.name.clone(), Arc::new(external));+ let mut dependent = Block::new("External|Child");+ dependent.entities.push(Entity::new(d.new_handle(), EntityKind::Text(text(HAlign::Aligned, VAlign::Baseline))));+ d.blocks.insert(dependent.name.clone(), Arc::new(dependent));+ d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("External"))).unwrap();+ for version in Version::ALL {+ let back = reopen(&d, "host.dwg", version, false);+ assert!(back.block("External").unwrap().entities.is_empty());+ assert!(back.block("External|Child").is_none());+ // The same contents are rejected if they belong to a stored local block.+ let mut local = d.clone();+ Arc::make_mut(local.blocks.get_mut("External").unwrap()).xref_path = None;+ assert!(cadcraft_io::write_with(&local, "local.dwg", &SaveOptions { version: Some(version), binary: false }).is_err());+ }+ assert_eq!(d.block("External").unwrap().entities.len(), 1);+ assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+}++#[test]+fn uncommitted_refedit_objects_do_not_block_export() {+ let mut d = Drawing::new_metric();+ let mut block = Block::new("Tag");+ let original = d.new_handle();+ block.entities.push(Entity::new(original, EntityKind::Text(text(HAlign::Left, VAlign::Bottom))));+ d.blocks.insert(block.name.clone(), Arc::new(block));+ let handle = d.add(&Space::Model, Common { visible: false, ..Default::default() }, EntityKind::Insert(insert("Tag"))).unwrap();+ let copy = d.add(&Space::Model, Common::default(), EntityKind::Text(text(HAlign::Fit, VAlign::Baseline))).unwrap();+ d.refedit = Some(cadcraft_doc::RefEdit {+ block: "Tag".into(),+ insert: handle,+ insert_was_visible: true,+ space: Space::Model,+ to_world: Mat3::IDENTITY,+ items: vec![cadcraft_doc::WorkItem { copy, source: Some(original) }],+ ghosts: vec![],+ released: vec![],+ });+ for version in Version::ALL {+ let back = reopen(&d, "during-edit.dwg", version, false);+ assert!(back.model.get(copy).is_none());+ assert!(back.model.get(handle).unwrap().common.visible);+ same_geometry(&d.committed(), &back);+ // Objects added from the host (source=None) survive discard and must be validated.+ let mut retained = d.clone();+ retained.refedit.as_mut().unwrap().items[0].source = None;+ assert!(cadcraft_io::write_with(&retained, "retained.dwg", &SaveOptions { version: Some(version), binary: false }).is_err());+ }+ assert!(d.refedit.is_some());+ assert!(d.model.get(copy).is_some());+ assert!(!d.model.get(handle).unwrap().common.visible);+ assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+}++#[test]+fn emitted_text_records_reject_missing_endpoints_and_nonfinite_implicit_anchors() {+ let mut cases = vec![text(HAlign::Aligned, VAlign::Baseline), text(HAlign::Fit, VAlign::Baseline)];+ for coordinate in 0..3 {+ for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {+ let mut t = text(HAlign::Right, VAlign::Top);+ match coordinate {+ 0 => t.insert.x = value,+ 1 => t.insert.y = value,+ _ => t.insert.z = value,+ }+ cases.push(t);+ }+ }+ for t in cases {+ for location in 0..5 {+ let mut d = Drawing::new_metric();+ let mut a = attribute();+ a.text = t.clone();+ match location {+ 0 => {+ d.add(&Space::Model, Common::default(), EntityKind::Text(t.clone())).unwrap();+ }+ 1 => {+ d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Text(t.clone())).unwrap();+ }+ 2 | 3 => {+ let mut block = Block::new("Tag");+ let kind = if location == 2 { EntityKind::Text(t.clone()) } else { EntityKind::AttDef(a) };+ block.entities.push(Entity::new(d.new_handle(), kind));+ d.blocks.insert(block.name.clone(), Arc::new(block));+ }+ _ => {+ d.blocks.insert("Tag".into(), Arc::new(Block::new("Tag")));+ let mut i = insert("Tag");+ i.attribs.push(a);+ d.add(&Space::Model, Common::default(), EntityKind::Insert(i)).unwrap();+ }+ }+ let before = d.clone();+ for version in Version::ALL {+ let Err(error) = cadcraft_io::write_with(&d, "invalid.dwg", &SaveOptions { version: Some(version), binary: false }) else {+ panic!("location {location} must reject {t:?}");+ };+ let reason = if matches!(t.halign, HAlign::Aligned | HAlign::Fit) { "second alignment point" } else { "must be finite" };+ assert!(error.to_string().contains(reason), "{error}");+ assert!(d.model.same_as(&before.model), "failed export must not mutate the source");+ assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+ }+ }+ }+}++#[test]+fn dwg_resolves_text_records_in_blocks_layouts_and_attributes() {+ let mut d = drawing(text(HAlign::Left, VAlign::Bottom));+ d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Text(text(HAlign::Left, VAlign::Bottom))).unwrap();+ let mut block = Block::new("Anchors");+ block.entities.push(Entity::new(d.new_handle(), EntityKind::Text(text(HAlign::Left, VAlign::Bottom))));+ block.entities.push(Entity::new(d.new_handle(), EntityKind::AttDef(attribute())));+ let insert = insert("Anchors");+ // Stored records, not expanded references: even a cycle must terminate.+ block.entities.push(Entity::new(d.new_handle(), EntityKind::Insert(insert.clone())));+ d.blocks.insert(block.name.clone(), Arc::new(block));+ d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { attribs: vec![attribute()], ..insert })).unwrap();+ for version in Version::ALL {+ let back = reopen(&d, "locations.dwg", version, false);+ let records = texts(&back);+ assert_eq!(records.len(), 5);+ for t in records {+ assert_eq!(t.align_pt, Some(Vec3::new(12.0, 7.0, 5.0)));+ assert_eq!(t.justify_point(), Vec2::new(12.0, 7.0));+ }+ }+ assert!(texts(&d).iter().all(|t| t.align_pt.is_none()));+}