Merge pull request #1330 from mechpanos/feat/right-click-menus

feat(ui): right-click context menus with command keywords, PLINE/ARC keyword parity, grip menu
This commit is contained in:
Panos authored and GitHub committed 2026-09-17 16:05:31 +03:00
commit 8e05ed552c
58 files changed
+6437 -538

No files matched your search

+168 -91
View File
@@ -3451,102 +3451,21 @@ impl OpenCADStudio {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.clear_left_selection_gesture();
}
// Grip-menu Base Point: this click re-bases the gesture at the
// cursor instead of placing anything.
if self.tabs[i].grip_base_pending {
self.tabs[i].grip_base_pending = false;
if let Some(active) = self.tabs[i].active_grip.as_mut() {
active.origin_world = active.last_world;
}
return Task::none();
}
let moved = grip.last_world != grip.origin_world;
if is_click && !moved {
// Engaging click — stay hot, wait for the placement click.
return Task::none();
}
if matches!(
grip.mode,
GripEditMode::Lengthen
| GripEditMode::Radius
| GripEditMode::ArcLength
| GripEditMode::RectangleWidth
| GripEditMode::RectangleHeight
| GripEditMode::MoveParallel
) {
self.grip_pending = None;
self.command_line.input.clear();
}
let added_vertex_focus = grip.targets.iter().find_map(|target| {
let original = self
.grip_originals
.iter()
.find(|(handle, _)| *handle == target.handle)
.map(|(_, entity)| entity)?;
let current = self.tabs[i].scene.document.get_entity(target.handle)?;
is_added_polyline_vertex(original, current, target.grip_id)
.then_some(target.grip_id)
});
// Keep originals available until every history shape has a valid
// final display. A rejected rebuild cancels the entire gesture.
let history_handles: Vec<_> = self
.grip_preview_handles
.iter()
.copied()
.filter(|handle| {
self.tabs[i]
.scene
.document
.solid_history_operation(*handle)
.is_some()
})
.collect();
for handle in history_handles {
if !self.tabs[i].scene.finalize_solid_history(handle) {
self.cancel_active_grip_edit();
self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref());
self.refresh_properties();
return Task::none();
}
}
self.tabs[i].active_grip = None;
// Commit the grip drag as one undoable group, then put every
// edited entity back into the resident tessellation.
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let history_originals = std::mem::take(&mut self.grip_history_originals);
let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty);
if !handles.is_empty() {
if !originals.is_empty() {
self.push_entity_group_history(
i,
"GRIP",
originals
.into_iter()
.map(|(handle, entity)| (handle, std::sync::Arc::new(entity)))
.collect(),
history_originals
.into_iter()
.flat_map(|(_, objects)| objects)
.collect(),
dirty_before,
);
self.tabs[i].dirty = true;
}
self.grip_reference_wires.clear();
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
let changes: Vec<_> = handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities_after_parametric_solve(&changes);
}
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
if let Some(vertex_id) = added_vertex_focus {
self.tabs[i].properties.prop_vertex = vertex_id;
self.tabs[i].properties.prop_vertex_indicator_active = true;
crate::scene::view::dispatch::set_prop_current_vertex(vertex_id);
}
self.refresh_properties();
return Task::none();
return self.commit_active_grip_edit();
}
// Map the release point into the active Model tile so the
@@ -6219,6 +6138,164 @@ properties={:.1}ms picked={}",
}
Task::none()
}
/// Commit the active grip edit at its current (already applied) position:
/// one undoable group per gesture, every edited entity back into the
/// resident tessellation. With the grip-menu Copy toggle on, the
/// originals are put back and the moved shapes are added as new
/// entities, and the grip stays hot for the next copy.
pub(in crate::app) fn commit_active_grip_edit(&mut self) -> Task<Message> {
let i = self.active_tab;
let Some(grip) = self.tabs[i].active_grip.clone() else {
return Task::none();
};
if grip.last_world == grip.origin_world {
// Nothing placed yet — the grip simply stays hot.
return Task::none();
}
if self.tabs[i].grip_copy {
return self.commit_active_grip_edit_as_copy(grip);
}
self.tabs[i].grip_base_pending = false;
if matches!(
grip.mode,
GripEditMode::Lengthen
| GripEditMode::Radius
| GripEditMode::ArcLength
| GripEditMode::RectangleWidth
| GripEditMode::RectangleHeight
| GripEditMode::MoveParallel
) {
self.grip_pending = None;
self.command_line.input.clear();
}
let added_vertex_focus = grip.targets.iter().find_map(|target| {
let original = self
.grip_originals
.iter()
.find(|(handle, _)| *handle == target.handle)
.map(|(_, entity)| entity)?;
let current = self.tabs[i].scene.document.get_entity(target.handle)?;
is_added_polyline_vertex(original, current, target.grip_id)
.then_some(target.grip_id)
});
// Keep originals available until every history shape has a valid
// final display. A rejected rebuild cancels the entire gesture.
let history_handles: Vec<_> = self
.grip_preview_handles
.iter()
.copied()
.filter(|handle| {
self.tabs[i]
.scene
.document
.solid_history_operation(*handle)
.is_some()
})
.collect();
for handle in history_handles {
if !self.tabs[i].scene.finalize_solid_history(handle) {
self.cancel_active_grip_edit();
self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref());
self.refresh_properties();
return Task::none();
}
}
self.tabs[i].active_grip = None;
// Commit the grip drag as one undoable group, then put every
// edited entity back into the resident tessellation.
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let history_originals = std::mem::take(&mut self.grip_history_originals);
let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty);
if !handles.is_empty() {
if !originals.is_empty() {
self.push_entity_group_history(
i,
"GRIP",
originals
.into_iter()
.map(|(handle, entity)| (handle, std::sync::Arc::new(entity)))
.collect(),
history_originals
.into_iter()
.flat_map(|(_, objects)| objects)
.collect(),
dirty_before,
);
self.tabs[i].dirty = true;
}
self.grip_reference_wires.clear();
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
let changes: Vec<_> = handles
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities_after_parametric_solve(&changes);
}
// Placement confirmed — keep the just-added leader.
self.grip_add_provisional = None;
self.tabs[i].snap_result = None;
if let Some(vertex_id) = added_vertex_focus {
self.tabs[i].properties.prop_vertex = vertex_id;
self.tabs[i].properties.prop_vertex_indicator_active = true;
crate::scene::view::dispatch::set_prop_current_vertex(vertex_id);
}
self.refresh_properties();
Task::none()
}
/// Grip-menu Copy: leave the originals where they were and add the
/// edited shapes as new entities, then re-arm the same grip at its
/// origin so the next placement makes another copy (grip Copy, as in commercial solutions).
fn commit_active_grip_edit_as_copy(&mut self, grip: GripEdit) -> Task<Message> {
let i = self.active_tab;
let handles = std::mem::take(&mut self.grip_preview_handles);
let originals = std::mem::take(&mut self.grip_originals);
let _ = std::mem::take(&mut self.grip_history_originals);
self.grip_dirty_before = None;
self.grip_add_provisional = None;
self.grip_reference_wires.clear();
self.grip_text_verts = Vec::new();
self.grip_text_slide = false;
for &handle in &handles {
self.tabs[i].scene.preview_hidden.remove(&handle);
}
self.tabs[i].scene.clear_preview_wire();
if !originals.is_empty() {
self.push_undo_snapshot(i, "GRIP");
let mut restored = Vec::with_capacity(originals.len());
for (handle, original) in &originals {
let Some(moved) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
continue;
};
if self.tabs[i].scene.update_entity(original.clone()) {
restored.push((*handle, crate::scene::ChangeKind::Modified));
}
self.tabs[i].scene.add_entity_clone(moved);
}
self.tabs[i].scene.bump_entities_after_parametric_solve(&restored);
self.finish_pending_history(i);
self.tabs[i].dirty = true;
}
self.tabs[i].snap_result = None;
// Re-arm: the grip sits at its origin again over the restored shape.
let mut again = grip;
let delta = again.last_world - again.origin_world;
again.last_world = again.origin_world;
for target in &mut again.targets {
target.last_world -= delta;
}
self.tabs[i].active_grip = Some(again);
self.refresh_properties();
Task::none()
}
}
#[cfg(test)]