diff --git a/ROADMAP.md b/ROADMAP.md index 65ead0053..7f9ca4d16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -196,7 +196,7 @@ Underlay (PDF/DWF/DGN) | Named Views (VIEW komutu) | ✅ | | Named UCS kaydetme | ✅ UCS SAVE/DELETE/LIST | | VPORTS (viewport bölme) | ⬜ | -| Nesne snap izleme (Object Snap Tracking) | ⬜ | +| Nesne snap izleme (Object Snap Tracking) | ✅ F11 toggle, dwell-acquire tracking lines | | Dynamic Input overlay | ✅ F12 toggle, absolute XY + relative dist/angle | --- @@ -209,7 +209,7 @@ Underlay (PDF/DWF/DGN) | Perpendicular / Tangent / Nearest | ✅ | | Grid snap | ✅ Zoom-adaptive spacing | | Polar tracking | ✅ Configurable angle, guide line | -| Object snap tracking | ⬜ | +| Object snap tracking | ✅ Dwell-acquire, H/V tracking lines, alignment snap | --- diff --git a/src/app/mod.rs b/src/app/mod.rs index 916e4fdef..d21b9940c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -234,6 +234,8 @@ pub enum Message { SetPolarAngle(f32), /// Toggle dynamic input overlay (F12). ToggleDynInput, + /// Toggle object snap tracking (F11). + ToggleOTrack, /// Toggle an individual snap mode (from popup row click). ToggleSnap(crate::snap::SnapType), /// Open / close the OSNAP popup (▾ arrow click). diff --git a/src/app/update.rs b/src/app/update.rs index 7912ac03b..7856fb9bc 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1010,6 +1010,24 @@ impl H7CAD { } else { self.snapper.snap(cursor_world, p, &all_wires, view_proj, bounds) }; + + // Object Snap Tracking: update dwell and override snap if tracking. + let otrack_snap_world = { + 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) + } 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; + } + let effective = { // snap.world is paper-space for viewport-projected wires; convert // to model-space so previews use consistent coordinates. @@ -1660,6 +1678,13 @@ impl H7CAD { Task::none() } Message::ToggleDynInput => { self.dyn_input ^= true; Task::none() } + Message::ToggleOTrack => { + self.snapper.otrack_enabled ^= true; + if !self.snapper.otrack_enabled { + self.snapper.clear_tracking(); + } + Task::none() + } Message::SetPolarAngle(deg) => { self.polar_increment_deg = deg; self.polar_mode = true; diff --git a/src/app/view.rs b/src/app/view.rs index a045e7c24..8021092b9 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -83,7 +83,23 @@ impl H7CAD { None }; - overlay::selection_overlay(sel, snap_info, grips, grid, ucs_icon) + // OST tracking points → screen positions. + let ost_points: Vec = if self.snapper.otrack_enabled { + let vp_mat = tab.scene.camera.borrow().view_proj(vp_bounds); + self.snapper.tracking_points.iter().map(|&wp| { + let ndc = vp_mat.project_point3(wp); + overlay::OstTrackPoint { + screen: iced::Point::new( + (ndc.x + 1.0) * 0.5 * vp_bounds.width, + (1.0 - ndc.y) * 0.5 * vp_bounds.height, + ), + } + }).collect() + } else { + vec![] + }; + + overlay::selection_overlay(sel, snap_info, grips, grid, ucs_icon, ost_points, tab.last_cursor_screen) }; let nav = container(overlay::nav_toolbar()) @@ -202,6 +218,7 @@ impl H7CAD { self.polar_increment_deg, self.show_grid, self.dyn_input, + self.snapper.otrack_enabled, tab.scene.layout_names(), tab.scene.current_layout.clone(), self.layout_rename_state.as_ref(), @@ -365,6 +382,9 @@ impl H7CAD { keyboard::Key::Named(keyboard::key::Named::F10) => { Some(Message::TogglePolar) } + keyboard::Key::Named(keyboard::key::Named::F11) => { + Some(Message::ToggleOTrack) + } keyboard::Key::Named(keyboard::key::Named::F12) => { Some(Message::ToggleDynInput) } diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 22be9f79a..1c69a731d 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -75,6 +75,14 @@ pub struct Snapper { pub grid_spacing: f32, /// Pixel-radius threshold. pub snap_radius_px: f32, + /// Object Snap Tracking on/off (F11). + pub otrack_enabled: bool, + /// Acquired OST points (world XZ, Y=0 plane). + pub tracking_points: Vec, + /// Last snap world position (for dwell detection). + pub last_snap_world: Option, + /// How many consecutive moves the cursor has been near last_snap_world. + pub dwell_count: u32, } impl Default for Snapper { @@ -92,6 +100,10 @@ impl Default for Snapper { enabled, grid_spacing: 1.0, snap_radius_px: CROSSHAIR_ARM, + otrack_enabled: false, + tracking_points: Vec::new(), + last_snap_world: None, + dwell_count: 0, } } } @@ -134,6 +146,100 @@ impl Snapper { self.enabled.clear(); } + /// Update dwell tracking and possibly acquire a new OST point. + /// Should be called on every ViewportMove when snap is active. + /// `snap_world` is the current snap result world point (if any). + pub fn update_otrack_dwell(&mut self, snap_world: Option, view_proj: glam::Mat4, bounds: iced::Rectangle) { + if !self.otrack_enabled { + self.dwell_count = 0; + self.last_snap_world = None; + return; + } + const DWELL_THRESHOLD: u32 = 4; + const DWELL_PX: f32 = 8.0; + + match snap_world { + None => { + self.dwell_count = 0; + self.last_snap_world = None; + } + Some(p) => { + // Convert to screen to measure pixel distance. + let is_same = if let Some(prev) = self.last_snap_world { + let dp = world_to_screen(p, view_proj, bounds); + let dp2 = world_to_screen(prev, view_proj, bounds); + let dx = dp.x - dp2.x; + let dy = dp.y - dp2.y; + (dx * dx + dy * dy).sqrt() < DWELL_PX + } else { + false + }; + if is_same { + self.dwell_count += 1; + if self.dwell_count == DWELL_THRESHOLD { + // Acquire this point (max 4 tracked points). + if !self.tracking_points.iter().any(|t| { + let d = (*t - p).length(); + d < self.grid_spacing * 0.1 + }) { + if self.tracking_points.len() >= 4 { + self.tracking_points.remove(0); + } + self.tracking_points.push(p); + } + } + } else { + self.dwell_count = 1; + self.last_snap_world = Some(p); + } + } + } + } + + /// 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. + pub fn otrack_snap( + &self, + cursor_world: Vec3, + view_proj: glam::Mat4, + bounds: iced::Rectangle, + ) -> Option<(Vec3, usize)> { + if !self.otrack_enabled || self.tracking_points.is_empty() { + return None; + } + + 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)); + } + + // 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 + } + + /// Clear all acquired tracking points (e.g. when command ends). + pub fn clear_tracking(&mut self) { + self.tracking_points.clear(); + self.dwell_count = 0; + self.last_snap_world = None; + } + /// Only runs Tangent snap — used when a command needs object picks via tangent. pub fn snap_tangent_only( &self, @@ -152,6 +258,10 @@ impl Snapper { }, grid_spacing: self.grid_spacing, snap_radius_px: self.snap_radius_px, + otrack_enabled: false, + tracking_points: Vec::new(), + last_snap_world: None, + dwell_count: 0, }; tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds) } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 8adac9c0c..6cb8ab77c 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -190,12 +190,20 @@ pub struct UcsIconParams { // ── Selection overlay ─────────────────────────────────────────────────── +/// An acquired OST tracking point with its screen position. +#[derive(Clone, Debug)] +pub struct OstTrackPoint { + pub screen: Point, +} + pub fn selection_overlay<'a>( selection: SelectionState, snap: Option<(Point, SnapType)>, grips: Vec, grid: Option, ucs_icon: Option, + ost_points: Vec, + cursor_screen: Point, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -203,6 +211,8 @@ pub fn selection_overlay<'a>( grips, grid, ucs_icon, + ost_points, + cursor_screen, }) .width(Length::Fill) .height(Length::Fill) @@ -215,6 +225,8 @@ struct SelectionCanvas { grips: Vec, grid: Option, ucs_icon: Option, + ost_points: Vec, + cursor_screen: Point, } impl canvas::Program for SelectionCanvas { @@ -588,6 +600,40 @@ impl canvas::Program for SelectionCanvas { draw_ucs_icon(&mut frame, ucs.view_proj, ucs.bounds); } + // ── Object Snap Tracking lines ──────────────────────────────────── + for ost in &self.ost_points { + let tp = ost.screen; + let cx = self.cursor_screen.x; + let cy = self.cursor_screen.y; + let track_color = Color { r: 0.15, g: 0.85, b: 0.95, a: 0.7 }; + let dash_stroke = canvas::Stroke::default() + .with_color(track_color) + .with_width(1.0); + + // Draw horizontal line from tracking point to cursor. + if (cy - tp.y).abs() < 8.0 { + let path = canvas::Path::line(tp, Point { x: cx, y: tp.y }); + frame.stroke(&path, dash_stroke.clone()); + } + // Draw vertical line. + if (cx - tp.x).abs() < 8.0 { + let path = canvas::Path::line(tp, Point { x: tp.x, y: cy }); + frame.stroke(&path, dash_stroke.clone()); + } + // Small cross at the tracking point. + let sz = 5.0_f32; + let h = canvas::Path::line( + Point { x: tp.x - sz, y: tp.y }, + Point { x: tp.x + sz, y: tp.y }, + ); + let v = canvas::Path::line( + Point { x: tp.x, y: tp.y - sz }, + Point { x: tp.x, y: tp.y + sz }, + ); + frame.stroke(&h, dash_stroke.clone()); + frame.stroke(&v, dash_stroke); + } + vec![frame.into_geometry()] } } diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 47d068c42..895ba0a53 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -29,6 +29,7 @@ impl StatusBar { polar_increment_deg: f32, show_grid: bool, dyn_input: bool, + otrack: bool, layouts: Vec, current_layout: String, // If `Some((original, edit_value))`, the named tab shows a text input. @@ -87,6 +88,10 @@ impl StatusBar { toggle_pill("DYN", dyn_input, Message::ToggleDynInput), "Dynamic Input\nF12" ), + tip( + toggle_pill("OTRACK", otrack, Message::ToggleOTrack), + "Object Snap Tracking\nF11" + ), osnap_btn(osnap_active, snapper.snap_enabled, popup_open), status_pill(space_label), status_pill(scale_label),