feat(otrack): polar coordinates from tracking point (#69)

OTRACK alignment was computed in the wrong plane (it used Z as the
second axis while the model is XY / Z-up), so tracking never aligned
correctly and the aligned point was never fed to a pick.

Rework it: project the cursor onto a ray from a tracking point in the
XY plane — horizontal/vertical by default, or every POLAR increment
when POLAR is on. The aligned point is now used for both the live
preview and the committed click, and a dashed guide shows the ray.

While aligned, a typed distance places the point along the ray from the
tracking point, via the command line and the dynamic-input box (which
shows a single Distance field). The alignment clears with the command.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-09 23:38:08 +03:00
1 parent d62690b652
commit f2fd36b5fa
3 files changed
+195 -46

No files matched your search

+5
View File
@@ -198,6 +198,10 @@ pub(super) struct OpenCADStudio {
/// Last persisted user preferences (DYN/OSNAP/OTRACK/POLAR/…). Compared
/// after each message so a change is written to disk exactly once.
last_saved_settings: Option<settings::UserSettings>,
/// Active OTRACK alignment `(tracking_point, unit_direction)` when the
/// 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::Vec3, glam::Vec3)>,
/// Whether Tangent snap was enabled before a tangent-pick command started.
pre_cmd_tangent: Option<bool>,
/// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°.
@@ -1271,6 +1275,7 @@ impl OpenCADStudio {
selection_filter_popup_open: false,
statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(),
last_saved_settings: None,
otrack_active: None,
clean_screen: false,
quick_properties: false,
selection_cycling: false,
+133 -26
View File
@@ -69,6 +69,7 @@ impl OpenCADStudio {
&& !self.snapper.tracking_points.is_empty()
{
self.snapper.clear_tracking();
self.otrack_active = None;
}
task
}
@@ -1227,6 +1228,25 @@ impl OpenCADStudio {
return Task::none();
}
// OTRACK: while aligned to a tracking ray, a bare distance
// places the point along the ray from the tracking point
// (issue #69).
if let Some((base, dir)) = self.otrack_active {
if let Some(dist) = super::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist as f32;
self.last_point = Some(pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
if let Some(r) = result {
let task = self.apply_cmd_result(r);
self.refresh_active_cmd_preview(i);
return task;
}
return Task::none();
}
}
if let Some((coord, kind)) = parse_coord(&text) {
// Resolve relative vs absolute. `@` forces relative,
// `#` forces absolute, no prefix follows DYN: when
@@ -2384,44 +2404,52 @@ impl OpenCADStudio {
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
};
// Object Snap Tracking: update dwell and override snap if tracking.
let otrack_snap_world = {
// Object Snap Tracking: update dwell, then align the cursor
// to a tracking ray (and store the alignment so a typed
// distance can place a point along it — issue #69).
let otrack_hit = {
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)
let step = if self.polar_mode {
Some(self.polar_increment_deg)
} else {
None
};
self.snapper.otrack_snap(cursor_world, view_proj, bounds, step)
} 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;
}
self.otrack_active = otrack_hit.map(|h| (h.base, h.dir));
let effective = {
// snap.world is paper-space for viewport-projected wires; convert
// to model-space so previews use consistent coordinates.
let mut pt = self.tabs[i]
.snap_result
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
.unwrap_or(cursor_world);
// Clamp to world XY only when no UCS is active; with a UCS the
// point already lies on the UCS XY plane.
let mut pt = if let Some(h) = otrack_hit {
// Tracking alignment wins over the free cursor;
// ortho/polar don't re-constrain it.
h.aligned
} else {
// snap.world is paper-space for viewport-projected
// wires; convert to model-space for previews.
let mut pt = self.tabs[i]
.snap_result
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
.unwrap_or(cursor_world);
if let Some(base) = self.last_point {
if self.ortho_mode {
pt = ortho_constrain(pt, base);
} else if self.polar_mode {
pt = polar_constrain(pt, base, self.polar_increment_deg);
}
}
pt
};
// Clamp to world XY only when no UCS is active; with a
// UCS the point already lies on the UCS XY plane.
if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
if let Some(base) = self.last_point {
if self.ortho_mode {
pt = ortho_constrain(pt, base);
} else if self.polar_mode {
pt = polar_constrain(pt, base, self.polar_increment_deg);
}
}
pt
};
self.tabs[i].last_cursor_world = effective;
@@ -2485,6 +2513,35 @@ impl OpenCADStudio {
}
}
}
// OTRACK alignment guide: dashed ray through the tracking
// point along the aligned direction (issue #69).
if let Some(h) = otrack_hit {
if !needs_entity {
let far = 1e5_f32;
let far_pos = h.base + h.dir * far;
let far_neg = h.base - h.dir * far;
previews.push(crate::scene::WireModel {
name: "__otrack_guide__".into(),
points: vec![
[far_neg.x, far_neg.y, far_neg.z],
[far_pos.x, far_pos.y, far_pos.z],
],
color: [0.2, 0.9, 0.5, 0.6],
selected: false,
aci: 0,
pattern_length: 0.8,
pattern: [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: crate::scene::WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
}
self.tabs[i].scene.set_preview_wires(previews);
} else {
self.tabs[i].snap_result = None;
@@ -2833,7 +2890,24 @@ impl OpenCADStudio {
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
if let Some(base) = self.last_point {
// OTRACK alignment wins over ortho/polar; otherwise apply
// ortho/polar relative to the last point.
let otrack = if snap_hit.is_none() {
let step = if self.polar_mode {
Some(self.polar_increment_deg)
} else {
None
};
self.snapper.otrack_snap(raw, vp_mat, bounds, step)
} else {
None
};
if let Some(h) = otrack {
pt = h.aligned;
if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0;
}
} else if let Some(base) = self.last_point {
if self.ortho_mode {
pt = ortho_constrain(pt, base);
} else if self.polar_mode {
@@ -7224,7 +7298,13 @@ impl OpenCADStudio {
.map(|c| c.wants_text_input())
.unwrap_or(false);
let has_base = self.last_point.is_some();
// While aligned to an OTRACK ray, the point step reads a single
// distance along the ray (issue #69) — show one Distance box.
let otrack_dist = self.otrack_active.is_some()
&& !wants_text
&& matches!(field, crate::command::DynField::Point);
let default: Vec<DynComponent> = match field {
_ if otrack_dist => vec![DynComponent::Distance],
crate::command::DynField::Distance => vec![DynComponent::Distance],
crate::command::DynField::Angle => vec![DynComponent::Angle],
crate::command::DynField::Scalar => vec![DynComponent::Scalar],
@@ -7573,6 +7653,33 @@ impl OpenCADStudio {
{
return None;
}
// OTRACK: while aligned to a tracking ray, a typed value is a distance
// along the ray from the tracking point (issue #69).
if let Some((base, dir)) = self.otrack_active {
let wants_text = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false);
if !wants_text {
if let Some(text) = self.tabs[i].dyn_fields.iter().find_map(|f| f.buffer.clone()) {
if let Some(dist) = super::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist as f32;
self.last_point = Some(pt);
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
}
self.tabs[i].dyn_active = 0;
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
let task = result.map(|r| self.apply_cmd_result(r))?;
self.refresh_active_cmd_preview(i);
return Some(task);
}
}
}
}
// A text-input step reads its single box as a string and commits via
// `on_text_input` (a count, radius, distance) rather than resolving a
// point. Only the typed buffer matters here — a mouse-driven live
+57 -20
View File
@@ -61,6 +61,18 @@ pub struct SnapResult {
pub tangent_obj: Option<TangentObject>,
}
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
/// acquired tracking point.
#[derive(Debug, Clone, Copy)]
pub struct OtrackHit {
/// Cursor projected onto the tracking ray.
pub aligned: Vec3,
/// Unit ray direction toward the cursor side (for typed-distance entry).
pub dir: Vec3,
/// The tracking point the ray emanates from.
pub base: Vec3,
}
// ── Snapper ───────────────────────────────────────────────────────────────
use rustc_hash::FxHashSet as HashSet;
@@ -201,16 +213,19 @@ impl Snapper {
}
}
/// 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.
/// Project the cursor onto a tracking ray emanating from one of the
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
/// rays are horizontal / vertical (0° / 90°); with it, every polar
/// increment is a candidate so the user can track along POLAR angles.
/// Returns the aligned point, the unit ray direction (pointing toward the
/// cursor side, used for typed-distance entry), and the tracking point.
pub fn otrack_snap(
&self,
cursor_world: Vec3,
view_proj: glam::Mat4,
bounds: iced::Rectangle,
) -> Option<(Vec3, usize)> {
polar_step_deg: Option<f32>,
) -> Option<OtrackHit> {
if !self.otrack_enabled || self.tracking_points.is_empty() {
return None;
}
@@ -218,24 +233,46 @@ impl Snapper {
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));
// Candidate angles in [0,180); each ray extends both ways via the
// signed projection `t`, so 0°/90° cover horizontal/vertical.
let mut angles: Vec<f32> = Vec::new();
match polar_step_deg.filter(|s| *s > 1e-3) {
Some(step) => {
let mut a = 0.0_f32;
while a < 180.0 - 1e-3 {
angles.push(a);
a += step;
}
}
// 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 => {
angles.push(0.0);
angles.push(90.0);
}
}
None
let mut best: Option<(f32, OtrackHit)> = None;
for &tp in self.tracking_points.iter() {
for &adeg in &angles {
let ar = adeg.to_radians();
let dir = Vec3::new(ar.cos(), ar.sin(), 0.0);
let t = (cursor_world.x - tp.x) * dir.x + (cursor_world.y - tp.y) * dir.y;
let aligned = Vec3::new(tp.x + dir.x * t, tp.y + dir.y * t, tp.z);
let s = world_to_screen(aligned, view_proj, bounds);
let sd = ((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt();
if sd < r && best.as_ref().map_or(true, |(bd, _)| sd < *bd) {
let dir_out = if t >= 0.0 { dir } else { -dir };
best = Some((
sd,
OtrackHit {
aligned,
dir: dir_out,
base: tp,
},
));
}
}
}
best.map(|(_, h)| h)
}
/// Clear all acquired tracking points (e.g. when command ends).