fix(view): zoom cursor per navigation tool, and ZOOM EXTENTS frames everything

Two viewport-navigation defects, both measured against commercial solutions.

The cursor stayed a hand during a zoom. `selection_overlay` took one boolean
for all three interactive navigation tools, so ZOOM Dynamic and ORBIT both
showed PAN's grab hand — which reads as "drag the sheet", the wrong promise
for either. Split it into a `NavCursor` enum: PAN keeps the open/closed hand,
ORBIT gets four-way arrows, and ZOOM Dynamic gets the magnifier carrying the
sign of the zoom actually under way — "+" dragging up, "-" dragging down,
latched with a 2 px dead zone so a hand held still cannot strobe the glyph.
ZOOM Dynamic also reused `box_anchor` to remember where a drag began, so a
selection marquee rubber-banded across the screen while the view zoomed; the
drawing is now suppressed, the state left alone for `reproject_box_anchor`.

ZOOM EXTENTS did not reach the whole drawing, for three separate reasons, now
answered by one shared `Scene::visible_block_bounds`:

  - A quartile filter dropped any wire whose centroid sat outside the
    inter-quartile consensus of the others. It was meant to stop one junk wire
    from a bad parse poisoning the box, but it could not tell parser junk from
    an outlying detail, a survey point, or a block placed away from the rest,
    and silently refused to frame them. Gone; the absolute 1e16 sentinel gate
    the paper path already used is the only filter, and the XLine / Ray display
    segments that motivated the heuristic are handled explicitly.

  - HATCH tessellates to no wires at all, and the model path never read the
    fill models, so a drawing whose outermost geometry is a fill — or which is
    nothing but fills — had nothing to frame. It now folds them in, reusing the
    renderer's own warm cache on the hot path rather than re-exploding every
    INSERT per ZOOM EXTENTS.

  - Inside a floating viewport the fit came from `compute_model_space_extents`,
    which walked only `key_vertices` — empty for every tessellated curve.
    Circles, arcs, ellipses and splines contributed nothing. Fixing it also
    corrects plot Extents, sheet-set framing, viewport auto-fit and SpaceMouse
    model bounds, which all inherited that blind spot.

Bounds stay f64 into the camera: a UTM drawing lost whole metres to the old
f32 round-trip. Drawing extents also no longer read the camera — the result is
cached per geometry epoch, and a floating viewport's camera is derived FROM
the extents, so `camera_for_viewport` recursed into a stack overflow.

`model_space_extents_mixed_entities_accuracy` asserted the curve-blind result;
it now asserts the true bounds.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-10-01 16:27:26 +03:00
1 parent bf7f37aaf4
commit d596b5a6be
8 files changed
+514 -341

No files matched your search

+105 -1
View File
@@ -48,6 +48,11 @@ enum ScrollIntent {
Pan { dx: f32, dy: f32 },
}
/// How far a ZOOM Dynamic drag must travel vertically before the magnifier
/// cursor flips between "+" and "-". Without the dead zone a hand holding
/// still at the top of a drag strobes the glyph on every jittered pixel.
const ZOOM_DIR_FLIP_PX: f32 = 2.0;
/// A wheel notch zooms; a trackpad's two fingers pan.
///
/// A wheel reports notches, a precise-scrolling device reports pixels, and that
@@ -1238,7 +1243,14 @@ impl OpenCADStudio {
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started);
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
// Latch the direction for the magnifier cursor. A dead
// zone keeps the +/- glyph from strobing when a slow drag
// wobbles a pixel either side of where it started.
if dy.abs() >= ZOOM_DIR_FLIP_PX {
sel.zoom_dir_out = dy > 0.0;
}
sel.middle_last_pos = Some(p);
return Task::none();
}
// Shift+MMB drag orbits the model view instead of panning
@@ -3471,6 +3483,9 @@ impl OpenCADStudio {
sel.box_current = Some(p);
sel.box_crossing = false;
sel.box_crossing_locked = true;
// A fresh drag starts neutral: the magnifier shows "+" until
// the pointer has actually moved far enough to mean zoom out.
sel.zoom_dir_out = false;
}
return Task::none();
}
@@ -3740,6 +3755,7 @@ impl OpenCADStudio {
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing_locked = false;
sel.zoom_dir_out = false;
drop(sel);
self.arm_hover_after_navigation(i);
return Task::none();
@@ -6727,6 +6743,94 @@ mod scroll_intent_tests {
}
}
#[cfg(test)]
mod zoom_dynamic_cursor_tests {
use super::*;
use crate::app::OpenCADStudio;
use crate::ui::overlay::NavCursor;
fn zoom_armed_app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let _ = app.dispatch_command("ZOOM DYNAMIC");
app
}
fn drag_to(app: &mut OpenCADStudio, y: f32) {
let _ = app.update(Message::ViewportMove(Point::new(400.0, y)));
}
fn zoom_dir_out(app: &OpenCADStudio) -> bool {
app.tabs[0].scene.selection.borrow().zoom_dir_out
}
/// The magnifier has to carry the sign of the zoom actually under way:
/// dragging up zooms in, dragging back down zooms out.
#[test]
fn the_magnifier_follows_the_drag_direction() {
let mut app = zoom_armed_app();
assert!(app.tabs[0].zoom_dynamic_mode);
drag_to(&mut app, 300.0);
let _ = app.update(Message::ViewportLeftPress);
// A fresh drag starts neutral — "+" until told otherwise.
assert!(!zoom_dir_out(&app));
drag_to(&mut app, 200.0);
assert!(!zoom_dir_out(&app), "dragging up zooms in");
drag_to(&mut app, 400.0);
assert!(zoom_dir_out(&app), "dragging down zooms out");
drag_to(&mut app, 250.0);
assert!(!zoom_dir_out(&app), "dragging up again zooms in");
// Releasing disarms the direction, so the next hover shows "+".
let _ = app.update(Message::ViewportLeftRelease);
assert!(!zoom_dir_out(&app));
}
/// Sub-pixel wobble while a hand holds still must not strobe the glyph.
#[test]
fn jitter_below_the_dead_zone_does_not_flip_the_glyph() {
let mut app = zoom_armed_app();
drag_to(&mut app, 300.0);
let _ = app.update(Message::ViewportLeftPress);
drag_to(&mut app, 400.0);
assert!(zoom_dir_out(&app));
// Back up by less than ZOOM_DIR_FLIP_PX: still zooming out.
drag_to(&mut app, 399.0);
assert!(zoom_dir_out(&app), "1 px of wobble must not flip the sign");
}
/// Each navigation tool owns its own cursor. They used to share the pan
/// hand, which read as "drag the sheet" during a zoom and an orbit.
#[test]
fn each_navigation_tool_reports_its_own_mode() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
for (command, want) in [
("PAN", NavCursor::Pan),
("ORBIT", NavCursor::Orbit),
("ZOOM DYNAMIC", NavCursor::Zoom),
] {
let _ = app.dispatch_command(command);
assert_eq!(
crate::app::view::nav_cursor(&app.tabs[0]),
want,
"{command} must have its own cursor"
);
let _ = app.update(Message::CommandEscape);
assert_eq!(
crate::app::view::nav_cursor(&app.tabs[0]),
NavCursor::None,
"Esc must disarm {command}"
);
}
}
}
#[cfg(test)]
mod snap_elevation_tests {
use super::snap_keeps_elevation;