forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
dfdf8f6702
commit
d641ecfea2
2 files changed
+62
-26
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user