Merge pull request #691 from gianlucafiore/fix/grip-direct-distance

Support tracking and direct distance input for grip editing
This commit is contained in:
Hakan Seven committed 2026-08-07 11:16:49 +03:00
commit 687bc2079d
4 files changed
+231 -11

No files matched your search

+92 -4
View File
@@ -281,6 +281,95 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.refresh_properties();
return Task::none();
}
// Direct-distance entry while a normal grip stretch is active.
//
// The live grip-drag path has already resolved OSNAP / OTRACK /
// Extension / Polar / Ortho into `last_cursor_world`. A typed scalar
// therefore means: move from the grip's original position by that
// distance along the currently indicated direction.
{
let i = self.active_tab;
if let Some(grip) = self.tabs[i].active_grip.clone() {
if grip.mode == GripEditMode::Stretch {
let text =
crate::app::expr_eval::eval_to_string(self.command_line.input.trim());
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
let cursor = self.tabs[i].last_cursor_world;
// If the cursor is following OTRACK or Extension, use the actual
// reference ray that is currently driving the cursor. Otherwise
// fall back to the direction from the original grip position,
// which covers Polar / Ortho / free direct-distance entry.
let target = if let Some((base, dir)) = self.active_distance_ray(i) {
base + dir * dist
} else {
let direction = cursor - grip.origin_world;
let len = direction.length();
if len <= 1e-12 {
self.command_line.push_error(
"Move the cursor in a direction before entering a distance.",
);
return self.focus_cmd_input();
}
let dir = direction / len;
grip.origin_world + dir * dist
};
// The entity is already being edited live. Apply only the
// incremental movement from its current grip position to the
// exact typed-distance position.
let delta = target - grip.last_world;
let actions: Vec<_> = grip
.targets
.iter()
.map(|target_grip| {
let apply = if target_grip.is_translate {
GripApply::Translate(delta)
} else {
GripApply::Absolute(target_grip.last_world + delta)
};
(
target_grip.handle,
target_grip.grip_id,
apply,
)
})
.collect();
for (handle, grip_id, apply) in actions {
self.tabs[i]
.scene
.apply_grip(handle, grip_id, apply);
}
// Keep the live GripEdit state synchronized so the normal
// grip commit path sees the exact final position.
if let Some(active) = self.tabs[i].active_grip.as_mut() {
active.last_world = target;
for target_grip in &mut active.targets {
target_grip.last_world += delta;
}
}
self.tabs[i].last_cursor_world = target;
self.command_line.input.clear();
self.tabs[i].dirty = true;
// Reuse the existing click-move-click grip finalization:
// undo grouping, preview restoration, grip cleanup, etc.
return self.on_viewport_left_release();
}
}
}
}
// Interactive VPORTS: the entry after a bare `VPORTS` is the
// tiled configuration. Empty input defaults to SINGLE.
if self.awaiting_vports {
@@ -386,10 +475,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
return self.feed_command(crate::command::StepInput::Enter);
}
// 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 {
// OTRACK and Extension both allow a bare scalar to act as a
// distance measured along the active reference ray.
if let Some((base, dir)) = self.active_distance_ray(i) {
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist;
if !self.command_point_allowed(i, pt) {
+24 -5
View File
@@ -22,6 +22,25 @@ use iced::{mouse, Point, Task};
impl OpenCADStudio {
pub(in crate::app) fn active_distance_ray(
&self,
i: usize,
) -> Option<(glam::DVec3, glam::DVec3)> {
// OTRACK already exposes its acquired world-space ray.
if let Some((base, dir)) = self.otrack_active {
return Some((base, dir));
}
// Extension stores the snapped point. Recover the acquired endpoint
// and outward segment direction that produced that snap.
let snap = self.tabs[i].snap_result?;
if snap.snap_type == crate::snap::SnapType::Extension {
return self.snapper.extension_input_ray(snap.world);
}
None
}
/// Rebuild the active tab's dynamic-input field set to match what the
/// command is currently asking for. Called on cursor move and after
/// command-state changes. The field set only changes shape when the
@@ -79,11 +98,11 @@ impl OpenCADStudio {
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()
let guided_dist = self.active_distance_ray(i).is_some()
&& !wants_text
&& matches!(field, crate::command::DynField::Point);
let default: Vec<DynComponent> = match field {
_ if otrack_dist => vec![DynComponent::Distance],
_ if guided_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],
@@ -528,9 +547,9 @@ 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 {
// OTRACK and Extension both allow a typed scalar to act as a
// distance measured along the active reference ray.
if let Some((base, dir)) = self.active_distance_ray(i) {
let wants_text = self.tabs[i]
.active_cmd
.as_ref()
+70 -2
View File
@@ -981,8 +981,55 @@ impl OpenCADStudio {
let snap_hit =
self.snapper
.snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr);
let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
self.tabs[i].snap_result = snap_hit;
// Grip edits use the same tracking-point acquisition and OTRACK
// resolution as normal interactive point commands.
let otrack_hit = {
let snap_world = snap_hit.map(|s| s.world);
self.snapper.update_otrack_dwell(
snap_world,
&snap_candidates,
view_rot,
eye,
bounds,
Instant::now(),
);
if snap_hit.is_none() {
let polar_step = if self.polar_mode {
Some(self.polar_increment_deg)
} else {
None
};
let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
self.snapper.otrack_snap(
raw,
view_rot,
eye,
bounds,
polar_step,
Some(grip.origin_world),
self.ortho_mode,
ucs_x,
ucs_y,
)
} else {
None
}
};
self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir));
let mut snapped = if let Some(hit) = otrack_hit {
hit.aligned
} else {
snap_hit.map(|s| s.world).unwrap_or(raw)
};
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
@@ -1007,7 +1054,9 @@ impl OpenCADStudio {
}
if let Some(dir) = self.axis_lock_dir {
snapped = axis_lock_apply(snapped, grip.origin_world, dir);
} else if !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) {
} else if otrack_hit.is_none()
&& !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid)
{
let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
@@ -1027,6 +1076,11 @@ impl OpenCADStudio {
}
let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0;
// The overlay builds the active tracking guide from `otrack_active.base`
// to `last_cursor_world`. Keep it synchronized with the actual point used
// by the grip, otherwise the guide and the edited geometry diverge.
self.tabs[i].last_cursor_world = snapped;
self.tabs[i].last_cursor_screen = p_full;
let apply_started = Instant::now();
let delta = snapped - grip.last_world;
let lengthen = grip.mode == GripEditMode::Lengthen;
@@ -1988,6 +2042,20 @@ impl OpenCADStudio {
.snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr);
let world = snap_hit.map(|s| s.world).unwrap_or(raw);
self.tabs[i].snap_result = snap_hit;
if let Some(s) = self.tabs[i].snap_result.as_mut() {
s.screen.x += tile_b.x;
s.screen.y += tile_b.y;
if let Some(base) = s.extension_base.as_mut() {
base.x += tile_b.x;
base.y += tile_b.y;
}
if let Some(base) = s.extension_base2.as_mut() {
base.x += tile_b.x;
base.y += tile_b.y;
}
}
if let Some(s) = self.tabs[i].snap_result.as_mut() {
// Snap marker is pane-local; lift it to absolute canvas px.
s.screen.x += tile_b.x;
+45
View File
@@ -1620,6 +1620,51 @@ impl Snapper {
best
}
pub fn extension_input_ray(
&self,
snapped: glam::DVec3,
) -> Option<(glam::DVec3, glam::DVec3)> {
let mut best: Option<(f64, glam::DVec3, glam::DVec3)> = None;
for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) {
for &dir in dirs {
let len2 = dir.x * dir.x + dir.y * dir.y;
if len2 < 1e-12 {
continue;
}
let dx = snapped.x - origin.x;
let dy = snapped.y - origin.y;
let t = (dx * dir.x + dy * dir.y) / len2;
// Extension only exists beyond the acquired endpoint.
if t < 0.05 {
continue;
}
let projected = glam::DVec3::new(
origin.x + dir.x * t,
origin.y + dir.y * t,
origin.z,
);
let ex = snapped.x - projected.x;
let ey = snapped.y - projected.y;
let err2 = ex * ex + ey * ey;
let len = len2.sqrt();
let unit_dir =
glam::DVec3::new(dir.x / len, dir.y / len, 0.0);
if best.as_ref().map_or(true, |(best_err, _, _)| err2 < *best_err) {
best = Some((err2, origin, unit_dir));
}
}
}
best.map(|(_, origin, dir)| (origin, dir))
}
}
// ── Object-snap priority ───────────────────────────────────────────────────