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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-24 10:33:42 +03:00
1 parent e5c8eddedb
commit fe5ff376e5
1 file changed
+64 -30
+64 -30
View File
@@ -7,7 +7,7 @@
use rustc_hash::FxHashMap as HashMap;
use acadrust::Handle;
use glam::{Mat4, Vec3};
use glam::Mat4;
use iced::{Point, Rectangle};
use crate::scene::model::hatch_model::HatchModel;
@@ -55,7 +55,7 @@ pub fn click_hit<'a>(
let mut sx1 = f32::MIN;
let mut sy1 = f32::MIN;
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);
sx1 = sx1.max(s.x);
sy0 = sy0.min(s.y);
@@ -68,12 +68,12 @@ pub fn click_hit<'a>(
}
}
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() {
prev = None;
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 {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_dist {
@@ -103,12 +103,12 @@ pub fn click_hits_all<'a>(
let mut prev: Option<Point> = None;
let mut best_for_wire = CLICK_THRESHOLD_PX;
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() {
prev = None;
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 {
let d = dist_point_to_segment(cursor, p0, cur);
if d < best_for_wire {
@@ -141,18 +141,15 @@ pub fn mesh_click_hit<'a>(
for (handle, mesh) in meshes {
let v = &mesh.verts;
let idx = &mesh.indices;
let lo = &mesh.verts_low;
let mut t = 0;
while t + 2 < idx.len() {
let tri = [
v[idx[t] as usize],
v[idx[t + 1] as usize],
v[idx[t + 2] as usize],
];
let tri = [idx[t] as usize, idx[t + 1] as usize, idx[t + 2] as usize];
t += 3;
let mut sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32;
for (j, w) in tri.iter().enumerate() {
let ndc = view_rot.project_point3((Vec3::new(w[0], w[1], w[2]).as_dvec3() - eye).as_vec3());
for (j, &k) in tri.iter().enumerate() {
let ndc = view_rot.project_point3((mesh_vert(v[k], lo, k) - eye).as_vec3());
sp[j] = Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
@@ -171,12 +168,26 @@ pub fn mesh_click_hit<'a>(
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.
fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec<Point> {
mesh.verts
.iter()
.map(|w| {
let ndc = view_rot.project_point3((Vec3::new(w[0], w[1], w[2]).as_dvec3() - eye).as_vec3());
.enumerate()
.map(|(i, &w)| {
let ndc = view_rot.project_point3((mesh_vert(w, &mesh.verts_low, i) - eye).as_vec3());
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
@@ -336,16 +347,23 @@ pub fn box_hit<'a>(
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 all_inside = true;
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() {
prev = None;
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 inside(sp) {
hit = true;
@@ -424,16 +442,21 @@ pub fn poly_hit<'a>(
return None;
};
let low: &[[f32; 3]] = if !wire.points.is_empty() {
&wire.points_low
} else {
&[]
};
let mut hit = false;
let mut all_inside = true;
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() {
prev = None;
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 point_in_polygon(sp, poly) {
hit = true;
@@ -469,14 +492,28 @@ pub fn poly_hit<'a>(
// ── Helpers ───────────────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
let ndc = view_rot.project_point3((world.as_dvec3() - eye).as_vec3());
fn world_to_screen(world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
let ndc = view_rot.project_point3((world - eye).as_vec3());
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(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?
///
/// 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
// `world_origin` (f64). Reconstruct offset-rel WCS before
// projecting to screen.
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]);
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| {
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 {
// Preserve path separators for the NaN-aware
// point_in_polygon ray-cast.
@@ -654,12 +690,11 @@ pub fn box_hit_hatch(
if hatch.boundary.is_empty() {
return None;
}
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]);
let screen: Vec<Point> = hatch
.boundary
.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();
let hit = if crossing {
screen.iter().any(|&sp| inside(sp))
@@ -694,12 +729,11 @@ pub fn poly_hit_hatch(
if hatch.boundary.is_empty() {
return None;
}
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]);
let screen: Vec<Point> = hatch
.boundary
.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();
let hit = if crossing {
screen.iter().any(|&sp| point_in_polygon(sp, poly))