fix(pick): UTM-precise click / box / lasso selection

Selection projected wire vertices from points (the double-single HIGH half)
alone, ignoring points_low, so at UTM scale every selection test ran against
coordinates ~0.5 m off — box/crossing/window edges and click hit-tests missed
or grabbed the wrong entities, worsening with zoom.

hit_test now reconstructs each vertex's absolute f64 from points + points_low
(wp64) before projecting, across click_hit, click_hits_all, box_hit and
poly_hit (lasso). Hatch hit-tests use the f64 world_origin instead of an
f32-cast, and mesh hit-tests (click/box/lasso face picking) reconstruct from
verts + verts_low. world_to_screen now takes the f64 point directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-24 10:33:42 +03:00
commit fe5ff376e5

View file

@ -7,7 +7,7 @@
use rustc_hash::FxHashMap as HashMap; use rustc_hash::FxHashMap as HashMap;
use acadrust::Handle; use acadrust::Handle;
use glam::{Mat4, Vec3}; use glam::Mat4;
use iced::{Point, Rectangle}; use iced::{Point, Rectangle};
use crate::scene::model::hatch_model::HatchModel; use crate::scene::model::hatch_model::HatchModel;
@ -55,7 +55,7 @@ pub fn click_hit<'a>(
let mut sx1 = f32::MIN; let mut sx1 = f32::MIN;
let mut sy1 = f32::MIN; let mut sy1 = f32::MIN;
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] { for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
let s = world_to_screen(Vec3::new(cx, cy, 0.0), view_rot, eye, bounds); let s = world_to_screen(glam::DVec3::new(cx as f64, cy as f64, 0.0), view_rot, eye, bounds);
sx0 = sx0.min(s.x); sx0 = sx0.min(s.x);
sx1 = sx1.max(s.x); sx1 = sx1.max(s.x);
sy0 = sy0.min(s.y); sy0 = sy0.min(s.y);
@ -68,12 +68,12 @@ pub fn click_hit<'a>(
} }
} }
let mut prev: Option<Point> = None; let mut prev: Option<Point> = None;
for &[px, py, pz] in &wire.points { for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() { if px.is_nan() {
prev = None; prev = None;
continue; continue;
} }
let cur = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds);
if let Some(p0) = prev { if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur); let d = dist_point_to_segment(cursor, p0, cur);
if d < best_dist { if d < best_dist {
@ -103,12 +103,12 @@ pub fn click_hits_all<'a>(
let mut prev: Option<Point> = None; let mut prev: Option<Point> = None;
let mut best_for_wire = CLICK_THRESHOLD_PX; let mut best_for_wire = CLICK_THRESHOLD_PX;
let mut hit = false; let mut hit = false;
for &[px, py, pz] in &wire.points { for (i, &[px, py, pz]) in wire.points.iter().enumerate() {
if px.is_nan() { if px.is_nan() {
prev = None; prev = None;
continue; continue;
} }
let cur = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); let cur = world_to_screen(wp64([px, py, pz], &wire.points_low, i), view_rot, eye, bounds);
if let Some(p0) = prev { if let Some(p0) = prev {
let d = dist_point_to_segment(cursor, p0, cur); let d = dist_point_to_segment(cursor, p0, cur);
if d < best_for_wire { if d < best_for_wire {
@ -141,18 +141,15 @@ pub fn mesh_click_hit<'a>(
for (handle, mesh) in meshes { for (handle, mesh) in meshes {
let v = &mesh.verts; let v = &mesh.verts;
let idx = &mesh.indices; let idx = &mesh.indices;
let lo = &mesh.verts_low;
let mut t = 0; let mut t = 0;
while t + 2 < idx.len() { while t + 2 < idx.len() {
let tri = [ let tri = [idx[t] as usize, idx[t + 1] as usize, idx[t + 2] as usize];
v[idx[t] as usize],
v[idx[t + 1] as usize],
v[idx[t + 2] as usize],
];
t += 3; t += 3;
let mut sp = [Point::ORIGIN; 3]; let mut sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32; let mut depth = 0.0f32;
for (j, w) in tri.iter().enumerate() { for (j, &k) in tri.iter().enumerate() {
let ndc = view_rot.project_point3((Vec3::new(w[0], w[1], w[2]).as_dvec3() - eye).as_vec3()); let ndc = view_rot.project_point3((mesh_vert(v[k], lo, k) - eye).as_vec3());
sp[j] = Point::new( sp[j] = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width, (ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height, (1.0 - ndc.y) * 0.5 * bounds.height,
@ -171,12 +168,26 @@ pub fn mesh_click_hit<'a>(
best.map(|(_, h)| h) best.map(|(_, h)| h)
} }
/// Reconstruct a mesh vertex's absolute f64 position from its high/low pair —
/// without the low residual the f32 high alone is ~0.5 m off at UTM scale and
/// box / lasso / face selection lands on the wrong place.
#[inline]
fn mesh_vert(hi: [f32; 3], low: &[[f32; 3]], i: usize) -> glam::DVec3 {
let l = low.get(i).copied().unwrap_or([0.0; 3]);
glam::DVec3::new(
hi[0] as f64 + l[0] as f64,
hi[1] as f64 + l[1] as f64,
hi[2] as f64 + l[2] as f64,
)
}
/// Project a mesh's vertices to screen space. /// Project a mesh's vertices to screen space.
fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec<Point> { fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec<Point> {
mesh.verts mesh.verts
.iter() .iter()
.map(|w| { .enumerate()
let ndc = view_rot.project_point3((Vec3::new(w[0], w[1], w[2]).as_dvec3() - eye).as_vec3()); .map(|(i, &w)| {
let ndc = view_rot.project_point3((mesh_vert(w, &mesh.verts_low, i) - eye).as_vec3());
Point::new( Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width, (ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height, (1.0 - ndc.y) * 0.5 * bounds.height,
@ -336,16 +347,23 @@ pub fn box_hit<'a>(
return None; return None;
}; };
// Low residual parallel to `pts` (empty for the AABB fallback,
// whose coarse f32 box doesn't carry one).
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut hit = false; let mut hit = false;
let mut all_inside = true; let mut all_inside = true;
let mut prev: Option<Point> = None; let mut prev: Option<Point> = None;
for &[px, py, pz] in pts { for (i, &[px, py, pz]) in pts.iter().enumerate() {
if px.is_nan() { if px.is_nan() {
prev = None; prev = None;
continue; continue;
} }
let sp = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); let sp = world_to_screen(wp64([px, py, pz], low, i), view_rot, eye, bounds);
if crossing { if crossing {
if inside(sp) { if inside(sp) {
hit = true; hit = true;
@ -424,16 +442,21 @@ pub fn poly_hit<'a>(
return None; return None;
}; };
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut hit = false; let mut hit = false;
let mut all_inside = true; let mut all_inside = true;
let mut prev: Option<Point> = None; let mut prev: Option<Point> = None;
for &[px, py, pz] in pts { for (i, &[px, py, pz]) in pts.iter().enumerate() {
if px.is_nan() { if px.is_nan() {
prev = None; prev = None;
continue; continue;
} }
let sp = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); let sp = world_to_screen(wp64([px, py, pz], low, i), view_rot, eye, bounds);
if crossing { if crossing {
if point_in_polygon(sp, poly) { if point_in_polygon(sp, poly) {
hit = true; hit = true;
@ -469,14 +492,28 @@ pub fn poly_hit<'a>(
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point { fn world_to_screen(world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
let ndc = view_rot.project_point3((world.as_dvec3() - eye).as_vec3()); let ndc = view_rot.project_point3((world - eye).as_vec3());
Point::new( Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width, (ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height, (1.0 - ndc.y) * 0.5 * bounds.height,
) )
} }
/// Reconstruct the absolute-f64 world position of wire vertex `i` from its
/// double-single high (`points`) + low (`points_low`) pair. At UTM scale the
/// high f32 alone is ~0.5 m off, which throws box / lasso / click selection
/// edges off by metres; adding the low residual restores f64 precision.
#[inline]
fn wp64(hi: [f32; 3], low: &[[f32; 3]], i: usize) -> glam::DVec3 {
let l = low.get(i).copied().unwrap_or([0.0; 3]);
glam::DVec3::new(
hi[0] as f64 + l[0] as f64,
hi[1] as f64 + l[1] as f64,
hi[2] as f64 + l[2] as f64,
)
}
/// Even-odd ray-casting test: is `p` inside the polygon? /// Even-odd ray-casting test: is `p` inside the polygon?
/// ///
/// Handles multi-path boundaries: NaN points (used as path separators by /// Handles multi-path boundaries: NaN points (used as path separators by
@ -609,14 +646,13 @@ fn hatch_contains_screen_point(
// boundary verts are stored as small f32 offsets from // boundary verts are stored as small f32 offsets from
// `world_origin` (f64). Reconstruct offset-rel WCS before // `world_origin` (f64). Reconstruct offset-rel WCS before
// projecting to screen. // projecting to screen.
let ox = hatch.world_origin[0] as f32; let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]);
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch let screen: Vec<Point> = hatch
.boundary .boundary
.iter() .iter()
.map(|&[x, y]| { .map(|&[x, y]| {
if x.is_finite() && y.is_finite() { if x.is_finite() && y.is_finite() {
world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_rot, eye, bounds) world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds)
} else { } else {
// Preserve path separators for the NaN-aware // Preserve path separators for the NaN-aware
// point_in_polygon ray-cast. // point_in_polygon ray-cast.
@ -654,12 +690,11 @@ pub fn box_hit_hatch(
if hatch.boundary.is_empty() { if hatch.boundary.is_empty() {
return None; return None;
} }
let ox = hatch.world_origin[0] as f32; let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]);
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch let screen: Vec<Point> = hatch
.boundary .boundary
.iter() .iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_rot, eye, bounds)) .map(|&[x, y]| world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds))
.collect(); .collect();
let hit = if crossing { let hit = if crossing {
screen.iter().any(|&sp| inside(sp)) screen.iter().any(|&sp| inside(sp))
@ -694,12 +729,11 @@ pub fn poly_hit_hatch(
if hatch.boundary.is_empty() { if hatch.boundary.is_empty() {
return None; return None;
} }
let ox = hatch.world_origin[0] as f32; let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]);
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch let screen: Vec<Point> = hatch
.boundary .boundary
.iter() .iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_rot, eye, bounds)) .map(|&[x, y]| world_to_screen(glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), view_rot, eye, bounds))
.collect(); .collect();
let hit = if crossing { let hit = if crossing {
screen.iter().any(|&sp| point_in_polygon(sp, poly)) screen.iter().any(|&sp| point_in_polygon(sp, poly))