fix(view): correct 3D mesh interaction

Sync projection controls from the active camera, preserve the UCS icon in perspective views, and use visible or selected 3D geometry for orbit pivots. Dense mesh vertex grips remain hidden.
This commit is contained in:
Hakan Seven 2026-08-12 19:49:52 +03:00
commit 0df2356243
6 changed files with 136 additions and 80 deletions

View file

@ -351,9 +351,8 @@ impl OpenCADStudio {
(origin, (x.as_vec3(), y.as_vec3(), z.as_vec3())) (origin, (x.as_vec3(), y.as_vec3(), z.as_vec3()))
} }
/// Adopt the active viewport's grid *display* into the live toggle. Called /// Adopt the active viewport's display state into the live toggles. Called
/// on load and whenever the active tab or viewport changes, so the grid /// on load and whenever the active tab or viewport changes.
/// drawing follows the active viewport.
/// ///
/// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays /// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays
/// off by default everywhere and is controlled solely by the user's snap /// off by default everywhere and is controlled solely by the user's snap
@ -363,6 +362,9 @@ impl OpenCADStudio {
if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() { if let Some((grid_on, _snap_on)) = self.tabs[i].scene.active_tile_grid_snap() {
self.show_grid = grid_on; self.show_grid = grid_on;
} }
let ortho = self.tabs[i].scene.active_camera_projection()
== crate::scene::Projection::Orthographic;
self.ribbon.set_ortho(ortho);
} }
pub(in crate::app) fn sync_render_mode_to_active_tile(&mut self, i: usize) { pub(in crate::app) fn sync_render_mode_to_active_tile(&mut self, i: usize) {
@ -982,12 +984,14 @@ impl OpenCADStudio {
return Task::none(); return Task::none();
} else if self.tabs[i].scene.current_layout == "Model" { } else if self.tabs[i].scene.current_layout == "Model" {
if sel.orbit_pivot.is_none() { if sel.orbit_pivot.is_none() {
// Selection centre when something is selected, let scene = &self.tabs[i].scene;
// otherwise the point under the cursor. (#229) let bounds = scene.active_model_tile_bounds(vp_size.0, vp_size.1);
sel.orbit_pivot = self.tabs[i] sel.orbit_pivot = Some(
.scene scene
.orbit_pivot() .orbit_pivot()
.or(Some(self.tabs[i].last_cursor_world)); .or_else(|| scene.view_center_surface_pivot(bounds))
.unwrap_or_else(|| scene.camera.borrow().target),
);
} }
let pivot = sel.orbit_pivot; let pivot = sel.orbit_pivot;
drop(sel); drop(sel);

View file

@ -1101,11 +1101,7 @@ impl RenderConvertible for Mesh {
impl Grippable for Mesh { impl Grippable for Mesh {
fn grips(&self) -> Vec<GripDef> { fn grips(&self) -> Vec<GripDef> {
self.vertices Vec::new()
.iter()
.enumerate()
.map(|(i, v)| square_grip(i, glam::DVec3::new(v.x, v.y, v.z)))
.collect()
} }
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {

View file

@ -7878,6 +7878,34 @@ impl Scene {
) )
} }
pub fn view_center_surface_pivot(
&self,
bounds: iced::Rectangle,
) -> Option<glam::DVec3> {
let camera = self.camera.borrow();
let view_rot = camera.view_proj_rte(bounds);
let eye = camera.eye();
drop(camera);
let meshes = self.interaction_meshes_arc();
let center = iced::Point::new(bounds.width * 0.5, bounds.height * 0.5);
pick::hit_test::mesh_click_point(
center,
meshes.iter().filter_map(|set| {
let handle = set.entity_handle()?;
let mesh = set.geometry_lods().first()?;
Some((
handle,
mesh,
set.instance_transform,
mesh_interaction_aabb(set)?,
))
}),
view_rot,
eye,
bounds,
)
}
/// Hover may use the coarsest cached solid LOD. Click selection keeps the /// Hover may use the coarsest cached solid LOD. Click selection keeps the
/// full-resolution mesh, while rollover only needs a stable parent handle /// full-resolution mesh, while rollover only needs a stable parent handle
/// and must stay within an interactive frame budget. /// and must stay within an interactive frame budget.
@ -9061,8 +9089,8 @@ impl Scene {
/// The AABB centre of the current selection, in absolute world coordinates /// The AABB centre of the current selection, in absolute world coordinates
/// (same space as `Camera::target`) — the point the 3D view orbits around /// (same space as `Camera::target`) — the point the 3D view orbits around
/// when something is selected. `None` when nothing is selected; the caller /// when something is selected. `None` keeps the current camera target as
/// then orbits about the point under the cursor. (#229) /// the orbit centre. (#229)
pub fn orbit_pivot(&self) -> Option<glam::DVec3> { pub fn orbit_pivot(&self) -> Option<glam::DVec3> {
if self.selected.is_empty() { if self.selected.is_empty() {
return None; return None;
@ -9086,9 +9114,16 @@ impl Scene {
self.annotation_all_visible(), self.annotation_all_visible(),
None, None,
); );
let mut min = glam::DVec2::splat(f64::INFINITY); let mut min = glam::DVec3::splat(f64::INFINITY);
let mut max = glam::DVec2::splat(f64::NEG_INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
let mut any = false; let mut any = false;
let mut include = |point: glam::DVec3| {
if point.is_finite() {
min = min.min(point);
max = max.max(point);
any = true;
}
};
for wire in &wires { for wire in &wires {
let Some(h) = Self::handle_from_wire_name(&wire.name) else { let Some(h) = Self::handle_from_wire_name(&wire.name) else {
continue; continue;
@ -9096,17 +9131,33 @@ impl Scene {
if !self.selected.contains(&h) { if !self.selected.contains(&h) {
continue; continue;
} }
for &[x, y, _] in &wire.points { for (index, &[x, y, z]) in wire.points.iter().enumerate() {
if x.is_finite() && y.is_finite() { let [lx, ly, lz] = wire
min = min.min(glam::DVec2::new(x as f64, y as f64)); .points_low
max = max.max(glam::DVec2::new(x as f64, y as f64)); .get(index)
any = true; .copied()
} .unwrap_or([0.0; 3]);
include(glam::DVec3::new(
x as f64 + lx as f64,
y as f64 + ly as f64,
z as f64 + lz as f64,
));
}
}
for set in self.interaction_meshes_arc().iter() {
let Some(handle) = set.entity_handle() else {
continue;
};
if !self.selected.contains(&handle) {
continue;
}
if let Some(aabb) = mesh_interaction_aabb(set) {
include(glam::DVec3::new(aabb[0], aabb[1], aabb[2]));
include(glam::DVec3::new(aabb[3], aabb[4], aabb[5]));
} }
} }
if any { if any {
let c = (min + max) * 0.5; Some((min + max) * 0.5)
Some(glam::DVec3::new(c.x, c.y, 0.0))
} else { } else {
None None
} }

View file

@ -446,6 +446,15 @@ impl Scene {
self.camera.borrow().rotation self.camera.borrow().rotation
} }
pub fn active_camera_projection(&self) -> view::camera::Projection {
if let Some(handle) = self.active_viewport {
if let Some(camera) = self.camera_for_viewport(handle) {
return camera.projection;
}
}
self.camera.borrow().projection
}
/// View-rotation matrix for the active viewport (MSPACE), or the /// View-rotation matrix for the active viewport (MSPACE), or the
/// paper-space camera's matrix when not in MSPACE. /// paper-space camera's matrix when not in MSPACE.
/// Used by ViewCube hit-testing so clicks map to the correct camera. /// Used by ViewCube hit-testing so clicks map to the correct camera.

View file

@ -658,20 +658,40 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
hits.into_iter().map(|(_, name)| name).collect() hits.into_iter().map(|(_, name)| name).collect()
} }
type MeshPickItem<'a> = (
Handle,
&'a MeshModel,
Option<acadrust::types::Transform>,
[f64; 6],
);
pub(crate) fn mesh_click_hit<'a>( pub(crate) fn mesh_click_hit<'a>(
cursor: Point, cursor: Point,
meshes: impl Iterator< meshes: impl Iterator<Item = MeshPickItem<'a>>,
Item = (
Handle,
&'a MeshModel,
Option<acadrust::types::Transform>,
[f64; 6],
),
>,
view_rot: Mat4, view_rot: Mat4,
eye: glam::DVec3, eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
) -> Option<Handle> { ) -> Option<Handle> {
mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, handle, _)| handle)
}
pub(crate) fn mesh_click_point<'a>(
cursor: Point,
meshes: impl Iterator<Item = MeshPickItem<'a>>,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<glam::DVec3> {
mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, _, point)| point)
}
fn mesh_click_result<'a>(
cursor: Point,
meshes: impl Iterator<Item = MeshPickItem<'a>>,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<(f64, Handle, glam::DVec3)> {
let profile = crate::perf::enabled().then(std::time::Instant::now); let profile = crate::perf::enabled().then(std::time::Instant::now);
let mut set_count = 0usize; let mut set_count = 0usize;
let mut bound_hits = 0usize; let mut bound_hits = 0usize;
@ -692,7 +712,7 @@ pub(crate) fn mesh_click_hit<'a>(
{ {
return None; return None;
} }
let mut best: Option<(f64, Handle)> = None; let mut best: Option<(f64, Handle, glam::DVec3)> = None;
for (handle, mesh, transform, aabb) in meshes { for (handle, mesh, transform, aabb) in meshes {
set_count += 1; set_count += 1;
let Some((near_t, _)) = ray_aabb(near, world_direction, aabb) else { let Some((near_t, _)) = ray_aabb(near, world_direction, aabb) else {
@ -700,7 +720,7 @@ pub(crate) fn mesh_click_hit<'a>(
}; };
bound_hits += 1; bound_hits += 1;
exact_triangles += mesh.indices.len() / 3; exact_triangles += mesh.indices.len() / 3;
if best.is_some_and(|(distance, _)| near_t > distance) { if best.is_some_and(|(distance, _, _)| near_t > distance) {
continue; continue;
} }
let model = transform.map(codec_transform_matrix); let model = transform.map(codec_transform_matrix);
@ -747,8 +767,8 @@ pub(crate) fn mesh_click_hit<'a>(
let local_hit = origin + direction * local_t; let local_hit = origin + direction * local_t;
let world_hit = model.map_or(local_hit, |model| model.transform_point3(local_hit)); let world_hit = model.map_or(local_hit, |model| model.transform_point3(local_hit));
let distance = (world_hit - near).dot(world_direction); let distance = (world_hit - near).dot(world_direction);
if distance >= 0.0 && best.is_none_or(|(current, _)| distance < current) { if distance >= 0.0 && best.is_none_or(|(current, _, _)| distance < current) {
best = Some((distance, handle)); best = Some((distance, handle, world_hit));
} }
} }
} }
@ -761,7 +781,7 @@ pub(crate) fn mesh_click_hit<'a>(
exact_triangles, exact_triangles,
); );
} }
best.map(|(_, h)| h) best
} }
fn codec_transform_matrix(transform: acadrust::types::Transform) -> glam::DMat4 { fn codec_transform_matrix(transform: acadrust::types::Transform) -> glam::DMat4 {

View file

@ -1841,39 +1841,28 @@ fn ucs_icon_geometry(
return None; return None;
} }
// Project to NDC (including depth) then to screen pixels. // Transform directions directly. Projecting a point at the eye origin is
let w2ndc = |world: Vec3| -> Option<Vec3> { // undefined in perspective views.
let ndc = vp.project_point3(world); let axis_screen = |axis: Vec3| -> Option<(f32, f32, f32, f32)> {
if !ndc.x.is_finite() || !ndc.y.is_finite() || !ndc.z.is_finite() { let clip = vp * axis.extend(0.0);
if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() {
return None; return None;
} }
Some(ndc) let dx = clip.x * 0.5 * bounds.width;
}; let dy = -clip.y * 0.5 * bounds.height;
let ndc2s = |ndc: Vec3| -> Point { Some((dx, dy, (dx * dx + dy * dy).sqrt(), clip.z))
Point::new(
bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width,
bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height,
)
}; };
// Project the UCS axis directions (not fixed world axes) so the tripod
// rotates to the active UCS. Directions are translation-invariant, so a
// common origin is fine.
let (ax, ay, az) = axes; let (ax, ay, az) = axes;
let org = w2ndc(Vec3::ZERO)?; let (xdx, xdy, xlen, xdepth) = axis_screen(ax)?;
let xn = w2ndc(ax)?; let (ydx, ydy, ylen, ydepth) = axis_screen(ay)?;
let yn = w2ndc(ay)?; let (zdx, zdy, zlen, zdepth) = axis_screen(az)?;
let zn = w2ndc(az)?;
let org_s = ndc2s(org);
let corner = Point::new( let corner = Point::new(
bounds.x + UCS_ICON_MARGIN, bounds.x + UCS_ICON_MARGIN,
bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN), bounds.y + (bounds.height - UCS_ICON_MARGIN).max(UCS_ICON_MARGIN),
); );
// Snap the tripod to the projected UCS origin when it is on-screen // Snap the tripod to the projected UCS origin when it is on-screen
// (UCSICON ORigin); otherwise keep it in the corner. `org_s` (the // (UCSICON ORigin); otherwise keep it in the corner.
// rotation-only origin) stays the reference for axis-tip directions —
// those are translation-invariant, so only the anchor point moves.
let (icon_origin, at_origin) = match origin_screen { let (icon_origin, at_origin) = match origin_screen {
Some(p) Some(p)
if p.x >= bounds.x if p.x >= bounds.x
@ -1886,37 +1875,24 @@ fn ucs_icon_geometry(
_ => (corner, false), _ => (corner, false),
}; };
// Raw screen-space displacement for each axis tip.
let raw = |ndc_tip: Vec3| -> (f32, f32, f32) {
let s = ndc2s(ndc_tip);
let dx = s.x - org_s.x;
let dy = s.y - org_s.y;
(dx, dy, (dx * dx + dy * dy).sqrt())
};
let (xdx, xdy, xlen) = raw(xn);
let (ydx, ydy, ylen) = raw(yn);
let (zdx, zdy, zlen) = raw(zn);
// Scale so the longest projected axis fills UCS_ICON_LEN; shorter axes // Scale so the longest projected axis fills UCS_ICON_LEN; shorter axes
// stay proportionally shorter (this IS the foreshortening effect). // stay proportionally shorter (this IS the foreshortening effect).
let max_len = xlen.max(ylen).max(zlen).max(1e-4); let max_len = xlen.max(ylen).max(zlen).max(1e-4);
let sc = UCS_ICON_LEN / max_len; let sc = UCS_ICON_LEN / max_len;
let mk = |dx: f32, dy: f32, len: f32, tip_z: f32| IconAxis { let mk = |dx: f32, dy: f32, len: f32, depth: f32| IconAxis {
dx: dx * sc, dx: dx * sc,
dy: dy * sc, dy: dy * sc,
sc_len: len * sc, sc_len: len * sc,
// depth > 0 → tip farther than origin (into screen); < 0 → toward viewer. depth,
depth: tip_z - org.z,
}; };
Some(( Some((
icon_origin, icon_origin,
at_origin, at_origin,
[ [
mk(xdx, xdy, xlen, xn.z), mk(xdx, xdy, xlen, xdepth),
mk(ydx, ydy, ylen, yn.z), mk(ydx, ydy, ylen, ydepth),
mk(zdx, zdy, zlen, zn.z), mk(zdx, zdy, zlen, zdepth),
], ],
)) ))
} }