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 <noreply@anthropic.com>
This commit is contained in:
parent
d62690b652
commit
f2fd36b5fa
3 changed files with 194 additions and 45 deletions
|
|
@ -198,6 +198,10 @@ pub(super) struct OpenCADStudio {
|
||||||
/// Last persisted user preferences (DYN/OSNAP/OTRACK/POLAR/…). Compared
|
/// Last persisted user preferences (DYN/OSNAP/OTRACK/POLAR/…). Compared
|
||||||
/// after each message so a change is written to disk exactly once.
|
/// after each message so a change is written to disk exactly once.
|
||||||
last_saved_settings: Option<settings::UserSettings>,
|
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.
|
/// Whether Tangent snap was enabled before a tangent-pick command started.
|
||||||
pre_cmd_tangent: Option<bool>,
|
pre_cmd_tangent: Option<bool>,
|
||||||
/// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°.
|
/// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°.
|
||||||
|
|
@ -1271,6 +1275,7 @@ impl OpenCADStudio {
|
||||||
selection_filter_popup_open: false,
|
selection_filter_popup_open: false,
|
||||||
statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(),
|
statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(),
|
||||||
last_saved_settings: None,
|
last_saved_settings: None,
|
||||||
|
otrack_active: None,
|
||||||
clean_screen: false,
|
clean_screen: false,
|
||||||
quick_properties: false,
|
quick_properties: false,
|
||||||
selection_cycling: false,
|
selection_cycling: false,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ impl OpenCADStudio {
|
||||||
&& !self.snapper.tracking_points.is_empty()
|
&& !self.snapper.tracking_points.is_empty()
|
||||||
{
|
{
|
||||||
self.snapper.clear_tracking();
|
self.snapper.clear_tracking();
|
||||||
|
self.otrack_active = None;
|
||||||
}
|
}
|
||||||
task
|
task
|
||||||
}
|
}
|
||||||
|
|
@ -1227,6 +1228,25 @@ impl OpenCADStudio {
|
||||||
return Task::none();
|
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) {
|
if let Some((coord, kind)) = parse_coord(&text) {
|
||||||
// Resolve relative vs absolute. `@` forces relative,
|
// Resolve relative vs absolute. `@` forces relative,
|
||||||
// `#` forces absolute, no prefix follows DYN: when
|
// `#` forces absolute, no prefix follows DYN: when
|
||||||
|
|
@ -2384,44 +2404,52 @@ impl OpenCADStudio {
|
||||||
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
|
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Object Snap Tracking: update dwell and override snap if tracking.
|
// Object Snap Tracking: update dwell, then align the cursor
|
||||||
let otrack_snap_world = {
|
// 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);
|
let snap_world = self.tabs[i].snap_result.map(|s| s.world);
|
||||||
self.snapper
|
self.snapper
|
||||||
.update_otrack_dwell(snap_world, view_proj, bounds);
|
.update_otrack_dwell(snap_world, view_proj, bounds);
|
||||||
if self.tabs[i].snap_result.is_none() {
|
if self.tabs[i].snap_result.is_none() {
|
||||||
self.snapper
|
let step = if self.polar_mode {
|
||||||
.otrack_snap(cursor_world, view_proj, bounds)
|
Some(self.polar_increment_deg)
|
||||||
.map(|(w, _)| w)
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.snapper.otrack_snap(cursor_world, view_proj, bounds, step)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(ow) = otrack_snap_world {
|
self.otrack_active = otrack_hit.map(|h| (h.base, h.dir));
|
||||||
// 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
|
let mut pt = if let Some(h) = otrack_hit {
|
||||||
// to model-space so previews use consistent coordinates.
|
// Tracking alignment wins over the free cursor;
|
||||||
let mut pt = self.tabs[i]
|
// ortho/polar don't re-constrain it.
|
||||||
.snap_result
|
h.aligned
|
||||||
.map(|s| self.tabs[i].scene.paper_to_model(s.world))
|
} else {
|
||||||
.unwrap_or(cursor_world);
|
// snap.world is paper-space for viewport-projected
|
||||||
// Clamp to world XY only when no UCS is active; with a UCS the
|
// wires; convert to model-space for previews.
|
||||||
// point already lies on the UCS XY plane.
|
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() {
|
if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() {
|
||||||
pt.z = 0.0;
|
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
|
pt
|
||||||
};
|
};
|
||||||
self.tabs[i].last_cursor_world = effective;
|
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);
|
self.tabs[i].scene.set_preview_wires(previews);
|
||||||
} else {
|
} else {
|
||||||
self.tabs[i].snap_result = None;
|
self.tabs[i].snap_result = None;
|
||||||
|
|
@ -2833,7 +2890,24 @@ impl OpenCADStudio {
|
||||||
if self.tabs[i].active_ucs.is_none() {
|
if self.tabs[i].active_ucs.is_none() {
|
||||||
pt.z = 0.0;
|
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 {
|
if self.ortho_mode {
|
||||||
pt = ortho_constrain(pt, base);
|
pt = ortho_constrain(pt, base);
|
||||||
} else if self.polar_mode {
|
} else if self.polar_mode {
|
||||||
|
|
@ -7224,7 +7298,13 @@ impl OpenCADStudio {
|
||||||
.map(|c| c.wants_text_input())
|
.map(|c| c.wants_text_input())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let has_base = self.last_point.is_some();
|
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 {
|
let default: Vec<DynComponent> = match field {
|
||||||
|
_ if otrack_dist => vec![DynComponent::Distance],
|
||||||
crate::command::DynField::Distance => vec![DynComponent::Distance],
|
crate::command::DynField::Distance => vec![DynComponent::Distance],
|
||||||
crate::command::DynField::Angle => vec![DynComponent::Angle],
|
crate::command::DynField::Angle => vec![DynComponent::Angle],
|
||||||
crate::command::DynField::Scalar => vec![DynComponent::Scalar],
|
crate::command::DynField::Scalar => vec![DynComponent::Scalar],
|
||||||
|
|
@ -7573,6 +7653,33 @@ impl OpenCADStudio {
|
||||||
{
|
{
|
||||||
return None;
|
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
|
// 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
|
// `on_text_input` (a count, radius, distance) rather than resolving a
|
||||||
// point. Only the typed buffer matters here — a mouse-driven live
|
// point. Only the typed buffer matters here — a mouse-driven live
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,18 @@ pub struct SnapResult {
|
||||||
pub tangent_obj: Option<TangentObject>,
|
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 ───────────────────────────────────────────────────────────────
|
// ── Snapper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
use rustc_hash::FxHashSet as HashSet;
|
use rustc_hash::FxHashSet as HashSet;
|
||||||
|
|
@ -201,16 +213,19 @@ impl Snapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Given the current cursor world position, check if it aligns with any
|
/// Project the cursor onto a tracking ray emanating from one of the
|
||||||
/// tracking point horizontally or vertically. Returns the snapped world
|
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
|
||||||
/// position (and index of the tracking point) if alignment is found within
|
/// rays are horizontal / vertical (0° / 90°); with it, every polar
|
||||||
/// `snap_radius_px` screen pixels.
|
/// 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(
|
pub fn otrack_snap(
|
||||||
&self,
|
&self,
|
||||||
cursor_world: Vec3,
|
cursor_world: Vec3,
|
||||||
view_proj: glam::Mat4,
|
view_proj: glam::Mat4,
|
||||||
bounds: iced::Rectangle,
|
bounds: iced::Rectangle,
|
||||||
) -> Option<(Vec3, usize)> {
|
polar_step_deg: Option<f32>,
|
||||||
|
) -> Option<OtrackHit> {
|
||||||
if !self.otrack_enabled || self.tracking_points.is_empty() {
|
if !self.otrack_enabled || self.tracking_points.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -218,24 +233,46 @@ impl Snapper {
|
||||||
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds);
|
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds);
|
||||||
let r = self.snap_radius_px;
|
let r = self.snap_radius_px;
|
||||||
|
|
||||||
for (idx, &tp) in self.tracking_points.iter().enumerate() {
|
// Candidate angles in [0,180); each ray extends both ways via the
|
||||||
// Horizontal alignment: cursor.z ≈ tp.z
|
// signed projection `t`, so 0°/90° cover horizontal/vertical.
|
||||||
let aligned_h = Vec3::new(cursor_world.x, 0.0, tp.z);
|
let mut angles: Vec<f32> = Vec::new();
|
||||||
let s = world_to_screen(aligned_h, view_proj, bounds);
|
match polar_step_deg.filter(|s| *s > 1e-3) {
|
||||||
let dy = (s.y - cursor_screen.y).abs();
|
Some(step) => {
|
||||||
if dy < r {
|
let mut a = 0.0_f32;
|
||||||
return Some((aligned_h, idx));
|
while a < 180.0 - 1e-3 {
|
||||||
|
angles.push(a);
|
||||||
|
a += step;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
// Vertical alignment: cursor.x ≈ tp.x
|
angles.push(0.0);
|
||||||
let aligned_v = Vec3::new(tp.x, 0.0, cursor_world.z);
|
angles.push(90.0);
|
||||||
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
|
|
||||||
|
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).
|
/// Clear all acquired tracking points (e.g. when command ends).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue