collective

Browse / github.com/storytold/cadcraft / patch

A layout viewport keeps its view twist through DXF and DWG (part of #1316)

A layout viewport keeps its view twist through DXF and DWG (part of #1316) Part of #1316 (the view twist only). ## Problem A layout viewport's view twist (VIEWPORT group 51) was never read, so a twisted viewport came back untwisted. A drawing saved by CADCraft then wrote it without its twist, in DXF and in DWG. In AutoCAD 2023, a viewport twisted 30° (synthetic file from #1781's repro) reads 0° after a DWG round trip through CADCraft 0.12.0, so every sheet built on twisted viewports opens rotated. ## Change - `Viewport::twist` (radians, counterclockwise, `serde(default)`), next to the other viewport settings in `crates/doc/src/entity.rs`. - `dxf_read.rs` reads gr

Status
open
Score
0.00
Adopters
0 other operators
Author
ATARYS-LAB (imported from pull request #2022, open)
Base
63394e872ddfc72856c1ecf2d67790cd78eb6f92
Size
+94 −2 in 18 files
Review
pending
Shared
2026-10-28
Id
02cb21f0713e8772a4c11fee

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/entity.rs crates/engine/src/cmd/modify2_tests.rs crates/engine/tests/it/measure_distance_snapping.rs crates/io/src/dxf_read.rs crates/io/src/dxf_write.rs crates/io/src/tests.rs crates/io/tests/it/dwg_roundtrip_losses.rs crates/io/tests/it/paperview_marker.rs crates/io/tests/it/pdf_layers.rs crates/io/tests/pdf_text.rs crates/render/src/tests.rs crates/render/tests/it/plot_line_styles.rs crates/render/tests/it/psltscale.rs crates/render/tests/it/render_props.rs crates/render/tests/it/viewport_frozen_layer.rs crates/ui-egui/tests/it/measure_distance_snapping.rs crates/ui-egui/tests/it/mspace_viewport_view.rs docs/file-format-parity.md

diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs--- a/crates/doc/src/entity.rs+++ b/crates/doc/src/entity.rs@@ -822,6 +822,10 @@ pub struct Viewport {     /// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.     #[serde(default = "yes")]     pub on: bool,+    /// View twist (DXF group 51; radians, counterclockwise): kept through files, not yet applied+    /// to the display or plots.+    #[serde(default)]+    pub twist: f64, } impl Default for Viewport {     fn default() -> Self {@@ -837,6 +841,7 @@ impl Default for Viewport {             layer_colors: Vec::new(),             clip: None,             on: true,+            twist: 0.0,         }     } }diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs--- a/crates/engine/src/cmd/modify2_tests.rs+++ b/crates/engine/src/cmd/modify2_tests.rs@@ -404,6 +404,7 @@ fn chspace_json_and_command_line() {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     };     s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();     let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();diff --git a/crates/engine/tests/it/measure_distance_snapping.rs b/crates/engine/tests/it/measure_distance_snapping.rs--- a/crates/engine/tests/it/measure_distance_snapping.rs+++ b/crates/engine/tests/it/measure_distance_snapping.rs@@ -27,6 +27,7 @@ fn drawing() -> (Drawing, Space) {             layer_colors: Vec::new(),             clip: None,             on: true,+            twist: 0.0,         }),     )     .unwrap();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@@ -499,6 +499,7 @@ fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usi             // Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off.             clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0),             on: t.i(90).unwrap_or(0) & 131072 == 0,+            twist: t.f(51).map_or(0.0, |a| cadcraft_geom::norm_angle(a.to_radians())),         }),         "WIPEOUT" => {             let o = t.p(10);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@@ -1162,6 +1162,10 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx             w.i(69, i64::from(v.id));             w.p2(12, v.view_center);             w.f(45, v.view_height);+            let twist = cadcraft_geom::norm_angle(v.twist);+            if twist != 0.0 {+                w.f(51, twist.to_degrees());+            }             // Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the             // 340 entity, 131072 = off.             let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some());diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs--- a/crates/io/src/tests.rs+++ b/crates/io/src/tests.rs@@ -505,6 +505,7 @@ fn pdf_layout_one_to_one_with_viewport() {             layer_colors: Vec::new(),             clip: None,             on: true,+            twist: 0.0,         }),     )     .unwrap();@@ -609,6 +610,7 @@ fn viewport_frozen_layers_roundtrip() {             layer_colors: Vec::new(),             clip: None,             on: true,+            twist: 0.0,         })     };     d.add(&paper, Common::default(), vp(2, vec!["Walls".into(), "A B".into()])).unwrap();@@ -645,6 +647,7 @@ fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     });     d.add(&paper, Common::default(), vp).unwrap();     let text = write_dxf(&d);@@ -768,6 +771,7 @@ fn viewport_layer_colors_roundtrip() {             layer_colors: colors,             clip: None,             on: true,+            twist: 0.0,         })     };     let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))];diff --git a/crates/io/tests/it/dwg_roundtrip_losses.rs b/crates/io/tests/it/dwg_roundtrip_losses.rs--- a/crates/io/tests/it/dwg_roundtrip_losses.rs+++ b/crates/io/tests/it/dwg_roundtrip_losses.rs@@ -27,6 +27,7 @@ fn viewport(id: u32) -> EntityKind {         layer_colors: vec![],         clip: None,         on: true,+        twist: 0.0,     }) } @@ -159,3 +160,67 @@ fn a_paper_viewport_behind_a_model_viewport_stays_id_1_through_dwg() {         assert_eq!((id(model), id(sheet)), (Some(2), Some(1)), "layout {name}");     } }++/// A layout viewport's view twist (group 51, #1316) is read and written, so DXF and DWG keep it;+/// a hostile angle reads as no twist and never panics.+#[test]+fn viewport_twist_survives_dxf_and_dwg() {+    let mut d = Drawing::new_metric();+    let name = d.layouts[0].name.clone();+    let space = Space::Paper(name.clone());+    let twist = 30f64.to_radians();+    let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() };+    vp.twist = twist;+    let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();+    let twist_of = |d: &Drawing| {+        let l = d.layouts.iter().find(|l| l.name == name).unwrap();+        l.entities+            .iter()+            .find_map(|e| {+                if let EntityKind::Viewport(v) = &e.kind+                    && v.id == 2+                {+                    Some(v.twist)+                } else {+                    None+                }+            })+            .unwrap()+    };++    let dxf = cadcraft_io::write(&d, "x.dxf").unwrap();+    let text = String::from_utf8_lossy(&dxf).to_string();+    let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap();+    assert_eq!(group(&rec, "51").and_then(|v| v.parse::<f64>().ok()).map(f64::round), Some(30.0), "{rec:?}");+    assert!((twist_of(&cadcraft_io::read(&dxf, "x.dxf").unwrap()) - twist).abs() < 1e-9);+    assert!((twist_of(&through_dwg(&d)) - twist).abs() < 1e-9);++    // An untwisted viewport writes no group 51; a hostile twist (from JSON or MCP) writes a finite+    // angle within one turn, or none.+    for (value, want) in [(0.0, None), (f64::NAN, None), (f64::INFINITY, None), (1e300, Some(()))] {+        let mut d = Drawing::new_metric();+        let EntityKind::Viewport(mut vp) = viewport(2) else { unreachable!() };+        vp.twist = value;+        let h = d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();+        let text = String::from_utf8_lossy(&cadcraft_io::write(&d, "x.dxf").unwrap()).to_string();+        let rec = records(&text, "VIEWPORT").into_iter().find(|r| group(r, "5") == Some(h.hex().as_str())).unwrap();+        let written = group(&rec, "51").map(|v| v.parse::<f64>().unwrap());+        assert_eq!(written.map(|_| ()), want, "{value}: {rec:?}");+        assert!(written.is_none_or(|a| a.is_finite() && (0.0..360.0).contains(&a)), "{value}: {written:?}");+    }++    // Hostile angles: not a number reads as 0, a huge one is brought into one turn.+    for (value, want) in+        [("nan", Some(0.0)), ("1e400", Some(0.0)), ("-1e-20", Some(0.0)), ("-330", Some(30f64.to_radians())), ("7200000000000000000030", None)]+    {+        let mut lines: Vec<String> = text.lines().map(str::to_string).collect();+        let at = lines.windows(2).position(|w| w[0].trim() == "51" && Some(w[1].trim()) == group(&rec, "51")).unwrap();+        lines[at + 1] = value.to_string();+        let bad = lines.join("\n");+        let back = twist_of(&cadcraft_io::read(bad.as_bytes(), "x.dxf").unwrap());+        assert!(back.is_finite() && (0.0..std::f64::consts::TAU).contains(&back), "{value}: {back}");+        if let Some(w) = want {+            assert!((back - w).abs() < 1e-9, "{value}: {back}");+        }+    }+}diff --git a/crates/io/tests/it/paperview_marker.rs b/crates/io/tests/it/paperview_marker.rs--- a/crates/io/tests/it/paperview_marker.rs+++ b/crates/io/tests/it/paperview_marker.rs@@ -18,6 +18,7 @@ fn viewport(id: u32, frozen_layers: Vec<String>, layer_colors: Vec<(String, Colo         layer_colors,         clip: None,         on: true,+        twist: 0.0,     }) } diff --git a/crates/io/tests/it/pdf_layers.rs b/crates/io/tests/it/pdf_layers.rs--- a/crates/io/tests/it/pdf_layers.rs+++ b/crates/io/tests/it/pdf_layers.rs@@ -166,6 +166,7 @@ fn viewport_contents_keep_their_layers() {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     };     d.add(&paper, Common { layer: "Viewports".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();     add_line(&mut d, &paper, "Title", (10.0, 10.0), (50.0, 10.0));diff --git a/crates/io/tests/pdf_text.rs b/crates/io/tests/pdf_text.rs--- a/crates/io/tests/pdf_text.rs+++ b/crates/io/tests/pdf_text.rs@@ -333,6 +333,7 @@ fn text_seen_through_a_viewport_and_on_layers() {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     };     d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap();     text(&mut d, &paper, "Real", (20.0, 20.0), "B", 0.0, 1.0, 0.0);diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -137,6 +137,7 @@ fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     } } diff --git a/crates/render/tests/it/plot_line_styles.rs b/crates/render/tests/it/plot_line_styles.rs--- a/crates/render/tests/it/plot_line_styles.rs+++ b/crates/render/tests/it/plot_line_styles.rs@@ -167,6 +167,7 @@ fn viewport_contents_get_their_pens() {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     };     let space = Space::Paper("Layout1".into());     d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap();diff --git a/crates/render/tests/it/psltscale.rs b/crates/render/tests/it/psltscale.rs--- a/crates/render/tests/it/psltscale.rs+++ b/crates/render/tests/it/psltscale.rs@@ -25,6 +25,7 @@ fn drawing(psltscale: i64) -> Drawing {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     };     d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();     ddiff --git a/crates/render/tests/it/render_props.rs b/crates/render/tests/it/render_props.rs--- a/crates/render/tests/it/render_props.rs+++ b/crates/render/tests/it/render_props.rs@@ -62,6 +62,7 @@ fn dimensions_take_the_viewport_layer_colour() {         layer_colors: vec![("a".into(), Color::Index(3))],         clip: None,         on: true,+        twist: 0.0,     };     d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();     let l = build(&d, &Space::Paper("Layout1".into()), &Options::default());diff --git a/crates/render/tests/it/viewport_frozen_layer.rs b/crates/render/tests/it/viewport_frozen_layer.rs--- a/crates/render/tests/it/viewport_frozen_layer.rs+++ b/crates/render/tests/it/viewport_frozen_layer.rs@@ -21,6 +21,7 @@ fn drawing() -> Drawing {         layer_colors: Vec::new(),         clip: None,         on: true,+        twist: 0.0,     };     d.add(&Space::Paper("Layout1".into()), Common { layer: "VPORTS".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();     ddiff --git a/crates/ui-egui/tests/it/measure_distance_snapping.rs b/crates/ui-egui/tests/it/measure_distance_snapping.rs--- a/crates/ui-egui/tests/it/measure_distance_snapping.rs+++ b/crates/ui-egui/tests/it/measure_distance_snapping.rs@@ -30,6 +30,7 @@ fn harness(paper: bool, render: bool) -> Harness<'static, CadApp> {             layer_colors: Vec::new(),             clip: None,             on: true,+            twist: 0.0,         }),     )     .unwrap();diff --git a/crates/ui-egui/tests/it/mspace_viewport_view.rs b/crates/ui-egui/tests/it/mspace_viewport_view.rs--- a/crates/ui-egui/tests/it/mspace_viewport_view.rs+++ b/crates/ui-egui/tests/it/mspace_viewport_view.rs@@ -37,6 +37,7 @@ fn harness(clipped: bool) -> Harness<'static, CadApp> {             layer_colors: Vec::new(),             clip,             on: true,+            twist: 0.0,         }),     )     .unwrap();diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- 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-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (VIEWPORT view twist kept through DXF and DWG) · **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@@ -85,7 +85,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | 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), 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) |+| VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read; view twist (group 51) kept through DXF and DWG, not yet shown or plotted (#1316) | 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 | |@@ -140,6 +140,7 @@ our output in AutoCAD black-box.  | Date | Change | Summary | |---|---|---|+| 2026-10-23 | minor | VIEWPORT view twist (group 51) read and written, so a twisted layout viewport keeps its twist through DXF and DWG (part of #1316); the display and plots don't apply it yet | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |