From 02d40bd2ecca01313a8a0f3882569df556f637aa Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 24 Jun 2026 17:45:33 +0300 Subject: [PATCH] fix(viewport): clip pick/snap to the viewport rect (no off-screen hits) The GPU scissors floating-viewport content to the viewport rectangle, but the CPU hit-test/snap wire set runs past it (the frustum cull keeps a margin and lines extend beyond the rect), so box selection, lasso, click and object snap could reach geometry clipped out of the viewport. Reject candidates whose projected screen point falls outside `bounds` (the pane rectangle), mirroring the scissor. `bounds` is the full canvas in model space, so every change is a no-op there. - snap: reject out-of-bounds points in try_pt, the grid block and tangent. - click_hit / click_hits_all: no hit when the cursor is off-pane. - box_hit: clamp the selection box to the pane rectangle. - poly_hit: drop wire points projected off-pane. - hatch click: no hit when the cursor is off-pane. Co-Authored-By: Claude Opus 4.8 --- src/scene/pick/hit_test.rs | 33 ++++++++++++++++++++++++++++----- src/snap/mod.rs | 22 +++++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index af0165e13..3f3f5d495 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -30,6 +30,11 @@ pub fn click_hit<'a>( eye: glam::DVec3, bounds: Rectangle, ) -> Option<&'a str> { + // A click outside the pane rectangle (e.g. on the paper around a floating + // viewport) must not reach geometry scissored out of the viewport. + if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height { + return None; + } let mut best_dist = CLICK_THRESHOLD_PX; let mut best: Option<&str> = None; @@ -98,6 +103,9 @@ pub fn click_hits_all<'a>( eye: glam::DVec3, bounds: Rectangle, ) -> Vec<&'a str> { + if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height { + return Vec::new(); + } let mut hits: Vec<(f32, &str)> = Vec::new(); for wire in wires { let mut prev: Option = None; @@ -305,12 +313,15 @@ pub fn box_hit<'a>( eye: glam::DVec3, bounds: Rectangle, ) -> Vec<&'a str> { - let min_x = corner_a.x.min(corner_b.x); - let max_x = corner_a.x.max(corner_b.x); - let min_y = corner_a.y.min(corner_b.y); - let max_y = corner_a.y.max(corner_b.y); + // Clamp the selection box to the pane rectangle so it can't reach geometry + // the GPU scissored out of a floating viewport (the hit-test wire set runs + // past the visible rect). No-op in model space, where bounds is the canvas. + let min_x = corner_a.x.min(corner_b.x).max(0.0); + let max_x = corner_a.x.max(corner_b.x).min(bounds.width); + let min_y = corner_a.y.min(corner_b.y).max(0.0); + let max_y = corner_a.y.max(corner_b.y).min(bounds.height); - // Ignore zero-area boxes. + // Ignore zero-area boxes (including a box clamped entirely off-pane). if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 { return vec![]; } @@ -457,6 +468,13 @@ pub fn poly_hit<'a>( continue; } let sp = world_to_screen(wp64([px, py, pz], low, i), view_rot, eye, bounds); + // Reject points the GPU scissored out of a floating viewport so + // the lasso can't reach clipped geometry. No-op in model space. + if sp.x < 0.0 || sp.x > bounds.width || sp.y < 0.0 || sp.y > bounds.height { + all_inside = false; + prev = None; + continue; + } if crossing { if point_in_polygon(sp, poly) { hit = true; @@ -643,6 +661,11 @@ fn hatch_contains_screen_point( eye: glam::DVec3, bounds: Rectangle, ) -> bool { + // A cursor outside the pane rectangle can't pick a hatch scissored out of a + // floating viewport. No-op in model space (bounds is the canvas). + if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height { + return false; + } // boundary verts are stored as small f32 offsets from // `world_origin` (f64). Reconstruct offset-rel WCS before // projecting to screen. diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 8db7c63f2..483514701 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -490,6 +490,16 @@ impl Snapper { let mut best_rank = u8::MAX; let mut best_d2 = f32::MAX; + // Reject candidates projecting outside the pane rectangle. The GPU + // scissors viewport content to exactly `bounds`, but the hit-test wire + // set reaches past it (the cull keeps a margin and lines run beyond the + // rect), so without this a snap could land on geometry clipped out of + // the viewport. `bounds` is the full canvas in model space, so this is a + // no-op there. + let in_bounds = |s: Point| -> bool { + s.x >= 0.0 && s.x <= bounds.width && s.y >= 0.0 && s.y <= bounds.height + }; + // ── Grid snap — a SEPARATE system from object snap ─────────────────── // Grid snap has its own toggle (`grid_snap_on`) and is independent of // the object-snap master (`snap_enabled`) and the object-snap mode set. @@ -511,7 +521,7 @@ impl Snapper { let gp = origin + ax * ux + ay * uy + az * uz; let screen = world_to_screen(gp, view_rot, eye, bounds); let d2 = dist2(screen, cursor_screen); - if d2 < radius2 { + if d2 < radius2 && in_bounds(screen) { best = Some(SnapResult { world: gp, screen, @@ -570,6 +580,9 @@ impl Snapper { let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { let screen = world_to_screen(world, view_rot, eye, bounds); + if !in_bounds(screen) { + return; + } let d2 = dist2(screen, cursor_screen); // `!(d2 < radius2)` (not `d2 >= radius2`) so a NaN distance from // degenerate geometry is rejected: with priority selection a NaN @@ -855,10 +868,13 @@ impl Snapper { } }; let rank = snap_priority(SnapType::Tangent); - if d2 < radius2 && (rank < best_rank || (rank == best_rank && d2 < best_d2)) { + let screen_pt = world_to_screen(world_pt.as_dvec3(), view_rot, eye, bounds); + if d2 < radius2 + && in_bounds(screen_pt) + && (rank < best_rank || (rank == best_rank && d2 < best_d2)) + { best_rank = rank; best_d2 = d2; - let screen_pt = world_to_screen(world_pt.as_dvec3(), view_rot, eye, bounds); let tangent_obj = match tg { TangentGeom::Line { p1, p2 } => TangentObject::Line { p1: glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64),