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.from_point = None;
|
||||||
self.snapper.clear_tracking();
|
self.snapper.clear_tracking();
|
||||||
self.otrack_active = None;
|
self.otrack_active = None;
|
||||||
|
self.otrack_cross = None;
|
||||||
self.otrack_kind = None;
|
self.otrack_kind = None;
|
||||||
self.axis_lock_dir = None;
|
self.axis_lock_dir = None;
|
||||||
self.dyn_user_reshaped = false;
|
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
|
/// 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.
|
/// the ray from the tracking point (issue #69). `None` when not aligned.
|
||||||
otrack_active: Option<(glam::DVec3, glam::DVec3)>,
|
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.
|
/// Active OTRACK ray kind, separate from typed-distance geometry.
|
||||||
otrack_kind: Option<crate::snap::TrackingKind>,
|
otrack_kind: Option<crate::snap::TrackingKind>,
|
||||||
/// Whether Tangent snap was enabled before a tangent-pick command started.
|
/// Whether Tangent snap was enabled before a tangent-pick command started.
|
||||||
@@ -3834,6 +3839,7 @@ impl OpenCADStudio {
|
|||||||
annotation_auto_scale: -4,
|
annotation_auto_scale: -4,
|
||||||
last_saved_config: None,
|
last_saved_config: None,
|
||||||
otrack_active: None,
|
otrack_active: None,
|
||||||
|
otrack_cross: None,
|
||||||
otrack_kind: None,
|
otrack_kind: None,
|
||||||
clean_screen: false,
|
clean_screen: false,
|
||||||
quick_properties: false,
|
quick_properties: false,
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ impl OpenCADStudio {
|
|||||||
{
|
{
|
||||||
self.snapper.clear_tracking();
|
self.snapper.clear_tracking();
|
||||||
self.otrack_active = None;
|
self.otrack_active = None;
|
||||||
|
self.otrack_cross = None;
|
||||||
self.otrack_kind = None;
|
self.otrack_kind = None;
|
||||||
}
|
}
|
||||||
if let Some((started, tab_id, before)) = perf_edit_before {
|
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) {
|
pub(in crate::app) fn reset_tracking_after_point(&mut self) {
|
||||||
self.snapper.clear_tracking();
|
self.snapper.clear_tracking();
|
||||||
self.otrack_active = None;
|
self.otrack_active = None;
|
||||||
|
self.otrack_cross = None;
|
||||||
self.otrack_kind = None;
|
self.otrack_kind = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3969,6 +3971,7 @@ impl OpenCADStudio {
|
|||||||
if !self.snapper.otrack_enabled {
|
if !self.snapper.otrack_enabled {
|
||||||
self.snapper.clear_tracking();
|
self.snapper.clear_tracking();
|
||||||
self.otrack_active = None;
|
self.otrack_active = None;
|
||||||
|
self.otrack_cross = None;
|
||||||
self.otrack_kind = None;
|
self.otrack_kind = None;
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
|
|||||||
@@ -1479,6 +1479,7 @@ impl OpenCADStudio {
|
|||||||
Some((base, dir))
|
Some((base, dir))
|
||||||
});
|
});
|
||||||
self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)).or(drafting_guide);
|
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);
|
self.otrack_kind = otrack_hit.map(|hit| hit.kind);
|
||||||
|
|
||||||
@@ -2190,6 +2191,7 @@ impl OpenCADStudio {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
self.otrack_active = otrack_hit.map(|h| (h.base, h.dir));
|
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);
|
self.otrack_kind = otrack_hit.map(|h| h.kind);
|
||||||
|
|
||||||
// Parallel snap: with nothing else snapped or tracked, lock
|
// Parallel snap: with nothing else snapped or tracked, lock
|
||||||
@@ -2208,6 +2210,7 @@ impl OpenCADStudio {
|
|||||||
(self.last_point, self.snapper.parallel_ref)
|
(self.last_point, self.snapper.parallel_ref)
|
||||||
{
|
{
|
||||||
self.otrack_active = Some((base, dir.as_dvec3()));
|
self.otrack_active = Some((base, dir.as_dvec3()));
|
||||||
|
self.otrack_cross = None;
|
||||||
}
|
}
|
||||||
self.tabs[i].snap_result = Some(par);
|
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
|
// The active alignment vector: a dashed guide from the acquired
|
||||||
// tracking point through the locked cursor, so the user sees the
|
// tracking point through the locked cursor, so the user sees the
|
||||||
// extension / tracking line they are snapped to (#219).
|
// extension / tracking line they are snapped to (#219).
|
||||||
let otrack_line: Option<(iced::Point, iced::Point)> =
|
// An intersection lock is the meeting of two tracking vectors, so
|
||||||
match (otrack_proj, self.otrack_active) {
|
// both are drawn — one guide alone hides what the point is (#1313).
|
||||||
(Some((view_rot, eye, ob)), Some((base, _dir))) => {
|
let otrack_lines: Vec<(iced::Point, iced::Point)> = match otrack_proj {
|
||||||
let b = ost_project(base, view_rot, eye, ob);
|
Some((view_rot, eye, ob)) => {
|
||||||
let a = ost_project(tab.last_cursor_world, 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))
|
self.otrack_active
|
||||||
}
|
.into_iter()
|
||||||
_ => None,
|
.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).
|
// The acquired Parallel-snap reference, marked on its line (#277).
|
||||||
let parallel_ref_marker: Option<iced::Point> =
|
let parallel_ref_marker: Option<iced::Point> =
|
||||||
match (otrack_proj, self.snapper.parallel_ref) {
|
match (otrack_proj, self.snapper.parallel_ref) {
|
||||||
@@ -894,7 +901,7 @@ bg={bg_ms:.1}ms n={view_count}"
|
|||||||
grip_clip,
|
grip_clip,
|
||||||
ucs_icons,
|
ucs_icons,
|
||||||
ost_points,
|
ost_points,
|
||||||
otrack_line,
|
otrack_lines,
|
||||||
parallel_ref_marker,
|
parallel_ref_marker,
|
||||||
// ViewCube hover region matches the drawn cube — gone when hidden.
|
// ViewCube hover region matches the drawn cube — gone when hidden.
|
||||||
!is_paper && viewcube_visible,
|
!is_paper && viewcube_visible,
|
||||||
|
|||||||
+144
-5
@@ -177,6 +177,12 @@ pub struct OtrackHit {
|
|||||||
pub dir: DVec3,
|
pub dir: DVec3,
|
||||||
/// The tracking point the ray emanates from.
|
/// The tracking point the ray emanates from.
|
||||||
pub base: DVec3,
|
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,
|
pub kind: TrackingKind,
|
||||||
}
|
}
|
||||||
@@ -917,19 +923,35 @@ impl Snapper {
|
|||||||
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 acquired tracking ray (not an auxiliary
|
// Report an acquired tracking ray (not an auxiliary
|
||||||
// last_point ray) as base/dir for typed-distance entry.
|
// last_point ray) as base/dir for typed-distance entry.
|
||||||
let ot = if rays[i].group != POLAR_GROUP && rays[i].group != ORTHO_GROUP {
|
let (ot, other) = if rays[i].group != POLAR_GROUP
|
||||||
&rays[i]
|
&& rays[i].group != ORTHO_GROUP
|
||||||
|
{
|
||||||
|
(&rays[i], &rays[j])
|
||||||
} else {
|
} 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;
|
// Point each ray the way the crossing lies from its own
|
||||||
let dir_out = if t >= 0.0 { ot.dir } else { -ot.dir };
|
// 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((
|
best_x = Some((
|
||||||
sd,
|
sd,
|
||||||
OtrackHit {
|
OtrackHit {
|
||||||
aligned: x,
|
aligned: x,
|
||||||
dir: dir_out,
|
dir: dir_out,
|
||||||
base: ot.origin,
|
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,
|
kind: ot.kind,
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
@@ -970,6 +992,8 @@ impl Snapper {
|
|||||||
aligned,
|
aligned,
|
||||||
dir: dir_out,
|
dir: dir_out,
|
||||||
base: ray.origin,
|
base: ray.origin,
|
||||||
|
// A single-ray alignment has no second vector.
|
||||||
|
cross: None,
|
||||||
kind: ray.kind,
|
kind: ray.kind,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -3647,6 +3671,121 @@ mod ext_tests {
|
|||||||
assert!(none.is_none(), "no base point → no base→corner alignment");
|
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]
|
#[test]
|
||||||
fn tangent_points_are_perpendicular_to_the_radius() {
|
fn tangent_points_are_perpendicular_to_the_radius() {
|
||||||
let c = Vec3::new(0.0, 0.0, 0.0);
|
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>,
|
grip_clip: Option<iced::Rectangle>,
|
||||||
ucs_icons: Vec<UcsIconParams>,
|
ucs_icons: Vec<UcsIconParams>,
|
||||||
ost_points: Vec<OstTrackPoint>,
|
ost_points: Vec<OstTrackPoint>,
|
||||||
otrack_line: Option<(Point, Point)>,
|
otrack_lines: Vec<(Point, Point)>,
|
||||||
parallel_ref_marker: Option<Point>,
|
parallel_ref_marker: Option<Point>,
|
||||||
show_viewcube: bool,
|
show_viewcube: bool,
|
||||||
dividers: Vec<iced::Rectangle>,
|
dividers: Vec<iced::Rectangle>,
|
||||||
@@ -877,7 +877,7 @@ pub fn selection_overlay<'a>(
|
|||||||
grip_clip,
|
grip_clip,
|
||||||
ucs_icons,
|
ucs_icons,
|
||||||
ost_points,
|
ost_points,
|
||||||
otrack_line,
|
otrack_lines,
|
||||||
parallel_ref_marker,
|
parallel_ref_marker,
|
||||||
show_viewcube,
|
show_viewcube,
|
||||||
dividers,
|
dividers,
|
||||||
@@ -918,11 +918,12 @@ 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>,
|
||||||
/// Active OTRACK alignment: (acquired tracking point, locked cursor), both
|
/// Active OTRACK alignments, each (acquired tracking point, locked cursor)
|
||||||
/// in screen space. Drawn as a dashed guide extended a little past the
|
/// in screen space. Drawn as dashed guides extended a little past the
|
||||||
/// cursor so the extension / tracking line the user snapped to is visible.
|
/// cursor so the extension / tracking line the user snapped to is visible
|
||||||
/// (#219)
|
/// (#219). An intersection lock contributes both of its crossing vectors,
|
||||||
otrack_line: Option<(Point, Point)>,
|
/// 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
|
/// The acquired Parallel-snap reference point (screen), marked with a small
|
||||||
/// ∥ glyph so the user sees which line is the parallel reference. (#277)
|
/// ∥ glyph so the user sees which line is the parallel reference. (#277)
|
||||||
parallel_ref_marker: Option<Point>,
|
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
|
// real angle from the acquired point through the lock and a little
|
||||||
// beyond, dashed so it reads as a construction guide. This covers the
|
// beyond, dashed so it reads as a construction guide. This covers the
|
||||||
// ortho (0°/90°), polar, and edge-extension cases uniformly (#219).
|
// 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 dx = tip.x - base.x;
|
||||||
let dy = tip.y - base.y;
|
let dy = tip.y - base.y;
|
||||||
let len = (dx * dx + dy * dy).sqrt();
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
|
|||||||
Reference in new issue
Block a user