From a66940a3f0996cbe9580d5c812e039483741328f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 31 Jul 2026 22:05:30 +0300 Subject: [PATCH] feat(grip): support multi-grip editing Refs #606 --- src/app/command_driver.rs | 27 ++-- src/app/document.rs | 6 + src/app/history.rs | 20 +-- src/app/mod.rs | 24 ++-- src/app/properties.rs | 49 +++++--- src/app/update/command.rs | 56 +++++---- src/app/update/mod.rs | 1 + src/app/update/viewport.rs | 244 +++++++++++++++++++++++++++---------- src/app/view/mod.rs | 30 +++-- src/app/visibility.rs | 1 + src/scene/pick/grip.rs | 58 ++++++--- src/scene/preview.rs | 18 +-- src/ui/properties.rs | 4 + 13 files changed, 368 insertions(+), 170 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 448fcdf7a..eb8ba74f4 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -35,7 +35,7 @@ impl OpenCADStudio { let i = self.active_tab; let had_grip = self.tabs[i].active_grip.take().is_some() || self.grip_add_provisional.is_some() - || self.grip_preview_handle.is_some(); + || !self.grip_preview_handles.is_empty(); if !had_grip { return false; } @@ -54,18 +54,23 @@ impl OpenCADStudio { .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); } - if let Some(handle) = self.grip_preview_handle.take() { - if let Some(original) = self.grip_original.take() { - if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { - *entity = original; - } + let handles = std::mem::take(&mut self.grip_preview_handles); + let originals = std::mem::take(&mut self.grip_originals); + for (handle, original) in originals { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + *entity = original; } + } + for &handle in &handles { self.tabs[i].scene.preview_hidden.remove(&handle); - self.tabs[i] - .scene - .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); - } else { - self.grip_original = None; + } + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + if let Some(dirty_before) = self.grip_dirty_before.take() { + self.tabs[i].dirty = dirty_before; } self.grip_text_verts.clear(); diff --git a/src/app/document.rs b/src/app/document.rs index 48756983e..292544b2a 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -135,6 +135,10 @@ pub(super) struct DocumentTab { pub(super) snap_result: Option, pub(super) active_grip: Option, pub(super) selected_grips: Vec, + /// Entity handle for each entry in `selected_grips`. + pub(super) selected_grip_handles: Vec, + /// Shift-selected grips, keyed by entity and object-local grip id. + pub(super) hot_grips: rustc_hash::FxHashSet<(Handle, usize)>, pub(super) selected_handle: Option, /// Dynamic-block visibility grip for the current single selection. pub(super) visibility_grip: Option, @@ -430,6 +434,8 @@ impl DocumentTab { snap_result: None, active_grip: None, selected_grips: vec![], + selected_grip_handles: vec![], + hot_grips: rustc_hash::FxHashSet::default(), selected_handle: None, visibility_grip: None, wireframe: false, diff --git a/src/app/history.rs b/src/app/history.rs index 751dec81f..2ac2e0027 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -184,21 +184,27 @@ impl OpenCADStudio { ); } - pub(super) fn push_single_entity_history( + pub(super) fn push_entity_group_history( &mut self, i: usize, label: impl Into, - handle: Handle, - before: Arc, + before: Vec<(Handle, Arc)>, + dirty_before: bool, ) { self.finish_pending_history(i); - let Some(after) = self.tabs[i].scene.document.get_entity_arc(handle) else { + let entities: Vec<_> = before + .into_iter() + .filter_map(|(handle, original)| { + let after = self.tabs[i].scene.document.get_entity_arc(handle)?; + Some((handle, Some(original), Some(after))) + }) + .collect(); + if entities.is_empty() { return; - }; + } let selected: Vec = self.tabs[i].scene.selected.iter().copied().collect(); - let dirty_before = self.tabs[i].dirty; let delta = DeltaSnapshot { - entities: vec![(handle, Some(before), Some(after))], + entities, current_layout_before: self.tabs[i].scene.current_layout.clone(), current_layout_after: self.tabs[i].scene.current_layout.clone(), selected_before: selected.clone(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 6b847d00e..cc1b3e6ef 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -429,23 +429,20 @@ pub(super) struct OpenCADStudio { /// being placed (follows the cursor). `(entity handle, new-arrow grip id)`. /// Esc before the placement click removes it again. grip_add_provisional: Option<(acadrust::Handle, usize)>, - /// Handle hidden from the base tessellation during an in-progress grip - /// drag. While dragging, the edited entity is excluded from the cached - /// wire set and shown as a cheap overlay preview instead, so each move - /// updates only the overlay rather than re-tessellating the whole model. - /// Committed (un-hidden + one re-tess) when the drag ends. `None` = idle. - grip_preview_handle: Option, + /// Handles hidden from the base tessellation during an in-progress grip + /// drag. The edited entities are shown in the overlay until commit. + grip_preview_handles: Vec, /// Pending rollover hit-test. Each idle cursor move stashes /// `(last_move_at, point, tab)` here and clears the live highlight; /// `HoverDwellTick` runs the pick once the cursor has been still for /// `HOVER_DWELL_MS`. Skipping the pick mid-stroke avoids the per-frame /// O(N) wire+hatch+mesh sweep that froze the cursor on large drawings. hover_dwell: Option, - /// Snapshot of the edited entity taken at the start of a grip drag, used to - /// restore it if the user presses Esc to cancel the drag. The drag mutates - /// the document live (so grips / properties track), so cancel reverts from - /// this backup. Dropped (kept) on a normal commit. - grip_original: Option, + /// Snapshots of edited entities taken at the start of a grip drag. The drag + /// mutates the document live, so Escape restores this group atomically. + grip_originals: Vec<(acadrust::Handle, acadrust::EntityType)>, + /// Document dirty state before the live grip mutation began. + grip_dirty_before: Option, /// Drag-start snapshot of the dragged entity's SDF glyph quads. A whole- /// entity text move slides these each frame (translating the already-shaped /// glyphs) instead of re-tessellating the run every cursor move (issue #316). @@ -2700,9 +2697,10 @@ impl OpenCADStudio { grip_pending: None, visibility_popup: None, grip_add_provisional: None, - grip_preview_handle: None, + grip_preview_handles: Vec::new(), hover_dwell: None, - grip_original: None, + grip_originals: Vec::new(), + grip_dirty_before: None, grip_text_verts: Vec::new(), grip_text_slide: false, qselect: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index e67fa9896..97e310f20 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -136,6 +136,11 @@ impl OpenCADStudio { } else { 0 }; + let prop_vertex_indicator_active = if cur_handles == prev_handles { + self.tabs[i].properties.prop_vertex_indicator_active + } else { + false + }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); let new_panel = { @@ -1540,6 +1545,7 @@ impl OpenCADStudio { panel.expanded_groups = expanded_groups; panel.source_handles = new_handles; panel.prop_vertex = prop_vertex; + panel.prop_vertex_indicator_active = prop_vertex_indicator_active; panel }; @@ -1666,33 +1672,40 @@ impl OpenCADStudio { } else { [0.0_f64; 3] }; - let (new_handle, new_grips) = { + let (new_handle, new_grips, new_grip_handles) = { let selected = self.tabs[i].scene.selected_entities(); - if selected.len() == 1 { - let (handle, entity) = selected[0]; + let single_handle = (selected.len() == 1).then(|| selected[0].0); + let mut grips = Vec::new(); + let mut handles = Vec::new(); + for (handle, entity) in selected { let contextual = crate::scene::annotative::entity_for_active_context( &self.tabs[i].scene.document, entity, ); - let grips = dispatch::grips(contextual.as_ref()) - .into_iter() - .map(|mut g| { - // Subtract in f64: at UTM magnitudes an f32 cast before - // the offset costs ~1 unit and draws the grip off the - // wire. - g.world.x -= wo[0]; - g.world.y -= wo[1]; - g.world.z -= wo[2]; - g - }) - .collect(); - (Some(handle), grips) - } else { - (None, vec![]) + for mut grip in dispatch::grips(contextual.as_ref()) { + // Subtract in f64: at UTM magnitudes an f32 cast before + // the offset costs ~1 unit and draws the grip off the wire. + grip.world.x -= wo[0]; + grip.world.y -= wo[1]; + grip.world.z -= wo[2]; + handles.push(handle); + grips.push(grip); + } } + (single_handle, grips, handles) }; self.tabs[i].selected_handle = new_handle; self.tabs[i].selected_grips = new_grips; + self.tabs[i].selected_grip_handles = new_grip_handles; + let available: rustc_hash::FxHashSet<_> = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter().map(|grip| grip.id)) + .collect(); + self.tabs[i] + .hot_grips + .retain(|key| available.contains(key)); // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 431377225..32ee5b1c8 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -898,10 +898,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // click places it (click-move-click) — same as picking the // grip directly in the viewport. Without this the menu just // closed and the grip never became hot (issue #48). - if let Some(g) = self.tabs[i] - .selected_grips + if let Some((_, g)) = self.tabs[i] + .selected_grip_handles .iter() - .find(|g| g.id == popup.grip_id) + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, g)| **owner == popup.handle && g.id == popup.grip_id) { // "Move with Leader" drags the whole multileader; the // others move just the picked grip. @@ -911,13 +912,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { (popup.grip_id, g.is_midpoint) }; - self.tabs[i].active_grip = Some(GripEdit { - handle: popup.handle, + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, grip_id, is_translate, - origin_world: g.world, - last_world: g.world, - }); + g.world, + )); } return Task::none(); } @@ -1006,14 +1006,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Grab the new arrow so it follows the cursor (click places it, // Esc removes it). if let Some(new_gid) = add_leader_gid { - if let Some(g) = self.tabs[i].selected_grips.iter().find(|g| g.id == new_gid) { - self.tabs[i].active_grip = Some(GripEdit { - handle: popup.handle, - grip_id: new_gid, - is_translate: false, - origin_world: g.world, - last_world: g.world, - }); + if let Some((_, g)) = self.tabs[i] + .selected_grip_handles + .iter() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, g)| **owner == popup.handle && g.id == new_gid) + { + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + new_gid, + false, + g.world, + )); self.grip_add_provisional = Some((popup.handle, new_gid)); } } @@ -1021,18 +1025,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // in Absolute mode, so the arc re-fits through the cursor as // it moves and the next click seats it (#339). if matches!(item.action, GripMenuAction::ConvertToArc) { - if let Some(g) = self.tabs[i] - .selected_grips + if let Some((_, g)) = self.tabs[i] + .selected_grip_handles .iter() - .find(|g| g.id == popup.grip_id) + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, g)| **owner == popup.handle && g.id == popup.grip_id) { - self.tabs[i].active_grip = Some(GripEdit { - handle: popup.handle, - grip_id: popup.grip_id, - is_translate: false, - origin_world: g.world, - last_world: g.world, - }); + self.tabs[i].active_grip = Some(GripEdit::single( + popup.handle, + popup.grip_id, + false, + g.world, + )); } } Task::none() diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ca0cc1b2b..5b23adbce 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3727,6 +3727,7 @@ impl OpenCADStudio { // Wrap around so ◀ from the first vertex lands on the last. let next = (cur + delta as i64).rem_euclid(n as i64) as usize; self.tabs[i].properties.prop_vertex = next; + self.tabs[i].properties.prop_vertex_indicator_active = next != cur as usize; self.refresh_properties(); } Task::none() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 65ba44ed5..c699a2559 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -10,7 +10,9 @@ use crate::app::helpers::{ 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}; +use crate::scene::pick::grip::{ + find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripTarget, +}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; @@ -242,8 +244,64 @@ impl OpenCADStudio { } } + fn grip_edit_for_hit( + &mut self, + i: usize, + handle: Handle, + grip_id: usize, + is_translate: bool, + world: glam::DVec3, + ) -> GripEdit { + if !self.tabs[i].hot_grips.contains(&(handle, grip_id)) { + self.tabs[i].hot_grips.clear(); + return GripEdit::single(handle, grip_id, is_translate, world); + } + + let mut targets: Vec = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id))) + .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .map(|(owner, grip)| GripTarget { + handle: owner, + grip_id: grip.id, + is_translate: grip.is_midpoint, + last_world: grip.world, + }) + .collect(); + + // A midpoint/centre grip translates its whole entity. If that entity also + // has hot point grips, applying both would move it twice; one translate + // target owns the entity in that case. + let translate_handles: rustc_hash::FxHashSet<_> = targets + .iter() + .filter(|target| target.is_translate) + .map(|target| target.handle) + .collect(); + let mut used_translates = rustc_hash::FxHashSet::default(); + targets.retain(|target| { + if translate_handles.contains(&target.handle) { + target.is_translate && used_translates.insert(target.handle) + } else { + true + } + }); + if targets.is_empty() { + return GripEdit::single(handle, grip_id, is_translate, world); + } + GripEdit { + handle, + grip_id, + origin_world: world, + last_world: world, + targets, + } + } + pub(in crate::app) fn update_grip_hover(&mut self, i: usize, p: iced::Point) { - const HOVER_OPEN_MS: u128 = 600; + const HOVER_OPEN_MS: u128 = 1_000; const POPUP_DISMISS_PX: f32 = 80.0; if self.tabs[i].active_cmd.is_some() || self.tabs[i].active_grip.is_some() @@ -253,11 +311,6 @@ impl OpenCADStudio { self.grip_popup = None; return; } - let Some(handle) = self.tabs[i].selected_handle else { - self.grip_hover = None; - self.grip_popup = None; - return; - }; let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; let bounds = iced::Rectangle { x: 0.0, @@ -306,7 +359,12 @@ impl OpenCADStudio { find_hit_grip(p, &self.tabs[i].selected_grips, &cam, bounds) }; match hit { - Some((grip_id, _, _)) => { + Some((grip_index, grip_id, _, _)) => { + let Some(&handle) = self.tabs[i].selected_grip_handles.get(grip_index) else { + self.grip_hover = None; + self.grip_popup = None; + return; + }; let same = self .grip_hover .as_ref() @@ -745,23 +803,43 @@ impl OpenCADStudio { } }; - // First move of this drag: hide the edited entity from the - // base tessellation (one re-tess) so subsequent moves only - // refresh a cheap overlay preview instead of re-tessellating - // the whole model on every move. - if self.grip_preview_handle != Some(grip.handle) { - if let Some(prev) = self.grip_preview_handle.take() { - self.tabs[i].scene.preview_hidden.remove(&prev); + let mut seen_handles = rustc_hash::FxHashSet::default(); + let edited_handles: Vec<_> = grip + .targets + .iter() + .map(|target| target.handle) + .filter(|handle| seen_handles.insert(*handle)) + .collect(); + + // First move of this drag: hide every edited entity from the base + // tessellation so subsequent moves refresh only the overlay. + if self.grip_preview_handles != edited_handles { + if self.grip_dirty_before.is_none() { + self.grip_dirty_before = Some(self.tabs[i].dirty); } - // Back up the original geometry so Esc can cancel the drag. - self.grip_original = self.tabs[i].scene.document.get_entity(grip.handle).cloned(); - self.tabs[i].scene.preview_hidden.insert(grip.handle); - // Hiding changes exactly one resident run. Publishing a full - // delta here made the first grip move rebuild every wire. - self.tabs[i] - .scene - .bump_entities(&[(grip.handle, crate::scene::ChangeKind::Modified)]); - self.grip_preview_handle = Some(grip.handle); + for handle in std::mem::take(&mut self.grip_preview_handles) { + self.tabs[i].scene.preview_hidden.remove(&handle); + } + self.grip_originals = edited_handles + .iter() + .filter_map(|&handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) + .collect(); + for &handle in &edited_handles { + self.tabs[i].scene.preview_hidden.insert(handle); + } + let changes: Vec<_> = edited_handles + .iter() + .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.grip_preview_handles = edited_handles.clone(); // Snapshot the entity's glyph quads once so each move can // slide the already-shaped text rather than re-shaping it // (issue #316). The fast slide path only runs for a rigid @@ -769,18 +847,26 @@ impl OpenCADStudio { // dimension re-tessellates) and a Square insertion grip (so // an MTEXT width handle, a Triangle, still re-tessellates so // the re-wrap is exact). - let snap = self.tabs[i].scene.wire_models_for(&[grip.handle]); + let snap = self.tabs[i].scene.wire_models_for(&edited_handles); self.grip_text_verts = snap .iter() .flat_map(|w| w.text_verts.iter().copied()) .collect(); let square_grip = self.tabs[i] - .selected_grips + .selected_grip_handles .iter() - .find(|g| g.id == grip.grip_id) - .map(|g| g.shape == crate::scene::model::object::GripShape::Square) + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, grip_def)| { + *owner == grip.handle && grip_def.id == grip.grip_id + }) + .map(|(_, grip_def)| { + grip_def.shape == crate::scene::model::object::GripShape::Square + }) .unwrap_or(false); - self.grip_text_slide = !self.grip_text_verts.is_empty() + self.grip_text_slide = edited_handles.len() == 1 + && grip.targets.len() == 1 + && !self.grip_text_verts.is_empty() && snap.iter().all(|w| w.points.is_empty()) && square_grip; } @@ -861,22 +947,30 @@ impl OpenCADStudio { let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; let apply_started = Instant::now(); - let apply = if grip.is_translate { - GripApply::Translate(snapped - grip.last_world) - } else { - GripApply::Absolute(snapped) - }; - self.tabs[i] - .scene - .apply_grip(grip.handle, grip.grip_id, apply); - if matches!( - self.tabs[i].scene.document.get_entity(grip.handle), - Some(acadrust::EntityType::Hatch(_)) - ) { - self.tabs[i].scene.set_preview_hatch(grip.handle); + 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(); + for (handle, grip_id, apply) in actions { + self.tabs[i].scene.apply_grip(handle, grip_id, apply); } + self.tabs[i].scene.set_preview_hatches(&edited_handles); self.tabs[i].dirty = true; - self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped; + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.last_world = snapped; + for target in &mut active.targets { + target.last_world += delta; + } + } let apply_ms = apply_started.elapsed().as_secs_f64() * 1000.0; let preview_started = Instant::now(); // Overlay the moved entity (hidden from the base). Pure text @@ -898,7 +992,7 @@ impl OpenCADStudio { // preview WireModels carry the entity's glyphs (gathered // for the preview-text buffer in the render path), so a // dimension / MTEXT-width drag keeps its text visible too. - let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]); + let preview = self.tabs[i].scene.wire_models_for(&edited_handles); self.tabs[i].scene.set_preview_wires(preview); } let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0; @@ -2022,7 +2116,7 @@ impl OpenCADStudio { && self.tabs[i].active_grip.is_none() && !self.tabs[i].selected_grips.is_empty() { - if let Some(handle) = self.tabs[i].selected_handle { + { let is_paper = self.tabs[i].scene.current_layout != "Model"; // In-viewport grips are model-space; project them with the // viewport camera so they hit-test where the GPU draws @@ -2057,7 +2151,10 @@ impl OpenCADStudio { let cam = self.tabs[i].scene.camera.borrow(); find_hit_grip(p, &self.tabs[i].selected_grips, &cam, bounds) }; - if let Some((grip_id, is_translate, world)) = grip_hit { + if let Some((grip_index, grip_id, is_translate, world)) = grip_hit { + let Some(&handle) = self.tabs[i].selected_grip_handles.get(grip_index) else { + return Task::none(); + }; // The visibility (lookup) grip opens a state // dropdown instead of starting a stretch drag. if grip_id == crate::app::visibility::VIS_GRIP_ID { @@ -2066,13 +2163,22 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } - self.tabs[i].active_grip = Some(GripEdit { + if self.shift_down { + let key = (handle, grip_id); + if !self.tabs[i].hot_grips.remove(&key) { + self.tabs[i].hot_grips.insert(key); + } + self.grip_hover = None; + self.grip_popup = None; + return Task::none(); + } + self.tabs[i].active_grip = Some(self.grip_edit_for_hit( + i, handle, grip_id, is_translate, - origin_world: world, - last_world: world, - }); + world, + )); self.grip_hover = None; self.grip_popup = None; return Task::none(); @@ -2148,25 +2254,35 @@ impl OpenCADStudio { return Task::none(); } self.tabs[i].active_grip = None; - // Commit the grip drag: keep the doc's dragged geometry, - // un-hide the edited entity and re-tessellate the base - // once, dropping the overlay preview. - if let Some(h) = self.grip_preview_handle.take() { - // Undo entry for the drag (#332): the pre-drag backup - // and live dragged image form a one-entity Arc delta. The old - // swap/full-document snapshot made a vertex drag O(document). - if let Some(orig) = self.grip_original.take() { - self.push_single_entity_history(i, "GRIP", h, std::sync::Arc::new(orig)); + // 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 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(), + dirty_before, + ); self.tabs[i].dirty = true; } self.grip_text_verts = Vec::new(); self.grip_text_slide = false; - self.tabs[i].scene.preview_hidden.remove(&h); + for &handle in &handles { + self.tabs[i].scene.preview_hidden.remove(&handle); + } self.tabs[i].scene.clear_preview_wire(); - // Only the dragged entity changed — re-tessellate just it. - self.tabs[i] - .scene - .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); } // Placement confirmed — keep the just-added leader. self.grip_add_provisional = None; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7feec2cc1..8bd2802a0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -315,14 +315,19 @@ impl OpenCADStudio { // mark that grip hot so the navigated vertex is visible in // the drawing. Only for a single selected polyline, whose // vertex grips are ids 0..n. (Properties vertex stepper) - let current_vertex_grip: Option = sel_h.and_then(|h| { + let current_vertex_grip: Option = tab + .properties + .prop_vertex_indicator_active + .then(|| sel_h) + .flatten() + .and_then(|h| { matches!( tab.scene.document.get_entity(h), Some(acadrust::EntityType::LwPolyline(_)) | Some(acadrust::EntityType::Polyline2D(_)) ) .then_some(tab.properties.prop_vertex) - }); + }); // In-viewport grips are model-space; project them with the // viewport camera so they sit on the wire the GPU draws. // Paper entities use the 2-D paper transform; the model tab @@ -349,7 +354,8 @@ impl OpenCADStudio { }; screen_grips .into_iter() - .filter(|(_, screen, _, _, _)| { + .enumerate() + .filter(|(_, (_, screen, _, _, _))| { screen.x.is_finite() && screen.y.is_finite() && screen.x >= -bounds.width @@ -357,12 +363,18 @@ impl OpenCADStudio { && screen.y >= -bounds.height && screen.y <= bounds.height * 2.0 }) - .map(|(grip_id, screen, _is_midpoint, shape, dir)| { - let is_hot = tab - .active_grip - .as_ref() - .map_or(false, |g| Some(g.handle) == sel_h && g.grip_id == grip_id) - || Some(grip_id) == current_vertex_grip; + .map(|(index, (grip_id, screen, _is_midpoint, shape, dir))| { + let owner = tab.selected_grip_handles.get(index).copied(); + let is_hot = owner.is_some_and(|handle| { + tab.hot_grips.contains(&(handle, grip_id)) + || tab.active_grip.as_ref().is_some_and(|edit| { + edit.targets.iter().any(|target| { + target.handle == handle && target.grip_id == grip_id + }) + }) + || (Some(handle) == sel_h + && Some(grip_id) == current_vertex_grip) + }); crate::ui::overlay::GripMarker { pos: screen, shape, diff --git a/src/app/visibility.rs b/src/app/visibility.rs index 9236a4dc1..8c8a12511 100644 --- a/src/app/visibility.rs +++ b/src/app/visibility.rs @@ -152,6 +152,7 @@ impl OpenCADStudio { shape: GripShape::Triangle, dir: None, }); + self.tabs[i].selected_grip_handles.push(handle); self.tabs[i].visibility_grip = Some(VisibilityGrip { insert_handle: handle, state_names, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 8f03ab740..ee1931055 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -20,12 +20,42 @@ pub struct GripEdit { pub handle: Handle, /// Index into the entity's grip list. pub grip_id: usize, - /// `true` → midpoint / translate grip; `false` → endpoint / absolute grip. - pub is_translate: bool, /// World-space position of the grip when the drag started (ortho/polar base). pub origin_world: DVec3, /// Last world-space cursor position (needed for incremental delta on translate drags). pub last_world: DVec3, + /// Every hot grip moved by this edit. A normal grip edit contains one target. + pub targets: Vec, +} + +#[derive(Clone, Debug)] +pub struct GripTarget { + pub handle: Handle, + pub grip_id: usize, + pub is_translate: bool, + pub last_world: DVec3, +} + +impl GripEdit { + pub fn single( + handle: Handle, + grip_id: usize, + is_translate: bool, + world: DVec3, + ) -> Self { + Self { + handle, + grip_id, + origin_world: world, + last_world: world, + targets: vec![GripTarget { + handle, + grip_id, + is_translate, + last_world: world, + }], + } + } } // ── Screen-space helpers ─────────────────────────────────────────────────── @@ -83,11 +113,11 @@ pub fn find_hit_grip_paper( half_w: f32, half_h: f32, bounds: Rectangle, -) -> Option<(usize, bool, DVec3)> { +) -> Option<(usize, usize, bool, DVec3)> { let mut best_dist = GRIP_THRESHOLD_PX; - let mut best: Option<(usize, bool, DVec3)> = None; + let mut best: Option<(usize, usize, bool, DVec3)> = None; - for g in grips { + for (index, g) in grips.iter().enumerate() { let screen = Point::new( (g.world.x as f32 - tx + half_w) / (2.0 * half_w) * bounds.width, (ty + half_h - g.world.y as f32) / (2.0 * half_h) * bounds.height, @@ -97,7 +127,7 @@ pub fn find_hit_grip_paper( let d = (dx * dx + dy * dy).sqrt(); if d < best_dist { best_dist = d; - best = Some((g.id, g.is_midpoint, g.world)); + best = Some((index, g.id, g.is_midpoint, g.world)); } } best @@ -110,11 +140,11 @@ pub fn find_hit_grip( grips: &[GripDef], camera: &crate::scene::view::camera::Camera, bounds: Rectangle, -) -> Option<(usize, bool, DVec3)> { +) -> Option<(usize, usize, bool, DVec3)> { let mut best_dist = GRIP_THRESHOLD_PX; - let mut best: Option<(usize, bool, DVec3)> = None; + let mut best: Option<(usize, usize, bool, DVec3)> = None; - for g in grips { + for (index, g) in grips.iter().enumerate() { let Some(screen) = camera.project(g.world, bounds) else { continue; }; @@ -124,7 +154,7 @@ pub fn find_hit_grip( let d = (dx * dx + dy * dy).sqrt(); if d < best_dist { best_dist = d; - best = Some((g.id, g.is_midpoint, g.world)); + best = Some((index, g.id, g.is_midpoint, g.world)); } } best @@ -176,11 +206,11 @@ pub fn find_hit_grip_rte( view_rot: Mat4, eye: DVec3, bounds: Rectangle, -) -> Option<(usize, bool, DVec3)> { +) -> Option<(usize, usize, bool, DVec3)> { let mut best_dist = GRIP_THRESHOLD_PX; - let mut best: Option<(usize, bool, DVec3)> = None; + let mut best: Option<(usize, usize, bool, DVec3)> = None; - for g in grips { + for (index, g) in grips.iter().enumerate() { let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else { continue; }; @@ -189,7 +219,7 @@ pub fn find_hit_grip_rte( let d = (dx * dx + dy * dy).sqrt(); if d < best_dist { best_dist = d; - best = Some((g.id, g.is_midpoint, g.world)); + best = Some((index, g.id, g.is_midpoint, g.world)); } } best diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 7c10a4640..925d6310b 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -14,16 +14,20 @@ impl Scene { self.preview_wires = wires; } - /// Publish the current cached hatch as a one-entity live fill overlay. - /// The edited hatch is hidden from the resident set during a grip drag; - /// this keeps its pattern visible without rebuilding the full hatch batch. - pub fn set_preview_hatch(&mut self, handle: Handle) { + /// Publish all edited hatches as one live fill overlay. + pub fn set_preview_hatches(&mut self, handles: &[Handle]) { + let mut models = Vec::new(); + for &handle in handles { + self.append_preview_hatch(handle, &mut models); + } + self.preview_hatches = std::sync::Arc::new(models); + } + + fn append_preview_hatch(&self, handle: Handle, models: &mut Vec) { let Some(mut model) = self.hatches.get(&handle).cloned() else { - self.preview_hatches = std::sync::Arc::new(Vec::new()); return; }; let Some(entity) = self.document.get_entity(handle) else { - self.preview_hatches = std::sync::Arc::new(Vec::new()); return; }; @@ -41,7 +45,6 @@ impl Scene { .get(&handle.value()) .map_or(0.0, |depth| depth[0]); - let mut models = Vec::with_capacity(2); if let EntityType::Hatch(hatch) = entity { if let Some(background) = crate::entities::hatch::background_color(hatch) { let mut backdrop = model.clone(); @@ -63,7 +66,6 @@ impl Scene { } } models.push(model); - self.preview_hatches = std::sync::Arc::new(models); } pub fn set_preview_text(&mut self, verts: Vec) { diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 879413f3c..3f1bacc75 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -287,6 +287,9 @@ pub struct PropertiesPanel { /// Which vertex a multi-vertex entity (polyline) is focused on — driven by /// the Current Vertex ◀ / ▶ stepper. Reset to 0 when the selection changes. pub prop_vertex: usize, + /// Draw the Current Vertex indicator only after the user changes the + /// stepper for the current selection. + pub prop_vertex_indicator_active: bool, /// Coordinate groups ("Position", "Scale", …) the user expanded into their /// component X/Y/Z rows. Collapsed by default; keyed `section:base` and /// carried across panel rebuilds so the state survives edits and selection @@ -319,6 +322,7 @@ impl Default for PropertiesPanel { bg_color_picker_open: false, open_color_field: None, prop_vertex: 0, + prop_vertex_indicator_active: false, expanded_groups: HashSet::default(), edit_choice_open: false, }