feat(grips): improve direct editing

Refit arcs from fixed control points and highlight hovered grips.
Let Lengthen preview from the cursor or accept a typed distance.
This commit is contained in:
Hakan Seven 2026-08-04 16:27:03 +03:00
commit 539d83de47
10 changed files with 421 additions and 59 deletions

View file

@ -85,9 +85,9 @@ pub struct GripPopup {
}
/// Pending follow-up value for grip-menu actions that need a number
/// (Lengthen / Radius / Arc Length / Rotate Text). The next number
/// typed in the command line is parsed and routed into
/// `apply_grip_menu_value` for `(handle, grip_id, action)`.
/// (Lengthen / Radius / Arc Length / Rotate Text). Lengthen can also resolve
/// from a viewport point; otherwise the next typed number is parsed and routed
/// into `apply_grip_menu_value` for `(handle, grip_id, action)`.
#[derive(Clone, Debug)]
pub struct GripPendingValue {
pub handle: acadrust::Handle,

View file

@ -9,7 +9,9 @@ use crate::app::helpers::{
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::pick::grip::{
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode,
};
use crate::scene::model::object::GripApply;
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
@ -242,9 +244,21 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
"{}: expected a number, got \"{raw}\"",
pending.label
).as_ref());
return Task::none();
self.grip_pending = Some(pending);
return self.focus_cmd_input();
};
let i = self.active_tab;
let interactive_lengthen = self.tabs[i]
.active_grip
.as_ref()
.is_some_and(|grip| {
grip.mode == GripEditMode::Lengthen
&& grip.handle == pending.handle
&& grip.grip_id == pending.grip_id
});
if interactive_lengthen {
self.cancel_active_grip_edit();
}
use crate::entities::traits::EntityTypeOps;
self.push_undo_snapshot(i, pending.label);
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(pending.handle)
@ -576,6 +590,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if self.visibility_popup.take().is_some() {
return Task::none();
}
if self.tabs[self.active_tab].active_grip.is_some() {
self.grip_pending = None;
self.command_line.input.clear();
if self.cancel_active_grip_edit() {
return Task::none();
}
}
if self.grip_pending.take().is_some() {
self.command_line.input.clear();
return Task::none();
@ -987,7 +1008,26 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
action: item.action,
label,
});
self.command_line.push_info(crate::tf!("{label}:").as_ref());
if matches!(item.action, GripMenuAction::Lengthen) {
if let Some((_, grip)) = self.tabs[i]
.selected_grip_handles
.iter()
.zip(self.tabs[i].selected_grips.iter())
.find(|(owner, grip)| {
**owner == popup.handle && grip.id == popup.grip_id
})
{
self.tabs[i].active_grip = Some(GripEdit::lengthen(
popup.handle,
popup.grip_id,
grip.world,
));
}
self.command_line
.push_info(crate::t!("Specify point or enter distance:").as_ref());
} else {
self.command_line.push_info(crate::tf!("{label}:").as_ref());
}
return self.focus_cmd_input();
}
// Break at vertex replaces the entity with the split pieces —

View file

@ -11,7 +11,7 @@ use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::model::object::GripApply;
use crate::scene::pick::grip::{
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripTarget,
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, GripTarget,
};
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
@ -344,6 +344,7 @@ impl OpenCADStudio {
grip_id,
origin_world: world,
last_world: world,
mode: GripEditMode::Stretch,
targets,
}
}
@ -1003,18 +1004,91 @@ impl OpenCADStudio {
let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0;
let apply_started = Instant::now();
let delta = snapped - grip.last_world;
let actions: Vec<_> = grip
.targets
.iter()
.map(|target| {
let apply = if target.is_translate {
GripApply::Translate(delta)
} else {
GripApply::Absolute(target.last_world + delta)
};
(target.handle, target.grip_id, apply)
})
.collect();
let lengthen = grip.mode == GripEditMode::Lengthen;
let actions: Vec<_> = if lengthen {
Vec::new()
} else {
grip.targets
.iter()
.map(|target| {
let apply = if target.is_translate {
GripApply::Translate(delta)
} else {
GripApply::Absolute(target.last_world + delta)
};
(target.handle, target.grip_id, apply)
})
.collect()
};
if lengthen {
let original = self
.grip_originals
.iter()
.find(|(handle, _)| *handle == grip.handle)
.map(|(_, entity)| entity.clone());
if let Some(original) = original {
let action = crate::scene::model::object::GripMenuAction::Lengthen;
let value = crate::scene::view::dispatch::grip_menu_point_value(
&original,
grip.grip_id,
action,
snapped,
);
if let Some(value) = value {
if let Some(current) =
self.tabs[i].scene.document.get_entity_mut(grip.handle)
{
*current = original;
crate::entities::traits::EntityTypeOps::apply_grip_menu_value(
current,
grip.grip_id,
action,
value,
);
}
}
}
}
// Arc point grips are coupled: moving one point must rebuild the
// circle from the drag-start start/middle/end set, otherwise the
// supposedly fixed points drift a little on every mouse event.
let mut arc_grip_edits: rustc_hash::FxHashMap<
Handle,
Vec<(usize, glam::DVec3)>,
> = rustc_hash::FxHashMap::default();
for (handle, grip_id, apply) in &actions {
if let GripApply::Absolute(point) = apply {
if (1..=3).contains(grip_id) {
arc_grip_edits
.entry(*handle)
.or_default()
.push((*grip_id, *point));
}
}
}
let mut rebuilt_arcs = rustc_hash::FxHashSet::default();
for (handle, edits) in arc_grip_edits {
let original = self
.grip_originals
.iter()
.find(|(original_handle, _)| *original_handle == handle)
.map(|(_, entity)| entity.clone());
let Some(original) = original else {
continue;
};
let Some(current) = self.tabs[i].scene.document.get_entity_mut(handle) else {
continue;
};
if crate::scene::view::dispatch::refit_arc_grips(
current,
&original,
&edits,
)
.is_some()
{
rebuilt_arcs.insert(handle);
}
}
let refit_arc_targets: Vec<_> = grip
.targets
.iter()
@ -1033,7 +1107,9 @@ impl OpenCADStudio {
})
.collect();
for (handle, grip_id, apply) in actions {
self.tabs[i].scene.apply_grip(handle, grip_id, apply);
if !rebuilt_arcs.contains(&handle) {
self.tabs[i].scene.apply_grip(handle, grip_id, apply);
}
}
for (handle, vertex_id, original_bulge) in refit_arc_targets {
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
@ -2334,6 +2410,10 @@ impl OpenCADStudio {
// Engaging click — stay hot, wait for the placement click.
return Task::none();
}
if grip.mode == GripEditMode::Lengthen {
self.grip_pending = None;
self.command_line.input.clear();
}
let added_vertex_focus = grip.targets.iter().find_map(|target| {
let original = self
.grip_originals

View file

@ -451,10 +451,16 @@ impl OpenCADStudio {
|| (Some(handle) == sel_h
&& Some(grip_id) == current_vertex_grip)
});
let is_hovered = owner.is_some_and(|handle| {
self.grip_hover.as_ref().is_some_and(|hover| {
hover.handle == handle && hover.grip_id == grip_id
})
});
crate::ui::overlay::GripMarker {
pos: screen,
shape,
is_hot,
is_hovered,
dir,
}
})

View file

@ -4,7 +4,8 @@ use truck_modeling::{builder, Point3};
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro,
square_grip,
};
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
@ -103,26 +104,114 @@ fn to_truck(arc: &Arc) -> TruckEntity {
}
}
fn angle_span(start: f32, end: f32) -> f32 {
let mut span = end - start;
if span < 0.0 {
span += std::f32::consts::TAU;
fn control_points(arc: &Arc) -> [glam::DVec3; 3] {
let center = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z);
let sweep = (arc.end_angle - arc.start_angle).rem_euclid(TAU);
let point = |angle: f64| {
center
+ glam::DVec3::new(
arc.radius * angle.cos(),
arc.radius * angle.sin(),
0.0,
)
};
[
point(arc.start_angle),
point(arc.start_angle + sweep * 0.5),
point(arc.end_angle),
]
}
fn circumcircle(
a: glam::DVec3,
b: glam::DVec3,
c: glam::DVec3,
) -> Option<(glam::DVec3, f64)> {
// Work relative to the first point so large drawing coordinates do not
// lose the small differences that define the circle.
let ab = b - a;
let ac = c - a;
let bc = c - b;
let scale2 = ab
.length_squared()
.max(ac.length_squared())
.max(bc.length_squared());
if scale2 < 1.0e-18 {
return None;
}
span
let det = 2.0 * (ab.x * ac.y - ab.y * ac.x);
if det.abs() <= scale2 * 1.0e-12 {
return None;
}
let ab2 = ab.x * ab.x + ab.y * ab.y;
let ac2 = ac.x * ac.x + ac.y * ac.y;
let center = a
+ glam::DVec3::new(
(ab2 * ac.y - ac2 * ab.y) / det,
(ab.x * ac2 - ac.x * ab2) / det,
0.0,
);
let radius = center.distance(a);
radius.is_finite().then_some((center, radius))
}
pub(crate) fn refit_grips(
arc: &mut Arc,
original: &Arc,
edits: &[(usize, glam::DVec3)],
) -> bool {
let mut points = control_points(original);
let mut changed = false;
for &(grip_id, point) in edits {
let index = match grip_id {
1 => 0,
2 => 2,
3 => 1,
_ => continue,
};
points[index] = point;
changed = true;
}
if !changed {
return false;
}
let Some((center, radius)) = circumcircle(points[0], points[1], points[2]) else {
return false;
};
if radius <= 1.0e-9 {
return false;
}
let start = (points[0].y - center.y).atan2(points[0].x - center.x);
let middle = (points[1].y - center.y).atan2(points[1].x - center.x);
let end = (points[2].y - center.y).atan2(points[2].x - center.x);
let sweep = (end - start).rem_euclid(TAU);
let middle_sweep = (middle - start).rem_euclid(TAU);
// Crossing the two fixed points makes the three-point definition
// degenerate before it reverses. Keep the last valid preview instead of
// swapping the start and end grip identities under the cursor.
if sweep <= 1.0e-9 || middle_sweep > sweep + 1.0e-9 {
return false;
}
arc.center.x = center.x;
arc.center.y = center.y;
arc.center.z = original.center.z;
arc.radius = radius;
arc.start_angle = start;
arc.end_angle = end;
true
}
fn grips(arc: &Arc) -> Vec<GripDef> {
let ctr = glam::DVec3::new(arc.center.x, arc.center.y, arc.center.z);
let r = arc.radius;
let sa = arc.start_angle as f32;
let ea = arc.end_angle as f32;
let ma = (sa + angle_span(sa, ea) * 0.5) as f64;
let (sa, ea) = (arc.start_angle, arc.end_angle);
let [start, middle, end] = control_points(arc);
vec![
center_grip(0, ctr),
square_grip(1, ctr + glam::DVec3::new(r * sa.cos(), r * sa.sin(), 0.0)),
square_grip(2, ctr + glam::DVec3::new(r * ea.cos(), r * ea.sin(), 0.0)),
center_grip(3, ctr + glam::DVec3::new(r * ma.cos(), r * ma.sin(), 0.0)),
square_grip(1, start),
square_grip(2, end),
square_grip(3, middle),
]
}
@ -204,31 +293,9 @@ fn apply_grip(arc: &mut Arc, grip_id: usize, apply: GripApply) {
arc.center.y = p.y;
arc.center.z = p.z;
}
(1, GripApply::Absolute(p)) => {
let dx = p.x - arc.center.x;
let dy = p.y - arc.center.y;
arc.start_angle = dy.atan2(dx);
}
(2, GripApply::Absolute(p)) => {
let dx = p.x - arc.center.x;
let dy = p.y - arc.center.y;
arc.end_angle = dy.atan2(dx);
}
(3, GripApply::Translate(d)) => {
let sa = arc.start_angle;
let ea = arc.end_angle;
let span = angle_span(sa as f32, ea as f32) as f64;
let mid_a = sa + span * 0.5;
let current_mid_x = arc.center.x + arc.radius * mid_a.cos();
let current_mid_y = arc.center.y + arc.radius * mid_a.sin();
let new_mid_x = current_mid_x + d.x;
let new_mid_y = current_mid_y + d.y;
let dx = new_mid_x - arc.center.x;
let dy = new_mid_y - arc.center.y;
let new_r = (dx * dx + dy * dy).sqrt();
if new_r > 1e-6 {
arc.radius = new_r;
}
(1..=3, GripApply::Absolute(p)) => {
let original = arc.clone();
let _ = refit_grips(arc, &original, &[(grip_id, p)]);
}
_ => {}
}
@ -316,6 +383,29 @@ impl crate::entities::traits::Grippable for Arc {
}
}
fn grip_menu_point_value(
&self,
grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
use crate::scene::model::object::GripMenuAction as A;
if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 {
return None;
}
let cursor_angle = (point.y - self.center.y).atan2(point.x - self.center.x);
let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU);
let desired_sweep = match grip_id {
1 => (self.end_angle - cursor_angle).rem_euclid(TAU),
2 => (cursor_angle - self.start_angle).rem_euclid(TAU),
_ => return None,
};
if desired_sweep <= 1.0e-9 {
return None;
}
Some((desired_sweep - current_sweep) * self.radius)
}
fn apply_grip_menu_value(
&mut self,
grip_id: usize,

View file

@ -210,6 +210,34 @@ impl crate::entities::traits::Grippable for Line {
}
}
fn grip_menu_point_value(
&self,
grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
use crate::scene::model::object::GripMenuAction as A;
if !matches!(action, A::Lengthen) {
return None;
}
let direction = glam::DVec3::new(
self.end.x - self.start.x,
self.end.y - self.start.y,
self.end.z - self.start.z,
);
let length = direction.length();
if length < 1.0e-12 {
return None;
}
let unit = direction / length;
let value = match grip_id {
0 => (glam::DVec3::new(self.start.x, self.start.y, self.start.z) - point).dot(unit),
1 => (point - glam::DVec3::new(self.end.x, self.end.y, self.end.z)).dot(unit),
_ => return None,
};
(length + value > 1.0e-9).then_some(value)
}
fn apply_grip_menu_value(
&mut self,
grip_id: usize,

View file

@ -57,6 +57,17 @@ pub trait Grippable {
) -> Option<&'static str> {
None
}
/// Convert an interactive cursor point into the numeric value consumed by
/// `apply_grip_menu_value`. Actions without a point-driven form return
/// `None`.
fn grip_menu_point_value(
&self,
_grip_id: usize,
_action: GripMenuAction,
_point: glam::DVec3,
) -> Option<f64> {
None
}
}
pub trait PropertyEditable {
@ -163,6 +174,12 @@ pub trait EntityTypeOps {
grip_id: usize,
action: GripMenuAction,
) -> Option<&'static str>;
fn grip_menu_point_value(
&self,
grip_id: usize,
action: GripMenuAction,
point: glam::DVec3,
) -> Option<f64>;
fn apply_grip_menu_value(
&mut self,
grip_id: usize,
@ -405,6 +422,28 @@ impl EntityTypeOps for EntityType {
)
}
fn grip_menu_point_value(
&self,
grip_id: usize,
action: GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
dispatch!(self,
|e| Grippable::grip_menu_point_value(e, grip_id, action, point),
[
Line, Circle, Arc, Ellipse, LwPolyline, Polyline, Polyline2D,
Polyline3D, Ray, XLine, RasterImage, Wipeout,
AttributeDefinition, AttributeEntity, MLine, Tolerance,
Solid, Solid3D, Region, Body, Surface, Face3D, PolygonMesh,
PolyfaceMesh, Mesh, Table, Point, Spline, Text, MText,
Viewport, Insert, Leader, MultiLeader, Dimension, Hatch,
Underlay, Shape, Ole2Frame, SectionSymbol, ViewBorder,
Extended,
],
_ => None,
)
}
fn apply_grip_menu_value(
&mut self,
grip_id: usize,

View file

@ -24,10 +24,18 @@ pub struct GripEdit {
pub origin_world: DVec3,
/// Last world-space cursor position (needed for incremental delta on translate drags).
pub last_world: DVec3,
/// How cursor movement modifies the selected grip.
pub mode: GripEditMode,
/// Every hot grip moved by this edit. A normal grip edit contains one target.
pub targets: Vec<GripTarget>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GripEditMode {
Stretch,
Lengthen,
}
#[derive(Clone, Debug)]
pub struct GripTarget {
pub handle: Handle,
@ -48,6 +56,7 @@ impl GripEdit {
grip_id,
origin_world: world,
last_world: world,
mode: GripEditMode::Stretch,
targets: vec![GripTarget {
handle,
grip_id,
@ -56,6 +65,12 @@ impl GripEdit {
}],
}
}
pub fn lengthen(handle: Handle, grip_id: usize, world: DVec3) -> Self {
let mut edit = Self::single(handle, grip_id, false, world);
edit.mode = GripEditMode::Lengthen;
edit
}
}
// ── Screen-space helpers ───────────────────────────────────────────────────

View file

@ -245,6 +245,57 @@ pub fn apply_grip(entity: &mut EntityType, grip_id: usize, apply: crate::scene::
EntityTypeOps::apply_grip(entity, grip_id, apply);
}
/// Rebuild an arc from its drag-start control points and the point grips moved
/// in the current frame. Returns `None` for non-arc entities and `Some(false)`
/// when the requested points are temporarily degenerate.
pub fn refit_arc_grips(
entity: &mut EntityType,
original: &EntityType,
edits: &[(usize, glam::DVec3)],
) -> Option<bool> {
let EntityType::Arc(arc) = entity else {
return None;
};
let EntityType::Arc(original_arc) = original else {
return None;
};
let edits = match planar_ocs_normal(original) {
Some(normal) => edits
.iter()
.map(|&(grip_id, point)| {
let (x, y, z) =
super::transform::wcs_point_to_ocs((point.x, point.y, point.z), normal);
(grip_id, glam::DVec3::new(x, y, z))
})
.collect::<Vec<_>>(),
None => edits.to_vec(),
};
Some(crate::entities::arc::refit_grips(
arc,
original_arc,
&edits,
))
}
/// Convert a world-space cursor point into the value expected by an
/// interactive grip-menu action.
pub fn grip_menu_point_value(
entity: &EntityType,
grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
point: glam::DVec3,
) -> Option<f64> {
let point = match planar_ocs_normal(entity) {
Some(normal) => {
let (x, y, z) =
super::transform::wcs_point_to_ocs((point.x, point.y, point.z), normal);
glam::DVec3::new(x, y, z)
}
None => point,
};
EntityTypeOps::grip_menu_point_value(entity, grip_id, action, point)
}
pub fn apply_transform(entity: &mut EntityType, t: &EntityTransform) {
EntityTypeOps::apply_transform(entity, t);
}

View file

@ -26,6 +26,8 @@ pub struct GripMarker {
pub shape: GripShape,
/// True → grip is currently being dragged (drawn filled red).
pub is_hot: bool,
/// True → pointer is over this grip (drawn with the hover fill).
pub is_hovered: bool,
/// World-XY direction vector — only consumed by the `Rectangle`
/// shape to orient the box along its segment. `None` for grips
/// that don't need rotation.
@ -338,6 +340,17 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme)
if grip.is_hot {
frame.fill(&path, theme.palette().danger.base.color);
} else if grip.is_hovered {
let pair = theme.palette().primary.strong;
frame.fill(&path, pair.color);
frame.stroke(
&path,
canvas::Stroke {
width: 1.5,
style: canvas::Style::Solid(pair.text),
..Default::default()
},
);
} else {
let palette = theme.palette();
let color = palette.primary.base.color;