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
@@ -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, } } }@@ -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();@@ -27,6 +27,7 @@ fn drawing() -> (Drawing, Space) { layer_colors: Vec::new(), clip: None, on: true,+ twist: 0.0, }), ) .unwrap();@@ -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);@@ -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());@@ -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)))];@@ -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}");+ }+ }+}@@ -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, }) } @@ -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));@@ -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);@@ -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, } } @@ -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();@@ -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(); d@@ -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());@@ -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(); d@@ -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();@@ -37,6 +37,7 @@ fn harness(clipped: bool) -> Harness<'static, CadApp> { layer_colors: Vec::new(), clip, on: true,+ twist: 0.0, }), ) .unwrap();@@ -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 |