feat(snap,pick): eye-relative CPU projection — drop full view_proj at UTM

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-23 23:33:21 +03:00
commit 96aaf2a293
7 changed files with 187 additions and 177 deletions

View file

@ -228,14 +228,15 @@ pub(super) fn polar_constrain_near(
pt: glam::Vec3, pt: glam::Vec3,
base: glam::Vec3, base: glam::Vec3,
step_deg: f32, step_deg: f32,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
tol_px: f32, tol_px: f32,
xf: &UcsXform, xf: &UcsXform,
) -> glam::Vec3 { ) -> glam::Vec3 {
let snapped = polar_constrain(pt, base, step_deg, xf); let snapped = polar_constrain(pt, base, step_deg, xf);
let to_screen = |w: glam::Vec3| { 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, (ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height, (1.0 - ndc.y) * 0.5 * bounds.height,

View file

@ -2618,7 +2618,7 @@ impl OpenCADStudio {
}; };
let cam = self.tabs[i].scene.camera.borrow(); let cam = self.tabs[i].scene.camera.borrow();
let raw_paper = cam.pick_on_target_plane(p, bounds); 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); drop(cam);
let raw = self.tabs[i].scene.paper_to_model(raw_paper); let raw = self.tabs[i].scene.paper_to_model(raw_paper);
@ -2648,7 +2648,7 @@ impl OpenCADStudio {
self.snapper.from_point = None; self.snapper.from_point = None;
let (go, gr) = self.tabs[i].ucs_grid_basis(); let (go, gr) = self.tabs[i].ucs_grid_basis();
let snap_hit = 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); let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
self.tabs[i].snap_result = snap_hit; self.tabs[i].snap_result = snap_hit;
if let Some(s) = self.tabs[i].snap_result.as_mut() { if let Some(s) = self.tabs[i].snap_result.as_mut() {
@ -2666,7 +2666,8 @@ impl OpenCADStudio {
snapped, snapped,
base, base,
self.polar_increment_deg, self.polar_increment_deg,
vp_mat, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
&ucs_xf, &ucs_xf,
@ -2782,10 +2783,13 @@ impl OpenCADStudio {
.borrow() .borrow()
.pick_on_target_plane(p, bounds) .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. // Sync grid-snap spacing to the adaptive spacing of the visible grid.
self.snapper.grid_spacing = 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 // In MSPACE, map paper-space cursor to model space so that
// command previews and snapping work in the correct coordinate space. // command previews and snapping work in the correct coordinate space.
let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper); let cursor_world = self.tabs[i].scene.paper_to_model(cursor_paper);
@ -2818,14 +2822,15 @@ impl OpenCADStudio {
cursor_world, cursor_world,
p, p,
&all_wires[..], &all_wires[..],
view_proj, view_rot,
eye,
bounds, bounds,
) )
} else { } else {
let (go, gr) = self.tabs[i].ucs_grid_basis(); let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point; self.snapper.from_point = self.last_point;
self.snapper 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 // 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); let snap_world = self.tabs[i].snap_result.map(|s| s.world);
self.snapper.update_otrack_dwell( self.snapper.update_otrack_dwell(
snap_world, snap_world,
view_proj, view_rot,
eye,
bounds, bounds,
Instant::now(), Instant::now(),
); );
@ -2848,7 +2854,8 @@ impl OpenCADStudio {
let ucs = self.tabs[i].scene.viewcube_ucs_mat(); let ucs = self.tabs[i].scene.viewcube_ucs_mat();
self.snapper.otrack_snap( self.snapper.otrack_snap(
cursor_world, cursor_world,
view_proj, view_rot,
eye,
bounds, bounds,
step, step,
self.last_point, self.last_point,
@ -2889,7 +2896,8 @@ impl OpenCADStudio {
pt, pt,
base, base,
self.polar_increment_deg, self.polar_increment_deg,
view_proj, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
&ucs_xf, &ucs_xf,
@ -2912,7 +2920,7 @@ impl OpenCADStudio {
// last point) so the dynamic-input overlay can place its // last point) so the dynamic-input overlay can place its
// guide geometry and labels. // guide geometry and labels.
let project = |bp: glam::Vec3| { 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( iced::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,
@ -2948,7 +2956,7 @@ impl OpenCADStudio {
}); });
if let Some(pick) = pick { if let Some(pick) = pick {
let world = glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z); 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( let screen = iced::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,
@ -2989,7 +2997,7 @@ impl OpenCADStudio {
p p
} else if needs_entity { } else if needs_entity {
let hover_handle = 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)) .and_then(|s| Scene::handle_from_wire_name(s))
.unwrap_or(acadrust::Handle::NULL); .unwrap_or(acadrust::Handle::NULL);
let mut p = self.tabs[i] let mut p = self.tabs[i]
@ -3440,7 +3448,7 @@ impl OpenCADStudio {
}; };
// Convert paper-space → model-space when inside a viewport. // Convert paper-space → model-space when inside a viewport.
let raw = self.tabs[i].scene.paper_to_model(raw_paper); 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 all_wires = self.tabs[i].scene.hit_test_wires();
let needs_tan = self.tabs[i] let needs_tan = self.tabs[i]
.active_cmd .active_cmd
@ -3456,11 +3464,11 @@ impl OpenCADStudio {
None None
} else if needs_tan { } else if needs_tan {
self.snapper self.snapper
.snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds) .snap_tangent_only(raw, p, &all_wires[..], view_rot, eye, bounds)
} else { } else {
let (go, gr) = self.tabs[i].ucs_grid_basis(); let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point; 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); // snap.world is in paper-space (projected wire coords in MSPACE);
// convert to model-space so commands receive consistent coordinates. // convert to model-space so commands receive consistent coordinates.
@ -3482,7 +3490,7 @@ impl OpenCADStudio {
}; };
let ucs = self.tabs[i].scene.viewcube_ucs_mat(); let ucs = self.tabs[i].scene.viewcube_ucs_mat();
self.snapper 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 { } else {
None None
}; };
@ -3505,7 +3513,8 @@ impl OpenCADStudio {
pt, pt,
base, base,
self.polar_increment_deg, self.polar_increment_deg,
vp_mat, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
&ucs_xf, &ucs_xf,
@ -3569,9 +3578,9 @@ impl OpenCADStudio {
.map(|c| c.needs_entity_pick()) .map(|c| c.needs_entity_pick())
.unwrap_or(false) .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 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)); .and_then(|s| Scene::handle_from_wire_name(s));
if let Some(handle) = hit { if let Some(handle) = hit {
// Some commands (e.g. SS_CATCHMENT) need the entity // Some commands (e.g. SS_CATCHMENT) need the entity
@ -3759,13 +3768,14 @@ impl OpenCADStudio {
if let Some(a) = box_anchor { if let Some(a) = box_anchor {
let crossing = box_crossing; let crossing = box_crossing;
let all_wires = self.tabs[i].scene.hit_test_wires(); 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<Handle> = scene::pick::hit_test::box_hit( let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a, a,
p, p,
crossing, crossing,
&all_wires[..], &all_wires[..],
vp_mat, view_rot,
eye,
bounds, bounds,
) )
.into_iter() .into_iter()
@ -3776,14 +3786,15 @@ impl OpenCADStudio {
p, p,
crossing, crossing,
&self.tabs[i].scene.visible_hatches_for_click(), &self.tabs[i].scene.visible_hatches_for_click(),
vp_mat, view_rot,
eye,
bounds, bounds,
)); ));
handles.extend( 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( 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 // Box/lasso accumulates like individual picks
// (issue #83): a plain box adds to the current // (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; self.tabs[i].scene.selection.borrow_mut().poly_last_crossing = crossing;
let all_wires = self.tabs[i].scene.hit_test_wires(); 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<Handle> = scene::pick::hit_test::poly_hit( let mut handles: Vec<Handle> = scene::pick::hit_test::poly_hit(
&poly_pts, &poly_pts,
crossing, crossing,
&all_wires[..], &all_wires[..],
vp_mat, view_rot,
eye,
bounds, bounds,
) )
.into_iter() .into_iter()
@ -3833,14 +3845,15 @@ impl OpenCADStudio {
&poly_pts, &poly_pts,
crossing, crossing,
&self.tabs[i].scene.visible_hatches_for_click(), &self.tabs[i].scene.visible_hatches_for_click(),
vp_mat, view_rot,
eye,
bounds, bounds,
)); ));
handles.extend( 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( 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. // Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
@ -3870,7 +3883,7 @@ impl OpenCADStudio {
} else { } else {
if box_anchor.is_none() { if box_anchor.is_none() {
let all_wires = self.tabs[i].scene.hit_test_wires(); 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 // Selection cycling: where two or more objects
// overlap, open a list box to pick which one; a // overlap, open a list box to pick which one; a
@ -3882,7 +3895,8 @@ impl OpenCADStudio {
let cands: Vec<Handle> = scene::pick::hit_test::click_hits_all( let cands: Vec<Handle> = scene::pick::hit_test::click_hits_all(
p, p,
&all_wires[..], &all_wires[..],
vp_mat, view_rot,
eye,
bounds, bounds,
) )
.into_iter() .into_iter()
@ -3898,13 +3912,14 @@ impl OpenCADStudio {
if !handled_by_cycling { if !handled_by_cycling {
let hit = 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)) .and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| { .or_else(|| {
scene::pick::hit_test::click_hit_hatch( scene::pick::hit_test::click_hit_hatch(
p, p,
&self.tabs[i].scene.visible_hatches_for_click(), &self.tabs[i].scene.visible_hatches_for_click(),
vp_mat, view_rot,
eye,
bounds, bounds,
) )
}) })
@ -3914,7 +3929,8 @@ impl OpenCADStudio {
scene::pick::hit_test::click_hit_insert_hatch( scene::pick::hit_test::click_hit_insert_hatch(
p, p,
&self.tabs[i].scene.insert_hatches_for_click(), &self.tabs[i].scene.insert_hatches_for_click(),
vp_mat, view_rot,
eye,
bounds, bounds,
) )
}) })
@ -3923,7 +3939,7 @@ impl OpenCADStudio {
// body — top-level solids and block-internal // body — top-level solids and block-internal
// ones together, front-most wins (a block in // ones together, front-most wins (a block in
// front of a solid resolves to the block). // 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. // Selection filter: drop a pick whose type is excluded.
let hit = let hit =
@ -3960,13 +3976,14 @@ impl OpenCADStudio {
let a = box_anchor.unwrap(); let a = box_anchor.unwrap();
let crossing = box_crossing; let crossing = box_crossing;
let all_wires = self.tabs[i].scene.hit_test_wires(); 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<Handle> = scene::pick::hit_test::box_hit( let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a, a,
p, p,
crossing, crossing,
&all_wires[..], &all_wires[..],
vp_mat, view_rot,
eye,
bounds, bounds,
) )
.into_iter() .into_iter()
@ -3977,14 +3994,15 @@ impl OpenCADStudio {
p, p,
crossing, crossing,
&self.tabs[i].scene.visible_hatches_for_click(), &self.tabs[i].scene.visible_hatches_for_click(),
vp_mat, view_rot,
eye,
bounds, bounds,
)); ));
handles.extend( 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( 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. // Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
@ -4061,14 +4079,14 @@ impl OpenCADStudio {
width: vw, width: vw,
height: vh, 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(); let all_wires = self.tabs[i].scene.hit_test_wires();
// Resolve the double-clicked object — its wire, or (for a // Resolve the double-clicked object — its wire, or (for a
// block/solid with no wire under the cursor) its shaded // block/solid with no wire under the cursor) its shaded
// body, which maps to the parent INSERT. // 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)) .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 { if let Some(handle) = hit {
// Any text-bearing entity opens its in-place editor // Any text-bearing entity opens its in-place editor
// (plain box or rich MText editor, per type). A // (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. // 1) Try direct wire hit — works when the border is clicked.
let hit_vp: Option<acadrust::Handle> = { let hit_vp: Option<acadrust::Handle> = {
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 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(|s| Scene::handle_from_wire_name(s))
.and_then(|h| { .and_then(|h| {
if let Some(AcadEntityType::Viewport(vp)) = if let Some(AcadEntityType::Viewport(vp)) =
@ -4416,18 +4434,19 @@ impl OpenCADStudio {
height: dwell.tile_size.1, height: dwell.tile_size.1,
}; };
let p = dwell.point; 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(); let all_wires = self.tabs[i].scene.hit_test_wires();
// Mirror the click-selection pick order so the rollover // Mirror the click-selection pick order so the rollover
// highlights every selectable object: wire → hatch → // highlights every selectable object: wire → hatch →
// block-internal hatch → shaded 3D solid body. // 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)) .and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| { .or_else(|| {
scene::pick::hit_test::click_hit_hatch( scene::pick::hit_test::click_hit_hatch(
p, p,
&self.tabs[i].scene.visible_hatches_for_click(), &self.tabs[i].scene.visible_hatches_for_click(),
view_proj, view_rot,
eye,
bounds, bounds,
) )
}) })
@ -4435,11 +4454,12 @@ impl OpenCADStudio {
scene::pick::hit_test::click_hit_insert_hatch( scene::pick::hit_test::click_hit_insert_hatch(
p, p,
&self.tabs[i].scene.insert_hatches_for_click(), &self.tabs[i].scene.insert_hatches_for_click(),
view_proj, view_rot,
eye,
bounds, 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.tabs[i].scene.set_hover_highlight(hovered);
self.hover_dwell = None; self.hover_dwell = None;
Task::none() Task::none()

View file

@ -2501,14 +2501,15 @@ impl Scene {
pub fn mesh_click_hit( pub fn mesh_click_hit(
&self, &self,
cursor: iced::Point, cursor: iced::Point,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
) -> Option<Handle> { ) -> Option<Handle> {
let iter = self let iter = self
.meshes .meshes
.iter() .iter()
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); .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 / /// True when any handle resolves to an ACIS volume entity (3D solid /
@ -2532,14 +2533,15 @@ impl Scene {
a: iced::Point, a: iced::Point,
b: iced::Point, b: iced::Point,
crossing: bool, crossing: bool,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
let iter = self let iter = self
.meshes .meshes
.iter() .iter()
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); .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. /// Top-level solid handles caught by a lasso polygon.
@ -2547,14 +2549,15 @@ impl Scene {
&self, &self,
poly: &[iced::Point], poly: &[iced::Point],
crossing: bool, crossing: bool,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
let iter = self let iter = self
.meshes .meshes
.iter() .iter()
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); .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 /// Front-most solid under the cursor across BOTH top-level solid meshes
@ -2565,7 +2568,8 @@ impl Scene {
pub fn solid_click_hit( pub fn solid_click_hit(
&self, &self,
cursor: iced::Point, cursor: iced::Point,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
) -> Option<Handle> { ) -> Option<Handle> {
// Block-internal instances must be owned (transformed copies); keep // Block-internal instances must be owned (transformed copies); keep
@ -2597,7 +2601,7 @@ impl Scene {
.iter() .iter()
.filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m)));
let blk = block_owned.iter().map(|(h, 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 /// Parent INSERT handles whose block-internal solid meshes fall in a
@ -2609,7 +2613,8 @@ impl Scene {
a: iced::Point, a: iced::Point,
b: iced::Point, b: iced::Point,
crossing: bool, crossing: bool,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
if self.block_meshes.is_empty() { if self.block_meshes.is_empty() {
@ -2635,7 +2640,8 @@ impl Scene {
b, b,
crossing, crossing,
std::iter::once((ins.common.handle, m)), std::iter::once((ins.common.handle, m)),
view_proj, view_rot,
eye,
bounds, bounds,
) )
.is_empty() .is_empty()
@ -2653,7 +2659,8 @@ impl Scene {
&self, &self,
poly: &[iced::Point], poly: &[iced::Point],
crossing: bool, crossing: bool,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
if self.block_meshes.is_empty() { if self.block_meshes.is_empty() {
@ -2678,7 +2685,8 @@ impl Scene {
poly, poly,
crossing, crossing,
std::iter::once((ins.common.handle, m)), std::iter::once((ins.common.handle, m)),
view_proj, view_rot,
eye,
bounds, bounds,
) )
.is_empty() .is_empty()

View file

@ -26,7 +26,8 @@ const CLICK_THRESHOLD_PX: f32 = 8.0;
pub fn click_hit<'a>( pub fn click_hit<'a>(
cursor: Point, cursor: Point,
wires: &'a [WireModel], wires: &'a [WireModel],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Option<&'a str> { ) -> Option<&'a str> {
let mut best_dist = CLICK_THRESHOLD_PX; 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 // 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 // reject wires nowhere near the cursor without projecting any of their
// points (the dominant per-move cost on 100 k-wire drawings). // 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. // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires { for wire in wires {
@ -54,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_proj, bounds); let s = world_to_screen(Vec3::new(cx, cy, 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);
@ -72,7 +73,7 @@ pub fn click_hit<'a>(
prev = None; prev = None;
continue; 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 { 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 {
@ -93,7 +94,8 @@ pub fn click_hit<'a>(
pub fn click_hits_all<'a>( pub fn click_hits_all<'a>(
cursor: Point, cursor: Point,
wires: &'a [WireModel], wires: &'a [WireModel],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<&'a str> { ) -> Vec<&'a str> {
let mut hits: Vec<(f32, &str)> = Vec::new(); let mut hits: Vec<(f32, &str)> = Vec::new();
@ -106,7 +108,7 @@ pub fn click_hits_all<'a>(
prev = None; prev = None;
continue; 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 { 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 {
@ -131,7 +133,8 @@ pub fn click_hits_all<'a>(
pub fn mesh_click_hit<'a>( pub fn mesh_click_hit<'a>(
cursor: Point, cursor: Point,
meshes: impl Iterator<Item = (Handle, &'a MeshModel)>, meshes: impl Iterator<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Option<Handle> { ) -> Option<Handle> {
let mut best: Option<(f32, Handle)> = None; 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 sp = [Point::ORIGIN; 3];
let mut depth = 0.0f32; let mut depth = 0.0f32;
for (j, w) in tri.iter().enumerate() { 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( 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,
@ -169,11 +172,11 @@ pub fn mesh_click_hit<'a>(
} }
/// Project a mesh's vertices to screen space. /// Project a mesh's vertices to screen space.
fn project_mesh_verts(mesh: &MeshModel, view_proj: Mat4, 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| { .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( 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,
@ -208,7 +211,8 @@ pub fn mesh_box_hit<'a>(
b: Point, b: Point,
crossing: bool, crossing: bool,
meshes: impl Iterator<Item = (Handle, &'a MeshModel)>, meshes: impl Iterator<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
let (min_x, max_x) = (a.x.min(b.x), a.x.max(b.x)); 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(); let mut out = Vec::new();
for (h, mesh) in meshes { 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() { if proj.is_empty() {
continue; continue;
} }
@ -245,7 +249,8 @@ pub fn mesh_poly_hit<'a>(
poly: &[Point], poly: &[Point],
crossing: bool, crossing: bool,
meshes: impl Iterator<Item = (Handle, &'a MeshModel)>, meshes: impl Iterator<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
if poly.len() < 3 { if poly.len() < 3 {
@ -253,7 +258,7 @@ pub fn mesh_poly_hit<'a>(
} }
let mut out = Vec::new(); let mut out = Vec::new();
for (h, mesh) in meshes { 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() { if proj.is_empty() {
continue; continue;
} }
@ -285,7 +290,8 @@ pub fn box_hit<'a>(
corner_b: Point, corner_b: Point,
crossing: bool, crossing: bool,
wires: &'a [WireModel], wires: &'a [WireModel],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<&'a str> { ) -> Vec<&'a str> {
let min_x = corner_a.x.min(corner_b.x); let min_x = corner_a.x.min(corner_b.x);
@ -339,7 +345,7 @@ pub fn box_hit<'a>(
prev = None; prev = None;
continue; 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 crossing {
if inside(sp) { if inside(sp) {
hit = true; hit = true;
@ -385,7 +391,8 @@ pub fn poly_hit<'a>(
poly: &[Point], poly: &[Point],
crossing: bool, crossing: bool,
wires: &'a [WireModel], wires: &'a [WireModel],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<&'a str> { ) -> Vec<&'a str> {
if poly.len() < 3 { if poly.len() < 3 {
@ -426,7 +433,7 @@ pub fn poly_hit<'a>(
prev = None; prev = None;
continue; 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 crossing {
if point_in_polygon(sp, poly) { if point_in_polygon(sp, poly) {
hit = true; hit = true;
@ -462,8 +469,8 @@ pub fn poly_hit<'a>(
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point { fn world_to_screen(world: Vec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
let ndc = view_proj.project_point3(world); let ndc = view_rot.project_point3((world.as_dvec3() - 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,
@ -560,11 +567,12 @@ fn segments_intersect(a: Point, b: Point, c: Point, d: Point) -> bool {
pub fn click_hit_hatch( pub fn click_hit_hatch(
cursor: Point, cursor: Point,
hatches: &HashMap<Handle, HatchModel>, hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Option<Handle> { ) -> Option<Handle> {
for (&handle, hatch) in hatches { 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); return Some(handle);
} }
} }
@ -579,11 +587,12 @@ pub fn click_hit_hatch(
pub fn click_hit_insert_hatch( pub fn click_hit_insert_hatch(
cursor: Point, cursor: Point,
insert_hatches: &[(Handle, HatchModel)], insert_hatches: &[(Handle, HatchModel)],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Option<Handle> { ) -> Option<Handle> {
for (handle, hatch) in insert_hatches { 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); return Some(*handle);
} }
} }
@ -593,7 +602,8 @@ pub fn click_hit_insert_hatch(
fn hatch_contains_screen_point( fn hatch_contains_screen_point(
hatch: &HatchModel, hatch: &HatchModel,
cursor: Point, cursor: Point,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> bool { ) -> bool {
// boundary verts are stored as small f32 offsets from // boundary verts are stored as small f32 offsets from
@ -606,7 +616,7 @@ fn hatch_contains_screen_point(
.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_proj, bounds) world_to_screen(Vec3::new(x + ox, y + 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.
@ -623,7 +633,8 @@ pub fn box_hit_hatch(
corner_b: Point, corner_b: Point,
crossing: bool, crossing: bool,
hatches: &HashMap<Handle, HatchModel>, hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
let min_x = corner_a.x.min(corner_b.x); let min_x = corner_a.x.min(corner_b.x);
@ -648,7 +659,7 @@ pub fn box_hit_hatch(
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_proj, bounds)) .map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + 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))
@ -669,7 +680,8 @@ pub fn poly_hit_hatch(
poly: &[Point], poly: &[Point],
crossing: bool, crossing: bool,
hatches: &HashMap<Handle, HatchModel>, hatches: &HashMap<Handle, HatchModel>,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Vec<Handle> { ) -> Vec<Handle> {
if poly.len() < 3 { if poly.len() < 3 {
@ -687,7 +699,7 @@ pub fn poly_hit_hatch(
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_proj, bounds)) .map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + 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))

View file

@ -225,65 +225,17 @@ impl Camera {
plane_normal: Vec3, plane_normal: Vec3,
plane_point: Vec3, plane_point: Vec3,
) -> Vec3 { ) -> Vec3 {
let ndc_x = (screen.x / bounds.width) * 2.0 - 1.0; // Delegate to the eye-relative f64 unproject so the cursor stays
let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; // precise at UTM-scale coordinates (the old full view_proj.inverse()
let inv = self.view_proj(bounds).inverse(); // cancelled catastrophically in f32).
self.unproject_on_plane_f64(screen, bounds, plane_normal, plane_point.as_dvec3())
let (ray_origin, ray_dir) = match self.projection { .as_vec3()
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
} }
pub fn pick_on_target_plane(&self, screen: Point, bounds: Rectangle) -> 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 forward = (self.target.as_vec3() - self.eye()).normalize_or(Vec3::NEG_Z);
let ndc_y = 1.0 - (screen.y / bounds.height) * 2.0; self.unproject_on_plane_f64(screen, bounds, forward, self.target)
let inv = self.view_proj(bounds).inverse(); .as_vec3()
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
}
}
} }

View file

@ -177,7 +177,8 @@ impl Snapper {
pub fn update_otrack_dwell( pub fn update_otrack_dwell(
&mut self, &mut self,
snap_world: Option<Vec3>, snap_world: Option<Vec3>,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
now: Instant, now: Instant,
) { ) {
@ -202,8 +203,8 @@ impl Snapper {
Some(p) => { Some(p) => {
// Convert to screen to measure pixel distance. // Convert to screen to measure pixel distance.
let is_same = if let Some(prev) = self.last_snap_world { let is_same = if let Some(prev) = self.last_snap_world {
let dp = world_to_screen(p, view_proj, bounds); let dp = world_to_screen(p, view_rot, eye, bounds);
let dp2 = world_to_screen(prev, view_proj, bounds); let dp2 = world_to_screen(prev, view_rot, eye, bounds);
let dx = dp.x - dp2.x; let dx = dp.x - dp2.x;
let dy = dp.y - dp2.y; let dy = dp.y - dp2.y;
(dx * dx + dy * dy).sqrt() < DWELL_PX (dx * dx + dy * dy).sqrt() < DWELL_PX
@ -259,7 +260,8 @@ impl Snapper {
pub fn otrack_snap( pub fn otrack_snap(
&self, &self,
cursor_world: Vec3, cursor_world: Vec3,
view_proj: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
polar_step_deg: Option<f32>, polar_step_deg: Option<f32>,
last_point: Option<Vec3>, last_point: Option<Vec3>,
@ -271,11 +273,11 @@ impl Snapper {
return None; 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. // Use the same aperture as OSNAP so the catch distance is uniform.
let r = self.osnap_radius_px; let r = self.osnap_radius_px;
let screen_dist = |w: Vec3| { 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() ((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_world: Vec3,
cursor_screen: Point, cursor_screen: Point,
wires: &[WireModel], wires: &[WireModel],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Option<SnapResult> { ) -> Option<SnapResult> {
let tmp = Snapper { let tmp = Snapper {
@ -433,7 +436,8 @@ impl Snapper {
cursor_world, cursor_world,
cursor_screen, cursor_screen,
wires, wires,
view_proj, view_rot,
eye,
bounds, bounds,
Vec3::ZERO, Vec3::ZERO,
Mat4::IDENTITY, Mat4::IDENTITY,
@ -446,7 +450,8 @@ impl Snapper {
cursor_world: Vec3, cursor_world: Vec3,
cursor_screen: Point, cursor_screen: Point,
wires: &[WireModel], wires: &[WireModel],
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
// Grid origin (render/wire space) and UCS→world rotation, so grid snap // Grid origin (render/wire space) and UCS→world rotation, so grid snap
// lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid. // 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, // view_proj col-0 x = 2*zoom / viewport_width for an orthographic camera,
// so scale_x * (width/2) = pixels per world unit. // so scale_x * (width/2) = pixels per world unit.
let world_snap_r = { 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 { if s > 1e-6 {
self.osnap_radius_px / s self.osnap_radius_px / s
} else { } else {
@ -495,7 +500,7 @@ impl Snapper {
}; };
let mut try_pt = |world: Vec3, snap_type: SnapType| { 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); let d2 = dist2(screen, cursor_screen);
// `!(d2 < radius2)` (not `d2 >= radius2`) so a NaN distance from // `!(d2 < radius2)` (not `d2 >= radius2`) so a NaN distance from
// degenerate geometry is rejected: with priority selection a NaN // degenerate geometry is rejected: with priority selection a NaN
@ -672,7 +677,8 @@ impl Snapper {
cursor_world, cursor_world,
p0, p0,
p0 - p1, p0 - p1,
view_proj, view_rot,
eye,
bounds, bounds,
self.osnap_radius_px, self.osnap_radius_px,
) { ) {
@ -687,7 +693,8 @@ impl Snapper {
cursor_world, cursor_world,
p_last, p_last,
p_last - p_prev, p_last - p_prev,
view_proj, view_rot,
eye,
bounds, bounds,
self.osnap_radius_px, self.osnap_radius_px,
) { ) {
@ -709,7 +716,7 @@ impl Snapper {
Some( Some(
w.points w.points
.iter() .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::<Vec<_>>(), .collect::<Vec<_>>(),
) )
}) })
@ -762,8 +769,8 @@ impl Snapper {
for tg in &wire.tangent_geoms { for tg in &wire.tangent_geoms {
let (world_pt, d2) = match tg { let (world_pt, d2) = match tg {
TangentGeom::Line { p1, p2 } => { TangentGeom::Line { p1, p2 } => {
let sp0 = world_to_screen(Vec3::from(*p1), view_proj, bounds); let sp0 = world_to_screen(Vec3::from(*p1), view_rot, eye, bounds);
let sp1 = world_to_screen(Vec3::from(*p2), view_proj, bounds); let sp1 = world_to_screen(Vec3::from(*p2), view_rot, eye, bounds);
let d2 = dist2_to_segment(cursor_screen, sp0, sp1); let d2 = dist2_to_segment(cursor_screen, sp0, sp1);
let t = t_on_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)); let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1));
@ -771,10 +778,11 @@ impl Snapper {
} }
TangentGeom::Circle { center, radius } => { TangentGeom::Circle { center, radius } => {
let cv = Vec3::from(*center); 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( let rim = world_to_screen(
Vec3::new(cv.x + radius, cv.y, cv.z), Vec3::new(cv.x + radius, cv.y, cv.z),
view_proj, view_rot,
eye,
bounds, bounds,
); );
let sr = dist2(sc, rim).sqrt(); let sr = dist2(sc, rim).sqrt();
@ -797,7 +805,7 @@ impl Snapper {
if d2 < radius2 && (rank < best_rank || (rank == best_rank && d2 < best_d2)) { if d2 < radius2 && (rank < best_rank || (rank == best_rank && d2 < best_d2)) {
best_rank = rank; best_rank = rank;
best_d2 = d2; 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 { let tangent_obj = match tg {
TangentGeom::Line { p1, p2 } => TangentObject::Line { TangentGeom::Line { p1, p2 } => TangentObject::Line {
p1: Vec3::from(*p1), p1: Vec3::from(*p1),
@ -940,7 +948,8 @@ fn extension_snap(
cursor_world: Vec3, cursor_world: Vec3,
origin: Vec3, origin: Vec3,
dir: Vec3, dir: Vec3,
view_proj: Mat4, view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
radius_px: f32, radius_px: f32,
) -> Option<Vec3> { ) -> Option<Vec3> {
@ -953,8 +962,8 @@ fn extension_snap(
return None; return None;
} // only beyond the endpoint } // only beyond the endpoint
let world_pt = Vec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z); 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 screen_pt = world_to_screen(world_pt, view_rot, eye, bounds);
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds); let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
if dist2(screen_pt, cursor_screen) > radius_px * radius_px { if dist2(screen_pt, cursor_screen) > radius_px * radius_px {
return None; return None;
} }
@ -963,8 +972,13 @@ fn extension_snap(
// ── Projection helpers ──────────────────────────────────────────────────── // ── Projection helpers ────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point { /// Project a world point to screen relative-to-eye: subtract the f64 eye first
let ndc = view_proj.project_point3(world); /// 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( 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,

View file

@ -69,10 +69,13 @@ pub struct GridParams {
/// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at /// 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` /// least `MIN_GRID_PX` pixels apart. This matches exactly what `draw_grid`
/// renders, so callers can sync snap spacing to the visible 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; 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 w2s = |world: Vec3| {
let ndc = vp.project_point3(world); let ndc = view_rot.project_point3(world);
glam::Vec2::new( glam::Vec2::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,