fix(viewport): frame paper viewports on the robust cluster centre

Floating viewports on a survey-coordinate drawing rendered blank: the
model camera was correct, but content viewports whose saved view was
rejected auto-fitted onto empty space.

Two causes, both in model-space extents handling:

- compute_model_space_extents tested vertex finiteness in f64 but stored
  f32. A long RAY/XLINE endpoint is a huge-but-finite f64 that overflows
  to inf in f32, poisoning the extents to (inf, inf). Also, the wire/mesh
  AABB paths checked only x while folding y in, so a vertical ray's
  infinite y leaked through. Now every component is validated after the
  f32 cast.

- camera_for_viewport framed its overlap test and auto-fit on the raw
  extents centre. When a drawing has a second, far cluster (a small-
  coordinate legend beside a UTM survey), that centre sits in the empty
  gap, so a valid saved view fails the overlap test and the auto-fit aims
  at blank space. cluster_extent_from_centers already computed a robust
  centre (median of entity centroids) but discarded it; surface it as
  Scene.local_center and frame on that instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-25 09:31:22 +03:00
1 parent dfdf8f6702
commit d641ecfea2
2 files changed
+62 -26

No files matched your search

+1
View File
@@ -634,6 +634,7 @@ impl OpenCADStudio {
// Caches were built on the background thread inside open_path(). // Caches were built on the background thread inside open_path().
self.tabs[i].scene.local_extent_max = caches.local_extent_max; self.tabs[i].scene.local_extent_max = caches.local_extent_max;
self.tabs[i].scene.local_center = caches.local_center;
self.tabs[i].scene.hatches = caches.hatches; self.tabs[i].scene.hatches = caches.hatches;
self.tabs[i].scene.images = caches.images; self.tabs[i].scene.images = caches.images;
self.tabs[i].scene.meshes = caches.meshes; self.tabs[i].scene.meshes = caches.meshes;
+61 -26
View File
@@ -93,6 +93,7 @@ pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) -
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DerivedCaches { pub struct DerivedCaches {
pub local_extent_max: f32, pub local_extent_max: f32,
pub local_center: [f64; 2],
pub hatches: HashMap<Handle, HatchModel>, pub hatches: HashMap<Handle, HatchModel>,
pub images: HashMap<Handle, ImageModel>, pub images: HashMap<Handle, ImageModel>,
pub meshes: HashMap<Handle, MeshLodSet>, pub meshes: HashMap<Handle, MeshLodSet>,
@@ -183,7 +184,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
centers.push(c); centers.push(c);
} }
} }
let local_extent_max = cluster_extent_from_centers(centers, &doc.header); let (local_center, local_extent_max) = cluster_extent_from_centers(centers, &doc.header);
// Default bg adaptation target at load: the model background (paper // Default bg adaptation target at load: the model background (paper
// bg is only relevant after the user enters a paper layout, and // bg is only relevant after the user enters a paper layout, and
@@ -262,6 +263,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
DerivedCaches { DerivedCaches {
local_extent_max, local_extent_max,
local_center,
hatches, hatches,
images, images,
meshes, meshes,
@@ -391,10 +393,14 @@ fn offset_centroid(
/// `$EXTMIN/$EXTMAX` only as a fallback when the entity scan found nothing. /// `$EXTMIN/$EXTMAX` only as a fallback when the entity scan found nothing.
/// `centers` is gathered by the caller's single entity walk (see /// `centers` is gathered by the caller's single entity walk (see
/// [`build_derived_caches`]) so no separate AABB pass is needed. /// [`build_derived_caches`]) so no separate AABB pass is needed.
/// Returns `(center, half_span)` of the dense entity cluster. The center is the
/// median of entity centroids — robust against a second, far cluster (e.g. a
/// small-coordinate legend beside a UTM survey), unlike the raw extents centre
/// which would land in the empty gap between them.
fn cluster_extent_from_centers( fn cluster_extent_from_centers(
centers: Vec<[f64; 3]>, centers: Vec<[f64; 3]>,
header: &acadrust::document::HeaderVariables, header: &acadrust::document::HeaderVariables,
) -> f32 { ) -> ([f64; 2], f32) {
const SANE_EXTENT: f64 = CLUSTER_SANE_EXTENT; const SANE_EXTENT: f64 = CLUSTER_SANE_EXTENT;
let entity_ok = !centers.is_empty(); let entity_ok = !centers.is_empty();
@@ -410,7 +416,7 @@ fn cluster_extent_from_centers(
let i = ((v.len() as f64 - 1.0) * frac).round() as usize; let i = ((v.len() as f64 - 1.0) * frac).round() as usize;
v[i] v[i]
}; };
let espan_max = if entity_ok { let (ecenter, espan_max) = if entity_ok {
let mut xs: Vec<f64> = centers.iter().map(|c| c[0]).collect(); let mut xs: Vec<f64> = centers.iter().map(|c| c[0]).collect();
let mut ys: Vec<f64> = centers.iter().map(|c| c[1]).collect(); let mut ys: Vec<f64> = centers.iter().map(|c| c[1]).collect();
let mx = median(&mut xs); let mx = median(&mut xs);
@@ -418,9 +424,9 @@ fn cluster_extent_from_centers(
let mut dx: Vec<f64> = centers.iter().map(|c| (c[0] - mx).abs()).collect(); let mut dx: Vec<f64> = centers.iter().map(|c| (c[0] - mx).abs()).collect();
let mut dy: Vec<f64> = centers.iter().map(|c| (c[1] - my).abs()).collect(); let mut dy: Vec<f64> = centers.iter().map(|c| (c[1] - my).abs()).collect();
let p95 = percentile(&mut dx, 0.95).max(percentile(&mut dy, 0.95)); let p95 = percentile(&mut dx, 0.95).max(percentile(&mut dy, 0.95));
(p95 * 2.0).max(1.0) as f32 ([mx, my], (p95 * 2.0).max(1.0) as f32)
} else { } else {
0.0 ([0.0, 0.0], 0.0)
}; };
// ── Header extents (fallback only) ─────────────────────────────────── // ── Header extents (fallback only) ───────────────────────────────────
@@ -437,14 +443,15 @@ fn cluster_extent_from_centers(
// relative-to-eye path keeps it precise at UTM scale), so only the // relative-to-eye path keeps it precise at UTM scale), so only the
// cluster span — for camera fit and cull — is derived from the content. // cluster span — for camera fit and cull — is derived from the content.
if entity_ok { if entity_ok {
espan_max (ecenter, espan_max)
} else if header_ok { } else if header_ok {
let hw = ((hmax.x - hmin.x) * 0.5) as f32; let hw = ((hmax.x - hmin.x) * 0.5) as f32;
let hh = ((hmax.y - hmin.y) * 0.5) as f32; let hh = ((hmax.y - hmin.y) * 0.5) as f32;
let hz = ((hmax.z - hmin.z) * 0.5).max(1.0) as f32; let hz = ((hmax.z - hmin.z) * 0.5).max(1.0) as f32;
hw.max(hh).max(hz) * 10.0 let hcenter = [(hmin.x + hmax.x) * 0.5, (hmin.y + hmax.y) * 0.5];
(hcenter, hw.max(hh).max(hz) * 10.0)
} else { } else {
1e9_f32 ([0.0, 0.0], 1e9_f32)
} }
} }
@@ -844,6 +851,11 @@ pub struct Scene {
/// EXTMIN/EXTMAX (10× safety margin). Used by fit_all() to ignore garbage /// EXTMIN/EXTMAX (10× safety margin). Used by fit_all() to ignore garbage
/// entity coordinates (origin-stuck entities, bad Ray/XLine direction vectors). /// entity coordinates (origin-stuck entities, bad Ray/XLine direction vectors).
pub local_extent_max: f32, pub local_extent_max: f32,
/// Robust centre (median of entity centroids) of the dense model-space
/// cluster. Used together with `local_extent_max` to frame a viewport whose
/// saved view is missing — aiming at the raw extents centre would land in
/// the empty gap when a drawing has a second, far cluster.
pub local_center: [f64; 2],
/// Current annotation scale (CANNOSCALE equivalent). /// Current annotation scale (CANNOSCALE equivalent).
/// Multiplier applied to Text/MText/Dimension sizes during tessellation. /// Multiplier applied to Text/MText/Dimension sizes during tessellation.
/// 1.0 = no scaling. 50.0 = "1:50" drawing scale. /// 1.0 = no scaling. 50.0 = "1:50" drawing scale.
@@ -971,6 +983,7 @@ impl Scene {
bg_color: [0.11, 0.11, 0.11, 1.0], bg_color: [0.11, 0.11, 0.11, 1.0],
paper_bg_color: [1.0, 1.0, 1.0, 1.0], paper_bg_color: [1.0, 1.0, 1.0, 1.0],
local_extent_max: 1e9, local_extent_max: 1e9,
local_center: [0.0, 0.0],
annotation_scale: 1.0, annotation_scale: 1.0,
model_extents_cache: RefCell::new(None), model_extents_cache: RefCell::new(None),
entity_block_map_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None),
@@ -3193,9 +3206,14 @@ impl Scene {
if epoch == self.geometry_epoch { if epoch == self.geometry_epoch {
for wire in arc.iter() { for wire in arc.iter() {
let [ax, ay, bx, by] = wire.aabb; let [ax, ay, bx, by] = wire.aabb;
if ax.is_finite() && bx.is_finite() { let lo = glam::Vec3::new(ax, ay, 0.0);
min = min.min(glam::Vec3::new(ax as f32, ay as f32, 0.0)); let hi = glam::Vec3::new(bx, by, 0.0);
max = max.max(glam::Vec3::new(bx as f32, by as f32, 0.0)); // Reject the whole AABB unless every component is finite:
// rays/xlines carry an unbounded AABB, and checking only
// x let a vertical ray's infinite y poison the extents.
if lo.is_finite() && hi.is_finite() {
min = min.min(lo);
max = max.max(hi);
any = true; any = true;
} }
} }
@@ -3203,9 +3221,11 @@ impl Scene {
// XY AABBs in too — otherwise ZOOM EXTENTS ignores them. // XY AABBs in too — otherwise ZOOM EXTENTS ignores them.
for set in self.meshes.values() { for set in self.meshes.values() {
let [ax, ay, bx, by] = set.world_aabb; let [ax, ay, bx, by] = set.world_aabb;
if ax.is_finite() && bx.is_finite() { let lo = glam::Vec3::new(ax, ay, 0.0);
min = min.min(glam::Vec3::new(ax as f32, ay as f32, 0.0)); let hi = glam::Vec3::new(bx, by, 0.0);
max = max.max(glam::Vec3::new(bx as f32, by as f32, 0.0)); if lo.is_finite() && hi.is_finite() {
min = min.min(lo);
max = max.max(hi);
any = true; any = true;
} }
} }
@@ -3230,9 +3250,13 @@ impl Scene {
} }
for wire in self.tessellate_one(entity) { for wire in self.tessellate_one(entity) {
for &[x, y, z] in &wire.key_vertices { for &[x, y, z] in &wire.key_vertices {
if x.is_finite() && y.is_finite() && z.is_finite() { let v = glam::Vec3::new(x as f32, y as f32, z as f32);
min = min.min(glam::Vec3::new(x as f32, y as f32, z as f32)); // Check finiteness *after* the f32 cast: a ray/xline endpoint
max = max.max(glam::Vec3::new(x as f32, y as f32, z as f32)); // is a huge-but-finite f64 that overflows to inf in f32, which
// the f64 `is_finite` test would have let through.
if v.is_finite() {
min = min.min(v);
max = max.max(v);
any = true; any = true;
} }
} }
@@ -3241,9 +3265,11 @@ impl Scene {
// Same mesh inclusion for the tessellate fallback path. // Same mesh inclusion for the tessellate fallback path.
for set in self.meshes.values() { for set in self.meshes.values() {
let [ax, ay, bx, by] = set.world_aabb; let [ax, ay, bx, by] = set.world_aabb;
if ax.is_finite() && bx.is_finite() { let lo = glam::Vec3::new(ax, ay, 0.0);
min = min.min(glam::Vec3::new(ax, ay, 0.0)); let hi = glam::Vec3::new(bx, by, 0.0);
max = max.max(glam::Vec3::new(bx, by, 0.0)); if lo.is_finite() && hi.is_finite() {
min = min.min(lo);
max = max.max(hi);
any = true; any = true;
} }
} }
@@ -7677,12 +7703,21 @@ impl Scene {
// re-centred the model there. Without it a UTM drawing sits ~5.7e6 away, // re-centred the model there. Without it a UTM drawing sits ~5.7e6 away,
// so a stale `(0,0,0)` saved view failed the overlap test AND the // so a stale `(0,0,0)` saved view failed the overlap test AND the
// auto-fit aimed at empty origin → blank viewports. // auto-fit aimed at empty origin → blank viewports.
let (cx, cy) = self // Frame the overlap test / auto-fit on the robust cluster centre (median
.model_space_extents() // of entity centroids), NOT the raw extents centre: a drawing with a
.map(|(mn, mx)| { // far second cluster (e.g. a small-coordinate legend beside a UTM survey)
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64) // has an extents centre in the empty gap, which would reject a valid
}) // saved view and then auto-fit onto blank space. Fall back to the extents
.unwrap_or((0.0, 0.0)); // centre only when no cluster centre was computed.
let (cx, cy) = if self.local_center != [0.0, 0.0] {
(self.local_center[0], self.local_center[1])
} else {
self.model_space_extents()
.map(|(mn, mx)| {
(((mn.x + mx.x) * 0.5) as f64, ((mn.y + mx.y) * 0.5) as f64)
})
.unwrap_or((0.0, 0.0))
};
if let Some(cam) = self.camera_from_view( if let Some(cam) = self.camera_from_view(
vp.view_direction, vp.view_direction,