forked from 1b-agent/opencadstudio-upstream
feat(otrack): draw both vectors of an intersection lock
An OTRACK crossing is the meeting of two tracking vectors, but `OtrackHit` carried only the one a typed distance is measured along, so the overlay had a single guide to draw. Snapping to the crossing of two vectors worked and looked like an unexplained point on one line. Carry the crossed ray out of `otrack_snap` as `OtrackHit::cross`, keep it on the app beside `otrack_active`, and let the overlay take a list of guides rather than one. `otrack_active` still drives typed-distance entry on its own, so distance entry is unchanged. Fixes HakanSeven12/OpenCADStudio#1313 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TQzYZsXBwPZ5HwBaGitEUV
This commit is contained in:
7 files changed
+183
-23
No files matched your search
@@ -104,6 +104,7 @@ impl OpenCADStudio {
|
||||
self.snapper.from_point = None;
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
self.axis_lock_dir = None;
|
||||
self.dyn_user_reshaped = false;
|
||||
|
||||
@@ -501,6 +501,11 @@ pub(super) struct OpenCADStudio {
|
||||
/// 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::DVec3, glam::DVec3)>,
|
||||
/// The vector `otrack_active` crosses, when the lock is the meeting of two
|
||||
/// tracking vectors. Drawn beside the first so the user can see that the
|
||||
/// point is their intersection; a typed distance still runs along
|
||||
/// `otrack_active` alone. `None` for a single-ray alignment. (#1313)
|
||||
otrack_cross: Option<(glam::DVec3, glam::DVec3)>,
|
||||
/// Active OTRACK ray kind, separate from typed-distance geometry.
|
||||
otrack_kind: Option<crate::snap::TrackingKind>,
|
||||
/// Whether Tangent snap was enabled before a tangent-pick command started.
|
||||
@@ -3834,6 +3839,7 @@ impl OpenCADStudio {
|
||||
annotation_auto_scale: -4,
|
||||
last_saved_config: None,
|
||||
otrack_active: None,
|
||||
otrack_cross: None,
|
||||
otrack_kind: None,
|
||||
clean_screen: false,
|
||||
quick_properties: false,
|
||||
|
||||
@@ -354,6 +354,7 @@ impl OpenCADStudio {
|
||||
{
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
}
|
||||
if let Some((started, tab_id, before)) = perf_edit_before {
|
||||
@@ -390,6 +391,7 @@ impl OpenCADStudio {
|
||||
pub(in crate::app) fn reset_tracking_after_point(&mut self) {
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
}
|
||||
|
||||
@@ -3969,6 +3971,7 @@ impl OpenCADStudio {
|
||||
if !self.snapper.otrack_enabled {
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
self.otrack_cross = None;
|
||||
self.otrack_kind = None;
|
||||
}
|
||||
Task::none()
|
||||
|
||||
@@ -1479,6 +1479,7 @@ impl OpenCADStudio {
|
||||
Some((base, dir))
|
||||
});
|
||||
self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)).or(drafting_guide);
|
||||
self.otrack_cross = otrack_hit.and_then(|hit| hit.cross);
|
||||
|
||||
self.otrack_kind = otrack_hit.map(|hit| hit.kind);
|
||||
|
||||
@@ -2190,6 +2191,7 @@ impl OpenCADStudio {
|
||||
None
|
||||
};
|
||||
self.otrack_active = otrack_hit.map(|h| (h.base, h.dir));
|
||||
self.otrack_cross = otrack_hit.and_then(|h| h.cross);
|
||||
self.otrack_kind = otrack_hit.map(|h| h.kind);
|
||||
|
||||
// Parallel snap: with nothing else snapped or tracked, lock
|
||||
@@ -2208,6 +2210,7 @@ impl OpenCADStudio {
|
||||
(self.last_point, self.snapper.parallel_ref)
|
||||
{
|
||||
self.otrack_active = Some((base, dir.as_dvec3()));
|
||||
self.otrack_cross = None;
|
||||
}
|
||||
self.tabs[i].snap_result = Some(par);
|
||||
}
|
||||
|
||||
+17
-10
@@ -770,15 +770,22 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
// 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,
|
||||
};
|
||||
// An intersection lock is the meeting of two tracking vectors, so
|
||||
// both are drawn — one guide alone hides what the point is (#1313).
|
||||
let otrack_lines: Vec<(iced::Point, iced::Point)> = match otrack_proj {
|
||||
Some((view_rot, eye, ob)) => {
|
||||
let a = ost_project(tab.last_cursor_world, view_rot, eye, ob);
|
||||
self.otrack_active
|
||||
.into_iter()
|
||||
.chain(self.otrack_cross)
|
||||
.filter_map(|(base, _dir)| {
|
||||
let b = ost_project(base, view_rot, eye, ob);
|
||||
(b.x.is_finite() && a.x.is_finite()).then_some((b, a))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
// The acquired Parallel-snap reference, marked on its line (#277).
|
||||
let parallel_ref_marker: Option<iced::Point> =
|
||||
match (otrack_proj, self.snapper.parallel_ref) {
|
||||
@@ -894,7 +901,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
grip_clip,
|
||||
ucs_icons,
|
||||
ost_points,
|
||||
otrack_line,
|
||||
otrack_lines,
|
||||
parallel_ref_marker,
|
||||
// ViewCube hover region matches the drawn cube — gone when hidden.
|
||||
!is_paper && viewcube_visible,
|
||||
|
||||
+144
-5
@@ -177,6 +177,12 @@ pub struct OtrackHit {
|
||||
pub dir: DVec3,
|
||||
/// The tracking point the ray emanates from.
|
||||
pub base: DVec3,
|
||||
/// The second ray of an intersection lock, as `(base, outward direction)`.
|
||||
/// A crossing is the meeting of two tracking vectors, and the user has to
|
||||
/// see both of them to read what the lock means; `base`/`dir` above carry
|
||||
/// only the one a typed distance is measured along. `None` for a
|
||||
/// single-ray alignment, which has no second vector. (#1313)
|
||||
pub cross: Option<(DVec3, DVec3)>,
|
||||
|
||||
pub kind: TrackingKind,
|
||||
}
|
||||
@@ -917,19 +923,35 @@ impl Snapper {
|
||||
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
|
||||
// Report an acquired tracking ray (not an auxiliary
|
||||
// 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]
|
||||
let (ot, other) = if rays[i].group != POLAR_GROUP
|
||||
&& rays[i].group != ORTHO_GROUP
|
||||
{
|
||||
(&rays[i], &rays[j])
|
||||
} else {
|
||||
&rays[j]
|
||||
(&rays[j], &rays[i])
|
||||
};
|
||||
let t = (x.x - ot.origin.x) * ot.dir.x + (x.y - ot.origin.y) * ot.dir.y;
|
||||
let dir_out = if t >= 0.0 { ot.dir } else { -ot.dir };
|
||||
// Point each ray the way the crossing lies from its own
|
||||
// origin, so the guide drawn for it runs through the lock
|
||||
// rather than away from it.
|
||||
let outward = |ray: &Ray| {
|
||||
let t = (x.x - ray.origin.x) * ray.dir.x
|
||||
+ (x.y - ray.origin.y) * ray.dir.y;
|
||||
if t >= 0.0 {
|
||||
ray.dir
|
||||
} else {
|
||||
-ray.dir
|
||||
}
|
||||
};
|
||||
let dir_out = outward(ot);
|
||||
best_x = Some((
|
||||
sd,
|
||||
OtrackHit {
|
||||
aligned: x,
|
||||
dir: dir_out,
|
||||
base: ot.origin,
|
||||
// The vector the reported one crosses. Both are
|
||||
// drawn, so the intersection reads as one. (#1313)
|
||||
cross: Some((other.origin, outward(other))),
|
||||
kind: ot.kind,
|
||||
}
|
||||
));
|
||||
@@ -970,6 +992,8 @@ impl Snapper {
|
||||
aligned,
|
||||
dir: dir_out,
|
||||
base: ray.origin,
|
||||
// A single-ray alignment has no second vector.
|
||||
cross: None,
|
||||
kind: ray.kind,
|
||||
},
|
||||
));
|
||||
@@ -3647,6 +3671,121 @@ mod ext_tests {
|
||||
assert!(none.is_none(), "no base point → no base→corner alignment");
|
||||
}
|
||||
|
||||
/// #1313: an intersection lock must report both of the vectors it is the
|
||||
/// crossing of, so the overlay can draw both. Reporting only the one a
|
||||
/// typed distance runs along leaves the user with a single guide and no
|
||||
/// sign of what the point actually is.
|
||||
#[test]
|
||||
fn intersection_lock_reports_both_crossing_vectors() {
|
||||
let mut s = Snapper::default();
|
||||
s.otrack_enabled = true;
|
||||
s.osnap_radius_px = 10.0;
|
||||
// Two acquired corners. With no polar step each offers a horizontal and
|
||||
// a vertical ray, so their rays cross at (10, 0) and at (0, 5).
|
||||
let first = DVec3::new(0.0, 0.0, 0.0);
|
||||
let second = DVec3::new(10.0, 5.0, 0.0);
|
||||
for corner in [first, second] {
|
||||
s.tracking_points.push(corner);
|
||||
s.tracking_dirs.push(Vec::new());
|
||||
}
|
||||
|
||||
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
|
||||
let eye = glam::DVec3::ZERO;
|
||||
let bounds = Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1000.0,
|
||||
height: 1000.0,
|
||||
};
|
||||
|
||||
// Cursor a hair off the crossing of the first corner's horizontal ray
|
||||
// and the second corner's vertical one.
|
||||
let crossing = DVec3::new(10.0, 0.0, 0.0);
|
||||
let hit = s
|
||||
.otrack_snap(
|
||||
crossing + DVec3::new(0.02, 0.02, 0.0),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
DVec3::X,
|
||||
DVec3::Y,
|
||||
)
|
||||
.expect("the two rays cross inside the aperture");
|
||||
assert!(
|
||||
(hit.aligned - crossing).length() < 1e-9,
|
||||
"locked off the crossing: {:?}",
|
||||
hit.aligned
|
||||
);
|
||||
|
||||
let (cross_base, cross_dir) = hit.cross.expect("a crossing reports its second vector");
|
||||
let bases = [hit.base, cross_base];
|
||||
for corner in [first, second] {
|
||||
assert!(
|
||||
bases.iter().any(|b| (*b - corner).length() < 1e-9),
|
||||
"{corner:?} is not one of the two reported vectors: {bases:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Each vector runs from its own corner through the crossing, pointing
|
||||
// at it — the guides are drawn along these.
|
||||
for (base, dir) in [(hit.base, hit.dir), (cross_base, cross_dir)] {
|
||||
let off = crossing - base;
|
||||
assert!(
|
||||
(off.x * dir.y - off.y * dir.x).abs() < 1e-9,
|
||||
"the crossing is off the vector from {base:?} along {dir:?}"
|
||||
);
|
||||
assert!(
|
||||
off.dot(dir) > 0.0,
|
||||
"vector from {base:?} points away from the crossing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The second vector belongs to a crossing alone: a plain single-ray
|
||||
/// alignment has nothing to cross, and must not draw a second guide.
|
||||
#[test]
|
||||
fn single_ray_alignment_reports_no_crossing_vector() {
|
||||
let mut s = Snapper::default();
|
||||
s.otrack_enabled = true;
|
||||
s.osnap_radius_px = 10.0;
|
||||
let corner = DVec3::new(10.0, 5.0, 0.0);
|
||||
s.tracking_points.push(corner);
|
||||
s.tracking_dirs.push(Vec::new());
|
||||
|
||||
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
|
||||
let eye = glam::DVec3::ZERO;
|
||||
let bounds = Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1000.0,
|
||||
height: 1000.0,
|
||||
};
|
||||
|
||||
let hit = s
|
||||
.otrack_snap(
|
||||
DVec3::new(60.0, 5.02, 0.0),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
DVec3::X,
|
||||
DVec3::Y,
|
||||
)
|
||||
.expect("the corner's horizontal ray catches the cursor");
|
||||
assert!(
|
||||
hit.cross.is_none(),
|
||||
"a single-ray alignment reported a crossing vector: {:?}",
|
||||
hit.cross
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_points_are_perpendicular_to_the_radius() {
|
||||
let c = Vec3::new(0.0, 0.0, 0.0);
|
||||
|
||||
+9
-8
@@ -851,7 +851,7 @@ pub fn selection_overlay<'a>(
|
||||
grip_clip: Option<iced::Rectangle>,
|
||||
ucs_icons: Vec<UcsIconParams>,
|
||||
ost_points: Vec<OstTrackPoint>,
|
||||
otrack_line: Option<(Point, Point)>,
|
||||
otrack_lines: Vec<(Point, Point)>,
|
||||
parallel_ref_marker: Option<Point>,
|
||||
show_viewcube: bool,
|
||||
dividers: Vec<iced::Rectangle>,
|
||||
@@ -877,7 +877,7 @@ pub fn selection_overlay<'a>(
|
||||
grip_clip,
|
||||
ucs_icons,
|
||||
ost_points,
|
||||
otrack_line,
|
||||
otrack_lines,
|
||||
parallel_ref_marker,
|
||||
show_viewcube,
|
||||
dividers,
|
||||
@@ -918,11 +918,12 @@ struct SelectionCanvas {
|
||||
/// entry carries hover/selected (grips).
|
||||
ucs_icons: Vec<UcsIconParams>,
|
||||
ost_points: Vec<OstTrackPoint>,
|
||||
/// 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)>,
|
||||
/// Active OTRACK alignments, each (acquired tracking point, locked cursor)
|
||||
/// in screen space. Drawn as dashed guides extended a little past the
|
||||
/// cursor so the extension / tracking line the user snapped to is visible
|
||||
/// (#219). An intersection lock contributes both of its crossing vectors,
|
||||
/// so the point reads as their meeting rather than a lone guide (#1313).
|
||||
otrack_lines: Vec<(Point, Point)>,
|
||||
/// The acquired Parallel-snap reference point (screen), marked with a small
|
||||
/// ∥ glyph so the user sees which line is the parallel reference. (#277)
|
||||
parallel_ref_marker: Option<Point>,
|
||||
@@ -1902,7 +1903,7 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||
// 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 {
|
||||
for &(base, tip) in &self.otrack_lines {
|
||||
let dx = tip.x - base.x;
|
||||
let dy = tip.y - base.y;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
Reference in new issue
Block a user