From 5de7637a6917afe9dc3b98ace35abc6eba2aee3d Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:24:19 -0300 Subject: [PATCH 01/13] fix: measure live OTRACK distance from acquired snap origin --- src/app/view/mod.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a91981cc3..0c0a8ac2b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1036,6 +1036,20 @@ bg={bg_ms:.1}ms n={view_count}" { let w = tab.last_cursor_world; let base = tab.dyn_anchor.or(self.last_point); + // A single distance field on an acquired tracking ray is measured + // from the acquired OSNAP/OTRACK point, not from the command's + // previous point (or from WCS zero before the first pick). + // Keep typed fields and ordinary polar distance inputs unchanged. + let tracking_distance = if tab.dyn_fields.len() == 1 + && tab.dyn_fields[0].component == DynComponent::Distance + { + self.active_distance_ray(self.active_tab).and_then(|(origin, direction)| { + let axis = direction.try_normalize()?; + Some((w - origin).dot(axis).abs()) + }) + } else { + None + }; let label_screen = tab .active_cmd .as_ref() @@ -1185,6 +1199,16 @@ bg={bg_ms:.1}ms n={view_count}" format!("{lv:.4}") } } + // The displayed live distance must use the same + // acquired tracking origin as the guide and typed + // distance placement, not the last picked point. + (None, _) if f.component == DynComponent::Distance + && tracking_distance.is_some() => + { + crate::entities::common::format_length( + tracking_distance.unwrap() * f.role.value_scale() as f64, + ) + } _ => dyn_component_value( f, w, From 86eb7a744c3074c471dae4aedfcce0f4795d2048 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:28:51 -0300 Subject: [PATCH 02/13] fix: capture viewport click coordinates in same mouse event --- src/app/view/mod.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0c0a8ac2b..7a7dc658c 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -15,6 +15,46 @@ use iced::widget::{ use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; use iced_aw::ContextMenu; + +/// Invisible event surface for accurate point picking. MouseArea emits a +/// positionless press message; capturing the cursor in the actual button +/// event avoids using the previous hover location when clicking quickly. +#[derive(Debug, Clone, Copy)] +struct ViewportPickSurface; + +impl iced::widget::canvas::Program for ViewportPickSurface { + type State = (); + + fn update( + &self, + _state: &mut Self::State, + event: &iced::Event, + bounds: iced::Rectangle, + cursor: iced::mouse::Cursor, + ) -> Option> { + if let iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) = + event + { + return cursor.position_in(bounds).map(|position| { + iced::widget::canvas::Action::publish(Message::ViewportLeftPressAt(position)) + .and_capture() + }); + } + None + } + + fn draw( + &self, + _state: &Self::State, + _renderer: &iced::Renderer, + _theme: &iced::Theme, + _bounds: iced::Rectangle, + _cursor: iced::mouse::Cursor, + ) -> Vec { + Vec::new() + } +} + use std::sync::Arc; mod controls; @@ -986,9 +1026,15 @@ bg={bg_ms:.1}ms n={view_count}" mark("viewport_mouse"); let viewport_mouse = mouse_area(container( - iced::widget::Space::new().width(Fill).height(Fill), + iced::widget::canvas(ViewportPickSurface) + .width(Fill) + .height(Fill), )) .on_move(Message::ViewportMove) + // Left mouse presses are captured by the canvas with their exact + // event-time position. MouseArea::on_press has no coordinate, so + // queued cursor moves could otherwise make quick clicks use a stale + // snap position. Touch presses still use this fallback. .on_press(Message::ViewportLeftPress) .on_release(Message::ViewportLeftRelease) .on_right_press(Message::ViewportRightPress) From 9954888d1da67d78415571b63cf8afe11efeff61 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:29:07 -0300 Subject: [PATCH 03/13] fix: carry click-time pointer position in viewport input message --- src/app/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 076f2a07b..42248e3e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3181,6 +3181,8 @@ pub enum Message { /// never fall through and rotate a different camera. ViewportClick(Option), ViewportMove(Point), + /// Position recorded by the pointer event itself, not the last hover. + ViewportLeftPressAt(Point), ViewportLeftPress, ViewportLeftRelease, ViewportRightPress, From d4b7e4bfb8b2137128b7c084b93b0e925db2f396 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:29:31 -0300 Subject: [PATCH 04/13] fix: resolve precise snap at button-down before committing viewport pick --- src/app/update/mod.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 14443aa96..d96d14a66 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -50,7 +50,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { fn perf_message_label(msg: &Message) -> &'static str { match msg { - Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down", + Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down", Message::ViewportLeftRelease | Message::PaneRelease(_) => "pointer-up", Message::ViewportMove(_) | Message::PaneMove(_, _) => "pointer-move", Message::CommandFinalize => "command-finalize", @@ -4383,6 +4383,7 @@ impl OpenCADStudio { // A multi-finger gesture owns the pointer while it runs. Message::ViewportMove(_) + | Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::ViewportLeftRelease | Message::PaneMove(..) @@ -4467,6 +4468,17 @@ impl OpenCADStudio { self.update(Message::ViewportScroll(d)) } + Message::ViewportLeftPressAt(position) => { + // Use the pointer location captured by the actual button + // event. The last hover can lag behind fast cursor motion, + // particularly while expensive OSNAP/OTRACK previews run. + // Refresh the snap and world point before accepting the pick. + let moved = self.on_viewport_move(position); + self.graphic_attributes.open_menu = None; + let sweep = self.sync_active_field_if_any(); + Task::batch(vec![moved, sweep, self.on_viewport_left_press()]) + } + Message::ViewportLeftPress => { self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); From b6c4e0a81d7fd1102bd6c71a430ad403958b710f Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:37:42 -0300 Subject: [PATCH 05/13] ui: reuse precise point crosshair for selection without enabling snaps --- src/app/view/mod.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7a7dc658c..5f3b940cc 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -916,10 +916,13 @@ bg={bg_ms:.1}ms n={view_count}" .hover_highlight .map(|h| tab.scene.is_layer_locked(h)) .unwrap_or(false); - let point_cursor = tab - .active_cmd - .as_ref() - .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); + // Reuse the exact point-picking crosshair (cross + center dot) + // already used by LINE and RECTANG for ordinary selection and + // selection-gathering commands. This changes only cursor drawing, + // not object snapping or the selected world position. + let point_cursor = tab.active_cmd.as_ref().is_none_or(|cmd| { + !cmd.needs_entity_pick() || cmd.is_selection_gathering() + }); let constraint_cursor_badge = if is_paper { None } else { @@ -994,8 +997,7 @@ bg={bg_ms:.1}ms n={view_count}" // apart from both idle and point input (#1661). hide_arms: tab.active_cmd.as_ref().is_some_and(|c| { matches!(c.name(), "XLINE" | "RAY") - || c.needs_entity_pick() - || c.is_selection_gathering() + || (c.needs_entity_pick() && !c.is_selection_gathering()) }), snap_color: self.snap_marker_color, snap_size: self.model_space.snap_marker_size as f32, From d4879c5a512decf09d425184e6919f18efbb2f7b Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:45:01 -0300 Subject: [PATCH 06/13] fix: preserve acquired object snap over OTRACK at preview and click --- src/app/update/viewport.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 397be1f48..dc9c99f78 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2663,7 +2663,15 @@ impl OpenCADStudio { .map(|snap| snap.world) .unwrap_or(cursor_world); axis_lock_apply(point, base, dir) - } else if let Some(h) = otrack_hit { + } else if let Some(h) = otrack_hit.filter(|_| { + // A real object snap must win over tracking alignment. + // Otherwise the endpoint glyph can appear at the + // corner while the preview/accepted point follows the + // tracking line instead. + !self.tabs[i].snap_result.is_some_and(|s| { + s.snap_type != crate::snap::SnapType::Grid + }) + }) { h.aligned } else { // Snap runs in model space (viewport camera or the @@ -4498,7 +4506,11 @@ impl OpenCADStudio { } else { None }; - if let Some(h) = otrack { + if let Some(h) = otrack.filter(|_| { + // Match the hover path: never override a genuine OSNAP + // endpoint/intersection with a tracking projection. + !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) + }) { pt = h.aligned; if clamp_world_xy { pt.z = 0.0; From daf4ccdde866611a2855693092ed9b7539524eda Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:53:25 -0300 Subject: [PATCH 07/13] fix(ui): keep hollow pickbox idle; dot crosshair only during selection window --- src/app/view/mod.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 5f3b940cc..7ef9eafa3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -916,13 +916,16 @@ bg={bg_ms:.1}ms n={view_count}" .hover_highlight .map(|h| tab.scene.is_layer_locked(h)) .unwrap_or(false); - // Reuse the exact point-picking crosshair (cross + center dot) - // already used by LINE and RECTANG for ordinary selection and - // selection-gathering commands. This changes only cursor drawing, - // not object snapping or the selected world position. - let point_cursor = tab.active_cmd.as_ref().is_none_or(|cmd| { - !cmd.needs_entity_pick() || cmd.is_selection_gathering() - }); + // Keep the hollow pickbox at rest. Use the standard LINE/RECTANG + // dot crosshair only after the first window/crossing corner. + // No OSNAP or selection behavior changes. + let selecting_window = tab.active_cmd.is_none() + && tab.scene.selection.borrow().gesture.box_anchor.is_some(); + let point_cursor = selecting_window + || tab + .active_cmd + .as_ref() + .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); let constraint_cursor_badge = if is_paper { None } else { @@ -997,7 +1000,8 @@ bg={bg_ms:.1}ms n={view_count}" // apart from both idle and point input (#1661). hide_arms: tab.active_cmd.as_ref().is_some_and(|c| { matches!(c.name(), "XLINE" | "RAY") - || (c.needs_entity_pick() && !c.is_selection_gathering()) + || c.needs_entity_pick() + || c.is_selection_gathering() }), snap_color: self.snap_marker_color, snap_size: self.model_space.snap_marker_size as f32, From 85959c770c25c859048add98d0b7d7ae939c5508 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:02 -0300 Subject: [PATCH 08/13] ui: expose editable OSNAP capture aperture in drafting settings --- src/ui/window/drafting_settings.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 97bb36c0a..64eae7e5b 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -44,6 +44,7 @@ pub struct DraftingSettingsState { pub polar_increment_deg: f32, // Object Snap pub osnap_on: bool, + pub osnap_aperture_input: String, pub otrack_on: bool, pub snap_modes: rustc_hash::FxHashSet, // 3D Object Snap @@ -78,6 +79,7 @@ impl DraftingSettingsState { || self.ortho_on != saved.ortho_on || (self.polar_increment_deg - saved.polar_increment_deg).abs() > 0.001 || self.osnap_on != saved.osnap_on + || self.osnap_aperture_input != saved.osnap_aperture_input || self.otrack_on != saved.otrack_on || self.snap_modes != saved.snap_modes || self.osnap3d_on != saved.osnap3d_on @@ -508,6 +510,14 @@ pub fn view_window<'a>( column![ row![osnap_on_toggle, Space::new().width(24), otrack_on_toggle].align_y(iced::Center), + row![ + text("OSNAP capture radius (pixels)").size(11), + text_input("15", &state.osnap_aperture_input) + .on_input(Message::DraftingSettingsOsnapApertureChanged) + .width(70), + ] + .spacing(10) + .align_y(iced::Center), action_buttons, Space::new().height(4), snap_modes_group, From 5108e741636160ae641ba05cf7053ad5dde82954 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:06 -0300 Subject: [PATCH 09/13] ui: add OSNAP aperture input message --- src/app/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 42248e3e1..4f8bcace4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3484,6 +3484,7 @@ pub enum Message { DraftingSettingsTogglePolar, DraftingSettingsToggleOrtho, DraftingSettingsToggleOsnap, + DraftingSettingsOsnapApertureChanged(String), DraftingSettingsToggleOtrack, DraftingSettingsToggleSnapMode(crate::snap::SnapType), DraftingSettingsToggleSnapMode3d(crate::snap::SnapType), From 8669631dd92bd4154c4a48729e8f117ff7573520 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:11 -0300 Subject: [PATCH 10/13] ui: route OSNAP capture aperture setting --- src/app/update/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d96d14a66..a5c5e2e53 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6330,6 +6330,12 @@ impl OpenCADStudio { } Task::none() } + Message::DraftingSettingsOsnapApertureChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.osnap_aperture_input = value; + } + Task::none() + } Message::DraftingSettingsToggleOsnap => { if let Some(state) = &mut self.drafting_settings_state { state.osnap_on = !state.osnap_on; From 0b041e4c86ec08261a24f2ac1bc93bbcf9c96211 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:15 -0300 Subject: [PATCH 11/13] feat: apply validated configurable OSNAP capture radius --- src/app/drafting_settings.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 058fff8ab..b95cd229f 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -32,6 +32,7 @@ impl DraftingSettingsState { ortho_on: app.ortho_mode, polar_increment_deg: app.polar_increment_deg, osnap_on: app.snapper.snap_enabled, + osnap_aperture_input: format!("{}", app.snapper.osnap_radius_px), otrack_on: app.snapper.otrack_enabled, snap_modes: app.snapper.enabled.clone(), osnap3d_on: app.snapper.snap3d_enabled, @@ -107,6 +108,13 @@ impl OpenCADStudio { self.polar_mode = state.polar_on; self.ortho_mode = state.ortho_on; self.polar_increment_deg = state.polar_increment_deg; + let aperture = state.osnap_aperture_input.trim().parse::().ok() + .filter(|v| v.is_finite() && (4.0..=40.0).contains(v)); + let Some(aperture) = aperture else { + self.command_line.push_error("OSNAP capture radius must be between 4 and 40 pixels."); + return false; + }; + self.snapper.osnap_radius_px = aperture; self.snapper.snap_enabled = state.osnap_on; self.snapper.otrack_enabled = state.otrack_on; self.snapper.enabled = state.snap_modes.clone(); @@ -145,6 +153,7 @@ mod tests { ortho_on: false, polar_increment_deg: 15.0, osnap_on: true, + osnap_aperture_input: "15".to_string(), otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, @@ -186,6 +195,7 @@ mod tests { ortho_on: false, polar_increment_deg: 15.0, osnap_on: true, + osnap_aperture_input: "15".to_string(), otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, From f47e4649c362d8d2a8fb104bfb617a082d73b70a Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:12:05 -0300 Subject: [PATCH 12/13] Set default OSNAP capture radius to 25 screen pixels --- src/snap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/snap.rs b/src/snap.rs index 8e5260fad..bc8cd04a4 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -14,7 +14,7 @@ use kernel::geom2d::Curve; use crate::command::{DimensionAssociationSource, TangentObject}; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; -const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; +const DEFAULT_OSNAP_RADIUS_PX: f32 = 25.0; // ── Snap type ───────────────────────────────────────────────────────────── From aa6f88937d475825b83da99c11d85a92258b0566 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:12:08 -0300 Subject: [PATCH 13/13] Update OSNAP aperture placeholder to 25 px --- src/ui/window/drafting_settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 64eae7e5b..e1a768e1b 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -512,7 +512,7 @@ pub fn view_window<'a>( row![osnap_on_toggle, Space::new().width(24), otrack_on_toggle].align_y(iced::Center), row![ text("OSNAP capture radius (pixels)").size(11), - text_input("15", &state.osnap_aperture_input) + text_input("25", &state.osnap_aperture_input) .on_input(Message::DraftingSettingsOsnapApertureChanged) .width(70), ]