forked from 1b-agent/opencadstudio-upstream
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:
1 parent
331772eac2
commit
4b00504453
6 files changed
+287
-28
No files matched your search
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user