diff --git a/src/app/mod.rs b/src/app/mod.rs index d1887fe3a..396272732 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -198,6 +198,10 @@ pub(super) struct OpenCADStudio { /// Last persisted user preferences (DYN/OSNAP/OTRACK/POLAR/…). Compared /// after each message so a change is written to disk exactly once. last_saved_settings: Option, + /// Active OTRACK alignment `(tracking_point, unit_direction)` when the + /// cursor is on a tracking ray. Lets a typed distance place a point along + /// the ray from the tracking point (issue #69). `None` when not aligned. + otrack_active: Option<(glam::Vec3, glam::Vec3)>, /// Whether Tangent snap was enabled before a tangent-pick command started. pre_cmd_tangent: Option, /// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°. @@ -1271,6 +1275,7 @@ impl OpenCADStudio { selection_filter_popup_open: false, statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(), last_saved_settings: None, + otrack_active: None, clean_screen: false, quick_properties: false, selection_cycling: false, diff --git a/src/app/update.rs b/src/app/update.rs index 8950a3806..90710a511 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -69,6 +69,7 @@ impl OpenCADStudio { && !self.snapper.tracking_points.is_empty() { self.snapper.clear_tracking(); + self.otrack_active = None; } task } @@ -1227,6 +1228,25 @@ impl OpenCADStudio { return Task::none(); } + // 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 { + if let Some(dist) = super::expr_eval::eval_number(text.trim()) { + let pt = base + dir * dist as f32; + self.last_point = Some(pt); + self.dyn_user_reshaped = false; + self.sync_dyn_fields(); + let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); + if let Some(r) = result { + let task = self.apply_cmd_result(r); + self.refresh_active_cmd_preview(i); + return task; + } + return Task::none(); + } + } + if let Some((coord, kind)) = parse_coord(&text) { // Resolve relative vs absolute. `@` forces relative, // `#` forces absolute, no prefix follows DYN: when @@ -2384,44 +2404,52 @@ impl OpenCADStudio { .snap(cursor_world, p, &all_wires[..], view_proj, bounds) }; - // Object Snap Tracking: update dwell and override snap if tracking. - let otrack_snap_world = { + // Object Snap Tracking: update dwell, then align the cursor + // to a tracking ray (and store the alignment so a typed + // distance can place a point along it — issue #69). + let otrack_hit = { let snap_world = self.tabs[i].snap_result.map(|s| s.world); self.snapper .update_otrack_dwell(snap_world, view_proj, bounds); if self.tabs[i].snap_result.is_none() { - self.snapper - .otrack_snap(cursor_world, view_proj, bounds) - .map(|(w, _)| w) + let step = if self.polar_mode { + Some(self.polar_increment_deg) + } else { + None + }; + self.snapper.otrack_snap(cursor_world, view_proj, bounds, step) } else { None } }; - if let Some(ow) = otrack_snap_world { - // Override the effective point with the OST alignment. - // (don't set snap_result so the normal snap marker stays hidden) - self.tabs[i].last_cursor_world = ow; - } + self.otrack_active = otrack_hit.map(|h| (h.base, h.dir)); let effective = { - // snap.world is paper-space for viewport-projected wires; convert - // to model-space so previews use consistent coordinates. - let mut pt = self.tabs[i] - .snap_result - .map(|s| self.tabs[i].scene.paper_to_model(s.world)) - .unwrap_or(cursor_world); - // Clamp to world XY only when no UCS is active; with a UCS the - // point already lies on the UCS XY plane. + let mut pt = if let Some(h) = otrack_hit { + // Tracking alignment wins over the free cursor; + // ortho/polar don't re-constrain it. + h.aligned + } else { + // snap.world is paper-space for viewport-projected + // wires; convert to model-space for previews. + let mut pt = self.tabs[i] + .snap_result + .map(|s| self.tabs[i].scene.paper_to_model(s.world)) + .unwrap_or(cursor_world); + if let Some(base) = self.last_point { + if self.ortho_mode { + pt = ortho_constrain(pt, base); + } else if self.polar_mode { + pt = polar_constrain(pt, base, self.polar_increment_deg); + } + } + pt + }; + // Clamp to world XY only when no UCS is active; with a + // UCS the point already lies on the UCS XY plane. if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() { pt.z = 0.0; } - if let Some(base) = self.last_point { - if self.ortho_mode { - pt = ortho_constrain(pt, base); - } else if self.polar_mode { - pt = polar_constrain(pt, base, self.polar_increment_deg); - } - } pt }; self.tabs[i].last_cursor_world = effective; @@ -2485,6 +2513,35 @@ impl OpenCADStudio { } } } + // OTRACK alignment guide: dashed ray through the tracking + // point along the aligned direction (issue #69). + if let Some(h) = otrack_hit { + if !needs_entity { + let far = 1e5_f32; + let far_pos = h.base + h.dir * far; + let far_neg = h.base - h.dir * far; + previews.push(crate::scene::WireModel { + name: "__otrack_guide__".into(), + points: vec![ + [far_neg.x, far_neg.y, far_neg.z], + [far_pos.x, far_pos.y, far_pos.z], + ], + color: [0.2, 0.9, 0.5, 0.6], + selected: false, + aci: 0, + pattern_length: 0.8, + pattern: [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + key_vertices: vec![], + aabb: crate::scene::WireModel::UNBOUNDED_AABB, + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + }); + } + } self.tabs[i].scene.set_preview_wires(previews); } else { self.tabs[i].snap_result = None; @@ -2833,7 +2890,24 @@ impl OpenCADStudio { if self.tabs[i].active_ucs.is_none() { pt.z = 0.0; } - if let Some(base) = self.last_point { + // OTRACK alignment wins over ortho/polar; otherwise apply + // ortho/polar relative to the last point. + let otrack = if snap_hit.is_none() { + let step = if self.polar_mode { + Some(self.polar_increment_deg) + } else { + None + }; + self.snapper.otrack_snap(raw, vp_mat, bounds, step) + } else { + None + }; + if let Some(h) = otrack { + pt = h.aligned; + if self.tabs[i].active_ucs.is_none() { + pt.z = 0.0; + } + } else if let Some(base) = self.last_point { if self.ortho_mode { pt = ortho_constrain(pt, base); } else if self.polar_mode { @@ -7224,7 +7298,13 @@ impl OpenCADStudio { .map(|c| c.wants_text_input()) .unwrap_or(false); 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() + && !wants_text + && matches!(field, crate::command::DynField::Point); let default: Vec = match field { + _ if otrack_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], @@ -7573,6 +7653,33 @@ 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 { + let wants_text = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.wants_text_input()) + .unwrap_or(false); + if !wants_text { + if let Some(text) = self.tabs[i].dyn_fields.iter().find_map(|f| f.buffer.clone()) { + if let Some(dist) = super::expr_eval::eval_number(text.trim()) { + let pt = base + dir * dist as f32; + self.last_point = Some(pt); + for f in self.tabs[i].dyn_fields.iter_mut() { + f.buffer = None; + } + self.tabs[i].dyn_active = 0; + self.dyn_user_reshaped = false; + self.sync_dyn_fields(); + let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); + let task = result.map(|r| self.apply_cmd_result(r))?; + self.refresh_active_cmd_preview(i); + return Some(task); + } + } + } + } // A text-input step reads its single box as a string and commits via // `on_text_input` (a count, radius, distance) rather than resolving a // point. Only the typed buffer matters here — a mouse-driven live diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 548e4ded1..ed22bf1e6 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -61,6 +61,18 @@ pub struct SnapResult { pub tangent_obj: Option, } +/// Object-snap-tracking alignment: the cursor projected onto a ray from an +/// acquired tracking point. +#[derive(Debug, Clone, Copy)] +pub struct OtrackHit { + /// Cursor projected onto the tracking ray. + pub aligned: Vec3, + /// Unit ray direction toward the cursor side (for typed-distance entry). + pub dir: Vec3, + /// The tracking point the ray emanates from. + pub base: Vec3, +} + // ── Snapper ─────────────────────────────────────────────────────────────── use rustc_hash::FxHashSet as HashSet; @@ -201,16 +213,19 @@ impl Snapper { } } - /// Given the current cursor world position, check if it aligns with any - /// tracking point horizontally or vertically. Returns the snapped world - /// position (and index of the tracking point) if alignment is found within - /// `snap_radius_px` screen pixels. + /// Project the cursor onto a tracking ray emanating from one of the + /// acquired tracking points, in the XY plane. Without `polar_step_deg` the + /// rays are horizontal / vertical (0° / 90°); with it, every polar + /// increment is a candidate so the user can track along POLAR angles. + /// Returns the aligned point, the unit ray direction (pointing toward the + /// cursor side, used for typed-distance entry), and the tracking point. pub fn otrack_snap( &self, cursor_world: Vec3, view_proj: glam::Mat4, bounds: iced::Rectangle, - ) -> Option<(Vec3, usize)> { + polar_step_deg: Option, + ) -> Option { if !self.otrack_enabled || self.tracking_points.is_empty() { return None; } @@ -218,24 +233,46 @@ impl Snapper { let cursor_screen = world_to_screen(cursor_world, view_proj, bounds); let r = self.snap_radius_px; - for (idx, &tp) in self.tracking_points.iter().enumerate() { - // Horizontal alignment: cursor.z ≈ tp.z - let aligned_h = Vec3::new(cursor_world.x, 0.0, tp.z); - let s = world_to_screen(aligned_h, view_proj, bounds); - let dy = (s.y - cursor_screen.y).abs(); - if dy < r { - return Some((aligned_h, idx)); + // Candidate angles in [0,180); each ray extends both ways via the + // signed projection `t`, so 0°/90° cover horizontal/vertical. + let mut angles: Vec = Vec::new(); + match polar_step_deg.filter(|s| *s > 1e-3) { + Some(step) => { + let mut a = 0.0_f32; + while a < 180.0 - 1e-3 { + angles.push(a); + a += step; + } } - - // Vertical alignment: cursor.x ≈ tp.x - let aligned_v = Vec3::new(tp.x, 0.0, cursor_world.z); - let s = world_to_screen(aligned_v, view_proj, bounds); - let dx = (s.x - cursor_screen.x).abs(); - if dx < r { - return Some((aligned_v, idx)); + None => { + angles.push(0.0); + angles.push(90.0); } } - None + + let mut best: Option<(f32, OtrackHit)> = None; + for &tp in self.tracking_points.iter() { + for &adeg in &angles { + let ar = adeg.to_radians(); + let dir = Vec3::new(ar.cos(), ar.sin(), 0.0); + let t = (cursor_world.x - tp.x) * dir.x + (cursor_world.y - tp.y) * dir.y; + let aligned = Vec3::new(tp.x + dir.x * t, tp.y + dir.y * t, tp.z); + let s = world_to_screen(aligned, view_proj, bounds); + let sd = ((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt(); + if sd < r && best.as_ref().map_or(true, |(bd, _)| sd < *bd) { + let dir_out = if t >= 0.0 { dir } else { -dir }; + best = Some(( + sd, + OtrackHit { + aligned, + dir: dir_out, + base: tp, + }, + )); + } + } + } + best.map(|(_, h)| h) } /// Clear all acquired tracking points (e.g. when command ends).