diff --git a/src/app/update/command.rs b/src/app/update/command.rs index b5a88fa98..390d48a06 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -281,6 +281,95 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.refresh_properties(); return Task::none(); } + + // Direct-distance 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. + { + 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()); + + if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) { + let cursor = self.tabs[i].last_cursor_world; + + // 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(); + + if len <= 1e-12 { + self.command_line.push_error( + "Move the cursor in a direction before entering a distance.", + ); + return self.focus_cmd_input(); + } + + let dir = direction / len; + grip.origin_world + dir * dist + }; + + // The entity is already being edited live. Apply only the + // incremental movement from its current grip position to the + // exact typed-distance position. + let delta = target - grip.last_world; + + let actions: Vec<_> = grip + .targets + .iter() + .map(|target_grip| { + let apply = if target_grip.is_translate { + GripApply::Translate(delta) + } else { + GripApply::Absolute(target_grip.last_world + delta) + }; + + ( + target_grip.handle, + target_grip.grip_id, + apply, + ) + }) + .collect(); + + for (handle, grip_id, apply) in actions { + self.tabs[i] + .scene + .apply_grip(handle, grip_id, apply); + } + + // Keep the live GripEdit state synchronized so the normal + // grip commit path sees the exact final position. + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.last_world = target; + + for target_grip in &mut active.targets { + target_grip.last_world += delta; + } + } + + self.tabs[i].last_cursor_world = target; + self.command_line.input.clear(); + 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(); + } + } + } + } // Interactive VPORTS: the entry after a bare `VPORTS` is the // tiled configuration. Empty input defaults to SINGLE. if self.awaiting_vports { @@ -386,10 +475,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return self.feed_command(crate::command::StepInput::Enter); } - // OTRACK: while aligned to a tracking ray, a bare distance - // places the point along the ray from the tracking point - // (issue #69). - if let Some((base, dir)) = self.otrack_active { + // OTRACK and Extension both allow a bare scalar to act as a + // distance measured along the active reference ray. + if let Some((base, dir)) = self.active_distance_ray(i) { if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) { let pt = base + dir * dist; if !self.command_point_allowed(i, pt) { diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 7db870a9c..7d96dcf67 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -22,6 +22,25 @@ use iced::{mouse, Point, Task}; impl OpenCADStudio { + pub(in crate::app) fn active_distance_ray( + &self, + i: usize, + ) -> Option<(glam::DVec3, glam::DVec3)> { + // OTRACK already exposes its acquired world-space ray. + if let Some((base, dir)) = self.otrack_active { + return Some((base, dir)); + } + + // Extension stores the snapped point. Recover the acquired endpoint + // and outward segment direction that produced that snap. + let snap = self.tabs[i].snap_result?; + + if snap.snap_type == crate::snap::SnapType::Extension { + return self.snapper.extension_input_ray(snap.world); + } + + None + } /// Rebuild the active tab's dynamic-input field set to match what the /// command is currently asking for. Called on cursor move and after /// command-state changes. The field set only changes shape when the @@ -79,11 +98,11 @@ impl OpenCADStudio { let has_base = self.last_point.is_some(); // While aligned to an OTRACK ray, the point step reads a single // distance along the ray (issue #69) — show one Distance box. - let otrack_dist = self.otrack_active.is_some() + let guided_dist = self.active_distance_ray(i).is_some() && !wants_text && matches!(field, crate::command::DynField::Point); let default: Vec = match field { - _ if otrack_dist => vec![DynComponent::Distance], + _ if guided_dist => vec![DynComponent::Distance], crate::command::DynField::Distance => vec![DynComponent::Distance], crate::command::DynField::Angle => vec![DynComponent::Angle], crate::command::DynField::Scalar => vec![DynComponent::Scalar], @@ -528,9 +547,9 @@ impl OpenCADStudio { { return None; } - // OTRACK: while aligned to a tracking ray, a typed value is a distance - // along the ray from the tracking point (issue #69). - if let Some((base, dir)) = self.otrack_active { + // OTRACK and Extension both allow a typed scalar to act as a + // distance measured along the active reference ray. + if let Some((base, dir)) = self.active_distance_ray(i) { let wants_text = self.tabs[i] .active_cmd .as_ref() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c20817537..b62a496ad 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -981,8 +981,55 @@ impl OpenCADStudio { let snap_hit = self.snapper .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr); - let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw); + self.tabs[i].snap_result = snap_hit; + + // Grip edits use the same tracking-point acquisition and OTRACK + // resolution as normal interactive point commands. + let otrack_hit = { + let snap_world = snap_hit.map(|s| s.world); + + self.snapper.update_otrack_dwell( + snap_world, + &snap_candidates, + view_rot, + eye, + bounds, + Instant::now(), + ); + + if snap_hit.is_none() { + let polar_step = if self.polar_mode { + Some(self.polar_increment_deg) + } else { + None + }; + + let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + + self.snapper.otrack_snap( + raw, + view_rot, + eye, + bounds, + polar_step, + Some(grip.origin_world), + self.ortho_mode, + ucs_x, + ucs_y, + ) + } else { + None + } + }; + + self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)); + + let mut snapped = if let Some(hit) = otrack_hit { + hit.aligned + } else { + snap_hit.map(|s| s.world).unwrap_or(raw) + }; if let Some(s) = self.tabs[i].snap_result.as_mut() { s.screen.x += tile_b.x; s.screen.y += tile_b.y; @@ -1007,7 +1054,9 @@ impl OpenCADStudio { } if let Some(dir) = self.axis_lock_dir { snapped = axis_lock_apply(snapped, grip.origin_world, dir); - } else if !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) { + } else if otrack_hit.is_none() + && !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) + { let base = grip.origin_world; let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { @@ -1027,6 +1076,11 @@ impl OpenCADStudio { } let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; + // The overlay builds the active tracking guide from `otrack_active.base` + // to `last_cursor_world`. Keep it synchronized with the actual point used + // 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; let apply_started = Instant::now(); let delta = snapped - grip.last_world; let lengthen = grip.mode == GripEditMode::Lengthen; @@ -1988,6 +2042,20 @@ impl OpenCADStudio { .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr); let world = snap_hit.map(|s| s.world).unwrap_or(raw); self.tabs[i].snap_result = snap_hit; + if let Some(s) = self.tabs[i].snap_result.as_mut() { + s.screen.x += tile_b.x; + s.screen.y += tile_b.y; + + if let Some(base) = s.extension_base.as_mut() { + base.x += tile_b.x; + base.y += tile_b.y; + } + + if let Some(base) = s.extension_base2.as_mut() { + base.x += tile_b.x; + base.y += tile_b.y; + } + } if let Some(s) = self.tabs[i].snap_result.as_mut() { // Snap marker is pane-local; lift it to absolute canvas px. s.screen.x += tile_b.x; diff --git a/src/snap.rs b/src/snap.rs index 15a388952..9636dd505 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1620,6 +1620,51 @@ impl Snapper { best } + pub fn extension_input_ray( + &self, + snapped: glam::DVec3, + ) -> Option<(glam::DVec3, glam::DVec3)> { + let mut best: Option<(f64, glam::DVec3, glam::DVec3)> = None; + + for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) { + for &dir in dirs { + let len2 = dir.x * dir.x + dir.y * dir.y; + if len2 < 1e-12 { + continue; + } + + let dx = snapped.x - origin.x; + let dy = snapped.y - origin.y; + + let t = (dx * dir.x + dy * dir.y) / len2; + + // Extension only exists beyond the acquired endpoint. + if t < 0.05 { + continue; + } + + let projected = glam::DVec3::new( + origin.x + dir.x * t, + origin.y + dir.y * t, + origin.z, + ); + + let ex = snapped.x - projected.x; + let ey = snapped.y - projected.y; + let err2 = ex * ex + ey * ey; + + let len = len2.sqrt(); + let unit_dir = + glam::DVec3::new(dir.x / len, dir.y / len, 0.0); + + if best.as_ref().map_or(true, |(best_err, _, _)| err2 < *best_err) { + best = Some((err2, origin, unit_dir)); + } + } + } + + best.map(|(_, origin, dir)| (origin, dir)) + } } // ── Object-snap priority ───────────────────────────────────────────────────