From a277346c2f31385e7a76ace2952641df33d03df0 Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 2 Nov 2026 15:25:46 -0600 Subject: [PATCH] fix(io): a viewport looks at its view target plus its centre (#2492) (#2508) * fix(io): a viewport looks at its view target plus its centre (#2492) A DXF stores a view's centre in display coordinates, whose origin is the view target (DXF Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and 17/27/37, VIEW 10/20 and 12/22/32). The readers took the centre alone, so a file that keeps the location in the target with a centre of 0,0 (as ArchiCAD's AutoCAD 2018 export does) showed model point 0,0 in every layout viewport, on screen and when plotted. The VIEWPORT entity, VPORT tiles and named views now read the target plus the centre. A sum that overflows keeps the stored centre. The writers already store the whole location as the centre with a target of 0, so a save and reopen keeps the view, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * test(io): a viewport looking along another direction centres on its target Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read/common.rs | 9 ++ crates/io/src/dxf_read/entities.rs | 3 +- crates/io/src/dxf_read/tables.rs | 6 +- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/view_target.rs | 181 +++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 crates/io/tests/it/view_target.rs diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index e46f3c09d..1dbabf6e9 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -19,6 +19,15 @@ impl T<'_> { pub(super) fn p(&self, code: i32) -> Vec3 { Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) } + /// A view's centre in the drawing. The DXF stores it in display coordinates (`center`), whose + /// origin is the view target (`target`), so the point in the middle of a plan view is their + /// sum: a file whose view centre is 0,0 keeps the location in the target (#2492). A sum that + /// overflows leaves the stored centre. + pub(super) fn view_center(&self, center: i32, target: i32) -> Vec2 { + let c = self.p(center).xy(); + let sum = c + self.p(target).xy(); + if sum.is_finite() { sum } else { c } + } pub(super) fn all_f(&self, code: i32) -> Vec { self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() } diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 119d00bbd..980b1718c 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -246,7 +246,8 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef center: t.p(10), width: t.fd(40, 1.0), height: t.fd(41, 1.0), - view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), + // 12/22 is in display coordinates, whose origin is the view target 17/27/37. + view_center: t.view_center(12, 17), view_height: t.fd(45, 1.0), id: t.i(69).unwrap_or(0) as u32, locked: t.i(90).unwrap_or(0) & 16384 != 0, diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs index ee42b8a58..c914ea9f5 100644 --- a/crates/io/src/dxf_read/tables.rs +++ b/crates/io/src/dxf_read/tables.rs @@ -217,7 +217,8 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { continue; } - let center = t.p(10).xy(); + // The view centre 10/20 is in display coordinates, whose origin is the target 12/22/32. + let center = t.view_center(10, 12); if !(center.x.is_finite() && center.y.is_finite()) { continue; } @@ -316,7 +317,8 @@ const MAX_VPORT_TILES: usize = 64; /// are fractions of the drawing area; unusable tiles are skipped. fn vport_record(d: &mut Drawing, t: &T) { let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); - let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); + // The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37. + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17)); let height = t.fd(40, 0.0); let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 79c4e4b3e..aa77795fa 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -73,5 +73,6 @@ mod text_left_justify; mod tolerance_roundtrip; mod user_lin_pat_roundtrip; mod view_tables_roundtrip; +mod view_target; mod viewport_clip_roundtrip; mod xref_roundtrip; diff --git a/crates/io/tests/it/view_target.rs b/crates/io/tests/it/view_target.rs new file mode 100644 index 000000000..c6a2eea27 --- /dev/null +++ b/crates/io/tests/it/view_target.rs @@ -0,0 +1,181 @@ +//! A view's centre is stored in display coordinates, whose origin is the view target (DXF +//! Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and 17/27/37, VIEW 10/20 and 12/22/32). +//! ArchiCAD's AutoCAD 2018 export puts each layout viewport's location in the target with a +//! centre of 0,0, so reading the centre alone showed model point 0,0 (#2492). + +use cadcraft_doc::geom::Vec2; +use cadcraft_doc::{Drawing, EntityKind, Viewport}; +use cadcraft_io::{read_dxf, write_dxf}; + +/// An ASCII DXF of `tags`. +fn dxf(tags: &[(i32, &str)]) -> String { + tags.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +/// A layout viewport with view centre `center` (12/22) and view target `target` (17/27/37). +fn with_viewport(center: [&str; 2], target: [&str; 3]) -> Drawing { + looking_along(center, target, ["0", "0", "1"]) +} + +/// The same, looking along view direction `dir` (16/26/36). +fn looking_along(center: [&str; 2], target: [&str; 3], dir: [&str; 3]) -> Drawing { + let tags: Vec<(i32, &str)> = vec![ + (0, "SECTION"), + (2, "ENTITIES"), + (0, "VIEWPORT"), + (5, "40"), + (67, "1"), + (8, "0"), + (10, "148.5"), + (20, "105"), + (30, "0"), + (40, "250"), + (41, "180"), + (68, "2"), + (69, "2"), + (12, center[0]), + (22, center[1]), + (16, dir[0]), + (26, dir[1]), + (36, dir[2]), + (17, target[0]), + (27, target[1]), + (37, target[2]), + (45, "48680.2"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + read_dxf(dxf(&tags).as_bytes()).expect("read") +} + +fn viewports(d: &Drawing) -> Vec { + d.layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id > 1 => Some(v.clone()), + _ => None, + }) + .collect() +} + +fn view_center(d: &Drawing) -> Vec2 { + let vs = viewports(d); + assert_eq!(vs.len(), 1, "{vs:?}"); + vs[0].view_center +} + +/// `a` and `b` agree to within the rounding of adding two decimals. +fn close(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-6 +} + +#[test] +fn a_viewport_looks_at_its_target_plus_its_centre() { + // The issue's file: centre 0,0, the facade in the target. + let c = view_center(&with_viewport(["0", "0"], ["230748.7", "-867.4", "0"])); + assert_eq!(c, Vec2::new(230748.7, -867.4)); + // Both set: their sum. A target alone of 0 leaves the centre as it is. + assert_eq!(view_center(&with_viewport(["10", "5"], ["100", "50", "7"])), Vec2::new(110.0, 55.0)); + assert_eq!(view_center(&with_viewport(["10", "5"], ["0", "0", "0"])), Vec2::new(10.0, 5.0)); +} + +/// CADCraft draws every viewport as a plan view (the view direction, 16/26/36, isn't applied). A +/// viewport looking along another direction is centred on what it looks at, its target, as +/// AutoCAD's view of it is; its centre is in that view's own axes, so only the target is exact. +#[test] +fn a_viewport_looking_along_another_direction_centres_on_its_target() { + let c = view_center(&looking_along(["0", "0"], ["500", "-200", "30"], ["1", "1", "1"])); + assert_eq!(c, Vec2::new(500.0, -200.0)); +} + +/// Saved and reopened, the viewport still looks at the same place: CADCraft writes the whole +/// location as the centre with a target of 0. +#[test] +fn the_view_survives_a_save_and_reopen() { + let d = with_viewport(["3", "4"], ["84672.2", "25851.5", "0"]); + let back = read_dxf(write_dxf(&d).as_bytes()).expect("reopen"); + let c = view_center(&back); + assert!(close(c, Vec2::new(84675.2, 25855.5)), "{c:?}"); +} + +/// A target coordinate that is not a finite number reads as 0, as any DXF real does, and a sum +/// that overflows leaves the centre. +#[test] +fn a_hostile_target_leaves_the_centre() { + for target in [["1e999", "0", "0"], ["nan", "0", "0"], ["1e308", "0", "0"]] { + assert_eq!(view_center(&with_viewport(["1e308", "2"], target)), Vec2::new(1e308, 2.0), "{target:?}"); + } + assert_eq!(view_center(&with_viewport(["1", "2"], ["inf", "5", "0"])), Vec2::new(1.0, 7.0)); +} + +/// The model viewport tiles of a saved configuration (VPORT 12/22 and 17/27/37) and named views +/// (VIEW 10/20 and 12/22/32) are read the same way. +#[test] +fn vport_tiles_and_named_views_add_their_targets() { + let tags: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "TABLES"), + (0, "TABLE"), + (2, "VPORT"), + (0, "VPORT"), + (2, "TWO"), + (70, "0"), + (10, "0"), + (20, "0"), + (11, "0.5"), + (21, "1"), + (12, "1"), + (22, "2"), + (17, "100"), + (27, "200"), + (37, "0"), + (40, "50"), + (41, "1"), + (0, "VPORT"), + (2, "TWO"), + (70, "0"), + (10, "0.5"), + (20, "0"), + (11, "1"), + (21, "1"), + (12, "0"), + (22, "0"), + (17, "-40"), + (27, "30"), + (37, "0"), + (40, "50"), + (41, "1"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "VIEW"), + (0, "VIEW"), + (2, "Facade"), + (70, "0"), + (40, "20"), + (41, "30"), + (10, "1"), + (20, "2"), + (11, "0"), + (21, "0"), + (31, "1"), + (12, "367097.3"), + (22, "-441.8"), + (32, "0"), + (0, "ENDTAB"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + let d = read_dxf(dxf(tags).as_bytes()).expect("read"); + let two = d.vports.iter().find(|c| c.name.eq_ignore_ascii_case("TWO")).expect("configuration TWO"); + let centers: Vec = two.tiles.iter().map(|t| t.center).collect(); + assert_eq!(centers, [Vec2::new(101.0, 202.0), Vec2::new(-40.0, 30.0)]); + let facade = d.views.iter().find(|v| v.name == "Facade").expect("view Facade"); + assert!(close(facade.center, Vec2::new(367098.3, -439.8)), "{:?}", facade.center); + // And again after a save and reopen. + let back = read_dxf(write_dxf(&d).as_bytes()).expect("reopen"); + let two = back.vports.iter().find(|c| c.name.eq_ignore_ascii_case("TWO")).expect("configuration TWO"); + assert_eq!(two.tiles.iter().map(|t| t.center).collect::>(), centers); + let again = back.views.iter().find(|v| v.name == "Facade").map(|v| v.center).expect("view Facade"); + assert!(close(again, Vec2::new(367098.3, -439.8)), "{again:?}"); +}