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 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-30 16:09:00 +03:00
1 parent 331772eac2
commit 4b00504453
6 files changed
+287 -28

No files matched your search

+23
View File
@@ -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)
};
+82
View File
@@ -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<glam::DVec3> {
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<GripTarget> = 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
+27 -1
View File
@@ -1836,6 +1836,31 @@ fn grips(entity: &ExtendedEntity) -> Vec<GripDef> {
}
}
/// 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<GripDef> {
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);
}
}
+5
View File
@@ -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.
+71 -26
View File
@@ -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<Vec2>,
) -> [Option<Vec2>; 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<Vec2>,
project: impl Fn(DVec3) -> Option<Vec2>,
) -> 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<GripTarget>,
/// 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<DVec3>,
/// 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<DVec3>,
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();
+79 -1
View File
@@ -966,6 +966,80 @@ struct SelectionCanvas {
constraint_cursor_badge: Option<String>,
}
/// 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<Message> 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);
}
}
});
}