DXF: write viewport frozen layers as group 341 layer handles too (#827)

A layout VIEWPORT's frozen layers were saved only as CADCraft's own
1001 CADCRAFT / 1000 VPFROZEN xdata. Other programs ignore that, so in
any third-party viewer every viewport showed every layer.

The writer now also emits the standard group 341: one soft pointer per
frozen layer to its LAYER table record handle, after 90/340 and before
the xdata. A frozen name with no LAYER record is skipped for 341 only
(the xdata still carries it). The reader keeps preferring the xdata; a
VIEWPORT without it (a file from another program) resolves its 341
handles through the LAYER records' group-5 handles, ignoring unknown
handles and keeping the 341 order.

Not covered: the DXF-to-DWG converter does not carry group 341 yet, so
a DWG keeps per-viewport freezing only through the CADCraft xdata.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 04:46:15 -04:00
1 parent 5fd23f75fc
commit 5b037b8f92
4 files changed
+155 -6

No files matched your search

+14
View File
@@ -926,6 +926,8 @@ struct Rx {
pstyles: HashMap<String, String>,
/// Upper-case block name → associative array parameters from its BLOCK_RECORD.
block_arrays: HashMap<String, cadcraft_doc::ArrayParams>,
/// LAYER record handle (upper case) → layer name (VIEWPORT group 341 frozen layers).
layers: HashMap<String, String>,
}
/// 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);
}
_ => {}
}
}
+14 -4
View File
@@ -17,6 +17,8 @@ struct W {
next: u64,
/// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects).
pstyles: HashMap<String, String>,
/// Upper-case layer name → its LAYER table record handle (group 341 of viewports).
layers: HashMap<String, String>,
}
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<Tag> {
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<Tag> {
// 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<Tag> {
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) {
+124
View File
@@ -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<usize> {
let starts: Vec<usize> = (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<usize> = (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<String> {
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();
+3 -2
View File
@@ -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) |