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

View file

@ -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<Handle> = 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<Handle> = 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<Handle> = 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<Handle> = 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<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();
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()

View file

@ -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<Handle> {
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<Handle> {
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<Handle> {
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<Handle> {
// 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<Handle> {
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<Handle> {
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()

View file

@ -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<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<Handle> {
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<Point> {
fn project_mesh_verts(mesh: &MeshModel, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Vec<Point> {
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<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Vec<Handle> {
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<Item = (Handle, &'a MeshModel)>,
view_proj: Mat4,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Vec<Handle> {
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<Handle, HatchModel>,
view_proj: Mat4,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<Handle> {
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<Handle> {
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<Handle, HatchModel>,
view_proj: Mat4,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Vec<Handle> {
let min_x = corner_a.x.min(corner_b.x);
@ -648,7 +659,7 @@ pub fn box_hit_hatch(
let screen: Vec<Point> = 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<Handle, HatchModel>,
view_proj: Mat4,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Vec<Handle> {
if poly.len() < 3 {
@ -687,7 +699,7 @@ pub fn poly_hit_hatch(
let screen: Vec<Point> = 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))

View file

@ -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()
}

View file

@ -177,7 +177,8 @@ impl Snapper {
pub fn update_otrack_dwell(
&mut self,
snap_world: Option<Vec3>,
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<f32>,
last_point: Option<Vec3>,
@ -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<SnapResult> {
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::<Vec<_>>(),
)
})
@ -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<Vec3> {
@ -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,

View file

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