#218: with Ortho on and a base point the axis is now a hard lock — a lone tracking ray can't pull the cursor off it. Only a tracking ray crossing the ortho axis locks (the corner-finding case); otherwise the ortho constraint applies as the fallback, so Ortho reliably locks when OTRACK is also on. #219: an acquired corner now contributes an alignment ray along its own edge direction(s) (captured once at acquisition), so OTRACK follows a segment's extension, not just the ortho/polar axes. Acquisition is also captured on leave — a perfectly still cursor emits no move events, so "pause on a corner, then drag" now reliably acquires it. The acquired marker projects through the active pane's camera + rect (canvas offset included) instead of vanishing, and the active alignment path is drawn as a full-length dashed guide through the corner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0322128f48
commit
c3e86d08cc
4 changed files with 271 additions and 61 deletions
|
|
@ -900,6 +900,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||||
let snap_world = self.tabs[i].snap_result.map(|s| s.world.as_vec3());
|
let snap_world = self.tabs[i].snap_result.map(|s| s.world.as_vec3());
|
||||||
self.snapper.update_otrack_dwell(
|
self.snapper.update_otrack_dwell(
|
||||||
snap_world,
|
snap_world,
|
||||||
|
&all_wires[..],
|
||||||
view_rot,
|
view_rot,
|
||||||
eye,
|
eye,
|
||||||
bounds,
|
bounds,
|
||||||
|
|
@ -919,6 +920,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||||
bounds,
|
bounds,
|
||||||
step,
|
step,
|
||||||
self.last_point,
|
self.last_point,
|
||||||
|
self.ortho_mode,
|
||||||
ucs,
|
ucs,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1803,7 +1805,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||||
};
|
};
|
||||||
let ucs = self.tabs[i].scene.viewcube_ucs_mat();
|
let ucs = self.tabs[i].scene.viewcube_ucs_mat();
|
||||||
self.snapper
|
self.snapper
|
||||||
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, ucs)
|
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, self.ortho_mode, ucs)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -332,28 +332,60 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
// OST tracking points → screen positions, projected relative-to-eye
|
// OST tracking points → screen positions, projected relative-to-eye
|
||||||
// so they stay precise at UTM-scale coordinates (the full
|
// so they stay precise at UTM-scale coordinates (the full
|
||||||
// view-projection cancels catastrophically in f32).
|
// view-projection cancels catastrophically in f32). Must project
|
||||||
let ost_points: Vec<crate::ui::overlay::OstTrackPoint> = if self.snapper.otrack_enabled {
|
// through the active pane's camera at its rect (canvas offset
|
||||||
let (view_rot, eye) = {
|
// included) — like the grips and UCS icon above — or the marker
|
||||||
let cam = tab.scene.camera.borrow();
|
// lands in the wrong place (tiled layout) or off-screen (floating
|
||||||
(cam.view_proj_rte(vp_bounds), cam.eye())
|
// viewport). Without the `ob.x/ob.y` offset it silently vanishes.
|
||||||
|
// Shared OTRACK projection basis: the active pane's camera + rect
|
||||||
|
// (canvas offset included), matching the grips / UCS icon above.
|
||||||
|
let otrack_proj: Option<(glam::Mat4, glam::DVec3, iced::Rectangle)> =
|
||||||
|
if self.snapper.otrack_enabled {
|
||||||
|
Some(
|
||||||
|
if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) {
|
||||||
|
(vp_cam.view_proj_rte(full), vp_cam.eye(), full)
|
||||||
|
} else {
|
||||||
|
let cam = tab.scene.camera.borrow();
|
||||||
|
(cam.view_proj_rte(vp_bounds), cam.eye(), vp_bounds)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let ost_project =
|
||||||
|
|w: glam::Vec3, view_rot: glam::Mat4, eye: glam::DVec3, ob: iced::Rectangle| {
|
||||||
|
let ndc = view_rot.project_point3((w.as_dvec3() - eye).as_vec3());
|
||||||
|
iced::Point::new(
|
||||||
|
ob.x + (ndc.x + 1.0) * 0.5 * ob.width,
|
||||||
|
ob.y + (1.0 - ndc.y) * 0.5 * ob.height,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let ost_points: Vec<crate::ui::overlay::OstTrackPoint> =
|
||||||
|
if let Some((view_rot, eye, ob)) = otrack_proj {
|
||||||
|
self.snapper
|
||||||
|
.tracking_points
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&wp| {
|
||||||
|
let s = ost_project(wp, view_rot, eye, ob);
|
||||||
|
(s.x.is_finite() && s.y.is_finite())
|
||||||
|
.then_some(crate::ui::overlay::OstTrackPoint { screen: s })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
// The active alignment vector: a dashed guide from the acquired
|
||||||
|
// tracking point through the locked cursor, so the user sees the
|
||||||
|
// extension / tracking line they are snapped to (#219).
|
||||||
|
let otrack_line: Option<(iced::Point, iced::Point)> =
|
||||||
|
match (otrack_proj, self.otrack_active) {
|
||||||
|
(Some((view_rot, eye, ob)), Some((base, _dir))) => {
|
||||||
|
let b = ost_project(base, view_rot, eye, ob);
|
||||||
|
let a = ost_project(tab.last_cursor_world, view_rot, eye, ob);
|
||||||
|
(b.x.is_finite() && a.x.is_finite()).then_some((b, a))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
self.snapper
|
|
||||||
.tracking_points
|
|
||||||
.iter()
|
|
||||||
.map(|&wp| {
|
|
||||||
let ndc = view_rot.project_point3((wp.as_dvec3() - eye).as_vec3());
|
|
||||||
crate::ui::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![]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Model-space pane dividers (none in paper / single-pane layouts).
|
// Model-space pane dividers (none in paper / single-pane layouts).
|
||||||
let dividers = if !is_paper {
|
let dividers = if !is_paper {
|
||||||
|
|
@ -404,7 +436,7 @@ impl OpenCADStudio {
|
||||||
grips,
|
grips,
|
||||||
ucs_icons,
|
ucs_icons,
|
||||||
ost_points,
|
ost_points,
|
||||||
tab.last_cursor_screen,
|
otrack_line,
|
||||||
!is_paper && self.show_viewcube,
|
!is_paper && self.show_viewcube,
|
||||||
dividers,
|
dividers,
|
||||||
pane_move_rect,
|
pane_move_rect,
|
||||||
|
|
|
||||||
178
src/snap.rs
178
src/snap.rs
|
|
@ -109,6 +109,13 @@ pub struct Snapper {
|
||||||
pub otrack_enabled: bool,
|
pub otrack_enabled: bool,
|
||||||
/// Acquired OST points (world XZ, Y=0 plane).
|
/// Acquired OST points (world XZ, Y=0 plane).
|
||||||
pub tracking_points: Vec<Vec3>,
|
pub tracking_points: Vec<Vec3>,
|
||||||
|
/// Edge directions at each acquired point (parallel to `tracking_points`):
|
||||||
|
/// the line direction of every wire segment meeting at that corner, so
|
||||||
|
/// OTRACK can offer an alignment ray along a segment's extension, not only
|
||||||
|
/// the ortho/polar axes. Pulling the cursor along an acquired corner's edge
|
||||||
|
/// then locks to that line (#219). Empty for a point that is not a segment
|
||||||
|
/// endpoint (e.g. a midpoint or centre acquisition).
|
||||||
|
pub tracking_dirs: Vec<Vec<Vec3>>,
|
||||||
/// Last snap world position (for dwell detection).
|
/// Last snap world position (for dwell detection).
|
||||||
pub last_snap_world: Option<Vec3>,
|
pub last_snap_world: Option<Vec3>,
|
||||||
/// When the cursor first rested near `last_snap_world`.
|
/// When the cursor first rested near `last_snap_world`.
|
||||||
|
|
@ -140,6 +147,7 @@ impl Default for Snapper {
|
||||||
osnap_radius_px: CROSSHAIR_ARM * 0.25,
|
osnap_radius_px: CROSSHAIR_ARM * 0.25,
|
||||||
otrack_enabled: false,
|
otrack_enabled: false,
|
||||||
tracking_points: Vec::new(),
|
tracking_points: Vec::new(),
|
||||||
|
tracking_dirs: Vec::new(),
|
||||||
last_snap_world: None,
|
last_snap_world: None,
|
||||||
dwell_since: None,
|
dwell_since: None,
|
||||||
dwell_acquired: false,
|
dwell_acquired: false,
|
||||||
|
|
@ -201,6 +209,7 @@ impl Snapper {
|
||||||
pub fn update_otrack_dwell(
|
pub fn update_otrack_dwell(
|
||||||
&mut self,
|
&mut self,
|
||||||
snap_world: Option<Vec3>,
|
snap_world: Option<Vec3>,
|
||||||
|
wires: &[WireModel],
|
||||||
view_rot: glam::Mat4,
|
view_rot: glam::Mat4,
|
||||||
eye: glam::DVec3,
|
eye: glam::DVec3,
|
||||||
bounds: iced::Rectangle,
|
bounds: iced::Rectangle,
|
||||||
|
|
@ -220,6 +229,9 @@ impl Snapper {
|
||||||
|
|
||||||
match snap_world {
|
match snap_world {
|
||||||
None => {
|
None => {
|
||||||
|
// Leaving all geometry: capture the point we were dwelling on if
|
||||||
|
// it qualified, before the reset loses it.
|
||||||
|
self.acquire_on_leave(now, DWELL_MS, wires);
|
||||||
self.last_snap_world = None;
|
self.last_snap_world = None;
|
||||||
self.dwell_since = None;
|
self.dwell_since = None;
|
||||||
self.dwell_acquired = false;
|
self.dwell_acquired = false;
|
||||||
|
|
@ -242,7 +254,7 @@ impl Snapper {
|
||||||
if !self.dwell_acquired && elapsed >= DWELL_MS {
|
if !self.dwell_acquired && elapsed >= DWELL_MS {
|
||||||
self.dwell_acquired = true;
|
self.dwell_acquired = true;
|
||||||
// Dwelling over an already-acquired point removes it;
|
// Dwelling over an already-acquired point removes it;
|
||||||
// otherwise acquire it (max 4 tracked points).
|
// otherwise acquire it.
|
||||||
let existing = self.tracking_points.iter().position(|t| {
|
let existing = self.tracking_points.iter().position(|t| {
|
||||||
let d = (*t - p).length();
|
let d = (*t - p).length();
|
||||||
d < self.grid_spacing * 0.1
|
d < self.grid_spacing * 0.1
|
||||||
|
|
@ -250,16 +262,18 @@ impl Snapper {
|
||||||
match existing {
|
match existing {
|
||||||
Some(idx) => {
|
Some(idx) => {
|
||||||
self.tracking_points.remove(idx);
|
self.tracking_points.remove(idx);
|
||||||
}
|
if idx < self.tracking_dirs.len() {
|
||||||
None => {
|
self.tracking_dirs.remove(idx);
|
||||||
if self.tracking_points.len() >= 4 {
|
|
||||||
self.tracking_points.remove(0);
|
|
||||||
}
|
}
|
||||||
self.tracking_points.push(p);
|
|
||||||
}
|
}
|
||||||
|
None => self.acquire_tracking_point(p, wires),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Moved to a different snap point: capture the previous one
|
||||||
|
// first if it was dwelt on long enough, so a pause-then-drag
|
||||||
|
// gesture reliably acquires it even without in-place events.
|
||||||
|
self.acquire_on_leave(now, DWELL_MS, wires);
|
||||||
self.last_snap_world = Some(p);
|
self.last_snap_world = Some(p);
|
||||||
self.dwell_since = Some(now);
|
self.dwell_since = Some(now);
|
||||||
self.dwell_acquired = false;
|
self.dwell_acquired = false;
|
||||||
|
|
@ -268,10 +282,54 @@ impl Snapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add `p` as a tracking point (capturing its corner edge directions) unless
|
||||||
|
/// it is already tracked; drops the oldest when the 4-point cap is reached.
|
||||||
|
/// Edge directions are scanned once here, at acquisition, so OTRACK can align
|
||||||
|
/// to a segment's extension without rescanning geometry per move (#219).
|
||||||
|
fn acquire_tracking_point(&mut self, p: Vec3, wires: &[WireModel]) {
|
||||||
|
if self
|
||||||
|
.tracking_points
|
||||||
|
.iter()
|
||||||
|
.any(|t| (*t - p).length() < self.grid_spacing * 0.1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.tracking_points.len() >= 4 {
|
||||||
|
self.tracking_points.remove(0);
|
||||||
|
if !self.tracking_dirs.is_empty() {
|
||||||
|
self.tracking_dirs.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.tracking_points.push(p);
|
||||||
|
self.tracking_dirs.push(edge_dirs_at(p, wires));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the cursor dwelt on a snap point long enough but the in-place check
|
||||||
|
/// never fired (a perfectly still cursor emits no move events, so the timer
|
||||||
|
/// is only re-examined once the cursor moves off), acquire it now as the
|
||||||
|
/// cursor leaves. This makes "pause on a corner, then drag along its edge"
|
||||||
|
/// reliably capture the corner (#219).
|
||||||
|
fn acquire_on_leave(&mut self, now: Instant, dwell_ms: u128, wires: &[WireModel]) {
|
||||||
|
if self.dwell_acquired {
|
||||||
|
return; // already handled by the in-place branch
|
||||||
|
}
|
||||||
|
let Some(prev) = self.last_snap_world else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let elapsed = self
|
||||||
|
.dwell_since
|
||||||
|
.map_or(0, |t| now.duration_since(t).as_millis());
|
||||||
|
if elapsed >= dwell_ms {
|
||||||
|
self.acquire_tracking_point(prev, wires);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Project the cursor onto a tracking ray emanating from one of the
|
/// Project the cursor onto a tracking ray emanating from one of the
|
||||||
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
|
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
|
||||||
/// rays are horizontal / vertical (0° / 90°); with it, every polar
|
/// rays are horizontal / vertical (0° / 90°); with it, every polar
|
||||||
/// increment is a candidate so the user can track along POLAR angles.
|
/// increment is a candidate so the user can track along POLAR angles. Each
|
||||||
|
/// acquired corner also contributes a ray along its own edge directions, so
|
||||||
|
/// pulling the cursor along a segment's extension locks to that line (#219).
|
||||||
///
|
///
|
||||||
/// When the cursor sits near the crossing of two active vectors from
|
/// When the cursor sits near the crossing of two active vectors from
|
||||||
/// different origins the intersection point wins, so the cursor locks onto
|
/// different origins the intersection point wins, so the cursor locks onto
|
||||||
|
|
@ -289,6 +347,10 @@ impl Snapper {
|
||||||
bounds: iced::Rectangle,
|
bounds: iced::Rectangle,
|
||||||
polar_step_deg: Option<f32>,
|
polar_step_deg: Option<f32>,
|
||||||
last_point: Option<Vec3>,
|
last_point: Option<Vec3>,
|
||||||
|
// Ortho on: the axis from `last_point` is a hard lock. Only crossings of
|
||||||
|
// an acquired ray with that axis lock; single tracking rays are
|
||||||
|
// suppressed so the cursor can't leave the ortho axis. (#218)
|
||||||
|
ortho: bool,
|
||||||
// UCS→world rotation: tracking rays run along the UCS axes, matching
|
// UCS→world rotation: tracking rays run along the UCS axes, matching
|
||||||
// ortho/polar. Identity = world-aligned rays.
|
// ortho/polar. Identity = world-aligned rays.
|
||||||
ucs: glam::Mat4,
|
ucs: glam::Mat4,
|
||||||
|
|
@ -340,11 +402,26 @@ impl Snapper {
|
||||||
group: gi,
|
group: gi,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Extension rays along the corner's own edges (world-space geometry
|
||||||
|
// directions — already oriented, no UCS rotation). Included in the
|
||||||
|
// single-ray set so pulling the cursor along a segment's extension
|
||||||
|
// locks to it. (#219)
|
||||||
|
if let Some(edirs) = self.tracking_dirs.get(gi) {
|
||||||
|
for &d in edirs {
|
||||||
|
rays.push(Ray {
|
||||||
|
origin: tp,
|
||||||
|
dir: d,
|
||||||
|
group: gi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// OTRACK rays come first; polar rays (appended below) only participate
|
// OTRACK rays come first; the auxiliary rays appended below (polar from
|
||||||
// in intersection locking, never in single-ray fallback.
|
// last_point, ortho axis from last_point) only participate in
|
||||||
|
// intersection locking, never in single-ray fallback.
|
||||||
let otrack_ray_count = rays.len();
|
let otrack_ray_count = rays.len();
|
||||||
const POLAR_GROUP: usize = usize::MAX;
|
const POLAR_GROUP: usize = usize::MAX;
|
||||||
|
const ORTHO_GROUP: usize = usize::MAX - 1;
|
||||||
if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
|
if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
|
||||||
let mut a = 0.0_f32;
|
let mut a = 0.0_f32;
|
||||||
while a < 180.0 - 1e-3 {
|
while a < 180.0 - 1e-3 {
|
||||||
|
|
@ -357,6 +434,19 @@ impl Snapper {
|
||||||
a += step;
|
a += step;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ortho axis rays from `last_point`, so a tracking ray crossing the
|
||||||
|
// ortho axis locks on-axis (the useful corner-finding case). (#218)
|
||||||
|
let ortho_lock = ortho && last_point.is_some();
|
||||||
|
if let (true, Some(lp)) = (ortho_lock, last_point) {
|
||||||
|
for &adeg in &[0.0_f32, 90.0] {
|
||||||
|
let ar = adeg.to_radians();
|
||||||
|
rays.push(Ray {
|
||||||
|
origin: lp,
|
||||||
|
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
|
||||||
|
group: ORTHO_GROUP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Intersection lock — crossing of two vectors from distinct origins.
|
// ── Intersection lock — crossing of two vectors from distinct origins.
|
||||||
let mut best_x: Option<(f32, OtrackHit)> = None;
|
let mut best_x: Option<(f32, OtrackHit)> = None;
|
||||||
|
|
@ -365,6 +455,11 @@ impl Snapper {
|
||||||
if rays[i].group == rays[j].group {
|
if rays[i].group == rays[j].group {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Under an ortho lock only crossings that involve the ortho axis
|
||||||
|
// are valid — every other crossing lies off it. (#218)
|
||||||
|
if ortho_lock && rays[i].group != ORTHO_GROUP && rays[j].group != ORTHO_GROUP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(x) =
|
let Some(x) =
|
||||||
line_intersect_xy(rays[i].origin, rays[i].dir, rays[j].origin, rays[j].dir)
|
line_intersect_xy(rays[i].origin, rays[i].dir, rays[j].origin, rays[j].dir)
|
||||||
else {
|
else {
|
||||||
|
|
@ -372,8 +467,9 @@ impl Snapper {
|
||||||
};
|
};
|
||||||
let sd = screen_dist(x);
|
let sd = screen_dist(x);
|
||||||
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
|
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
|
||||||
// Report an OTRACK ray as base/dir for typed-distance entry.
|
// Report an acquired tracking ray (not an auxiliary
|
||||||
let ot = if rays[i].group != POLAR_GROUP {
|
// last_point ray) as base/dir for typed-distance entry.
|
||||||
|
let ot = if rays[i].group != POLAR_GROUP && rays[i].group != ORTHO_GROUP {
|
||||||
&rays[i]
|
&rays[i]
|
||||||
} else {
|
} else {
|
||||||
&rays[j]
|
&rays[j]
|
||||||
|
|
@ -395,6 +491,14 @@ impl Snapper {
|
||||||
return Some(h);
|
return Some(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With Ortho on and a base point, the axis is a hard lock: no single
|
||||||
|
// tracking ray may pull the cursor off it. Only crossings with the
|
||||||
|
// ortho axis (handled above) lock; otherwise defer to the caller's
|
||||||
|
// ortho constraint. (#218)
|
||||||
|
if ortho_lock {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Single-ray alignment (OTRACK rays only) ──
|
// ── Single-ray alignment (OTRACK rays only) ──
|
||||||
let mut best: Option<(f32, OtrackHit)> = None;
|
let mut best: Option<(f32, OtrackHit)> = None;
|
||||||
for ray in rays.iter().take(otrack_ray_count) {
|
for ray in rays.iter().take(otrack_ray_count) {
|
||||||
|
|
@ -424,6 +528,7 @@ impl Snapper {
|
||||||
/// Clear all acquired tracking points (e.g. when command ends).
|
/// Clear all acquired tracking points (e.g. when command ends).
|
||||||
pub fn clear_tracking(&mut self) {
|
pub fn clear_tracking(&mut self) {
|
||||||
self.tracking_points.clear();
|
self.tracking_points.clear();
|
||||||
|
self.tracking_dirs.clear();
|
||||||
self.last_snap_world = None;
|
self.last_snap_world = None;
|
||||||
self.dwell_since = None;
|
self.dwell_since = None;
|
||||||
self.dwell_acquired = false;
|
self.dwell_acquired = false;
|
||||||
|
|
@ -451,6 +556,7 @@ impl Snapper {
|
||||||
osnap_radius_px: self.osnap_radius_px,
|
osnap_radius_px: self.osnap_radius_px,
|
||||||
otrack_enabled: false,
|
otrack_enabled: false,
|
||||||
tracking_points: Vec::new(),
|
tracking_points: Vec::new(),
|
||||||
|
tracking_dirs: Vec::new(),
|
||||||
last_snap_world: None,
|
last_snap_world: None,
|
||||||
dwell_since: None,
|
dwell_since: None,
|
||||||
dwell_acquired: false,
|
dwell_acquired: false,
|
||||||
|
|
@ -1061,6 +1167,56 @@ fn snap_priority(t: SnapType) -> u8 {
|
||||||
|
|
||||||
// ── Geometric helpers ─────────────────────────────────────────────────────
|
// ── Geometric helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Line directions of every wire segment that has an endpoint at `p` (an
|
||||||
|
/// acquired corner), deduped by near-parallelism and capped. OTRACK offers an
|
||||||
|
/// alignment ray along each so the cursor can track a segment's extension, not
|
||||||
|
/// just the ortho/polar axes (#219). Scanned once, at acquisition — not per
|
||||||
|
/// move. Empty when `p` is not a segment endpoint (midpoint / centre / node).
|
||||||
|
fn edge_dirs_at(p: Vec3, wires: &[WireModel]) -> Vec<Vec3> {
|
||||||
|
// The acquired point is an f32 truncation of the true (f64) vertex, so its
|
||||||
|
// error grows with coordinate magnitude (~1 ULP ≈ 1.2e-7·mag). Scale the
|
||||||
|
// endpoint-match tolerance to a few multiples of that, with a tight floor
|
||||||
|
// near the origin — a fixed fraction would span metres at UTM scale and
|
||||||
|
// match unrelated vertices, while a magnitude-blind floor would miss the
|
||||||
|
// corner once f32 rounding exceeds it.
|
||||||
|
let tol = 1e-4_f32.max(4e-7 * p.x.abs().max(p.y.abs()));
|
||||||
|
let tol2 = (tol * tol) as f64;
|
||||||
|
let pd = p.as_dvec3();
|
||||||
|
let mut dirs: Vec<Vec3> = Vec::new();
|
||||||
|
'outer: for wire in wires {
|
||||||
|
let n = wire.points.len();
|
||||||
|
if n < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for i in 0..n - 1 {
|
||||||
|
let a = wp_f64(wire, i);
|
||||||
|
let b = wp_f64(wire, i + 1);
|
||||||
|
if !a.x.is_finite() || !b.x.is_finite() {
|
||||||
|
continue; // NaN sentinel separates sub-paths
|
||||||
|
}
|
||||||
|
if (a - pd).length_squared() >= tol2 && (b - pd).length_squared() >= tol2 {
|
||||||
|
continue; // neither endpoint is the acquired corner
|
||||||
|
}
|
||||||
|
let seg = b - a;
|
||||||
|
let l = (seg.x * seg.x + seg.y * seg.y).sqrt();
|
||||||
|
if l < 1e-9 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d = Vec3::new((seg.x / l) as f32, (seg.y / l) as f32, 0.0);
|
||||||
|
// Skip a direction already present (parallel within ~0.5°); the ray
|
||||||
|
// is bidirectional, so opposite signs are the same alignment line.
|
||||||
|
if dirs.iter().any(|e| (e.x * d.x + e.y * d.y).abs() > 0.99996) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dirs.push(d);
|
||||||
|
if dirs.len() >= 6 {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
/// Reconstruct the absolute f64 position of wire vertex `i` from its
|
/// Reconstruct the absolute f64 position of wire vertex `i` from its
|
||||||
/// double-single high/low pair. At UTM-scale coordinates the `points` (high)
|
/// double-single high/low pair. At UTM-scale coordinates the `points` (high)
|
||||||
/// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so
|
/// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ pub fn selection_overlay<'a>(
|
||||||
grips: Vec<GripMarker>,
|
grips: Vec<GripMarker>,
|
||||||
ucs_icons: Vec<UcsIconParams>,
|
ucs_icons: Vec<UcsIconParams>,
|
||||||
ost_points: Vec<OstTrackPoint>,
|
ost_points: Vec<OstTrackPoint>,
|
||||||
cursor_screen: Point,
|
otrack_line: Option<(Point, Point)>,
|
||||||
show_viewcube: bool,
|
show_viewcube: bool,
|
||||||
dividers: Vec<iced::Rectangle>,
|
dividers: Vec<iced::Rectangle>,
|
||||||
pane_move_rect: Option<iced::Rectangle>,
|
pane_move_rect: Option<iced::Rectangle>,
|
||||||
|
|
@ -202,7 +202,7 @@ pub fn selection_overlay<'a>(
|
||||||
grips,
|
grips,
|
||||||
ucs_icons,
|
ucs_icons,
|
||||||
ost_points,
|
ost_points,
|
||||||
cursor_screen,
|
otrack_line,
|
||||||
show_viewcube,
|
show_viewcube,
|
||||||
dividers,
|
dividers,
|
||||||
pane_move_rect,
|
pane_move_rect,
|
||||||
|
|
@ -231,7 +231,11 @@ struct SelectionCanvas {
|
||||||
/// entry carries hover/selected (grips).
|
/// entry carries hover/selected (grips).
|
||||||
ucs_icons: Vec<UcsIconParams>,
|
ucs_icons: Vec<UcsIconParams>,
|
||||||
ost_points: Vec<OstTrackPoint>,
|
ost_points: Vec<OstTrackPoint>,
|
||||||
cursor_screen: Point,
|
/// Active OTRACK alignment: (acquired tracking point, locked cursor), both
|
||||||
|
/// in screen space. Drawn as a dashed guide extended a little past the
|
||||||
|
/// cursor so the extension / tracking line the user snapped to is visible.
|
||||||
|
/// (#219)
|
||||||
|
otrack_line: Option<(Point, Point)>,
|
||||||
show_viewcube: bool,
|
show_viewcube: bool,
|
||||||
/// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
|
/// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
|
||||||
/// as filled lines and used to suppress the crosshair over a divider.
|
/// as filled lines and used to suppress the crosshair over a divider.
|
||||||
|
|
@ -1003,32 +1007,48 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Object Snap Tracking lines ────────────────────────────────────
|
// ── Object Snap Tracking ─────────────────────────────────────────────
|
||||||
|
let track_color = Color {
|
||||||
|
r: 0.15,
|
||||||
|
g: 0.85,
|
||||||
|
b: 0.95,
|
||||||
|
a: 0.7,
|
||||||
|
};
|
||||||
|
// The alignment line the cursor is currently locked to — drawn at its
|
||||||
|
// real angle from the acquired point through the lock and a little
|
||||||
|
// beyond, dashed so it reads as a construction guide. This covers the
|
||||||
|
// ortho (0°/90°), polar, and edge-extension cases uniformly (#219).
|
||||||
|
if let Some((base, tip)) = self.otrack_line {
|
||||||
|
let dx = tip.x - base.x;
|
||||||
|
let dy = tip.y - base.y;
|
||||||
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
|
if len > 1e-3 {
|
||||||
|
// Extend the alignment path well past both ends along its
|
||||||
|
// direction (the canvas clips it to the viewport) so it reads
|
||||||
|
// as a full construction line through the acquired point, not a
|
||||||
|
// stub between the corner and the cursor. (#219)
|
||||||
|
let (ux, uy) = (dx / len, dy / len);
|
||||||
|
const L: f32 = 5000.0;
|
||||||
|
let p0 = Point::new(base.x - ux * L, base.y - uy * L);
|
||||||
|
let p1 = Point::new(tip.x + ux * L, tip.y + uy * L);
|
||||||
|
let dash = canvas::Stroke {
|
||||||
|
line_dash: canvas::LineDash {
|
||||||
|
segments: &[6.0, 4.0],
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
..canvas::Stroke::default()
|
||||||
|
.with_color(track_color)
|
||||||
|
.with_width(1.0)
|
||||||
|
};
|
||||||
|
frame.stroke(&canvas::Path::line(p0, p1), dash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Small cross at each acquired tracking point.
|
||||||
for ost in &self.ost_points {
|
for ost in &self.ost_points {
|
||||||
let tp = ost.screen;
|
let tp = ost.screen;
|
||||||
let cx = self.cursor_screen.x;
|
let stroke = canvas::Stroke::default()
|
||||||
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_color(track_color)
|
||||||
.with_width(1.0);
|
.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 sz = 5.0_f32;
|
||||||
let h = canvas::Path::line(
|
let h = canvas::Path::line(
|
||||||
Point {
|
Point {
|
||||||
|
|
@ -1050,8 +1070,8 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||||
y: tp.y + sz,
|
y: tp.y + sz,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
frame.stroke(&h, dash_stroke.clone());
|
frame.stroke(&h, stroke.clone());
|
||||||
frame.stroke(&v, dash_stroke);
|
frame.stroke(&v, stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![frame.into_geometry()]
|
vec![frame.into_geometry()]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue