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 <noreply@anthropic.com>

* test(io): a viewport looking along another direction centres on its target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
austin62jake
2026-11-02 14:25:46 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 austin62jake
parent 3146b47656
commit a277346c2f
5 changed files with 197 additions and 3 deletions
+9
View File
@@ -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<f64> {
self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect()
}
+2 -1
View File
@@ -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,
+4 -2
View File
@@ -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)
+1
View File
@@ -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;
+181
View File
@@ -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<Viewport> {
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<Vec2> = 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::<Vec<_>>(), 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:?}");
}