From 0df23562430b26480608c27dabe91230310b26df Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 12 Aug 2026 19:49:52 +0300 Subject: [PATCH] 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. --- src/app/update/viewport.rs | 22 ++++++----- src/entities/mesh.rs | 6 +-- src/scene/mod.rs | 75 ++++++++++++++++++++++++++++++++------ src/scene/mspace.rs | 9 +++++ src/scene/pick/hit_test.rs | 46 ++++++++++++++++------- src/ui/overlay.rs | 58 +++++++++-------------------- 6 files changed, 136 insertions(+), 80 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index cafb76d0b..e0649bfc5 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -351,9 +351,8 @@ impl OpenCADStudio { (origin, (x.as_vec3(), y.as_vec3(), z.as_vec3())) } - /// Adopt the active viewport's grid *display* into the live toggle. Called - /// on load and whenever the active tab or viewport changes, so the grid - /// drawing follows the active viewport. + /// Adopt the active viewport's display state into the live toggles. Called + /// on load and whenever the active tab or viewport changes. /// /// Grid *snap* (`SnapType::Grid`) is deliberately NOT adopted here: it stays /// 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() { 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) { @@ -982,12 +984,14 @@ impl OpenCADStudio { return Task::none(); } else if self.tabs[i].scene.current_layout == "Model" { if sel.orbit_pivot.is_none() { - // Selection centre when something is selected, - // otherwise the point under the cursor. (#229) - sel.orbit_pivot = self.tabs[i] - .scene - .orbit_pivot() - .or(Some(self.tabs[i].last_cursor_world)); + let scene = &self.tabs[i].scene; + let bounds = scene.active_model_tile_bounds(vp_size.0, vp_size.1); + sel.orbit_pivot = Some( + scene + .orbit_pivot() + .or_else(|| scene.view_center_surface_pivot(bounds)) + .unwrap_or_else(|| scene.camera.borrow().target), + ); } let pivot = sel.orbit_pivot; drop(sel); diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs index 726087caa..be8cd4636 100644 --- a/src/entities/mesh.rs +++ b/src/entities/mesh.rs @@ -1101,11 +1101,7 @@ impl RenderConvertible for Mesh { impl Grippable for Mesh { fn grips(&self) -> Vec { - self.vertices - .iter() - .enumerate() - .map(|(i, v)| square_grip(i, glam::DVec3::new(v.x, v.y, v.z))) - .collect() + Vec::new() } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 58839940b..d8d6296dc 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7878,6 +7878,34 @@ impl Scene { ) } + pub fn view_center_surface_pivot( + &self, + bounds: iced::Rectangle, + ) -> Option { + 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 /// full-resolution mesh, while rollover only needs a stable parent handle /// 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 /// (same space as `Camera::target`) — the point the 3D view orbits around - /// when something is selected. `None` when nothing is selected; the caller - /// then orbits about the point under the cursor. (#229) + /// when something is selected. `None` keeps the current camera target as + /// the orbit centre. (#229) pub fn orbit_pivot(&self) -> Option { if self.selected.is_empty() { return None; @@ -9086,9 +9114,16 @@ impl Scene { self.annotation_all_visible(), None, ); - let mut min = glam::DVec2::splat(f64::INFINITY); - let mut max = glam::DVec2::splat(f64::NEG_INFINITY); + let mut min = glam::DVec3::splat(f64::INFINITY); + let mut max = glam::DVec3::splat(f64::NEG_INFINITY); 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 { let Some(h) = Self::handle_from_wire_name(&wire.name) else { continue; @@ -9096,17 +9131,33 @@ impl Scene { if !self.selected.contains(&h) { continue; } - for &[x, y, _] in &wire.points { - if x.is_finite() && y.is_finite() { - min = min.min(glam::DVec2::new(x as f64, y as f64)); - max = max.max(glam::DVec2::new(x as f64, y as f64)); - any = true; - } + for (index, &[x, y, z]) in wire.points.iter().enumerate() { + let [lx, ly, lz] = wire + .points_low + .get(index) + .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 { - let c = (min + max) * 0.5; - Some(glam::DVec3::new(c.x, c.y, 0.0)) + Some((min + max) * 0.5) } else { None } diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index 251b8af9e..6cad6e5c5 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -446,6 +446,15 @@ impl Scene { 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 /// paper-space camera's matrix when not in MSPACE. /// Used by ViewCube hit-testing so clicks map to the correct camera. diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index ea61460cc..6f0125a86 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -658,20 +658,40 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( hits.into_iter().map(|(_, name)| name).collect() } +type MeshPickItem<'a> = ( + Handle, + &'a MeshModel, + Option, + [f64; 6], +); + pub(crate) fn mesh_click_hit<'a>( cursor: Point, - meshes: impl Iterator< - Item = ( - Handle, - &'a MeshModel, - Option, - [f64; 6], - ), - >, + meshes: impl Iterator>, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, ) -> Option { + mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, handle, _)| handle) +} + +pub(crate) fn mesh_click_point<'a>( + cursor: Point, + meshes: impl Iterator>, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Option { + mesh_click_result(cursor, meshes, view_rot, eye, bounds).map(|(_, _, point)| point) +} + +fn mesh_click_result<'a>( + cursor: Point, + meshes: impl Iterator>, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Option<(f64, Handle, glam::DVec3)> { let profile = crate::perf::enabled().then(std::time::Instant::now); let mut set_count = 0usize; let mut bound_hits = 0usize; @@ -692,7 +712,7 @@ pub(crate) fn mesh_click_hit<'a>( { 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 { set_count += 1; 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; 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; } 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 world_hit = model.map_or(local_hit, |model| model.transform_point3(local_hit)); let distance = (world_hit - near).dot(world_direction); - if distance >= 0.0 && best.is_none_or(|(current, _)| distance < current) { - best = Some((distance, handle)); + if distance >= 0.0 && best.is_none_or(|(current, _, _)| distance < current) { + best = Some((distance, handle, world_hit)); } } } @@ -761,7 +781,7 @@ pub(crate) fn mesh_click_hit<'a>( exact_triangles, ); } - best.map(|(_, h)| h) + best } fn codec_transform_matrix(transform: acadrust::types::Transform) -> glam::DMat4 { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 917304456..5b1d41d3d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1841,39 +1841,28 @@ fn ucs_icon_geometry( return None; } - // Project to NDC (including depth) then to screen pixels. - let w2ndc = |world: Vec3| -> Option { - let ndc = vp.project_point3(world); - if !ndc.x.is_finite() || !ndc.y.is_finite() || !ndc.z.is_finite() { + // Transform directions directly. Projecting a point at the eye origin is + // undefined in perspective views. + let axis_screen = |axis: Vec3| -> Option<(f32, f32, f32, f32)> { + let clip = vp * axis.extend(0.0); + if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() { return None; } - Some(ndc) - }; - let ndc2s = |ndc: Vec3| -> Point { - Point::new( - bounds.x + (ndc.x + 1.0) * 0.5 * bounds.width, - bounds.y + (1.0 - ndc.y) * 0.5 * bounds.height, - ) + let dx = clip.x * 0.5 * bounds.width; + let dy = -clip.y * 0.5 * bounds.height; + Some((dx, dy, (dx * dx + dy * dy).sqrt(), clip.z)) }; - // 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 org = w2ndc(Vec3::ZERO)?; - let xn = w2ndc(ax)?; - let yn = w2ndc(ay)?; - let zn = w2ndc(az)?; - - let org_s = ndc2s(org); + let (xdx, xdy, xlen, xdepth) = axis_screen(ax)?; + let (ydx, ydy, ylen, ydepth) = axis_screen(ay)?; + let (zdx, zdy, zlen, zdepth) = axis_screen(az)?; let corner = Point::new( bounds.x + 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 - // (UCSICON ORigin); otherwise keep it in the corner. `org_s` (the - // rotation-only origin) stays the reference for axis-tip directions — - // those are translation-invariant, so only the anchor point moves. + // (UCSICON ORigin); otherwise keep it in the corner. let (icon_origin, at_origin) = match origin_screen { Some(p) if p.x >= bounds.x @@ -1886,37 +1875,24 @@ fn ucs_icon_geometry( _ => (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 // stay proportionally shorter (this IS the foreshortening effect). let max_len = xlen.max(ylen).max(zlen).max(1e-4); 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, dy: dy * sc, sc_len: len * sc, - // depth > 0 → tip farther than origin (into screen); < 0 → toward viewer. - depth: tip_z - org.z, + depth, }; Some(( icon_origin, at_origin, [ - mk(xdx, xdy, xlen, xn.z), - mk(ydx, ydy, ylen, yn.z), - mk(zdx, zdy, zlen, zn.z), + mk(xdx, xdy, xlen, xdepth), + mk(ydx, ydy, ylen, ydepth), + mk(zdx, zdy, zlen, zdepth), ], )) }