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