Merge PR #1577 from mechpanos/fix/zoom-cursor-and-extents: zoom cursor per navigation tool, and ZOOM EXTENTS frames everything

This commit is contained in:
Hakan Seven committed 2026-10-02 10:06:43 +03:00
commit e8a4f1316f
8 files changed
+514 -341

No files matched your search

+178 -215
View File
@@ -81,6 +81,26 @@ fn rendered_wire_bounds(
}
}
/// Merge `pair` into `bounds`, dropping coordinates that are parser junk
/// rather than geometry: DXF writes ±1e20 as its "no extents" sentinel, and
/// past 1e16 an f32 has no resolution left to place a point with.
fn fold_sane_bounds(
bounds: &mut Option<(glam::DVec3, glam::DVec3)>,
pair: Option<(glam::DVec3, glam::DVec3)>,
) {
const SANE_EXTENT: f64 = 1.0e16;
let Some((lo, hi)) = pair else {
return;
};
if lo.abs().max_element() >= SANE_EXTENT || hi.abs().max_element() >= SANE_EXTENT {
return;
}
*bounds = Some(match *bounds {
Some((min, max)) => (min.min(lo), max.max(hi)),
None => (lo, hi),
});
}
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5)
}
@@ -1321,6 +1341,155 @@ impl Scene {
self.camera_generation += 1;
}
/// Absolute-WCS bounds of everything visible in `block` — the one extents
/// walk behind ZOOM EXTENTS and the drawing-extents readers.
///
/// Nothing is rejected here on a guess. An earlier version dropped any wire
/// whose centroid fell outside the inter-quartile consensus of the others,
/// to stop one junk wire from a bad parse poisoning the box. It also
/// dropped legitimately remote geometry — an outlying detail, a survey
/// point, a block placed far from the rest — so ZOOM EXTENTS quietly
/// refused to reach it. `SANE_EXTENT` and the finite test are the only
/// filters now; the ±1e6 display segments that motivated the old heuristic
/// come from XLine / Ray, which are handled explicitly below.
pub(super) fn visible_block_bounds(&self, block: Handle) -> Option<(glam::DVec3, glam::DVec3)> {
if block.is_null() {
return None;
}
let mut bounds: Option<(glam::DVec3, glam::DVec3)> = None;
macro_rules! fold {
($pair:expr) => {
fold_sane_bounds(&mut bounds, $pair)
};
}
// Drawing extents must not depend on where the camera happens to be
// looking: the result is cached per geometry epoch, and a floating
// viewport's own camera is derived FROM the extents
// (`camera_for_viewport`), so reading the camera here recurses. A wire
// with no precise position falls back to the z = 0 plane.
let fallback_z = 0.0;
let scale = crate::scene::annotative::scale_handle_by_name(
&self.document,
&self.document.header.current_annotation_scale,
);
// The resident set is the full, un-culled, LOD-free tessellation the
// block actually renders from. Culled input would fit only what is
// already on screen, so each ZOOM EXTENTS would zoom out a little and
// reveal more, converging on the true extent after several uses (#51).
let wires = self.resident_wires_for(block, None, scale, None, None);
// Infinite display segments do not contribute to drawing extents.
// Keep their base points as a fallback for otherwise empty drawings.
let mut infinite_base: Vec<glam::DVec3> = Vec::new();
for wire in wires.iter() {
let is_infinite = Self::handle_from_wire_name(&wire.name)
.and_then(|handle| self.document.get_entity(handle))
.is_some_and(|entity| matches!(entity, EntityType::XLine(_) | EntityType::Ray(_)));
if is_infinite {
infinite_base.extend(
wire.key_vertices
.iter()
.map(|&[x, y, z]| glam::DVec3::new(x, y, z))
.filter(|point| point.is_finite()),
);
continue;
}
fold!(rendered_wire_bounds(std::slice::from_ref(wire), fallback_z));
}
// 3D solids render as meshes, not wires: a drawing of only solids has
// no wire to fit.
for (&handle, set) in &self.meshes {
let Some(entity) = self.document.get_entity(handle) else {
continue;
};
if !self.mesh_entity_visible(handle)
|| !self.belongs_to_visible_block(handle, entity.common().owner_handle, block)
{
continue;
}
let [x0, y0, x1, y1] = set.world_aabb;
let [z0, z1] = set.z_aabb;
let lo = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64);
let hi = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64);
if lo.is_finite() && hi.is_finite() {
fold!(Some((lo.min(hi), lo.max(hi))));
}
}
// Raster images, OLE frames and underlays draw as GPU quads; with
// their frames hidden they carry no wire outline at all.
for (&handle, image) in &self.images {
let Some(entity) = self.document.get_entity(handle) else {
continue;
};
if !self.mesh_entity_visible(handle)
|| !self.belongs_to_visible_block(handle, entity.common().owner_handle, block)
{
continue;
}
for (corner, low) in image.corners.iter().zip(image.corners_low.iter()) {
let point = glam::DVec3::new(
corner[0] as f64 + low[0] as f64,
corner[1] as f64 + low[1] as f64,
corner[2] as f64 + low[2] as f64,
);
if point.is_finite() {
fold!(Some((point, point)));
}
}
}
// HATCH tessellates to no wires at all (#131), so a drawing whose
// outermost geometry is a fill — or which is nothing but fills — has
// to pick its bounds up from the fill models themselves. The paper
// sheet already does this; model space did not.
// The renderer already holds the fill models for the block it is
// drawing, keyed by geometry epoch — reuse that rather than exploding
// every INSERT again on each ZOOM EXTENTS. Only a block that is not
// the one on screen (model space while a BEDIT is open) has to build
// its own set.
let hatches = if block == self.content_render_block_handle() {
// Tinted, matching the renderer's own call: the two then share one
// cache entry. Asking for an untinted set would key the same slot
// with a different selection signature and the two would evict
// each other on every frame. Tint is colour; it moves no vertex.
self.hatch_models_arc_for_view(true)
} else {
std::sync::Arc::new(self.synced_hatch_models(
block,
None,
scale,
self.annotation_all_visible(),
None,
false,
))
};
for hatch in hatches.iter() {
for &[x, y] in hatch.boundary.iter() {
// NaN-NaN separators between disconnected paths fall out on
// the finite test — they must not be treated as vertices.
let point = glam::DVec3::new(
hatch.world_origin[0] + x as f64,
hatch.world_origin[1] + y as f64,
0.0,
);
if point.is_finite() {
fold!(Some((point, point)));
}
}
}
// Drawing holds only infinite construction geometry: fall back to its
// base points rather than reporting nothing.
if bounds.is_none() {
for point in infinite_base {
fold!(Some((point, point)));
}
}
bounds
}
pub fn fit_all(&mut self) {
if self.active_viewport.is_some() {
if let Some((mut min, mut max)) = self.model_space_extents() {
@@ -1337,225 +1506,19 @@ impl Scene {
return;
}
// Use the FULL, un-culled wire set — not `entity_wires()`, which is
// frustum-culled to the current view. Culled input would fit only the
// entities already on screen, so each call would zoom out a little and
// reveal more, converging on the true extent only after several uses
// (issue #51). `wpp = None` also tessellates at a fixed tolerance so
// the bounds don't drift with zoom-adaptive curve sampling.
let layout_block = self.current_layout_block_handle();
let scale = if self.current_layout == "Model" {
crate::scene::annotative::scale_handle_by_name(
&self.document,
&self.document.header.current_annotation_scale,
)
} else {
self.paper_annotation_scale_handle()
let Some((min, mut max)) = self.visible_block_bounds(self.current_layout_block_handle())
else {
// Nothing usable to fit: leave the camera where it is.
return;
};
let mut wires = self.wires_for_block_culled(
layout_block,
None,
None,
None,
None,
scale,
self.annotation_all_visible(),
None,
);
// Infinite display segments do not contribute to drawing extents.
// Preserve their base points as a fallback for otherwise empty drawings.
let mut infinite_base_pts: Vec<glam::Vec3> = Vec::new();
wires.retain(|w| {
let is_infinite = Self::handle_from_wire_name(&w.name)
.and_then(|h| self.document.get_entity(h))
.map(|e| matches!(e, EntityType::XLine(_) | EntityType::Ray(_)))
.unwrap_or(false);
if is_infinite {
infinite_base_pts.extend(
w.key_vertices
.iter()
.map(|v| glam::Vec3::new(v[0] as f32, v[1] as f32, v[2] as f32)),
);
}
!is_infinite
});
// 3D solids render as meshes, not wires, so collect their complete
// world boxes separately — a drawing of only solids has no wires to fit.
let mesh_aabbs: Vec<([f32; 4], [f32; 2])> = self
.meshes
.iter()
.filter(|(h, _)| {
let handle = **h;
self.mesh_entity_visible(handle)
&& self.document.get_entity(handle).is_some_and(|entity| {
self.belongs_to_visible_block(
handle,
entity.common().owner_handle,
layout_block,
)
})
})
.map(|(_, set)| (set.world_aabb, set.z_aabb))
.filter(|(xy, z)| {
xy[0].is_finite()
&& xy[1].is_finite()
&& xy[2].is_finite()
&& xy[3].is_finite()
&& z[0].is_finite()
&& z[1].is_finite()
})
.collect();
// Raster images and underlays draw as GPU quads; with their frames
// hidden they carry no wire outline, so fold their corners in too.
let image_corners: Vec<glam::Vec3> = self
.images
.iter()
.filter(|(h, _)| {
let handle = **h;
self.mesh_entity_visible(handle)
&& self.document.get_entity(handle).is_some_and(|entity| {
self.belongs_to_visible_block(handle, entity.common().owner_handle, layout_block)
})
})
.flat_map(|(_, image)| {
image.corners.iter().zip(image.corners_low.iter()).map(|(c, low)| {
glam::Vec3::new(c[0] + low[0], c[1] + low[1], c[2] + low[2])
})
})
.filter(|p| p.is_finite())
.collect();
if wires.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() && image_corners.is_empty() {
return;
}
// Collect wire centroids for robust outlier rejection.
struct WireCent {
idx: usize,
cx: f32,
cy: f32,
}
let mut cents: Vec<WireCent> = Vec::with_capacity(wires.len());
for (idx, wire) in wires.iter().enumerate() {
let mut sx = 0.0_f64;
let mut sy = 0.0_f64;
let mut n = 0_usize;
for &[x, y, _] in &wire.points {
if !x.is_finite() || !y.is_finite() {
continue;
}
sx += x as f64;
sy += y as f64;
n += 1;
}
// SDF text is carried as textured glyph quads, not ordinary wire
// points. Include those vertices so a text-only wire participates
// in the robust-centre filter used by ZOOM EXTENTS.
for vertex in &wire.text_verts {
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64;
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64;
if !x.is_finite() || !y.is_finite() {
continue;
}
sx += x;
sy += y;
n += 1;
}
if n > 0 {
cents.push(WireCent {
idx,
cx: (sx / n as f64) as f32,
cy: (sy / n as f64) as f32,
});
}
}
if cents.is_empty() && mesh_aabbs.is_empty() && infinite_base_pts.is_empty() && image_corners.is_empty() {
return;
}
// Quartile rejection needs enough samples to be meaningful. With fewer
// wires, every finite visible point belongs to the drawing extents.
let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 {
let mut xs: Vec<f32> = cents.iter().map(|c| c.cx).collect();
let mut ys: Vec<f32> = cents.iter().map(|c| c.cy).collect();
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q = |v: &[f32], frac: f32| v[((v.len() as f32 - 1.0) * frac) as usize];
let q1x = q(&xs, 0.25);
let q3x = q(&xs, 0.75);
let q1y = q(&ys, 0.25);
let q3y = q(&ys, 0.75);
// Keep sparse annotations while rejecting isolated corrupt wires.
const K: f32 = 10.0;
let dx = (q3x - q1x).max(1.0) * K;
let dy = (q3y - q1y).max(1.0) * K;
(q1x - dx, q3x + dx, q1y - dy, q3y + dy)
} else {
(
f32::NEG_INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::INFINITY,
)
};
let mut min = glam::Vec3::splat(f32::MAX);
let mut max = glam::Vec3::splat(f32::MIN);
for c in &cents {
if c.cx < rx_lo || c.cx > rx_hi || c.cy < ry_lo || c.cy > ry_hi {
continue;
}
let wire = &wires[c.idx];
let hw = (wire.world_width * 0.5) as f32;
for &[x, y, z] in &wire.points {
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
continue;
}
if hw > 0.0 {
min = min.min(glam::Vec3::new(x - hw, y - hw, z - hw));
max = max.max(glam::Vec3::new(x + hw, y + hw, z + hw));
} else {
min = min.min(glam::Vec3::new(x, y, z));
max = max.max(glam::Vec3::new(x, y, z));
}
}
for vertex in &wire.text_verts {
let point = glam::Vec3::new(
vertex.pos[0] + vertex.pos_low[0],
vertex.pos[1] + vertex.pos_low[1],
vertex.pos[2] + vertex.pos_low[2],
);
if point.is_finite() {
min = min.min(point);
max = max.max(point);
}
}
}
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs {
min = min.min(glam::Vec3::new(*ax, *ay, *az));
max = max.max(glam::Vec3::new(*bx, *by, *bz));
}
for p in &image_corners {
min = min.min(*p);
max = max.max(*p);
}
// Drawing holds only infinite construction geometry: fit the view to
// its base points rather than leaving the camera unchanged.
if min.x > max.x {
for p in &infinite_base_pts {
min = min.min(*p);
max = max.max(*p);
}
}
// If no usable points found, leave the camera unchanged.
if min.x > max.x {
return;
}
if min == max {
max += glam::Vec3::splat(1.0);
max += glam::DVec3::splat(1.0);
}
let aspect = self.active_camera_aspect();
self.camera.borrow_mut().fit_to_bounds(min, max, aspect);
// f64 all the way into the camera: a UTM-scale drawing loses whole
// metres to an f32 round-trip, which shows up as a fit that sits
// slightly off the geometry it was asked to frame.
self.camera.borrow_mut().fit_to_bounds_f64(min, max, aspect);
self.projection_bounds_epoch.set(self.geometry_epoch);
self.camera_generation += 1;
}
+30 -108
View File
@@ -11953,78 +11953,15 @@ vis_index={:.1} visible_probe={:.1}",
fn compute_model_space_extents(&self) -> Option<(glam::Vec3, glam::Vec3)> {
let model_block = self.model_space_block_handle();
if model_block.is_null() {
return None;
}
// Reuse the full resident source in every layout. The kernel bounds
// its world-space key vertices; camera fitting never tessellates a
// second copy or reads the current paper sheet's wire cache.
let scale = crate::scene::annotative::scale_handle_by_name(
&self.document,
&self.document.header.current_annotation_scale,
);
let wires =
self.resident_wires_for(model_block, Some(self.annotation_scale), scale, None, None);
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
let mut has_points = false;
for wire in wires.iter() {
for &[x, y, z] in &wire.key_vertices {
if (x as f32).is_finite() && (y as f32).is_finite() && (z as f32).is_finite() {
min[0] = min[0].min(x);
min[1] = min[1].min(y);
min[2] = min[2].min(z);
max[0] = max[0].max(x);
max[1] = max[1].max(y);
max[2] = max[2].max(z);
has_points = true;
}
}
}
if !self.meshes.is_empty() {
for (&handle, set) in &self.meshes {
let Some(entity) = self.document.get_entity(handle) else {
continue;
};
if !self.mesh_entity_visible(handle)
|| !self.belongs_to_visible_block(
handle,
entity.common().owner_handle,
model_block,
)
{
continue;
}
let [ax, ay, bx, by] = set.world_aabb;
let [az, bz] = set.z_aabb;
let (ax, ay, bx, by, az, bz) = (
ax as f64, ay as f64, bx as f64, by as f64, az as f64, bz as f64,
);
if (ax as f32).is_finite()
&& (ay as f32).is_finite()
&& (az as f32).is_finite()
&& (bx as f32).is_finite()
&& (by as f32).is_finite()
&& (bz as f32).is_finite()
{
min[0] = min[0].min(ax.min(bx));
min[1] = min[1].min(ay.min(by));
min[2] = min[2].min(az.min(bz));
max[0] = max[0].max(ax.max(bx));
max[1] = max[1].max(ay.max(by));
max[2] = max[2].max(az.max(bz));
has_points = true;
}
}
}
if has_points {
return Some((
glam::Vec3::new(min[0] as f32, min[1] as f32, min[2] as f32),
glam::Vec3::new(max[0] as f32, max[1] as f32, max[2] as f32),
));
// Shared with ZOOM EXTENTS so the two never disagree about what the
// drawing covers. The previous walk here read only `key_vertices`,
// which is empty for every tessellated curve (see `WireModel`) — so
// circles, arcs, ellipses and splines contributed nothing, and
// neither did hatches or images. Everything that reads drawing
// extents (plot Extents, sheet-set framing, viewport auto-fit,
// SpaceMouse model bounds) inherited that blind spot.
if let Some((min, max)) = self.visible_block_bounds(model_block) {
return Some((min.as_vec3(), max.as_vec3()));
}
// Last resort: saved EXTMIN/EXTMAX before the wire cache is built.
const SANE_EXTENT: f64 = 1.0e16;
@@ -13663,42 +13600,27 @@ mod layout_cache_tests {
s.add_entity(EntityType::LwPolyline(pl));
let bounds = s.model_space_extents().expect("Extents must exist");
// Key vertices come from entities with distinct vertex positions (Line, LwPolyline).
// Line vertices: (0, 0, 10), (50, 100, 20)
// LwPolyline vertices: (300, -200, 0), (400, -100, 0)
// Min X = 0.0, Max X = 400.0
// Min Y = -200.0, Max Y = 100.0
// Min Z = 0.0, Max Z = 20.0
assert!(
(bounds.0.x - 0.0).abs() < 1e-3,
"min.x mismatch: {}",
bounds.0.x
);
assert!(
(bounds.1.x - 400.0).abs() < 1e-3,
"max.x mismatch: {}",
bounds.1.x
);
assert!(
(bounds.0.y - (-200.0)).abs() < 1e-3,
"min.y mismatch: {}",
bounds.0.y
);
assert!(
(bounds.1.y - 100.0).abs() < 1e-3,
"max.y mismatch: {}",
bounds.1.y
);
assert!(
(bounds.0.z - 0.0).abs() < 1e-3,
"min.z mismatch: {}",
bounds.0.z
);
assert!(
(bounds.1.z - 20.0).abs() < 1e-3,
"max.z mismatch: {}",
bounds.1.z
);
// Every entity counts, curves included. The circle and the arc used to
// contribute nothing at all: the walk read only `key_vertices`, which
// is empty for anything tessellated, so the drawing looked 275 units
// narrower and 150 shorter than it is.
// Line: (0, 0, 10) .. (50, 100, 20)
// Circle: (150, 150, 0) .. (250, 250, 0) centre 200,200 r50
// Arc: (-125, -50, -5) .. (-75, -25, -5) centre -100,-50 r25, 0..PI
// LwPolyline: (300, -200, 0) .. (400, -100, 0)
for (got, want, axis) in [
(bounds.0.x, -125.0, "min.x"),
(bounds.1.x, 400.0, "max.x"),
(bounds.0.y, -200.0, "min.y"),
(bounds.1.y, 250.0, "max.y"),
(bounds.0.z, -5.0, "min.z"),
(bounds.1.z, 20.0, "max.z"),
] {
assert!(
(got - want).abs() < 1e-3,
"{axis} mismatch: got {got}, want {want}"
);
}
}
#[test]
+5
View File
@@ -59,6 +59,11 @@ pub struct SelectionState {
pub middle_down: bool,
pub middle_last_pos: Option<Point>,
pub middle_last_press_time: Option<Instant>,
/// Which way a ZOOM Dynamic drag is currently zooming: `true` while it is
/// zooming out. Only the magnifier cursor reads it. The sign is latched
/// rather than taken from the live `dy` so that the sub-pixel jitter of a
/// slow drag cannot strobe the `+` / `−` glyph.
pub zoom_dir_out: bool,
}
/// Transient state of the open right-click context menu.