From 4b00504453bb2e67b9aa4a3f47546de5cbb98e34 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:09:00 +0300 Subject: [PATCH] Point cloud move gizmo A lone unlocked point cloud selected in a 3D visual style shows a move gizmo at its insertion point: X/Y/Z arrows and XY/YZ/ZX squares. Dragging an arrow moves the cloud along that axis, a square moves it in that plane; the hovered part is highlighted and each drag is one undo step. Co-Authored-By: Claude Opus 5.5 --- src/app/properties.rs | 23 +++++++++ src/app/update/viewport.rs | 82 ++++++++++++++++++++++++++++++++ src/entities/extended.rs | 28 ++++++++++- src/scene/model/object.rs | 5 ++ src/scene/pick/grip.rs | 97 ++++++++++++++++++++++++++++---------- src/ui/overlay.rs | 80 ++++++++++++++++++++++++++++++- 6 files changed, 287 insertions(+), 28 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index a8ed02411..6ab2ae1bb 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2966,6 +2966,29 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} grips.push(grip); } } + // A lone unlocked point cloud in a 3D visual style carries the + // move gizmo at its insertion point. + let gizmo = single_handle + .filter(|_| { + !is_paper + && self.tabs[i].scene.active_model_tile_render_mode() + != codec::entities::ViewportRenderMode::Wireframe2D + }) + .and_then(|handle| Some((handle, self.tabs[i].scene.document.get_entity(handle)?))) + .filter(|(_, entity)| !crate::scene::is_locked_point_cloud(entity)) + .and_then(|(handle, entity)| match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::PointCloudEx(data) => { + Some((handle, crate::entities::extended::point_cloud_gizmo_grips(data))) + } + _ => None, + }, + _ => None, + }); + if let Some((handle, gizmo)) = gizmo { + handles.extend(std::iter::repeat(handle).take(gizmo.len())); + grips.extend(gizmo); + } let (grips, handles) = apply_grip_budget(grips, handles); (single_handle, grips, handles) }; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7994fcc34..5d9fc1c16 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -167,6 +167,29 @@ fn cursor_on_projected_axis( .then_some(origin + direction * distance as f64) } +/// The point under `cursor` on the plane through `origin` with `normal`. +fn cursor_on_plane( + cursor: Point, + bounds: iced::Rectangle, + view: glam::Mat4, + eye: glam::DVec3, + origin: glam::DVec3, + normal: glam::DVec3, +) -> Option { + let inverse = view.as_dmat4().inverse(); + let x = cursor.x as f64 / bounds.width as f64 * 2.0 - 1.0; + let y = 1.0 - cursor.y as f64 / bounds.height as f64 * 2.0; + let near = inverse.project_point3(glam::DVec3::new(x, y, 0.0)); + let far = inverse.project_point3(glam::DVec3::new(x, y, 1.0)); + let direction = far - near; + let along = direction.dot(normal); + if along.abs() <= 1e-12 { + return None; + } + let t = ((origin - eye) - near).dot(normal) / along; + t.is_finite().then(|| eye + near + direction * t) +} + fn is_added_polyline_vertex( original: &AcadEntityType, current: &AcadEntityType, @@ -635,6 +658,36 @@ impl OpenCADStudio { .find(|(owner, grip)| *owner == handle && grip.id == grip_id) .and_then(|(_, grip)| grip.axis); + // A move-gizmo part moves its cloud alone, along its axis or plane. + let gizmo_shape = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, grip)| *owner == handle && grip.id == grip_id) + .map(|(_, grip)| grip.shape) + .filter(|shape| { + matches!( + shape, + crate::scene::model::object::GripShape::GizmoAxis(_) + | crate::scene::model::object::GripShape::GizmoPlane(_) + ) + }); + if let Some(shape) = gizmo_shape { + self.tabs[i].hot_grips.clear(); + let mut edit = GripEdit::single(handle, grip_id, true, world); + edit.axis = axis; + edit.gizmo = true; + if let crate::scene::model::object::GripShape::GizmoPlane(k) = shape { + let (a, b) = crate::scene::pick::grip::gizmo_plane_axes(k); + edit.plane = Some( + crate::scene::pick::grip::gizmo_axis(a) + .cross(crate::scene::pick::grip::gizmo_axis(b)), + ); + } + return edit; + } + let clicked_is_hot = self.tabs[i].hot_grips.contains(&(handle, grip_id)); let mut targets: Vec = if clicked_is_hot { @@ -716,6 +769,9 @@ impl OpenCADStudio { axis, targets, rectangle_frame: None, + plane: None, + grab: None, + gizmo: false, } } @@ -927,6 +983,10 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Visual style: {label}").as_ref()); + // The point cloud move gizmo follows the visual style. + if i == self.active_tab { + self.refresh_selected_grips(); + } Task::none() } @@ -1599,6 +1659,28 @@ impl OpenCADStudio { } } + if let Some(normal) = grip.plane { + if snap_hit.is_none_or(|hit| hit.snap_type == crate::snap::SnapType::Grid) { + snapped = cursor_on_plane(p, bounds, view_rot, eye, grip.origin_world, normal) + .unwrap_or(snapped); + } + } + if grip.gizmo { + // The first move measures where the part was grabbed; the + // cloud then follows the cursor by that offset. + let grab = match grip.grab { + Some(grab) => grab, + None => { + let grab = snapped - grip.origin_world; + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.grab = Some(grab); + } + grab + } + }; + snapped -= grab; + } + let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; // The overlay builds the active tracking guide from `otrack_active.base` // to `last_cursor_world`. Keep it synchronized with the actual point used diff --git a/src/entities/extended.rs b/src/entities/extended.rs index e547f0c2e..5e8414cc0 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -1836,6 +1836,31 @@ fn grips(entity: &ExtendedEntity) -> Vec { } } +/// The move gizmo at a point cloud's insertion point: arrows 1-3 along X/Y/Z +/// and squares 4-6 for the XY/YZ/ZX planes. +pub(crate) fn point_cloud_gizmo_grips(data: &PointCloudExData) -> Vec { + use crate::scene::model::object::GripShape; + let world = glam::DVec3::new(data.ucs_origin.x, data.ucs_origin.y, data.ucs_origin.z); + (0..3u8) + .map(|k| GripDef { + id: 1 + k as usize, + world, + is_midpoint: true, + shape: GripShape::GizmoAxis(k), + dir: None, + axis: Some(crate::scene::pick::grip::gizmo_axis(k)), + }) + .chain((0..3u8).map(|k| GripDef { + id: 4 + k as usize, + world, + is_midpoint: true, + shape: GripShape::GizmoPlane(k), + dir: None, + axis: None, + })) + .collect() +} + fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { match &mut entity.data { ExtendedEntityData::SectionObject(data) => { @@ -1911,8 +1936,9 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { data.ucs_origin = data.ucs_origin + delta; } } + // The insertion grip and every move-gizmo part move the cloud. ExtendedEntityData::PointCloudEx(data) => { - if grip_id == 0 { + if grip_id <= 6 { apply_point(&mut data.ucs_origin, apply); } } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 53b45797e..51d779e20 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -135,6 +135,11 @@ pub enum GripShape { Dropdown, /// Menu selector placed immediately beside its anchor grip. DropdownAdjacent, + /// Move-gizmo arrow along world X (0), Y (1) or Z (2); the grip's world + /// point is the gizmo centre, the marker sits at the arrow tip. + GizmoAxis(u8), + /// Move-gizmo square between two axes: XY (0), YZ (1) or ZX (2). + GizmoPlane(u8), } /// Describes one grip point for an entity. diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index b18497627..047edc769 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -29,6 +29,56 @@ fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { point } +/// Screen length of a move-gizmo arrow. +pub const GIZMO_AXIS_PX: f32 = 72.0; + +pub fn gizmo_axis(k: u8) -> DVec3 { + [DVec3::X, DVec3::Y, DVec3::Z][k as usize % 3] +} + +pub fn gizmo_plane_axes(k: u8) -> (u8, u8) { + [(0, 1), (1, 2), (2, 0)][k as usize % 3] +} + +/// Unit screen directions of the three gizmo axes from `center`; an axis +/// seen nearly end-on is left out. +pub fn gizmo_screen_axes( + center: Vec2, + world: DVec3, + project: impl Fn(DVec3) -> Option, +) -> [Option; 3] { + let spans = [0, 1, 2].map(|k| project(world + gizmo_axis(k)).map(|p| p - center)); + let longest = spans.iter().flatten().map(|v| v.length()).fold(0.0, f32::max); + spans.map(|span| span.filter(|v| longest > 0.0 && v.length() > 0.15 * longest).map(|v| v.normalize())) +} + +/// Marker position (and direction) of a grip whose centre projects to +/// `projected`: gizmo grips sit off their centre, others keep their offset. +fn placed_marker( + g: &GripDef, + projected: Option, + project: impl Fn(DVec3) -> Option, +) -> Option<(Vec2, Option<[f32; 2]>)> { + let p = projected?; + match g.shape { + GripShape::GizmoAxis(k) => { + let d = gizmo_screen_axes(p, g.world, project)[k as usize % 3]?; + Some((p + d * GIZMO_AXIS_PX, Some([d.x, -d.y]))) + } + GripShape::GizmoPlane(k) => { + let axes = gizmo_screen_axes(p, g.world, project); + let (a, b) = gizmo_plane_axes(k); + let (a, b) = (axes[a as usize]?, axes[b as usize]?); + Some((p + (a + b) * 0.3 * GIZMO_AXIS_PX, None)) + } + _ => { + let dir = g.dir.and_then(|dir| Some(marker_direction(p, project(g.world + dir)?))); + let s = marker_screen_position(Point::new(p.x, p.y), g.shape); + Some((Vec2::new(s.x, s.y), dir)) + } + } +} + // ── Active drag state ───────────────────────────────────────────────────── /// Stored on `OpenCADStudio` while a grip is being dragged. @@ -50,6 +100,12 @@ pub struct GripEdit { pub targets: Vec, /// Opposite corner and local width/height axes for rectangle corner resize. pub rectangle_frame: Option<(DVec3, DVec3, DVec3)>, + /// Normal of the plane a move-gizmo square drags in. + pub plane: Option, + /// Move gizmo: offset from the gizmo centre to where its part was + /// grabbed, taken on the first move so the drag does not jump. + pub grab: Option, + pub gizmo: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -108,6 +164,9 @@ impl GripEdit { last_world: world, }], rectangle_frame: None, + plane: None, + grab: None, + gizmo: false, } } @@ -181,18 +240,11 @@ pub fn grips_to_screen( // Project from the f64 grip position via the relative-to-eye path so // the grip stays glued to the wire at UTM-scale coordinates (an // `as_vec3` cast first would quantize it ~0.5 m off at high zoom). - let projected = camera.project(g.world, bounds); - let screen = match projected { - Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), - None => Point::new(f32::NAN, f32::NAN), + let project = |world: DVec3| camera.project(world, bounds); + let (screen, dir) = match placed_marker(g, project(g.world), project) { + Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir), + None => (Point::new(f32::NAN, f32::NAN), None), }; - let dir = g.dir.and_then(|dir| { - Some(marker_direction( - projected?, - camera.project(g.world + dir, bounds)?, - )) - }); - let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, dir) }) .collect() @@ -274,10 +326,10 @@ pub fn find_hit_grip( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let Some(screen) = camera.project(g.world, bounds) else { + let project = |world: DVec3| camera.project(world, bounds); + let Some((screen, _)) = placed_marker(g, project(g.world), project) else { continue; }; - let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; let d = (dx * dx + dy * dy).sqrt(); @@ -320,18 +372,11 @@ pub fn grips_to_screen_rte( grips .iter() .map(|g| { - let projected = project_rte(g.world, view_rot, eye, bounds); - let screen = match projected { - Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), - None => Point::new(f32::NAN, f32::NAN), + let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); + let (screen, dir) = match placed_marker(g, project(g.world), project) { + Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir), + None => (Point::new(f32::NAN, f32::NAN), None), }; - let dir = g.dir.and_then(|dir| { - Some(marker_direction( - projected?, - project_rte(g.world + dir, view_rot, eye, bounds)?, - )) - }); - let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, dir) }) .collect() @@ -350,10 +395,10 @@ pub fn find_hit_grip_rte( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else { + let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); + let Some((screen, _)) = placed_marker(g, project(g.world), project) else { continue; }; - let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; let d = (dx * dx + dy * dy).sqrt(); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 805ab4bb9..c6d38fa84 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -966,6 +966,80 @@ struct SelectionCanvas { constraint_cursor_badge: Option, } +/// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the +/// centre grip and a square per axis pair; the hovered or dragged part is +/// yellow. +fn draw_move_gizmo(frame: &mut canvas::Frame, grips: &[GripMarker]) { + use crate::scene::pick::grip::{gizmo_plane_axes, GIZMO_AXIS_PX}; + const COLORS: [Color; 3] = [ + Color::from_rgb(0.90, 0.22, 0.20), + Color::from_rgb(0.27, 0.70, 0.29), + Color::from_rgb(0.18, 0.52, 0.93), + ]; + let active = Color::from_rgb(1.0, 0.84, 0.0); + // Unit screen direction (y down) and tip of each shown arrow. + let mut axes: [Option<(Point, iced::Vector)>; 3] = [None; 3]; + for grip in grips { + if let (GripShape::GizmoAxis(k), Some([dx, dy])) = (grip.shape, grip.dir) { + axes[k as usize % 3] = Some((grip.pos, iced::Vector::new(dx, -dy))); + } + } + let Some(center) = axes + .iter() + .flatten() + .next() + .map(|(tip, d)| Point::new(tip.x - d.x * GIZMO_AXIS_PX, tip.y - d.y * GIZMO_AXIS_PX)) + else { + return; + }; + for grip in grips { + let GripShape::GizmoPlane(k) = grip.shape else { + continue; + }; + let (a, b) = gizmo_plane_axes(k); + let (Some((_, a)), Some((_, b))) = (axes[a as usize], axes[b as usize]) else { + continue; + }; + let corner = |u: f32, v: f32| { + Point::new( + center.x + (a.x * u + b.x * v) * GIZMO_AXIS_PX, + center.y + (a.y * u + b.y * v) * GIZMO_AXIS_PX, + ) + }; + let quad = canvas::Path::new(|p| { + p.move_to(corner(0.18, 0.18)); + p.line_to(corner(0.42, 0.18)); + p.line_to(corner(0.42, 0.42)); + p.line_to(corner(0.18, 0.42)); + p.close(); + }); + let lit = grip.is_hovered || grip.is_hot; + let color = if lit { active } else { Color::from_rgb(0.85, 0.85, 0.85) }; + frame.fill(&quad, color.scale_alpha(if lit { 0.55 } else { 0.25 })); + frame.stroke(&quad, canvas::Stroke::default().with_width(1.0).with_color(color)); + } + for grip in grips { + let (GripShape::GizmoAxis(k), Some((tip, d))) = + (grip.shape, grip.dir.map(|[dx, dy]| (grip.pos, iced::Vector::new(dx, -dy)))) + else { + continue; + }; + let color = if grip.is_hovered || grip.is_hot { active } else { COLORS[k as usize % 3] }; + let base = Point::new(tip.x - d.x * 13.0, tip.y - d.y * 13.0); + frame.stroke( + &canvas::Path::line(center, base), + canvas::Stroke::default().with_width(2.5).with_color(color), + ); + let head = canvas::Path::new(|p| { + p.move_to(tip); + p.line_to(Point::new(base.x - d.y * 6.0, base.y + d.x * 6.0)); + p.line_to(Point::new(base.x + d.y * 6.0, base.y - d.x * 6.0)); + p.close(); + }); + frame.fill(&head, color); + } +} + fn draw_grip_marker( frame: &mut canvas::Frame, grip: &GripMarker, @@ -1019,6 +1093,7 @@ fn draw_grip_marker( b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), + GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_) => return, GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| { b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); @@ -1393,8 +1468,11 @@ impl canvas::Program for SelectionCanvas { ); } } + draw_move_gizmo(frame, &self.grips); for grip in &self.grips { - draw_grip_marker(frame, grip, theme, &self.selection_visual); + if !matches!(grip.shape, GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_)) { + draw_grip_marker(frame, grip, theme, &self.selection_visual); + } } }); }