Merge pull request #691 from gianlucafiore/fix/grip-direct-distance
Support tracking and direct distance input for grip editing
This commit is contained in:
commit
687bc2079d
4 changed files with 231 additions and 11 deletions
|
|
@ -281,6 +281,95 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||||
self.refresh_properties();
|
self.refresh_properties();
|
||||||
return Task::none();
|
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
|
// Interactive VPORTS: the entry after a bare `VPORTS` is the
|
||||||
// tiled configuration. Empty input defaults to SINGLE.
|
// tiled configuration. Empty input defaults to SINGLE.
|
||||||
if self.awaiting_vports {
|
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);
|
return self.feed_command(crate::command::StepInput::Enter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OTRACK: while aligned to a tracking ray, a bare distance
|
// OTRACK and Extension both allow a bare scalar to act as a
|
||||||
// places the point along the ray from the tracking point
|
// distance measured along the active reference ray.
|
||||||
// (issue #69).
|
if let Some((base, dir)) = self.active_distance_ray(i) {
|
||||||
if let Some((base, dir)) = self.otrack_active {
|
|
||||||
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
|
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
|
||||||
let pt = base + dir * dist;
|
let pt = base + dir * dist;
|
||||||
if !self.command_point_allowed(i, pt) {
|
if !self.command_point_allowed(i, pt) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,25 @@ use iced::{mouse, Point, Task};
|
||||||
|
|
||||||
|
|
||||||
impl OpenCADStudio {
|
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
|
/// 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 is currently asking for. Called on cursor move and after
|
||||||
/// command-state changes. The field set only changes shape when the
|
/// 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();
|
let has_base = self.last_point.is_some();
|
||||||
// While aligned to an OTRACK ray, the point step reads a single
|
// While aligned to an OTRACK ray, the point step reads a single
|
||||||
// distance along the ray (issue #69) — show one Distance box.
|
// 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
|
&& !wants_text
|
||||||
&& matches!(field, crate::command::DynField::Point);
|
&& matches!(field, crate::command::DynField::Point);
|
||||||
let default: Vec<DynComponent> = match field {
|
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::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],
|
||||||
|
|
@ -528,9 +547,9 @@ impl OpenCADStudio {
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// OTRACK: while aligned to a tracking ray, a typed value is a distance
|
// OTRACK and Extension both allow a typed scalar to act as a
|
||||||
// along the ray from the tracking point (issue #69).
|
// distance measured along the active reference ray.
|
||||||
if let Some((base, dir)) = self.otrack_active {
|
if let Some((base, dir)) = self.active_distance_ray(i) {
|
||||||
let wants_text = self.tabs[i]
|
let wants_text = self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
|
||||||
|
|
@ -981,8 +981,55 @@ impl OpenCADStudio {
|
||||||
let snap_hit =
|
let snap_hit =
|
||||||
self.snapper
|
self.snapper
|
||||||
.snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr);
|
.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;
|
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() {
|
if let Some(s) = self.tabs[i].snap_result.as_mut() {
|
||||||
s.screen.x += tile_b.x;
|
s.screen.x += tile_b.x;
|
||||||
s.screen.y += tile_b.y;
|
s.screen.y += tile_b.y;
|
||||||
|
|
@ -1007,7 +1054,9 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
if let Some(dir) = self.axis_lock_dir {
|
if let Some(dir) = self.axis_lock_dir {
|
||||||
snapped = axis_lock_apply(snapped, grip.origin_world, 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 base = grip.origin_world;
|
||||||
let ucs_xf = self.tabs[i].ucs_xform();
|
let ucs_xf = self.tabs[i].ucs_xform();
|
||||||
if self.ortho_mode {
|
if self.ortho_mode {
|
||||||
|
|
@ -1027,6 +1076,11 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0;
|
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 apply_started = Instant::now();
|
||||||
let delta = snapped - grip.last_world;
|
let delta = snapped - grip.last_world;
|
||||||
let lengthen = grip.mode == GripEditMode::Lengthen;
|
let lengthen = grip.mode == GripEditMode::Lengthen;
|
||||||
|
|
@ -1988,6 +2042,20 @@ impl OpenCADStudio {
|
||||||
.snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr);
|
.snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr);
|
||||||
let world = snap_hit.map(|s| s.world).unwrap_or(raw);
|
let world = snap_hit.map(|s| s.world).unwrap_or(raw);
|
||||||
self.tabs[i].snap_result = snap_hit;
|
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() {
|
if let Some(s) = self.tabs[i].snap_result.as_mut() {
|
||||||
// Snap marker is pane-local; lift it to absolute canvas px.
|
// Snap marker is pane-local; lift it to absolute canvas px.
|
||||||
s.screen.x += tile_b.x;
|
s.screen.x += tile_b.x;
|
||||||
|
|
|
||||||
45
src/snap.rs
45
src/snap.rs
|
|
@ -1620,6 +1620,51 @@ impl Snapper {
|
||||||
|
|
||||||
best
|
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 ───────────────────────────────────────────────────
|
// ── Object-snap priority ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue