fix(scene): viewport auto-fit fallback + f64-precision projection

Three changes to make paper-space viewports render correctly on
UTM-scale drawings whose viewports were saved with default
view_target=(0, 0, 0) and a view_center pointing at empty WCS:

1. **Auto-fit fallback.** When the saved view's WCS rect doesn't
   overlap the IQR-bounded content cluster (`world_offset ±
   local_extent_max`), override `view_center` with `world_offset` and
   recompute `view_height` to fit the cluster into the paper viewport.
   Matches AutoCAD's silent auto-fit-on-open for stale / uninitialized
   viewports; without it those viewports rendered blank.

2. **f64 projection inner loop.** The previous f32 path computed
   `(wire_offset_rel - target_offset_rel).dot(view_right) -
   view_center` by subtracting values at ~5e6 magnitude (f32 ULP
   ~0.5 m) to land on a small paper offset. The cancellation produced
   centimetre-scale jitter on paper output even when the model was
   clean. Reconstruct the wire WCS coord, subtract the display center,
   and dot-project in f64; cast to f32 only at the final paper
   position where magnitudes are bounded by the viewport rect.

3. **examples/inspect_viewports.rs** — diagnostic that dumps every
   viewport's saved view fields (view_target, view_center, view_height
   vs paper height, custom_scale, status flags) plus drawing-level
   insertion_units and model-space extents. Used to verify that
   acadrust's DWG reader reads view_height correctly; the
   `view_height == vp.height` patterns observed on real files turn out
   to be genuine "default 1:1" file content rather than a reader bug
   (a round-trip test through DwgWriter / DwgReader survives all
   per-viewport values).

Also: switch the `acadrust` patch from `HakanSeven12/acadrust@feat/
expose-blockrecord-is-loaded` (a stale branch with the abandoned
is_loaded experiment) to `hakanaktt/acadrust@main`. Upstream main
now carries the layout paper-dimensions and mirrored-arc fixes that
this branch used to add — plus PR #20's DXF reading fixes that the
old branch was missing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-16 19:32:36 +03:00
commit de37ee04bf
4 changed files with 247 additions and 27 deletions

2
Cargo.lock generated
View file

@ -41,7 +41,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.3.4"
source = "git+https://github.com/HakanSeven12/acadrust?branch=feat%2Fexpose-blockrecord-is-loaded#44ee57604af6f29a3f3ace05c2e49879fb8ae18a"
source = "git+https://github.com/hakanaktt/acadrust?branch=main#ee5f0db7f8159ea39db3c4a85dd58bec9aceb121"
dependencies = [
"ahash",
"anyhow",

View file

@ -23,4 +23,4 @@ rayon = "1"
windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
[patch.crates-io]
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "feat/expose-blockrecord-is-loaded" }
acadrust = { git = "https://github.com/hakanaktt/acadrust", branch = "main" }

View file

@ -0,0 +1,126 @@
// Dumps every paper-space viewport's view fields from a DWG/DXF.
// cargo run --release --example inspect_viewports -- <path>
use acadrust::entities::EntityType;
use acadrust::io::dwg::DwgReader;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args().nth(1).expect("usage: inspect_viewports <file>");
let doc = DwgReader::from_file(&path)?.read()?;
println!("file: {}", path);
println!("total entities: {}", doc.entities().count());
let unit_name = match doc.header.insertion_units {
0 => "Unitless",
1 => "Inches",
2 => "Feet",
3 => "Miles",
4 => "Millimeters",
5 => "Centimeters",
6 => "Meters",
7 => "Kilometers",
_ => "Other",
};
println!(
"header.insertion_units = {} ({})",
doc.header.insertion_units, unit_name
);
println!(
"header.model_space_extents min=({:.3}, {:.3}, {:.3}) max=({:.3}, {:.3}, {:.3})",
doc.header.model_space_extents_min.x,
doc.header.model_space_extents_min.y,
doc.header.model_space_extents_min.z,
doc.header.model_space_extents_max.x,
doc.header.model_space_extents_max.y,
doc.header.model_space_extents_max.z,
);
println!();
let viewports: Vec<_> = doc
.entities()
.filter_map(|e| {
if let EntityType::Viewport(v) = e {
Some(v.clone())
} else {
None
}
})
.collect();
println!("viewports: {}\n", viewports.len());
for (i, vp) in viewports.iter().enumerate() {
let scale_from_view_height = if vp.view_height.abs() > 1e-9 {
vp.height / vp.view_height
} else {
f64::NAN
};
println!(
"[{}] id={} handle={:?}",
i, vp.id, vp.common.handle
);
println!(
" paper center=({:.3}, {:.3}) size={:.3} × {:.3}",
vp.center.x, vp.center.y, vp.width, vp.height
);
println!(
" view_target=({:.3}, {:.3}, {:.3})",
vp.view_target.x, vp.view_target.y, vp.view_target.z
);
println!(
" view_direction=({:.3}, {:.3}, {:.3})",
vp.view_direction.x, vp.view_direction.y, vp.view_direction.z
);
println!(
" view_center=({:.3}, {:.3})",
vp.view_center.x, vp.view_center.y
);
println!(
" view_height={:.6} == vp.height? {} ⇒ height/view_height = {:.6}",
vp.view_height,
(vp.view_height - vp.height).abs() < 1e-6,
scale_from_view_height
);
println!(
" custom_scale={:.6} twist={:.4} lens={:.2}",
vp.custom_scale, vp.twist_angle, vp.lens_length
);
println!(
" status: on={}, locked={}, perspective={}",
vp.status.is_on, vp.status.locked, vp.status.perspective
);
println!();
}
// Summary stats
let n_with_view_eq_height = viewports
.iter()
.filter(|v| (v.view_height - v.height).abs() < 1e-6)
.count();
let n_with_target_zero = viewports
.iter()
.filter(|v| {
v.view_target.x.abs() < 1e-9
&& v.view_target.y.abs() < 1e-9
&& v.view_target.z.abs() < 1e-9
})
.count();
let unique_view_heights: std::collections::BTreeSet<u64> = viewports
.iter()
.map(|v| v.view_height.to_bits())
.collect();
println!("─────── summary ───────");
println!(
" view_height == vp.height : {}/{}",
n_with_view_eq_height,
viewports.len()
);
println!(
" view_target == (0, 0, 0) : {}/{}",
n_with_target_zero,
viewports.len()
);
println!(
" unique view_height values : {}",
unique_view_heights.len()
);
Ok(())
}

View file

@ -1506,6 +1506,15 @@ impl Scene {
}
// Fallback: tessellate (first call or paper-space context).
// wire.key_vertices live in offset-rel coords (world_offset
// already subtracted at tessellation time). Add it back so the
// result matches Path 1 above and the caller's expectation —
// callers (auto_fit_viewport) write the centroid directly to
// `Viewport.view_target`, which is a WCS field; storing
// offset-rel coords there silently double-subtracts world_offset
// inside `camera_for_viewport` and points the viewport at the
// wrong location on UTM-scale drawings.
let oz = self.world_offset[2] as f32;
for entity in self.document.entities() {
let c = entity.common();
if c.owner_handle != model_block || c.invisible {
@ -1514,8 +1523,16 @@ impl Scene {
for wire in self.tessellate_one(entity) {
for &[x, y, z] in &wire.key_vertices {
if x.is_finite() && y.is_finite() && z.is_finite() {
min = min.min(glam::Vec3::new(x, y, z));
max = max.max(glam::Vec3::new(x, y, z));
min = min.min(glam::Vec3::new(
x + ox as f32,
y + oy as f32,
z + oz,
));
max = max.max(glam::Vec3::new(
x + ox as f32,
y + oy as f32,
z + oz,
));
any = true;
}
}
@ -1618,24 +1635,58 @@ impl Scene {
let view_right = cam_frame.rotation * glam::Vec3::X;
let view_up = cam_frame.rotation * glam::Vec3::Y;
// ── Scale & viewport parameters ───────────────────────────────
// view_height is always correct; custom_scale is unreliable in DWG files
// (acadrust DWG reader never populates it, so it stays at 1.0).
let scale = if vp.view_height.abs() > 1e-9 {
(vp.height / vp.view_height) as f32
// ── Scale, target, view_center — saved-view-then-fallback ─────
//
// Honor the file's saved view (view_target + view_center +
// view_height) whenever the WCS region it points at overlaps
// model content. Some DWG files (typical for AutoCAD
// viewports the user never explicitly panned/zoomed into)
// arrive with view_target = (0, 0, 0) and a stale view_center
// pointing at empty WCS — in that case AutoCAD silently
// auto-fits to the model on first display; mirror that so
// UTM-scale drawings don't open with blank viewports.
let mut effective_view_center = (vp.view_center.x, vp.view_center.y);
let mut effective_view_height = vp.view_height as f32;
// Project the saved view's WCS rect and test against
// `world_offset`-centered IQR cluster (`local_extent_max`).
let saved_center_wcs_x = vp.view_target.x + vp.view_center.x;
let saved_center_wcs_y = vp.view_target.y + vp.view_center.y;
let saved_half_h = (effective_view_height as f64) * 0.5;
let saved_half_w = saved_half_h * (vp.width / vp.height.max(1.0));
let cluster_half = self.local_extent_max.max(1.0) as f64;
let cluster_min_x = self.world_offset[0] - cluster_half;
let cluster_max_x = self.world_offset[0] + cluster_half;
let cluster_min_y = self.world_offset[1] - cluster_half;
let cluster_max_y = self.world_offset[1] + cluster_half;
let saved_overlaps = saved_center_wcs_x + saved_half_w >= cluster_min_x
&& saved_center_wcs_x - saved_half_w <= cluster_max_x
&& saved_center_wcs_y + saved_half_h >= cluster_min_y
&& saved_center_wcs_y - saved_half_h <= cluster_max_y
&& effective_view_height > 1e-9;
if !saved_overlaps {
// Saved view points at empty space — auto-fit to the
// outlier-immune content cluster (world_offset ±
// local_extent_max).
let margin = 1.05_f64;
let fit_h = cluster_half * 2.0 * margin;
let fit_w = fit_h * (vp.width / vp.height.max(1.0));
let scale_w = vp.width / fit_w;
let scale_h = vp.height / fit_h;
let fit_scale = scale_w.min(scale_h).max(1e-12);
effective_view_height = (vp.height / fit_scale) as f32;
effective_view_center = (self.world_offset[0], self.world_offset[1]);
}
let scale = if effective_view_height.abs() > 1e-9 {
vp.height as f32 / effective_view_height
} else if vp.custom_scale.abs() > 1e-9 {
vp.custom_scale as f32
} else {
1.0
};
// view_target is in raw model coords; wire points have world_offset
// subtracted, so bring target into the same wire-space.
let target = glam::Vec3::new(
(vp.view_target.x - self.world_offset[0]) as f32,
(vp.view_target.y - self.world_offset[1]) as f32,
(vp.view_target.z - self.world_offset[2]) as f32,
);
let pcx = vp.center.x as f32;
let pcy = vp.center.y as f32;
let pcz = vp.center.z as f32;
@ -1663,8 +1714,34 @@ impl Scene {
let mut projected: Vec<WireModel> = Vec::new();
// Precompute precision-stable WCS-space projection inputs in
// f64. The previous f32 inner loop suffered catastrophic
// cancellation on UTM-scale drawings: `(wire_offset_rel -
// target_offset_rel).dot(view_right) - view_center` is a
// small paper offset computed by subtracting two values at
// ~5e6 magnitude — f32 ULP there is ~0.5 m, so paper output
// jittered by cm even when the actual model was clean.
//
// Do everything WCS-relative in f64; cast to f32 only at the
// final paper position.
let display_center_x = vp.view_target.x + effective_view_center.0;
let display_center_y = vp.view_target.y + effective_view_center.1;
let display_center_z = vp.view_target.z;
let view_right_d = (
view_right.x as f64,
view_right.y as f64,
view_right.z as f64,
);
let view_up_d = (view_up.x as f64, view_up.y as f64, view_up.z as f64);
let view_fwd = cam_frame.rotation * glam::Vec3::Z;
let view_fwd_d = (view_fwd.x as f64, view_fwd.y as f64, view_fwd.z as f64);
let camera_dist_d = camera_dist as f64;
let scale_d = scale as f64;
let pcx_d = pcx as f64;
let pcy_d = pcy as f64;
let [wo_x, wo_y, wo_z] = self.world_offset;
for wire in model_wires.iter() {
// Project 3-D model points onto view plane → paper space.
let projected_pts: Vec<[f32; 3]> = wire
.points
.iter()
@ -1672,21 +1749,38 @@ impl Scene {
if mx.is_nan() || my.is_nan() || mz.is_nan() {
return [f32::NAN; 3];
}
let mp = glam::Vec3::new(mx, my, mz) - target;
// view_center is the 2-D DCS offset of the display centre from
// view_target; subtract it so model origin maps to viewport centre.
let u = mp.dot(view_right) - vp.view_center.x as f32;
let v = mp.dot(view_up) - vp.view_center.y as f32;
// wire stored offset-rel; reconstruct WCS in f64
// then subtract display center in WCS → small
// f64 mp_proj with full precision.
let mp_x = (mx as f64 + wo_x) - display_center_x;
let mp_y = (my as f64 + wo_y) - display_center_y;
let mp_z = (mz as f64 + wo_z) - display_center_z;
let u = mp_x * view_right_d.0
+ mp_y * view_right_d.1
+ mp_z * view_right_d.2;
let v = mp_x * view_up_d.0
+ mp_y * view_up_d.1
+ mp_z * view_up_d.2;
if use_perspective {
let d_vd = mp.dot(cam_frame.rotation * glam::Vec3::Z);
let fwd = camera_dist - d_vd;
let d_vd = mp_x * view_fwd_d.0
+ mp_y * view_fwd_d.1
+ mp_z * view_fwd_d.2;
let fwd = camera_dist_d - d_vd;
if fwd <= 0.001 {
return [f32::NAN; 3];
}
let factor = camera_dist / fwd;
[pcx + u * factor * scale, pcy + v * factor * scale, pcz]
let factor = camera_dist_d / fwd;
[
(pcx_d + u * factor * scale_d) as f32,
(pcy_d + v * factor * scale_d) as f32,
pcz,
]
} else {
[pcx + u * scale, pcy + v * scale, pcz]
[
(pcx_d + u * scale_d) as f32,
(pcy_d + v * scale_d) as f32,
pcz,
]
}
})
.collect();