diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index f33ec4c..10ab238 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -926,6 +926,8 @@ struct Rx { pstyles: HashMap, /// Upper-case block name → associative array parameters from its BLOCK_RECORD. block_arrays: HashMap, + /// LAYER record handle (upper case) → layer name (VIEWPORT group 341 frozen layers). + layers: HashMap, } /// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the @@ -1024,6 +1026,15 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m } rx.mleader_fix.push((h, pending)); } + // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. + ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { + v.frozen_layers = tags + .iter() + .take_while(|t| t.code < 1000) + .filter(|t| t.code == 341) + .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) + .collect(); + } _ => {} } } @@ -1311,6 +1322,9 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { "LTYPE" => { rx.ltypes.insert(h.trim().to_ascii_uppercase(), n); } + "LAYER" => { + rx.layers.insert(h.trim().to_ascii_uppercase(), n); + } _ => {} } } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 8857959..9ca0516 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -17,6 +17,8 @@ struct W { next: u64, /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects). pstyles: HashMap, + /// Upper-case layer name → its LAYER table record handle (group 341 of viewports). + layers: HashMap, } impl W { @@ -170,7 +172,7 @@ fn annotative_xdata(w: &mut W) { /// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension. fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) { - let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new() }; + let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new() }; let mut sah = false; for (k, v) in &dm.overrides { let Some(field) = DimStyle::field_name(k) else { continue }; @@ -972,6 +974,13 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx if let Some(h) = clip { w.s(340, h.hex()); } + // AutoCAD's frozen-layer list: one soft pointer per layer, to its LAYER record. Other + // programs read only this; CADCraft prefers its own xdata below when present. + for name in &v.frozen_layers { + if let Some(h) = w.layers.get(&name.to_ascii_uppercase()).cloned() { + w.s(341, h); + } + } // One CADCraft xdata group holds both lists: readers stop at the first group of an app. if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { w.s(1001, dxf_ext::APP); @@ -1465,7 +1474,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { let committed = d.committed(); let d: &Drawing = &committed; // Structural handles start above every entity handle. - let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new() }; + let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new(), layers: HashMap::new() }; // Pre-allocate handles for block records. let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect(); paper_layouts.sort_by_key(|l| l.tab_order); @@ -1768,7 +1777,8 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { // LAYER let th = table_head(&mut w, "LAYER", d.layers.len()); for l in &d.layers { - record_head(&mut w, "LAYER", &th, "AcDbLayerTableRecord"); + let lh = record_head(&mut w, "LAYER", &th, "AcDbLayerTableRecord"); + w.layers.insert(l.name.to_ascii_uppercase(), lh); w.s(2, &l.name); w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 }); let aci = match l.color { @@ -1929,7 +1939,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { 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() }; + let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: 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 }; if !dxf_ext::NEWER_CODES.contains(code) { diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index e9d337e..345a456 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -625,6 +625,130 @@ fn viewport_frozen_layers_roundtrip() { assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}"); } +/// A layout with viewport 2 frozen on `Walls` and `A B` (both real layers), and its written DXF as +/// (code, value) pairs. +fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) { + let mut d = sample(); + d.layers.push(Layer { name: "A B".into(), ..Layer::default() }); + let paper = Space::Paper("Layout1".into()); + let vp = EntityKind::Viewport(Viewport { + center: Vec3::new(5.0, 4.0, 0.0), + width: 8.0, + height: 6.0, + view_center: Vec2::new(5.0, 2.5), + view_height: 12.0, + id: 2, + locked: false, + frozen_layers: vec!["Walls".into(), "A B".into()], + layer_colors: Vec::new(), + clip: None, + on: true, + }); + d.add(&paper, Common::default(), vp).unwrap(); + let text = write_dxf(&d); + let lines: Vec<&str> = text.lines().collect(); + let pairs = lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect(); + (d, pairs) +} + +/// The pair range of the VIEWPORT record whose group 69 is `id`. +fn vp_record(p: &[(String, String)], id: &str) -> std::ops::Range { + let starts: Vec = (0..p.len()).filter(|&i| p[i].0 == "0").collect(); + for w in starts.windows(2) { + let r = w[0]..w[1]; + if p[w[0]].1 == "VIEWPORT" && p[r.clone()].iter().any(|(c, v)| c == "69" && v.trim() == id) { + return r; + } + } + panic!("no VIEWPORT {id}"); +} + +fn layer_handle(p: &[(String, String)], name: &str) -> String { + let starts: Vec = (0..p.len()).filter(|&i| p[i].0 == "0").collect(); + for w in starts.windows(2) { + let rec = &p[w[0]..w[1]]; + if rec[0].1 == "LAYER" && rec.iter().any(|(c, v)| c == "2" && v == name) { + return rec.iter().find(|(c, _)| c == "5").unwrap().1.clone(); + } + } + panic!("no LAYER {name}"); +} + +fn viewport_frozen(d: &Drawing, id: u32) -> Vec { + d.layouts + .iter() + .flat_map(|l| l.entities.iter()) + .find_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id == id => Some(v.frozen_layers.clone()), + _ => None, + }) + .expect("viewport") +} + +fn join(p: &[(String, String)]) -> String { + p.iter() + .map(|(c, v)| { + format!( + "{c} +{v} +" + ) + }) + .collect() +} + +#[test] +fn viewport_frozen_layers_written_as_341_handles() { + let (_, p) = frozen_vp_dxf(); + let r = vp_record(&p, "2"); + let rec = &p[r]; + let h341: Vec<&str> = rec.iter().filter(|(c, _)| c == "341").map(|(_, v)| v.as_str()).collect(); + assert_eq!(h341, vec![layer_handle(&p, "Walls").as_str(), layer_handle(&p, "A B").as_str()]); + let last341 = rec.iter().rposition(|(c, _)| c == "341").unwrap(); + let x = rec.iter().position(|(c, _)| c == "1001").expect("xdata"); + assert!(last341 < x, "341 must precede the xdata"); +} + +#[test] +fn viewport_frozen_layers_read_from_341_without_xdata() { + let (_, p) = frozen_vp_dxf(); + let r = vp_record(&p, "2"); + // Strip the CADCraft xdata, and add a 341 to a handle that names no layer. + let x = p[r.clone()].iter().position(|(c, _)| c == "1001").unwrap() + r.start; + let mut q: Vec<(String, String)> = p[..r.start].to_vec(); + let mut head = p[r.start..x].to_vec(); + head.push(("341".into(), "FFFFF".into())); + q.extend(head); + q.extend(p[r.end..].iter().cloned()); + let back = read_dxf(join(&q).as_bytes()).unwrap(); + assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); +} + +#[test] +fn viewport_frozen_xdata_wins_over_341() { + let (_, p) = frozen_vp_dxf(); + let r = vp_record(&p, "2"); + // Point every 341 at layer 0: the xdata list must still be what is read. + let zero = layer_handle(&p, "0"); + let mut q = p.clone(); + for t in &mut q[r] { + if t.0 == "341" { + t.1 = zero.clone(); + } + } + let back = read_dxf(join(&q).as_bytes()).unwrap(); + assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); +} + +#[test] +fn viewport_frozen_layers_survive_dwg() { + let (d, _) = frozen_vp_dxf(); + let dwg = write(&d, "t.dwg").unwrap(); + let back = read(&dwg, "t.dwg").unwrap(); + assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); + // Survives via the CADCraft xdata: the DWG converter does not yet carry group 341 itself. +} + #[test] fn viewport_layer_colors_roundtrip() { let mut d = sample(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 70080fa..d5a58a9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the Model layout's plot type is written as extents, not "Layout information") · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (VIEWPORT frozen layers as group 341; code pages, #505; native MULTILEADER, #459; foreign data kept, #528; PDF text, layers and sheets, #567, #571; templates and user files, #552; plotter devices, #564) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -80,7 +80,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr | HATCH gradient | yes | yes (#162, fixes #80) | | | SOLID, TRACE, 3DFACE | yes | yes | | | ACAD_TABLE | yes | yes (entity + block) | | -| VIEWPORT | yes | yes; frozen-layer lists kept (#150) | VP layer colour overrides lost (#99, PR #197) | +| VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) | | IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | no attach command or display of the picture yet (FF10) | | WIPEOUT | yes | yes (#147, fixes #79) | | | MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | | @@ -135,6 +135,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | +| 2026-10-12 | minor | VIEWPORT frozen layers also written and read as AutoCAD's group 341 layer handles, so third-party viewers honour per-viewport freezing in DXF (DWG converter gap noted) | | 2026-10-11 | minor | Rows refreshed for merged work: code pages and `\M+` (#505), MULTILEADER (#459), IMAGE, MLINE/HELIX/REGION/TOLERANCE, VPORT/VIEW/UCS tables, modelled and raw objects, foreign data (#528), DWT templates and user `.lin`/`.pat`/`.pgp` files (#552), PDF output (#567, #571), plotter devices (#564), data extraction (#433, #534); weighted percentages not re-measured | | 2026-10-11 | minor | DXF (ASCII and binary) and DWG saved at 2000–2018, 2018 by default (#421) | | 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) |