From 96aaf2a293d0c6f6b0b3588a494e2b0024d9fe1d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 23 Jun 2026 23:33:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(snap,pick):=20eye-relative=20CPU=20project?= =?UTF-8?q?ion=20=E2=80=94=20drop=20full=20view=5Fproj=20at=20UTM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route the remaining absolute-coordinate CPU projection through the relative-to-eye path so it no longer multiplies by a view_proj matrix carrying a ~1e7 translation (which cancels catastrophically in f32 after world_offset removal): - camera pick_on_plane / pick_on_target_plane delegate to unproject_on_plane_f64 (rotation-only inverse + f64 eye) - snapper (world_to_screen), hit_test (click_hit / mesh / hatch), the scene mesh/solid hit methods, grid + UCS axes, and polar_constrain_near all take view_rot + eye and project relative-to-eye - compute_grid_step measures the per-unit scale via the rotation-only matrix (no eye term needed) view_proj remains only for conservative CPU frustum-cull / scissor and the screen-space viewcube / UCS-icon directions. NOTE: SnapResult.world and the command-point chain are still Vec3 (f32) — a ~0.5 m quantization at extreme UTM zoom remains; the command layer goes f64 next. Co-Authored-By: Claude Opus 4.7 --- src/app/helpers.rs | 5 +- src/app/update.rs | 122 +++++++++++++++++++++---------------- src/scene/mod.rs | 32 ++++++---- src/scene/pick/hit_test.rs | 72 +++++++++++++--------- src/scene/view/camera.rs | 64 +++---------------- src/snap/mod.rs | 62 +++++++++++-------- src/ui/overlay.rs | 7 ++- 7 files changed, 187 insertions(+), 177 deletions(-) diff --git a/src/app/helpers.rs b/src/app/helpers.rs index a63e1c336..a4e0a2e89 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -228,14 +228,15 @@ pub(super) fn polar_constrain_near( pt: glam::Vec3, base: glam::Vec3, step_deg: f32, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, tol_px: f32, xf: &UcsXform, ) -> glam::Vec3 { let snapped = polar_constrain(pt, base, step_deg, xf); let to_screen = |w: glam::Vec3| { - let ndc = view_proj.project_point3(w); + let ndc = view_rot.project_point3((w.as_dvec3() - eye).as_vec3()); ( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, diff --git a/src/app/update.rs b/src/app/update.rs index 9f0c2a339..b18fdbe8c 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2618,7 +2618,7 @@ impl OpenCADStudio { }; let cam = self.tabs[i].scene.camera.borrow(); let raw_paper = cam.pick_on_target_plane(p, bounds); - let vp_mat = cam.view_proj(bounds); + let view_rot = cam.view_proj_rte(bounds); let eye = cam.eye_f64(); drop(cam); let raw = self.tabs[i].scene.paper_to_model(raw_paper); @@ -2648,7 +2648,7 @@ impl OpenCADStudio { self.snapper.from_point = None; let (go, gr) = self.tabs[i].ucs_grid_basis(); let snap_hit = - self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds, go, gr); + self.snapper.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr); let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw); self.tabs[i].snap_result = snap_hit; if let Some(s) = self.tabs[i].snap_result.as_mut() { @@ -2666,7 +2666,8 @@ impl OpenCADStudio { snapped, base, self.polar_increment_deg, - vp_mat, + view_rot, + eye, bounds, self.snapper.osnap_radius_px, &ucs_xf, @@ -2782,10 +2783,13 @@ impl OpenCADStudio { .borrow() .pick_on_target_plane(p, bounds) }; - let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye_f64()) + }; // Sync grid-snap spacing to the adaptive spacing of the visible grid. self.snapper.grid_spacing = - crate::ui::overlay::compute_grid_step(view_proj, bounds); + crate::ui::overlay::compute_grid_step(view_rot, bounds); // In MSPACE, map paper-space cursor to model space so that // command previews and snapping work in the correct coordinate space. let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper); @@ -2818,14 +2822,15 @@ impl OpenCADStudio { cursor_world, p, &all_wires[..], - view_proj, + view_rot, + eye, bounds, ) } else { let (go, gr) = self.tabs[i].ucs_grid_basis(); self.snapper.from_point = self.last_point; self.snapper - .snap(cursor_world, p, &all_wires[..], view_proj, bounds, go, gr) + .snap(cursor_world, p, &all_wires[..], view_rot, eye, bounds, go, gr) }; // Object Snap Tracking: update dwell, then align the cursor @@ -2835,7 +2840,8 @@ impl OpenCADStudio { let snap_world = self.tabs[i].snap_result.map(|s| s.world); self.snapper.update_otrack_dwell( snap_world, - view_proj, + view_rot, + eye, bounds, Instant::now(), ); @@ -2848,7 +2854,8 @@ impl OpenCADStudio { let ucs = self.tabs[i].scene.viewcube_ucs_mat(); self.snapper.otrack_snap( cursor_world, - view_proj, + view_rot, + eye, bounds, step, self.last_point, @@ -2889,7 +2896,8 @@ impl OpenCADStudio { pt, base, self.polar_increment_deg, - view_proj, + view_rot, + eye, bounds, self.snapper.osnap_radius_px, &ucs_xf, @@ -2912,7 +2920,7 @@ impl OpenCADStudio { // last point) so the dynamic-input overlay can place its // guide geometry and labels. let project = |bp: glam::Vec3| { - let ndc = view_proj.project_point3(bp); + let ndc = view_rot.project_point3((bp.as_dvec3() - eye).as_vec3()); iced::Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, @@ -2948,7 +2956,7 @@ impl OpenCADStudio { }); if let Some(pick) = pick { let world = glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z); - let ndc = view_proj.project_point3(world); + let ndc = view_rot.project_point3((world.as_dvec3() - eye).as_vec3()); let screen = iced::Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, @@ -2989,7 +2997,7 @@ impl OpenCADStudio { p } else if needs_entity { let hover_handle = - scene::pick::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) + scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .unwrap_or(acadrust::Handle::NULL); let mut p = self.tabs[i] @@ -3440,7 +3448,7 @@ impl OpenCADStudio { }; // Convert paper-space → model-space when inside a viewport. let raw = self.tabs[i].scene.paper_to_model(raw_paper); - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let all_wires = self.tabs[i].scene.hit_test_wires(); let needs_tan = self.tabs[i] .active_cmd @@ -3456,11 +3464,11 @@ impl OpenCADStudio { None } else if needs_tan { self.snapper - .snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds) + .snap_tangent_only(raw, p, &all_wires[..], view_rot, eye, bounds) } else { let (go, gr) = self.tabs[i].ucs_grid_basis(); self.snapper.from_point = self.last_point; - self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds, go, gr) + self.snapper.snap(raw, p, &all_wires[..], view_rot, eye, bounds, go, gr) }; // snap.world is in paper-space (projected wire coords in MSPACE); // convert to model-space so commands receive consistent coordinates. @@ -3482,7 +3490,7 @@ impl OpenCADStudio { }; let ucs = self.tabs[i].scene.viewcube_ucs_mat(); self.snapper - .otrack_snap(raw, vp_mat, bounds, step, self.last_point, ucs) + .otrack_snap(raw, view_rot, eye, bounds, step, self.last_point, ucs) } else { None }; @@ -3505,7 +3513,8 @@ impl OpenCADStudio { pt, base, self.polar_increment_deg, - vp_mat, + view_rot, + eye, bounds, self.snapper.osnap_radius_px, &ucs_xf, @@ -3569,9 +3578,9 @@ impl OpenCADStudio { .map(|c| c.needs_entity_pick()) .unwrap_or(false) { - let vp_mat2 = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot2, eye2) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let all_wires2 = self.tabs[i].scene.hit_test_wires(); - let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds) + let hit = scene::pick::hit_test::click_hit(p, &all_wires2[..], view_rot2, eye2, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { // Some commands (e.g. SS_CATCHMENT) need the entity @@ -3759,13 +3768,14 @@ impl OpenCADStudio { if let Some(a) = box_anchor { let crossing = box_crossing; let all_wires = self.tabs[i].scene.hit_test_wires(); - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let mut handles: Vec = scene::pick::hit_test::box_hit( a, p, crossing, &all_wires[..], - vp_mat, + view_rot, + eye, bounds, ) .into_iter() @@ -3776,14 +3786,15 @@ impl OpenCADStudio { p, crossing, &self.tabs[i].scene.visible_hatches_for_click(), - vp_mat, + view_rot, + eye, bounds, )); handles.extend( - self.tabs[i].scene.mesh_box_hit(a, p, crossing, vp_mat, bounds), + self.tabs[i].scene.mesh_box_hit(a, p, crossing, view_rot, eye, bounds), ); handles.extend(self.tabs[i].scene.block_mesh_box_hit( - a, p, crossing, vp_mat, bounds, + a, p, crossing, view_rot, eye, bounds, )); // Box/lasso accumulates like individual picks // (issue #83): a plain box adds to the current @@ -3818,12 +3829,13 @@ impl OpenCADStudio { }; self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing; let all_wires = self.tabs[i].scene.hit_test_wires(); - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let mut handles: Vec = scene::pick::hit_test::poly_hit( &poly_pts, crossing, &all_wires[..], - vp_mat, + view_rot, + eye, bounds, ) .into_iter() @@ -3833,14 +3845,15 @@ impl OpenCADStudio { &poly_pts, crossing, &self.tabs[i].scene.visible_hatches_for_click(), - vp_mat, + view_rot, + eye, bounds, )); handles.extend( - self.tabs[i].scene.mesh_poly_hit(&poly_pts, crossing, vp_mat, bounds), + self.tabs[i].scene.mesh_poly_hit(&poly_pts, crossing, view_rot, eye, bounds), ); handles.extend(self.tabs[i].scene.block_mesh_poly_hit( - &poly_pts, crossing, vp_mat, bounds, + &poly_pts, crossing, view_rot, eye, bounds, )); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); @@ -3870,7 +3883,7 @@ impl OpenCADStudio { } else { if box_anchor.is_none() { let all_wires = self.tabs[i].scene.hit_test_wires(); - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; // Selection cycling: where two or more objects // overlap, open a list box to pick which one; a @@ -3882,7 +3895,8 @@ impl OpenCADStudio { let cands: Vec = scene::pick::hit_test::click_hits_all( p, &all_wires[..], - vp_mat, + view_rot, + eye, bounds, ) .into_iter() @@ -3898,13 +3912,14 @@ impl OpenCADStudio { if !handled_by_cycling { let hit = - scene::pick::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) + scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { scene::pick::hit_test::click_hit_hatch( p, &self.tabs[i].scene.visible_hatches_for_click(), - vp_mat, + view_rot, + eye, bounds, ) }) @@ -3914,7 +3929,8 @@ impl OpenCADStudio { scene::pick::hit_test::click_hit_insert_hatch( p, &self.tabs[i].scene.insert_hatches_for_click(), - vp_mat, + view_rot, + eye, bounds, ) }) @@ -3923,7 +3939,7 @@ impl OpenCADStudio { // body — top-level solids and block-internal // ones together, front-most wins (a block in // front of a solid resolves to the block). - self.tabs[i].scene.solid_click_hit(p, vp_mat, bounds) + self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds) }); // Selection filter: drop a pick whose type is excluded. let hit = @@ -3960,13 +3976,14 @@ impl OpenCADStudio { let a = box_anchor.unwrap(); let crossing = box_crossing; let all_wires = self.tabs[i].scene.hit_test_wires(); - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let mut handles: Vec = scene::pick::hit_test::box_hit( a, p, crossing, &all_wires[..], - vp_mat, + view_rot, + eye, bounds, ) .into_iter() @@ -3977,14 +3994,15 @@ impl OpenCADStudio { p, crossing, &self.tabs[i].scene.visible_hatches_for_click(), - vp_mat, + view_rot, + eye, bounds, )); handles.extend( - self.tabs[i].scene.mesh_box_hit(a, p, crossing, vp_mat, bounds), + self.tabs[i].scene.mesh_box_hit(a, p, crossing, view_rot, eye, bounds), ); handles.extend(self.tabs[i].scene.block_mesh_box_hit( - a, p, crossing, vp_mat, bounds, + a, p, crossing, view_rot, eye, bounds, )); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); @@ -4061,14 +4079,14 @@ impl OpenCADStudio { width: vw, height: vh, }; - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let all_wires = self.tabs[i].scene.hit_test_wires(); // Resolve the double-clicked object — its wire, or (for a // block/solid with no wire under the cursor) its shaded // body, which maps to the parent INSERT. - let hit = scene::pick::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) + let hit = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) - .or_else(|| self.tabs[i].scene.solid_click_hit(p, vp_mat, bounds)); + .or_else(|| self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds)); if let Some(handle) = hit { // Any text-bearing entity opens its in-place editor // (plain box or rich MText editor, per type). A @@ -4133,9 +4151,9 @@ impl OpenCADStudio { // 1) Try direct wire hit — works when the border is clicked. let hit_vp: Option = { - let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let all_wires = self.tabs[i].scene.hit_test_wires(); - scene::pick::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) + scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .and_then(|h| { if let Some(AcadEntityType::Viewport(vp)) = @@ -4416,18 +4434,19 @@ impl OpenCADStudio { height: dwell.tile_size.1, }; let p = dwell.point; - let view_proj = self.tabs[i].scene.camera.borrow().view_proj(bounds); + let (view_rot, eye) = { let c = self.tabs[i].scene.camera.borrow(); (c.view_proj_rte(bounds), c.eye_f64()) }; let all_wires = self.tabs[i].scene.hit_test_wires(); // Mirror the click-selection pick order so the rollover // highlights every selectable object: wire → hatch → // block-internal hatch → shaded 3D solid body. - let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) + let hovered = scene::pick::hit_test::click_hit(p, &all_wires[..], view_rot, eye, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { scene::pick::hit_test::click_hit_hatch( p, &self.tabs[i].scene.visible_hatches_for_click(), - view_proj, + view_rot, + eye, bounds, ) }) @@ -4435,11 +4454,12 @@ impl OpenCADStudio { scene::pick::hit_test::click_hit_insert_hatch( p, &self.tabs[i].scene.insert_hatches_for_click(), - view_proj, + view_rot, + eye, bounds, ) }) - .or_else(|| self.tabs[i].scene.solid_click_hit(p, view_proj, bounds)); + .or_else(|| self.tabs[i].scene.solid_click_hit(p, view_rot, eye, bounds)); self.tabs[i].scene.set_hover_highlight(hovered); self.hover_dwell = None; Task::none() diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 485d4bc0a..b60b3836f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2501,14 +2501,15 @@ impl Scene { pub fn mesh_click_hit( &self, cursor: iced::Point, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option { let iter = self .meshes .iter() .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - pick::hit_test::mesh_click_hit(cursor, iter, view_proj, bounds) + pick::hit_test::mesh_click_hit(cursor, iter, view_rot, eye, bounds) } /// True when any handle resolves to an ACIS volume entity (3D solid / @@ -2532,14 +2533,15 @@ impl Scene { a: iced::Point, b: iced::Point, crossing: bool, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, ) -> Vec { let iter = self .meshes .iter() .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - pick::hit_test::mesh_box_hit(a, b, crossing, iter, view_proj, bounds) + pick::hit_test::mesh_box_hit(a, b, crossing, iter, view_rot, eye, bounds) } /// Top-level solid handles caught by a lasso polygon. @@ -2547,14 +2549,15 @@ impl Scene { &self, poly: &[iced::Point], crossing: bool, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, ) -> Vec { let iter = self .meshes .iter() .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - pick::hit_test::mesh_poly_hit(poly, crossing, iter, view_proj, bounds) + pick::hit_test::mesh_poly_hit(poly, crossing, iter, view_rot, eye, bounds) } /// Front-most solid under the cursor across BOTH top-level solid meshes @@ -2565,7 +2568,8 @@ impl Scene { pub fn solid_click_hit( &self, cursor: iced::Point, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option { // Block-internal instances must be owned (transformed copies); keep @@ -2597,7 +2601,7 @@ impl Scene { .iter() .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); let blk = block_owned.iter().map(|(h, m)| (*h, m)); - pick::hit_test::mesh_click_hit(cursor, top.chain(blk), view_proj, bounds) + pick::hit_test::mesh_click_hit(cursor, top.chain(blk), view_rot, eye, bounds) } /// Parent INSERT handles whose block-internal solid meshes fall in a @@ -2609,7 +2613,8 @@ impl Scene { a: iced::Point, b: iced::Point, crossing: bool, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, ) -> Vec { if self.block_meshes.is_empty() { @@ -2635,7 +2640,8 @@ impl Scene { b, crossing, std::iter::once((ins.common.handle, m)), - view_proj, + view_rot, + eye, bounds, ) .is_empty() @@ -2653,7 +2659,8 @@ impl Scene { &self, poly: &[iced::Point], crossing: bool, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, ) -> Vec { if self.block_meshes.is_empty() { @@ -2678,7 +2685,8 @@ impl Scene { poly, crossing, std::iter::once((ins.common.handle, m)), - view_proj, + view_rot, + eye, bounds, ) .is_empty() diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 67aae15c1..296651f6b 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -26,7 +26,8 @@ const CLICK_THRESHOLD_PX: f32 = 8.0; pub fn click_hit<'a>( cursor: Point, wires: &'a [WireModel], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Option<&'a str> { let mut best_dist = CLICK_THRESHOLD_PX; @@ -38,7 +39,7 @@ pub fn click_hit<'a>( // on its world x/y, so its world-space AABB projects exactly and we can // reject wires nowhere near the cursor without projecting any of their // points (the dominant per-move cost on 100 k-wire drawings). - let z_flat = view_proj.z_axis.x.abs() < 1e-9 && view_proj.z_axis.y.abs() < 1e-9; + let z_flat = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9; // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires { @@ -54,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_proj, bounds); + let s = world_to_screen(Vec3::new(cx, cy, 0.0), view_rot, eye, bounds); sx0 = sx0.min(s.x); sx1 = sx1.max(s.x); sy0 = sy0.min(s.y); @@ -72,7 +73,7 @@ pub fn click_hit<'a>( prev = None; continue; } - let cur = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + let cur = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); if let Some(p0) = prev { let d = dist_point_to_segment(cursor, p0, cur); if d < best_dist { @@ -93,7 +94,8 @@ pub fn click_hit<'a>( pub fn click_hits_all<'a>( cursor: Point, wires: &'a [WireModel], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec<&'a str> { let mut hits: Vec<(f32, &str)> = Vec::new(); @@ -106,7 +108,7 @@ pub fn click_hits_all<'a>( prev = None; continue; } - let cur = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + let cur = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); if let Some(p0) = prev { let d = dist_point_to_segment(cursor, p0, cur); if d < best_for_wire { @@ -131,7 +133,8 @@ pub fn click_hits_all<'a>( pub fn mesh_click_hit<'a>( cursor: Point, meshes: impl Iterator, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Option { let mut best: Option<(f32, Handle)> = None; @@ -149,7 +152,7 @@ pub fn mesh_click_hit<'a>( let mut sp = [Point::ORIGIN; 3]; let mut depth = 0.0f32; for (j, w) in tri.iter().enumerate() { - let ndc = view_proj.project_point3(Vec3::new(w[0], w[1], w[2])); + let ndc = view_rot.project_point3((Vec3::new(w[0], w[1], w[2]).as_dvec3() - eye).as_vec3()); sp[j] = Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, @@ -169,11 +172,11 @@ pub fn mesh_click_hit<'a>( } /// Project a mesh's vertices to screen space. -fn project_mesh_verts(mesh: &MeshModel, view_proj: Mat4, bounds: Rectangle) -> Vec { +fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec { mesh.verts .iter() .map(|w| { - let ndc = view_proj.project_point3(Vec3::new(w[0], w[1], w[2])); + let ndc = view_rot.project_point3((Vec3::new(w[0], w[1], w[2]).as_dvec3() - eye).as_vec3()); Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, @@ -208,7 +211,8 @@ pub fn mesh_box_hit<'a>( b: Point, crossing: bool, meshes: impl Iterator, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec { let (min_x, max_x) = (a.x.min(b.x), a.x.max(b.x)); @@ -222,7 +226,7 @@ pub fn mesh_box_hit<'a>( ]; let mut out = Vec::new(); for (h, mesh) in meshes { - let proj = project_mesh_verts(mesh, view_proj, bounds); + let proj = project_mesh_verts(mesh, view_rot, eye, bounds); if proj.is_empty() { continue; } @@ -245,7 +249,8 @@ pub fn mesh_poly_hit<'a>( poly: &[Point], crossing: bool, meshes: impl Iterator, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec { if poly.len() < 3 { @@ -253,7 +258,7 @@ pub fn mesh_poly_hit<'a>( } let mut out = Vec::new(); for (h, mesh) in meshes { - let proj = project_mesh_verts(mesh, view_proj, bounds); + let proj = project_mesh_verts(mesh, view_rot, eye, bounds); if proj.is_empty() { continue; } @@ -285,7 +290,8 @@ pub fn box_hit<'a>( corner_b: Point, crossing: bool, wires: &'a [WireModel], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec<&'a str> { let min_x = corner_a.x.min(corner_b.x); @@ -339,7 +345,7 @@ pub fn box_hit<'a>( prev = None; continue; } - let sp = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + let sp = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); if crossing { if inside(sp) { hit = true; @@ -385,7 +391,8 @@ pub fn poly_hit<'a>( poly: &[Point], crossing: bool, wires: &'a [WireModel], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec<&'a str> { if poly.len() < 3 { @@ -426,7 +433,7 @@ pub fn poly_hit<'a>( prev = None; continue; } - let sp = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + let sp = world_to_screen(Vec3::new(px, py, pz), view_rot, eye, bounds); if crossing { if point_in_polygon(sp, poly) { hit = true; @@ -462,8 +469,8 @@ pub fn poly_hit<'a>( // ── Helpers ─────────────────────────────────────────────────────────────── -fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point { - let ndc = view_proj.project_point3(world); +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()); Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, @@ -560,11 +567,12 @@ fn segments_intersect(a: Point, b: Point, c: Point, d: Point) -> bool { pub fn click_hit_hatch( cursor: Point, hatches: &HashMap, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Option { for (&handle, hatch) in hatches { - if hatch_contains_screen_point(hatch, cursor, view_proj, bounds) { + if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) { return Some(handle); } } @@ -579,11 +587,12 @@ pub fn click_hit_hatch( pub fn click_hit_insert_hatch( cursor: Point, insert_hatches: &[(Handle, HatchModel)], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Option { for (handle, hatch) in insert_hatches { - if hatch_contains_screen_point(hatch, cursor, view_proj, bounds) { + if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) { return Some(*handle); } } @@ -593,7 +602,8 @@ pub fn click_hit_insert_hatch( fn hatch_contains_screen_point( hatch: &HatchModel, cursor: Point, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> bool { // boundary verts are stored as small f32 offsets from @@ -606,7 +616,7 @@ fn hatch_contains_screen_point( .iter() .map(|&[x, y]| { if x.is_finite() && y.is_finite() { - world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds) + world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_rot, eye, bounds) } else { // Preserve path separators for the NaN-aware // point_in_polygon ray-cast. @@ -623,7 +633,8 @@ pub fn box_hit_hatch( corner_b: Point, crossing: bool, hatches: &HashMap, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec { let min_x = corner_a.x.min(corner_b.x); @@ -648,7 +659,7 @@ pub fn box_hit_hatch( let screen: Vec = hatch .boundary .iter() - .map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds)) + .map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_rot, eye, bounds)) .collect(); let hit = if crossing { screen.iter().any(|&sp| inside(sp)) @@ -669,7 +680,8 @@ pub fn poly_hit_hatch( poly: &[Point], crossing: bool, hatches: &HashMap, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Vec { if poly.len() < 3 { @@ -687,7 +699,7 @@ pub fn poly_hit_hatch( let screen: Vec = hatch .boundary .iter() - .map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds)) + .map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_rot, eye, bounds)) .collect(); let hit = if crossing { screen.iter().any(|&sp| point_in_polygon(sp, poly)) diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 66c865ea4..8fbac370c 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -225,65 +225,17 @@ impl Camera { plane_normal: Vec3, plane_point: Vec3, ) -> Vec3 { - let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; - let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; - let inv = self.view_proj(bounds).inverse(); - - let (ray_origin, ray_dir) = match self.projection { - Projection::Perspective => { - let near_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); - let far_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 1.0)); - let dir = (far_pt - near_pt).normalize(); - (near_pt, dir) - } - Projection::Orthographic => { - let origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); - let forward = (self.target.as_vec3() - self.eye()).normalize(); - (origin, forward) - } - }; - - let denom = ray_dir.dot(plane_normal); - if denom.abs() < 1e-6 { - return plane_point; - } - let t = (plane_point - ray_origin).dot(plane_normal) / denom; - if t < 0.0 { - return plane_point; - } - ray_origin + ray_dir * t + // Delegate to the eye-relative f64 unproject so the cursor stays + // precise at UTM-scale coordinates (the old full view_proj.inverse() + // cancelled catastrophically in f32). + self.unproject_on_plane_f64(screen, bounds, plane_normal, plane_point.as_dvec3()) + .as_vec3() } pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> Vec3 { - let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; - let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; - let inv = self.view_proj(bounds).inverse(); - - match self.projection { - Projection::Perspective => { - let target = self.target.as_vec3(); - let near_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); - let far_pt = inv.project_point3(Vec3::new(ndc_x, ndc_y, 1.0)); - let dir = (far_pt - near_pt).normalize(); - let forward = (target - self.eye()).normalize(); - let denom = dir.dot(forward); - if denom.abs() < 1e-6 { - return target; - } - let t = (target - near_pt).dot(forward) / denom; - if t < 0.0 { - return target; - } - near_pt + dir * t - } - Projection::Orthographic => { - let target = self.target.as_vec3(); - let ray_origin = inv.project_point3(Vec3::new(ndc_x, ndc_y, 0.0)); - let forward = (target - self.eye()).normalize(); - let t = (target - ray_origin).dot(forward) / forward.dot(forward); - ray_origin + forward * t - } - } + let forward = (self.target.as_vec3() - self.eye()).normalize_or(Vec3::NEG_Z); + self.unproject_on_plane_f64(screen, bounds, forward, self.target) + .as_vec3() } diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 5d70aa4c3..331601140 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -177,7 +177,8 @@ impl Snapper { pub fn update_otrack_dwell( &mut self, snap_world: Option, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, now: Instant, ) { @@ -202,8 +203,8 @@ impl Snapper { Some(p) => { // Convert to screen to measure pixel distance. let is_same = if let Some(prev) = self.last_snap_world { - let dp = world_to_screen(p, view_proj, bounds); - let dp2 = world_to_screen(prev, view_proj, bounds); + let dp = world_to_screen(p, view_rot, eye, bounds); + let dp2 = world_to_screen(prev, view_rot, eye, bounds); let dx = dp.x - dp2.x; let dy = dp.y - dp2.y; (dx * dx + dy * dy).sqrt() < DWELL_PX @@ -259,7 +260,8 @@ impl Snapper { pub fn otrack_snap( &self, cursor_world: Vec3, - view_proj: glam::Mat4, + view_rot: glam::Mat4, + eye: glam::DVec3, bounds: iced::Rectangle, polar_step_deg: Option, last_point: Option, @@ -271,11 +273,11 @@ impl Snapper { return None; } - let cursor_screen = world_to_screen(cursor_world, view_proj, bounds); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds); // Use the same aperture as OSNAP so the catch distance is uniform. let r = self.osnap_radius_px; let screen_dist = |w: Vec3| { - let s = world_to_screen(w, view_proj, bounds); + let s = world_to_screen(w, view_rot, eye, bounds); ((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt() }; @@ -409,7 +411,8 @@ impl Snapper { cursor_world: Vec3, cursor_screen: Point, wires: &[WireModel], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, ) -> Option { let tmp = Snapper { @@ -433,7 +436,8 @@ impl Snapper { cursor_world, cursor_screen, wires, - view_proj, + view_rot, + eye, bounds, Vec3::ZERO, Mat4::IDENTITY, @@ -446,7 +450,8 @@ impl Snapper { cursor_world: Vec3, cursor_screen: Point, wires: &[WireModel], - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, // Grid origin (render/wire space) and UCS→world rotation, so grid snap // lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid. @@ -474,7 +479,7 @@ impl Snapper { // view_proj col-0 x = 2*zoom / viewport_width for an orthographic camera, // so scale_x * (width/2) = pixels per world unit. let world_snap_r = { - let s = view_proj.col(0).x.abs() * bounds.width * 0.5; + let s = view_rot.col(0).x.abs() * bounds.width * 0.5; if s > 1e-6 { self.osnap_radius_px / s } else { @@ -495,7 +500,7 @@ impl Snapper { }; let mut try_pt = |world: Vec3, snap_type: SnapType| { - let screen = world_to_screen(world, view_proj, bounds); + let screen = world_to_screen(world, view_rot, eye, bounds); 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 @@ -672,7 +677,8 @@ impl Snapper { cursor_world, p0, p0 - p1, - view_proj, + view_rot, + eye, bounds, self.osnap_radius_px, ) { @@ -687,7 +693,8 @@ impl Snapper { cursor_world, p_last, p_last - p_prev, - view_proj, + view_rot, + eye, bounds, self.osnap_radius_px, ) { @@ -709,7 +716,7 @@ impl Snapper { Some( w.points .iter() - .map(|&p| world_to_screen(Vec3::from(p), view_proj, bounds)) + .map(|&p| world_to_screen(Vec3::from(p), view_rot, eye, bounds)) .collect::>(), ) }) @@ -762,8 +769,8 @@ impl Snapper { for tg in &wire.tangent_geoms { let (world_pt, d2) = match tg { TangentGeom::Line { p1, p2 } => { - let sp0 = world_to_screen(Vec3::from(*p1), view_proj, bounds); - let sp1 = world_to_screen(Vec3::from(*p2), view_proj, bounds); + let sp0 = world_to_screen(Vec3::from(*p1), view_rot, eye, bounds); + let sp1 = world_to_screen(Vec3::from(*p2), view_rot, eye, bounds); let d2 = dist2_to_segment(cursor_screen, sp0, sp1); let t = t_on_segment(cursor_screen, sp0, sp1); let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1)); @@ -771,10 +778,11 @@ impl Snapper { } TangentGeom::Circle { center, radius } => { let cv = Vec3::from(*center); - let sc = world_to_screen(cv, view_proj, bounds); + let sc = world_to_screen(cv, view_rot, eye, bounds); let rim = world_to_screen( Vec3::new(cv.x + radius, cv.y, cv.z), - view_proj, + view_rot, + eye, bounds, ); let sr = dist2(sc, rim).sqrt(); @@ -797,7 +805,7 @@ impl Snapper { if d2 < radius2 && (rank < best_rank || (rank == best_rank && d2 < best_d2)) { best_rank = rank; best_d2 = d2; - let screen_pt = world_to_screen(world_pt, view_proj, bounds); + let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds); let tangent_obj = match tg { TangentGeom::Line { p1, p2 } => TangentObject::Line { p1: Vec3::from(*p1), @@ -940,7 +948,8 @@ fn extension_snap( cursor_world: Vec3, origin: Vec3, dir: Vec3, - view_proj: Mat4, + view_rot: Mat4, + eye: glam::DVec3, bounds: Rectangle, radius_px: f32, ) -> Option { @@ -953,8 +962,8 @@ fn extension_snap( return None; } // only beyond the endpoint let world_pt = Vec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z); - let screen_pt = world_to_screen(world_pt, view_proj, bounds); - let cursor_screen = world_to_screen(cursor_world, view_proj, bounds); + let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds); if dist2(screen_pt, cursor_screen) > radius_px * radius_px { return None; } @@ -963,8 +972,13 @@ fn extension_snap( // ── Projection helpers ──────────────────────────────────────────────────── -fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point { - let ndc = view_proj.project_point3(world); +/// Project a world point to screen relative-to-eye: subtract the f64 eye first +/// so the result is precise at UTM-scale absolute coordinates (a full +/// view-projection with a ~1e7 translation cancels catastrophically in f32). +/// `view_rot` is the rotation-only view-projection (Camera::view_proj_rte). +fn world_to_screen(world: Vec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point { + let rel = (world.as_dvec3() - eye).as_vec3(); + let ndc = view_rot.project_point3(rel); Point::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height, diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index f49f23ff4..fcd6fce33 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -69,10 +69,13 @@ pub struct GridParams { /// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at /// least `MIN_GRID_PX` pixels apart. This matches exactly what `draw_grid` /// renders, so callers can sync snap spacing to the visible grid. -pub fn compute_grid_step(vp: Mat4, bounds: iced::Rectangle) -> f32 { +pub fn compute_grid_step(view_rot: Mat4, bounds: iced::Rectangle) -> f32 { use glam::Vec3; + // Only the per-unit screen scale is needed; project small eye-relative + // offsets (0 / X / Y) through the rotation-only matrix so this stays correct + // and precise at any absolute coordinate (no eye term required). let w2s = |world: Vec3| { - let ndc = vp.project_point3(world); + let ndc = view_rot.project_point3(world); glam::Vec2::new( (ndc.x + 1.0) * 0.5 * bounds.width, (1.0 - ndc.y) * 0.5 * bounds.height,