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();
     d
diff --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();
     d
diff --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 |
