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), ], )) }