collective

Browse / github.com/storytold/cadcraft / patch

Keep LAYOUT limits and paper image origin on DXF round trip

LAYOUT object written with fixed limits (0,0)–(12,9) and zero paper image origin Split out of #1463 (see https://github.com/storytold/cadcraft/issues/1463#issuecomment-6384594279), as @ffishman suggested. ## What happens The `LAYOUT` object written by `crates/io/src/dxf_write.rs` (the `layout_obj` closure) has fixed values for every drawing: - `AcDbLayout` groups 10/11 (LIMMIN/LIMMAX of the layout): `(0, 0)` – `(12, 9)` - `AcDbPlotSettings` groups 148/149 (paper image origin): `0, 0` The file's own values are not read back or kept. Measured on a ZWCAD 2025 drawing (private, ISO A3 layouts at plot scale 49.6): the original `LAYOUT` had LIMMIN/LIMMAX `(1.038, 0.694)` –

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #2132, open)
Base
e86d895ba1086a7efc2a83048348f89098282d08
Size
+174 −13 in 6 files
Review
pending
Shared
2026-10-28
Id
e4e9dda7d57060c12c154043

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/tables.rs crates/engine/src/cmd/layout.rs crates/io/src/dxf_read.rs crates/io/src/dxf_write.rs crates/io/tests/it/layout_limits_roundtrip.rs crates/io/tests/it/main.rs

diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs--- a/crates/doc/src/tables.rs+++ b/crates/doc/src/tables.rs@@ -787,6 +787,10 @@ pub struct PageSetup {     /// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the     /// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).     pub paper_mm: bool,+    /// Paper image origin (DXF plot settings groups 148/149) as read from a file; `None` for a+    /// page setup CADCraft created (written as 0,0).+    #[serde(default)]+    pub paper_image_origin: Option<Vec2>, }  impl Default for PageSetup {@@ -808,6 +812,7 @@ impl Default for PageSetup {             show_plot_styles: false,             plot_transparency: false,             paper_mm: false,+            paper_image_origin: None,         }     } }@@ -823,11 +828,15 @@ pub struct Layout {     /// Saved paper-space view (centre, height).     #[serde(default)]     pub view: Option<(Vec2, f64)>,+    /// The layout's limits (LIMMIN, LIMMAX; DXF AcDbLayout groups 10/11) as read from a file;+    /// `None` for a layout CADCraft created (written as (0,0)-(12,9)).+    #[serde(default)]+    pub limits: Option<(Vec2, Vec2)>, }  impl Layout {     pub fn new(name: &str, tab_order: u32) -> Self {-        Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }+        Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None }     } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs--- a/crates/engine/src/cmd/layout.rs+++ b/crates/engine/src/cmd/layout.rs@@ -309,7 +309,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {         let h = d.new_handle();         store.push(Entity { handle: h, ..Entity::clone(e) });     }-    d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });+    d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits });     Ok(json!({ "name": to })) } 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@@ -1701,7 +1701,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {     d.header.set_i64("INSUNITS", 0);     let mut rx = Rx { pstyles: plot_style_names(&secs), ..Rx::default() };     let mut blocks: Vec<(String, Vec3, String, Vec<(Option<String>, bool, Entity)>, i64)> = Vec::new();-    let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();+    let mut layout_objs: Vec<(String, u32, String, PageSetup, Option<(Vec2, Vec2)>)> = Vec::new();     let mut entities = Vec::new();     let mut objs = Objects::default();     // Every handle another program's file already uses is taken before anything gets a new one:@@ -1774,7 +1774,17 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {                         // LAYOUT has two 330s: owner dict and the block record; take the last.                         let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default();                         let plot = T(tg.get(..start).unwrap_or(&[]));-                        layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot)));+                        // Limits (10/11); kept only when all four values are finite and differ+                        // from what CADCraft writes for a layout it created.+                        let pt = |c: i32| Some(Vec2::new(t.f(c)?, t.f(c + 10)?)).filter(|p| p.x.is_finite() && p.y.is_finite());+                        let limits = pt(10).zip(pt(11)).filter(|l| *l != (Vec2::ZERO, Vec2::new(12.0, 9.0)));+                        layout_objs.push((+                            t.s(1).unwrap_or_default(),+                            t.i(71).unwrap_or(0) as u32,+                            br.to_ascii_uppercase(),+                            page_setup(&plot),+                            limits,+                        ));                     }                 }             }@@ -1787,11 +1797,11 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {     // Layouts.     let mut paper_block_for: HashMap<String, String> = HashMap::new();     layout_objs.sort_by_key(|l| l.1);-    for (name, order, br, page) in &layout_objs {+    for (name, order, br, page, limits) in &layout_objs {         if name.eq_ignore_ascii_case("Model") {             continue;         }-        d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) });+        d.layouts.push(Layout { page: page.clone(), limits: *limits, ..Layout::new(name, *order) });         if let Some(bname) = rx.brs.get(br) {             paper_block_for.insert(bname.to_ascii_uppercase(), name.clone());         }@@ -2366,6 +2376,9 @@ fn page_setup(t: &T) -> PageSetup {     .into();     p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 });     p.plot_style_table = t.s(7).unwrap_or_default();+    // Paper image origin (148/149); kept only when both values are finite and not 0,0 (what+    // CADCraft writes for a page setup it created).+    p.paper_image_origin = t.f(148).zip(t.f(149)).map(|(x, y)| Vec2::new(x, y)).filter(|o| o.x.is_finite() && o.y.is_finite() && *o != Vec2::ZERO);     p } 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@@ -2612,7 +2612,15 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {         w.s(3, &l.name);         w.s(350, layout_handles.get(i).cloned().unwrap_or_default());     }-    let layout_obj = |w: &mut W, h: &str, name: &str, tab: i64, brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| {+    let layout_obj = |w: &mut W,+                      h: &str,+                      name: &str,+                      tab: i64,+                      brh: &str,+                      flags: i64,+                      page: &PageSetup,+                      view: Option<(Vec2, f64)>,+                      limits: Option<(Vec2, Vec2)>| {         // Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units         // per 143 drawing units).         let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 };@@ -2665,14 +2673,19 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {         w.s(7, &page.plot_style_table);         w.i(75, if page.scale_to_fit { 0 } else { 16 });         w.f(147, scale);-        w.f(148, 0.0);-        w.f(149, 0.0);+        // Paper image origin and limits: the file's own values, else the defaults (a layout+        // CADCraft created, or a non-finite value).+        let finite = |p: &Vec2| p.x.is_finite() && p.y.is_finite();+        let origin = page.paper_image_origin.filter(finite).unwrap_or(Vec2::ZERO);+        w.f(148, origin.x);+        w.f(149, origin.y);         w.s(100, "AcDbLayout");         w.s(1, name);         w.i(70, flags);         w.i(71, tab);-        w.p2(10, Vec2::ZERO);-        w.p2(11, Vec2::new(12.0, 9.0));+        let (lim_min, lim_max) = limits.filter(|(a, b)| finite(a) && finite(b)).unwrap_or((Vec2::ZERO, Vec2::new(12.0, 9.0)));+        w.p2(10, lim_min);+        w.p2(11, lim_max);         w.p(12, Vec3::ZERO);         w.p(14, Vec3::ZERO);         w.p(15, Vec3::ZERO);@@ -2697,10 +2710,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {     // plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS group 74), which means     // nothing for model space: another program's Plot dialog then shows no plot area or scale.     let model_page = PageSetup { plot_area: "extents".into(), scale_to_fit: true, ..PageSetup::default() };-    layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &model_page, None);+    layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &model_page, None, None);     for (i, (_, brh, l)) in ps_brs.iter().enumerate() {         let h = layout_handles.get(i).cloned().unwrap_or_default();-        layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view);+        layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view, l.limits);     }     // Raster image definitions.     w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict));diff --git a/crates/io/tests/it/layout_limits_roundtrip.rs b/crates/io/tests/it/layout_limits_roundtrip.rsnew file mode 100644--- /dev/null+++ b/crates/io/tests/it/layout_limits_roundtrip.rs@@ -0,0 +1,125 @@+//! A layout's limits (AcDbLayout groups 10/11) and paper image origin (AcDbPlotSettings groups+//! 148/149) survive a DXF round trip (#2109). They place the sheet in paper space; with fixed+//! values another program drew the sheet away from the entities and shifted a "Layout" plot.+//! Layouts CADCraft creates keep (0,0)-(12,9) and origin 0,0.++use cadcraft_doc::*;+use cadcraft_geom::Vec2;++/// DXF text with groups of the "Layout1" LAYOUT record set to new values: (code, value,+/// whether the group comes after the record's `AcDbLayout` marker).+fn set_paper_layout(text: &str, values: &[(&str, &str, bool)]) -> String {+    let lines: Vec<&str> = text.lines().collect();+    let mut out: Vec<String> = Vec::with_capacity(lines.len());+    let mut i = 0;+    while i + 1 < lines.len() {+        let (code, value) = (lines[i].trim(), lines[i + 1].trim());+        if code == "0" && value == "LAYOUT" {+            // Collect the record.+            let mut j = i + 2;+            while j + 1 < lines.len() && lines[j].trim() != "0" {+                j += 2;+            }+            let mut rec: Vec<(String, String)> = (i..j).step_by(2).map(|k| (lines[k].to_string(), lines[k + 1].to_string())).collect();+            let at = rec.iter().position(|(c, v)| c.trim() == "100" && v.trim() == "AcDbLayout").unwrap();+            let name = rec.iter().skip(at).find(|(c, _)| c.trim() == "1").map(|(_, v)| v.trim().to_string()).unwrap();+            if name == "Layout1" {+                for (c, v, after) in values {+                    let k = rec.iter().enumerate().position(|(k, (rc, _))| rc.trim() == *c && (k > at) == *after).unwrap();+                    rec[k].1 = v.to_string();+                }+            }+            for (c, v) in rec {+                out.push(c);+                out.push(v);+            }+            i = j;+        } else {+            out.push(lines[i].to_string());+            out.push(lines[i + 1].to_string());+            i += 2;+        }+    }+    out.extend(lines.iter().skip(i).map(|l| l.to_string()));+    out.join("\n") + "\n"+}++/// Limits (10, 20, 11, 21) and paper image origin (148, 149) of every paper-space LAYOUT.+fn limits(text: &str) -> Vec<[f64; 6]> {+    let lines: Vec<&str> = text.lines().map(str::trim).collect();+    let mut recs: Vec<Vec<(String, String)>> = Vec::new();+    let mut inside = false;+    for pair in lines.chunks(2) {+        let [code, value] = pair else { break };+        if *code == "0" {+            inside = *value == "LAYOUT";+            if inside {+                recs.push(Vec::new());+            }+        } else if inside && let Some(rec) = recs.last_mut() {+            rec.push((code.to_string(), value.to_string()));+        }+    }+    recs.iter()+        .filter_map(|rec| {+            let at = rec.iter().position(|(c, v)| c == "100" && v == "AcDbLayout")?;+            let name = rec.iter().skip(at).find(|(c, _)| c == "1").map(|(_, v)| v.as_str())?;+            if name == "Model" {+                return None;+            }+            let after = |code: &str| rec.iter().skip(at).find(|(c, _)| c == code).and_then(|(_, v)| v.parse().ok()).unwrap_or(f64::NAN);+            let before = |code: &str| rec.iter().take(at).find(|(c, _)| c == code).and_then(|(_, v)| v.parse().ok()).unwrap_or(f64::NAN);+            Some([after("10"), after("20"), after("11"), after("21"), before("148"), before("149")])+        })+        .collect()+}++/// What CADCraft writes for a layout it created.+const DEFAULT: [f64; 6] = [0.0, 0.0, 12.0, 9.0, 0.0, 0.0];++#[test]+fn layout_limits_and_paper_image_origin_survive_a_round_trip() {+    let src = cadcraft_io::write_dxf(&Drawing::new_metric());+    let edited = set_paper_layout(+        &src,+        &[+            ("10", "1.038", true),+            ("20", "0.694", true),+            ("11", "9.506", true),+            ("21", "6.681", true),+            ("148", "-1.054", false),+            ("149", "-0.71", false),+        ],+    );+    let ours = [1.038, 0.694, 9.506, 6.681, -1.054, -0.71];+    assert_eq!(limits(&edited), vec![ours, DEFAULT]);+    let d = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();+    let out = cadcraft_io::write_dxf(&d);+    // Layout1 keeps the file's values; Layout2 had the defaults and keeps them.+    assert_eq!(limits(&out), vec![ours, DEFAULT]);+}++#[test]+fn new_layouts_keep_the_default_limits_and_origin() {+    let out = cadcraft_io::write_dxf(&Drawing::new_metric());+    assert_eq!(limits(&out), vec![DEFAULT, DEFAULT]);+}++#[test]+fn non_finite_limits_and_origin_fall_back_to_the_defaults() {+    let src = cadcraft_io::write_dxf(&Drawing::new_metric());+    let edited = set_paper_layout(&src, &[("10", "NaN", true), ("148", "NaN", false)]);+    let d = cadcraft_io::read_dxf(edited.as_bytes()).unwrap();+    let out = cadcraft_io::write_dxf(&d);+    assert_eq!(limits(&out), vec![DEFAULT, DEFAULT]);+}++#[test]+fn non_finite_limits_and_origin_in_memory_write_the_defaults() {+    let mut d = Drawing::new_metric();+    for l in &mut d.layouts {+        l.limits = Some((Vec2::new(f64::NAN, 0.0), Vec2::new(12.0, f64::INFINITY)));+        l.page.paper_image_origin = Some(Vec2::new(f64::NAN, 1.0));+    }+    assert_eq!(limits(&cadcraft_io::write_dxf(&d)), vec![DEFAULT, DEFAULT]);+}diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs--- a/crates/io/tests/it/main.rs+++ b/crates/io/tests/it/main.rs@@ -29,6 +29,7 @@ mod groups_roundtrip; mod hatch_assoc_roundtrip; mod hatch_pattern_def; mod helix_roundtrip;+mod layout_limits_roundtrip; mod layout_paper_units; mod layout_plot_window; #[cfg(not(target_arch = "wasm32"))]