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),