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, diff --git a/src/app/mod.rs b/src/app/mod.rs index 70f792b02..ee66dddd5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3189,6 +3189,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, @@ -3490,6 +3492,7 @@ pub enum Message { DraftingSettingsTogglePolar, DraftingSettingsToggleOrtho, DraftingSettingsToggleOsnap, + DraftingSettingsOsnapApertureChanged(String), DraftingSettingsToggleOtrack, DraftingSettingsToggleSnapMode(crate::snap::SnapType), DraftingSettingsToggleSnapMode3d(crate::snap::SnapType), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 0c0527a50..0fe152e57 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", @@ -4404,6 +4404,7 @@ impl OpenCADStudio { // A multi-finger gesture owns the pointer while it runs. Message::ViewportMove(_) + | Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::ViewportLeftRelease | Message::PaneMove(..) @@ -4488,6 +4489,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(); @@ -6339,6 +6351,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; 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; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a91981cc3..7ef9eafa3 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; @@ -876,10 +916,16 @@ 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()); + // 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 { @@ -986,9 +1032,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) @@ -1036,6 +1088,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 +1251,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, 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 ───────────────────────────────────────────────────────────── diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 97bb36c0a..e1a768e1b 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("25", &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,