feat: Object Snap Tracking (OTRACK / F11)
- snap/mod.rs: Snapper gains otrack_enabled, tracking_points, dwell state;
update_otrack_dwell() acquires a snap point after DWELL_THRESHOLD=4
consecutive moves near the same position; otrack_snap() returns an
H/V alignment snap position; clear_tracking() resets state on command end
- overlay.rs: selection_overlay accepts ost_points + cursor_screen;
draws light-blue dashed H/V lines from acquired points + small cross marker
- statusbar.rs: OTRACK pill (F11) added next to DYN
- view.rs: projects tracking_points to screen, passes to selection_overlay
- update.rs: calls update_otrack_dwell + otrack_snap in ViewportMove;
overrides effective point with OST alignment when no normal snap is active
- ROADMAP.md: Object Snap Tracking marked ✅
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d2a58bdd08
commit
f73d38f0a2
7 changed files with 211 additions and 3 deletions
|
|
@ -196,7 +196,7 @@ Underlay (PDF/DWF/DGN)
|
||||||
| Named Views (VIEW komutu) | ✅ |
|
| Named Views (VIEW komutu) | ✅ |
|
||||||
| Named UCS kaydetme | ✅ UCS SAVE/DELETE/LIST |
|
| Named UCS kaydetme | ✅ UCS SAVE/DELETE/LIST |
|
||||||
| VPORTS (viewport bölme) | ⬜ |
|
| 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 |
|
| Dynamic Input overlay | ✅ F12 toggle, absolute XY + relative dist/angle |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -209,7 +209,7 @@ Underlay (PDF/DWF/DGN)
|
||||||
| Perpendicular / Tangent / Nearest | ✅ |
|
| Perpendicular / Tangent / Nearest | ✅ |
|
||||||
| Grid snap | ✅ Zoom-adaptive spacing |
|
| Grid snap | ✅ Zoom-adaptive spacing |
|
||||||
| Polar tracking | ✅ Configurable angle, guide line |
|
| Polar tracking | ✅ Configurable angle, guide line |
|
||||||
| Object snap tracking | ⬜ |
|
| Object snap tracking | ✅ Dwell-acquire, H/V tracking lines, alignment snap |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,8 @@ pub enum Message {
|
||||||
SetPolarAngle(f32),
|
SetPolarAngle(f32),
|
||||||
/// Toggle dynamic input overlay (F12).
|
/// Toggle dynamic input overlay (F12).
|
||||||
ToggleDynInput,
|
ToggleDynInput,
|
||||||
|
/// Toggle object snap tracking (F11).
|
||||||
|
ToggleOTrack,
|
||||||
/// Toggle an individual snap mode (from popup row click).
|
/// Toggle an individual snap mode (from popup row click).
|
||||||
ToggleSnap(crate::snap::SnapType),
|
ToggleSnap(crate::snap::SnapType),
|
||||||
/// Open / close the OSNAP popup (▾ arrow click).
|
/// Open / close the OSNAP popup (▾ arrow click).
|
||||||
|
|
|
||||||
|
|
@ -1010,6 +1010,24 @@ impl H7CAD {
|
||||||
} else {
|
} else {
|
||||||
self.snapper.snap(cursor_world, p, &all_wires, view_proj, bounds)
|
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 = {
|
let effective = {
|
||||||
// snap.world is paper-space for viewport-projected wires; convert
|
// snap.world is paper-space for viewport-projected wires; convert
|
||||||
// to model-space so previews use consistent coordinates.
|
// to model-space so previews use consistent coordinates.
|
||||||
|
|
@ -1660,6 +1678,13 @@ impl H7CAD {
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ToggleDynInput => { self.dyn_input ^= true; 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) => {
|
Message::SetPolarAngle(deg) => {
|
||||||
self.polar_increment_deg = deg;
|
self.polar_increment_deg = deg;
|
||||||
self.polar_mode = true;
|
self.polar_mode = true;
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,23 @@ impl H7CAD {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
overlay::selection_overlay(sel, snap_info, grips, grid, ucs_icon)
|
// OST tracking points → screen positions.
|
||||||
|
let ost_points: Vec<overlay::OstTrackPoint> = 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())
|
let nav = container(overlay::nav_toolbar())
|
||||||
|
|
@ -202,6 +218,7 @@ impl H7CAD {
|
||||||
self.polar_increment_deg,
|
self.polar_increment_deg,
|
||||||
self.show_grid,
|
self.show_grid,
|
||||||
self.dyn_input,
|
self.dyn_input,
|
||||||
|
self.snapper.otrack_enabled,
|
||||||
tab.scene.layout_names(),
|
tab.scene.layout_names(),
|
||||||
tab.scene.current_layout.clone(),
|
tab.scene.current_layout.clone(),
|
||||||
self.layout_rename_state.as_ref(),
|
self.layout_rename_state.as_ref(),
|
||||||
|
|
@ -365,6 +382,9 @@ impl H7CAD {
|
||||||
keyboard::Key::Named(keyboard::key::Named::F10) => {
|
keyboard::Key::Named(keyboard::key::Named::F10) => {
|
||||||
Some(Message::TogglePolar)
|
Some(Message::TogglePolar)
|
||||||
}
|
}
|
||||||
|
keyboard::Key::Named(keyboard::key::Named::F11) => {
|
||||||
|
Some(Message::ToggleOTrack)
|
||||||
|
}
|
||||||
keyboard::Key::Named(keyboard::key::Named::F12) => {
|
keyboard::Key::Named(keyboard::key::Named::F12) => {
|
||||||
Some(Message::ToggleDynInput)
|
Some(Message::ToggleDynInput)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
src/snap/mod.rs
110
src/snap/mod.rs
|
|
@ -75,6 +75,14 @@ pub struct Snapper {
|
||||||
pub grid_spacing: f32,
|
pub grid_spacing: f32,
|
||||||
/// Pixel-radius threshold.
|
/// Pixel-radius threshold.
|
||||||
pub snap_radius_px: f32,
|
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<Vec3>,
|
||||||
|
/// Last snap world position (for dwell detection).
|
||||||
|
pub last_snap_world: Option<Vec3>,
|
||||||
|
/// How many consecutive moves the cursor has been near last_snap_world.
|
||||||
|
pub dwell_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Snapper {
|
impl Default for Snapper {
|
||||||
|
|
@ -92,6 +100,10 @@ impl Default for Snapper {
|
||||||
enabled,
|
enabled,
|
||||||
grid_spacing: 1.0,
|
grid_spacing: 1.0,
|
||||||
snap_radius_px: CROSSHAIR_ARM,
|
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();
|
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<Vec3>, 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.
|
/// Only runs Tangent snap — used when a command needs object picks via tangent.
|
||||||
pub fn snap_tangent_only(
|
pub fn snap_tangent_only(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -152,6 +258,10 @@ impl Snapper {
|
||||||
},
|
},
|
||||||
grid_spacing: self.grid_spacing,
|
grid_spacing: self.grid_spacing,
|
||||||
snap_radius_px: self.snap_radius_px,
|
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)
|
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,12 +190,20 @@ pub struct UcsIconParams {
|
||||||
|
|
||||||
// ── Selection overlay ───────────────────────────────────────────────────
|
// ── 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>(
|
pub fn selection_overlay<'a>(
|
||||||
selection: SelectionState,
|
selection: SelectionState,
|
||||||
snap: Option<(Point, SnapType)>,
|
snap: Option<(Point, SnapType)>,
|
||||||
grips: Vec<GripMarker>,
|
grips: Vec<GripMarker>,
|
||||||
grid: Option<GridParams>,
|
grid: Option<GridParams>,
|
||||||
ucs_icon: Option<UcsIconParams>,
|
ucs_icon: Option<UcsIconParams>,
|
||||||
|
ost_points: Vec<OstTrackPoint>,
|
||||||
|
cursor_screen: Point,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
canvas(SelectionCanvas {
|
canvas(SelectionCanvas {
|
||||||
selection,
|
selection,
|
||||||
|
|
@ -203,6 +211,8 @@ pub fn selection_overlay<'a>(
|
||||||
grips,
|
grips,
|
||||||
grid,
|
grid,
|
||||||
ucs_icon,
|
ucs_icon,
|
||||||
|
ost_points,
|
||||||
|
cursor_screen,
|
||||||
})
|
})
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
|
|
@ -215,6 +225,8 @@ struct SelectionCanvas {
|
||||||
grips: Vec<GripMarker>,
|
grips: Vec<GripMarker>,
|
||||||
grid: Option<GridParams>,
|
grid: Option<GridParams>,
|
||||||
ucs_icon: Option<UcsIconParams>,
|
ucs_icon: Option<UcsIconParams>,
|
||||||
|
ost_points: Vec<OstTrackPoint>,
|
||||||
|
cursor_screen: Point,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl canvas::Program<Message> for SelectionCanvas {
|
impl canvas::Program<Message> for SelectionCanvas {
|
||||||
|
|
@ -588,6 +600,40 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||||
draw_ucs_icon(&mut frame, ucs.view_proj, ucs.bounds);
|
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()]
|
vec![frame.into_geometry()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ impl StatusBar {
|
||||||
polar_increment_deg: f32,
|
polar_increment_deg: f32,
|
||||||
show_grid: bool,
|
show_grid: bool,
|
||||||
dyn_input: bool,
|
dyn_input: bool,
|
||||||
|
otrack: bool,
|
||||||
layouts: Vec<String>,
|
layouts: Vec<String>,
|
||||||
current_layout: String,
|
current_layout: String,
|
||||||
// If `Some((original, edit_value))`, the named tab shows a text input.
|
// 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),
|
toggle_pill("DYN", dyn_input, Message::ToggleDynInput),
|
||||||
"Dynamic Input\nF12"
|
"Dynamic Input\nF12"
|
||||||
),
|
),
|
||||||
|
tip(
|
||||||
|
toggle_pill("OTRACK", otrack, Message::ToggleOTrack),
|
||||||
|
"Object Snap Tracking\nF11"
|
||||||
|
),
|
||||||
osnap_btn(osnap_active, snapper.snap_enabled, popup_open),
|
osnap_btn(osnap_active, snapper.snap_enabled, popup_open),
|
||||||
status_pill(space_label),
|
status_pill(space_label),
|
||||||
status_pill(scale_label),
|
status_pill(scale_label),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue