diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index c40de15e5..c201bb1ec 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -151,6 +151,7 @@ impl OpenCADStudio { self.tabs[i].dirty = dirty_before; } + self.grip_snap_wires.clear(); self.grip_text_verts.clear(); self.grip_text_slide = false; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/mod.rs b/src/app/mod.rs index d10711d34..aabbb501a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -545,6 +545,13 @@ pub(super) struct OpenCADStudio { grip_originals: Vec<(acadrust::Handle, acadrust::EntityType)>, /// Document dirty state before the live grip mutation began. grip_dirty_before: Option, + /// Frozen wire geometry of the entities being grip-edited. + /// + /// The live entities are hidden from the resident hit-test set while their + /// grip preview is active. Keep their pre-drag geometry here so OSNAP, + /// Extension and OTRACK can still reference the entity's own vertices and + /// segments without snapping against the geometry being deformed. + grip_snap_wires: Vec, /// 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). @@ -3109,6 +3116,7 @@ impl OpenCADStudio { hover_dwell: None, grip_originals: Vec::new(), grip_dirty_before: None, + grip_snap_wires: Vec::new(), grip_text_verts: Vec::new(), grip_text_slide: false, qselect: None, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index f735c6d82..10acd7184 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -282,47 +282,95 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } - // Direct-distance entry while a normal grip stretch is active. + // Numeric entry while a normal grip stretch is active. // - // The live grip-drag path has already resolved OSNAP / OTRACK / - // Extension / Polar / Ortho into `last_cursor_world`. A typed scalar - // therefore means: move from the grip's original position by that - // distance along the currently indicated direction. + // With Dynamic Input enabled, typed values live in the shared + // Distance / Angle fields. Resolve those fields into an exact + // world point. Without DYN input, preserve the existing direct- + // distance command-line behaviour. { let i = self.active_tab; if let Some(grip) = self.tabs[i].active_grip.clone() { if grip.mode == GripEditMode::Stretch { - let text = - crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + let dyn_locked = self.tabs[i] + .dyn_fields + .iter() + .any(|field| field.buffer.is_some()); - if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) { - let cursor = self.tabs[i].last_cursor_world; + let target = if dyn_locked { + // Distance only: + // keep the cursor's current direction. + // + // Distance + Angle: + // resolve both typed values from the grip's + // original position (`dyn_anchor`). + self.dyn_resolve_point() + } else { + // Legacy command-line direct-distance entry. + let text = crate::app::expr_eval::eval_to_string( + self.command_line.input.trim(), + ); - // If the cursor is following OTRACK or Extension, use the actual - // reference ray that is currently driving the cursor. Otherwise - // fall back to the direction from the original grip position, - // which covers Polar / Ortho / free direct-distance entry. - let target = if let Some((base, dir)) = self.active_distance_ray(i) { - base + dir * dist - } else { - let direction = cursor - grip.origin_world; - let len = direction.length(); + crate::app::expr_eval::eval_number(text.trim()).map(|dist| { + let cursor = self.tabs[i].last_cursor_world; - if len <= 1e-12 { - self.command_line.push_error( - "Move the cursor in a direction before entering a distance.", - ); - return self.focus_cmd_input(); + // If the cursor is following OTRACK or Extension, + // preserve the existing reference-ray behaviour. + if let Some((base, dir)) = self.active_distance_ray(i) { + base + dir * dist + } else { + let direction = cursor - grip.origin_world; + let len = direction.length(); + + if len <= 1e-12 { + grip.origin_world + } else { + grip.origin_world + direction / len * dist + } + } + }) + }; + + if let Some(target) = target { + // Typed Dynamic Input can commit a grip before the mouse has moved. + // + // Normally the first ViewportMove initializes the grip preview handles and + // snapshots the original entities. Without that move the document changes, + // so the refreshed grips move, but the resident wire tessellation is never + // invalidated by the normal grip-finalization path. + // + // Seed the same bookkeeping here before modifying the entities. + if self.grip_preview_handles.is_empty() { + 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(); + + if self.grip_dirty_before.is_none() { + self.grip_dirty_before = Some(self.tabs[i].dirty); } - let dir = direction / len; - grip.origin_world + dir * dist - }; + if self.grip_originals.is_empty() { + self.grip_originals = edited_handles + .iter() + .filter_map(|&handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .map(|entity| (handle, entity)) + }) + .collect(); + } - // The entity is already being edited live. Apply only the - // incremental movement from its current grip position to the - // exact typed-distance position. + self.grip_preview_handles = edited_handles; + } let delta = target - grip.last_world; let actions: Vec<_> = grip @@ -332,7 +380,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let apply = if target_grip.is_translate { GripApply::Translate(delta) } else { - GripApply::Absolute(target_grip.last_world + delta) + GripApply::Absolute( + target_grip.last_world + delta, + ) }; ( @@ -349,8 +399,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .apply_grip(handle, grip_id, apply); } - // Keep the live GripEdit state synchronized so the normal - // grip commit path sees the exact final position. + // Keep GripEdit synchronized so the normal commit + // path records the exact final position. if let Some(active) = self.tabs[i].active_grip.as_mut() { active.last_world = target; @@ -361,11 +411,26 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].last_cursor_world = target; self.command_line.input.clear(); + + // Consume the typed Dynamic Input values. + for field in &mut self.tabs[i].dyn_fields { + field.buffer = None; + } + self.tabs[i].dyn_active = 0; + self.dyn_user_reshaped = false; + self.dyn_coord_absolute = false; + self.tabs[i].dirty = true; - // Reuse the existing click-move-click grip finalization: - // undo grouping, preview restoration, grip cleanup, etc. - return self.on_viewport_left_release(); + // Reuse the existing grip finalization path: + // undo grouping, preview restoration, cleanup, etc. + let task = self.on_viewport_left_release(); + + // active_grip is now gone, so remove the temporary + // Distance / Angle fields as well. + self.sync_dyn_fields(); + + return task; } } } @@ -600,11 +665,26 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if !self.command_line.input.trim().is_empty() { return self.update(Message::CommandSubmit); } - // A typed dynamic-input value commits as a point pick - // before the plain-Enter (on_enter) path runs. + // A grip edit is not an active CAD command, but its Dynamic Input + // fields use the same keyboard path. Route Enter through CommandSubmit, + // whose grip branch resolves Distance / Angle and finalizes the edit. + let i = self.active_tab; + let grip_dyn_locked = self.tabs[i].active_grip.is_some() + && self.dyn_input + && self.tabs[i] + .dyn_fields + .iter() + .any(|field| field.locked()); + + if grip_dyn_locked { + return self.update(Message::CommandSubmit); + } + + // Normal command Dynamic Input commit. if let Some(task) = self.try_dyn_commit() { return task; } + let i = self.active_tab; if self.tabs[i].active_cmd.is_some() { self.feed_command(crate::command::StepInput::Enter) diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 72a44921b..27f97734a 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -54,9 +54,55 @@ impl OpenCADStudio { pub(in crate::app) fn sync_dyn_fields(&mut self) { use crate::app::document::{DynComponent, DynFieldEntry}; let i = self.active_tab; - if !self.dyn_input || self.tabs[i].active_cmd.is_none() { + + if !self.dyn_input { self.tabs[i].dyn_fields.clear(); self.tabs[i].dyn_active = 0; + self.tabs[i].dyn_anchor = None; + self.tabs[i].dyn_ref = None; + return; + } + + // A normal 2D grip stretch behaves like a point-placement step: + // distance and angle are measured from the grip's original position. + // + // Grip editing is not an `active_cmd`, so handle it before the normal + // command-only path below. + let grip_origin = self.tabs[i] + .active_grip + .as_ref() + .filter(|grip| { + grip.mode == crate::scene::pick::grip::GripEditMode::Stretch + }) + .map(|grip| grip.origin_world); + + if let Some(origin) = grip_origin { + let wanted = [DynComponent::Distance, DynComponent::Angle]; + let current: Vec = self.tabs[i] + .dyn_fields + .iter() + .map(|field| field.component) + .collect(); + + if current.as_slice() != wanted { + self.tabs[i].dyn_fields = wanted + .into_iter() + .map(DynFieldEntry::new) + .collect(); + self.tabs[i].dyn_active = 0; + } + + self.tabs[i].dyn_guide = crate::command::DynGuide::Polar; + self.tabs[i].dyn_anchor = Some(origin); + self.tabs[i].dyn_ref = None; + return; + } + + if self.tabs[i].active_cmd.is_none() { + self.tabs[i].dyn_fields.clear(); + self.tabs[i].dyn_active = 0; + self.tabs[i].dyn_anchor = None; + self.tabs[i].dyn_ref = None; return; } // A command may describe its step explicitly via `dyn_spec()` — that diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e97172ddb..741a09331 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1087,7 +1087,12 @@ 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(&edited_handles); + let snap = self.tabs[i].scene.wire_models_for(&edited_handles); + + // Keep the entity's original geometry available for self-OSNAP, + // Extension and OTRACK while the live grip preview is being deformed. + self.grip_snap_wires = snap.clone(); + self.grip_text_verts = snap .iter() .flat_map(|w| w.text_verts.iter().copied()) @@ -1132,6 +1137,19 @@ impl OpenCADStudio { bounds, self.snapper.osnap_radius_px, ); + // `snap_candidates` contains only the spatially-local wires from the rest of + // the drawing. Add the frozen pre-drag geometry of the edited entity so its + // own vertices and segments remain valid snap references. + // + // This intentionally uses a plain Vec: Snapper will use its normal + // unindexed fallback over this already-small local candidate set rather than + // cloning/scanning the whole drawing. + let mut grip_snap_candidates: Vec<_> = + snap_candidates.iter().cloned().collect(); + + grip_snap_candidates.extend( + self.grip_snap_wires.iter().cloned() + ); // The engaged grip is the rubber-band origin. Perpendicular // snapping must drop its foot from this point, including when a // hot-grip set is moved by the same drag vector. @@ -1145,7 +1163,7 @@ impl OpenCADStudio { let snap_hit = self.snapper.snap( raw, p, - &snap_candidates, + &grip_snap_candidates, view_rot, eye, bounds, @@ -1158,7 +1176,7 @@ impl OpenCADStudio { self.snapper.update_otrack_dwell( snap_hit, - &snap_candidates, + &grip_snap_candidates, view_rot, eye, bounds, @@ -1230,6 +1248,17 @@ impl OpenCADStudio { // by the grip, otherwise the guide and the edited geometry diverge. self.tabs[i].last_cursor_world = snapped; self.tabs[i].last_cursor_screen = p_full; + + // Project the grip's original position into full-canvas coordinates. + // Dynamic Input uses this as the polar Distance/Angle anchor. + let anchor_ndc = + view_rot.project_point3((grip.origin_world - eye).as_vec3()); + + self.tabs[i].last_point_screen = Some(Point::new( + (anchor_ndc.x + 1.0) * 0.5 * bounds.width + tile_b.x, + (1.0 - anchor_ndc.y) * 0.5 * bounds.height + tile_b.y, + )); + let apply_started = Instant::now(); let delta = snapped - grip.last_world; let lengthen = grip.mode == GripEditMode::Lengthen; @@ -2587,6 +2616,11 @@ impl OpenCADStudio { )); self.grip_hover = None; self.grip_popup = None; + + // A grip edit is not a CAD command, so explicitly seed the shared + // dynamic-input fields for the newly engaged grip. + self.sync_dyn_fields(); + return Task::none(); } } @@ -2705,6 +2739,7 @@ impl OpenCADStudio { ); self.tabs[i].dirty = true; } + self.grip_snap_wires.clear(); self.grip_text_verts = Vec::new(); self.grip_text_slide = false; for &handle in &handles { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 91bc6835e..f1fed51d6 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -757,11 +757,11 @@ impl OpenCADStudio { .unwrap_or(false); let dyn_input_overlay: Option> = if self.dyn_input - && tab.active_cmd.is_some() + && (tab.active_cmd.is_some() || tab.active_grip.is_some()) && (!tab.dyn_fields.is_empty() || dyn_picks_object) { let w = tab.last_cursor_world; - let base = self.last_point; + let base = tab.dyn_anchor.or(self.last_point); // A command may drive a typed scalar by mouse (e.g. a // perpendicular distance to a picked object); show that live // value in the box until the user types over it. @@ -1536,7 +1536,9 @@ impl OpenCADStudio { // The MText preview also captures keystrokes (typing edits it), so the // command line must likewise release its on_input there. let dyn_capturing = - (self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty()) + (self.dyn_input + && (tab.active_cmd.is_some() || tab.active_grip.is_some()) + && !tab.dyn_fields.is_empty()) || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); let workspace: Element<'_, Message> = match (properties_el, self.properties_side) {