diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 4b8f85d13..ccc372c21 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -25,7 +25,8 @@ impl DraftingSettingsState { osnap_on: app.snapper.snap_enabled, otrack_on: app.snapper.otrack_enabled, snap_modes: app.snapper.enabled.clone(), - osnap3d_on: false, + osnap3d_on: app.snapper.snap3d_enabled, + snap3d_modes: app.snapper.enabled3d.clone(), dyn_input_on: app.dyn_input, quick_props_on: app.quick_properties, selection_cycling_on: app.selection_cycling, @@ -73,6 +74,8 @@ impl OpenCADStudio { self.snapper.snap_enabled = state.osnap_on; self.snapper.otrack_enabled = state.otrack_on; self.snapper.enabled = state.snap_modes.clone(); + self.snapper.snap3d_enabled = state.osnap3d_on; + self.snapper.enabled3d = state.snap3d_modes.clone(); self.dyn_input = state.dyn_input_on; self.quick_properties = state.quick_props_on; self.selection_cycling = state.selection_cycling_on; @@ -104,6 +107,7 @@ mod tests { otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, + snap3d_modes: rustc_hash::FxHashSet::default(), dyn_input_on: false, quick_props_on: false, selection_cycling_on: false, @@ -139,6 +143,7 @@ mod tests { otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, + snap3d_modes: rustc_hash::FxHashSet::default(), dyn_input_on: false, quick_props_on: false, selection_cycling_on: false, @@ -188,6 +193,10 @@ mod tests { modded.osnap3d_on = true; assert!(modded.is_dirty(&base)); + let mut modded = base.clone(); + modded.snap3d_modes.insert(crate::snap::SnapType::Vertex); + assert!(modded.is_dirty(&base)); + let mut modded = base.clone(); modded.dyn_input_on = true; assert!(modded.is_dirty(&base)); diff --git a/src/app/mod.rs b/src/app/mod.rs index a46a17382..c553441a0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2580,6 +2580,8 @@ pub enum Message { CommandEscape, /// Toggle the global snap on/off (OSNAP button body click). ToggleSnapEnabled, + /// Toggle 3D object snap on/off — F4. + ToggleSnap3dEnabled, /// Toggle grid-snap on/off — F9 / SNAP status-bar button. ToggleGridSnap, /// Enable or disable isometric drafting. @@ -2752,6 +2754,7 @@ pub enum Message { DraftingSettingsToggleOsnap, DraftingSettingsToggleOtrack, DraftingSettingsToggleSnapMode(crate::snap::SnapType), + DraftingSettingsToggleSnapMode3d(crate::snap::SnapType), DraftingSettingsSnapSelectAll, DraftingSettingsSnapClearAll, DraftingSettingsToggle3dOsnap, diff --git a/src/app/settings.rs b/src/app/settings.rs index 9f8ac2078..0db109cbd 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -193,7 +193,9 @@ const SNAP_ORDER: &[SnapType] = &[ ]; /// `$OSMODE` bit for each running object-snap mode. -/// `None` for OCS-only snaps (Grid, ObjectPick) that have no standard bit. +/// `None` for OCS-only snaps (Grid, ObjectPick) and the 3D solid snaps +/// (Vertex, EdgeMidpoint, FaceCenter, Knot, FacePerpendicular, NearestFace), +/// which live in the separate 3D set and have no standard bit. fn snap_bit(s: SnapType) -> Option { Some(match s { SnapType::Endpoint => 1, @@ -209,7 +211,14 @@ fn snap_bit(s: SnapType) -> Option { SnapType::ApparentIntersection => 2048, SnapType::Extension => 4096, SnapType::Parallel => 8192, - SnapType::Grid | SnapType::ObjectPick => return None, + SnapType::Grid + | SnapType::ObjectPick + | SnapType::Vertex + | SnapType::EdgeMidpoint + | SnapType::FaceCenter + | SnapType::Knot + | SnapType::FacePerpendicular + | SnapType::NearestFace => return None, }) } diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 8df20b273..dfb79bbe4 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap { ("F1".to_string(), "HELP"), ("F2".to_string(), "COMMANDHISTORY"), ("F3".to_string(), "TOGGLEOSNAP"), + ("F4".to_string(), "TOGGLE3DOSNAP"), ("F5".to_string(), "ISOPLANE"), ("F7".to_string(), "GRID"), ("F8".to_string(), "ORTHO"), @@ -159,6 +160,7 @@ pub(super) const INPUT_ACTIONS: &[&str] = &[ "CARETRIGHT", "COMMANDHISTORY", "TOGGLEOSNAP", + "TOGGLE3DOSNAP", "OTRACK", "DYNINPUT", "SELECTALL", @@ -314,6 +316,7 @@ impl OpenCADStudio { "CARETRIGHT" => Message::MTextCaretMove(1), "COMMANDHISTORY" => Message::CommandHistoryToggle, "TOGGLEOSNAP" => Message::ToggleSnapEnabled, + "TOGGLE3DOSNAP" => Message::ToggleSnap3dEnabled, "OTRACK" => Message::ToggleOTrack, "DYNINPUT" => Message::ToggleDynInput, "SELECTALL" => Message::SelectAllShortcut, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bfff47d56..6cd3d10d6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -26,6 +26,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { | Message::MTextCaretMove(_) | Message::DeleteSelected | Message::ToggleSnapEnabled + | Message::ToggleSnap3dEnabled | Message::ToggleGrid | Message::ToggleOrtho | Message::ToggleGridSnap @@ -3702,6 +3703,11 @@ impl OpenCADStudio { self.persist_settings_if_changed(); Task::none() } + Message::ToggleSnap3dEnabled => { + self.snapper.toggle_snap3d(); + self.sync_vport_display(self.active_tab); + Task::none() + } Message::ToggleGridSnap => { self.snapper.toggle_grid_snap(); self.sync_vport_display(self.active_tab); @@ -4562,6 +4568,14 @@ impl OpenCADStudio { } Task::none() } + Message::DraftingSettingsToggleSnapMode3d(snap_type) => { + if let Some(state) = &mut self.drafting_settings_state { + if !state.snap3d_modes.remove(&snap_type) { + state.snap3d_modes.insert(snap_type); + } + } + Task::none() + } Message::DraftingSettingsSnapSelectAll => { if let Some(state) = &mut self.drafting_settings_state { for &(snap_type, _, _) in crate::snap::ALL_SNAP_MODES { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ada2773e0..8eaa7cf08 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -35,6 +35,17 @@ fn pt_pt_d2(a: Point, b: Point) -> f32 { (a.x - b.x).powi(2) + (a.y - b.y).powi(2) } +/// Whether a command point keeps the elevation of its snap instead of being +/// clamped to the world XY plane. +/// +/// A genuine object snap carries its Z into the command point, so 3D vertices +/// stay snappable. Grid snaps, misses and entity picks stay on the +/// construction plane, preserving the long-standing 2D behavior exactly +/// (a planar snap still resolves to z = 0 as before). +pub(in crate::app) fn snap_keeps_elevation(hit: Option) -> bool { + matches!(hit, Some(t) if t != crate::snap::SnapType::Grid) +} + fn cursor_on_projected_axis( cursor: Point, bounds: iced::Rectangle, @@ -2044,6 +2055,39 @@ impl OpenCADStudio { } } + // Solid-face snaps (Nearest/Perpendicular to face): live + // mesh-triangle evaluation merged priority-aware with the wire + // snap above. Gated like ordinary point picks; the helper itself + // no-ops unless a face mode is on. + if !needs_entity + && !is_gathering + && !needs_structure + && !needs_tan + && !is_window_corner + && (self + .snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular) + || self + .snapper + .is_on_3d(crate::snap::SnapType::NearestFace)) + { + let face_hit = self.tabs[i].scene.solid_face_snaps( + p, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + self.last_point, + self.snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular), + self.snapper.is_on_3d(crate::snap::SnapType::NearestFace), + ); + if face_hit.is_some() { + self.tabs[i].snap_result = + crate::snap::merge_snap(self.tabs[i].snap_result, face_hit, p); + } + } + let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { !command.needs_entity_pick() && !command.needs_tangent_pick() @@ -2211,10 +2255,15 @@ impl OpenCADStudio { pt }; // Clamp to world XY only when no UCS is active; with a - // UCS the point already lies on the UCS XY plane. + // UCS the point already lies on the UCS XY plane. A genuine + // 3D object snap keeps its elevation — only grid snaps and + // misses are flattened. if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() && !uses_command_cursor_plane + && !snap_keeps_elevation( + self.tabs[i].snap_result.map(|s| s.snap_type), + ) { pt.z = 0.0; } @@ -3677,18 +3726,49 @@ impl OpenCADStudio { snap_hit = merged; } } + // Solid-face snaps (Nearest/Perpendicular to face), mirroring + // the cursor-move pass so click and preview agree. Merged + // before the accepted-snap record so face sources associate. + if !needs_entity_click + && !needs_tan + && !is_window_corner + && (self + .snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular) + || self + .snapper + .is_on_3d(crate::snap::SnapType::NearestFace)) + { + let face_hit = self.tabs[i].scene.solid_face_snaps( + p, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + self.last_point, + self.snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular), + self.snapper.is_on_3d(crate::snap::SnapType::NearestFace), + ); + if face_hit.is_some() { + snap_hit = crate::snap::merge_snap(snap_hit, face_hit, p); + } + } self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame)); // Snap runs in model space; the result is already model. let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); // When no UCS is active clamp to world XY; with a UCS the point is // already constrained to that plane by the ray–plane intersection. + // A genuine 3D object snap keeps its elevation — only grid snaps + // and misses are flattened. let uses_command_cursor_plane = self.tabs[i] .active_cmd .as_ref() .and_then(|command| command.cursor_plane()) .is_some(); - let clamp_world_xy = - self.tabs[i].active_ucs.is_none() && !uses_command_cursor_plane; + let clamp_world_xy = self.tabs[i].active_ucs.is_none() + && !uses_command_cursor_plane + && !snap_keeps_elevation(snap_hit.map(|s| s.snap_type)); if clamp_world_xy { pt.z = 0.0; } @@ -6124,6 +6204,36 @@ properties={:.1}ms picked={}", } } +#[cfg(test)] +mod snap_elevation_tests { + use super::snap_keeps_elevation; + use crate::snap::SnapType; + + #[test] + fn only_object_snaps_keep_elevation() { + assert!(!snap_keeps_elevation(None), "a miss stays on the plane"); + assert!(!snap_keeps_elevation(Some(SnapType::Grid)), "grid stays planar"); + for t in [ + SnapType::Endpoint, + SnapType::Midpoint, + SnapType::Center, + SnapType::Node, + SnapType::Quadrant, + SnapType::Intersection, + SnapType::Insertion, + SnapType::Perpendicular, + SnapType::Tangent, + SnapType::Nearest, + SnapType::ApparentIntersection, + SnapType::Extension, + SnapType::Parallel, + SnapType::ObjectPick, + ] { + assert!(snap_keeps_elevation(Some(t)), "{t:?} must keep its Z"); + } + } +} + #[cfg(test)] mod selection_preview_tests { use super::*; @@ -6593,4 +6703,525 @@ mod selection_preview_tests { "only the in-command bit: the idle rollover stays off", ); } + + /// Snapping the first LINE point to a 3D vertex must keep its elevation: + /// the marker, the preview point and (via the click path) the committed + /// point all carry the snapped Z instead of dropping to the XY plane. + #[test] + fn line_snap_to_3d_endpoint_keeps_elevation() { + use acadrust::{entities::Line, types::Vector3, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = true; + app.snapper.enabled.insert(SnapType::Endpoint); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 25.0), + ))); + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + assert!(app.tabs[i].active_cmd.is_some(), "LINE did not start"); + + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + glam::DVec3::new(10.0, 0.0, 25.0), + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("endpoint snap missed the 3D vertex"); + assert_eq!(hit.snap_type, SnapType::Endpoint); + assert!( + (hit.world.z - 25.0).abs() < 1e-6, + "snap landed off-elevation: {:?}", + hit.world + ); + let preview = app.tabs[i].last_cursor_world; + assert!( + (preview.z - 25.0).abs() < 1e-6, + "command point lost its elevation: {preview:?}" + ); + } + + /// The user's repro: BOX, then LINE snapping its first point to a box + /// corner. Solid B-rep corners snap as 3D Vertex points, gated by the + /// separate 3D master toggle — the 2D Node mode must not catch them. + #[test] + fn line_snaps_to_box_corner() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + // 2D osnap fully off: only the 3D system may fire. + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::Vertex); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + // In-app BOX equivalent: kernel box committed as a SAT solid. + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + let h = app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // A real corner straight from the solid's edge data: the topmost + // one, since coincident corners resolve to the nearer eye. + let target = { + let set = app.tabs[i].scene.meshes.get(&h).expect("box mesh"); + let (edges, lows) = set.geometry_edges(); + assert!(edges.len() >= 2, "box mesh has no B-rep edges"); + let at = |index: usize| { + let hi = edges[index]; + let lo = lows.get(index).copied().unwrap_or([0.0; 3]); + glam::DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + }; + (0..edges.len()) + .map(at) + .max_by(|a, b| a.z.total_cmp(&b.z)) + .expect("box mesh has no endpoints") + }; + + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + assert!(app.tabs[i].active_cmd.is_some(), "LINE did not start"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box corner"); + assert_eq!( + hit.snap_type, + SnapType::Vertex, + "expected a box-corner vertex, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the corner: {:?} vs {target:?}", + hit.world + ); + let preview = app.tabs[i].last_cursor_world; + assert!( + (preview - target).length() < 1e-6, + "command point left the corner: {preview:?} vs {target:?}" + ); + } + + /// With the 3D master off, solid corners are invisible even when the + /// Vertex mode is configured — and the 2D system must not catch them. + #[test] + fn box_corner_ignores_3d_master_off() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = true; + app.snapper.snap3d_enabled = false; + app.snapper.enabled3d.insert(SnapType::Vertex); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + let h = app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + let target = { + let set = app.tabs[i].scene.meshes.get(&h).expect("box mesh"); + let (edges, lows) = set.geometry_edges(); + let hi = edges[0]; + let lo = lows.first().copied().unwrap_or([0.0; 3]); + glam::DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + }; + + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + assert!( + app.tabs[i].snap_result.is_none(), + "3D master off must hide solid corners, got {:?}", + app.tabs[i].snap_result.map(|s| (s.snap_type, s.world)) + ); + } + + /// Face centres snap as their own 3D type at the loop average (exact + /// for the box's planar faces). + #[test] + fn line_snaps_to_box_face_center() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::FaceCenter); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // Top-face centre of the 10×10×5 box. + let target = glam::DVec3::new(0.0, 0.0, 5.0); + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box face"); + assert_eq!( + hit.snap_type, + SnapType::FaceCenter, + "expected a face centre, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the face centre: {:?} vs {target:?}", + hit.world + ); + } + + /// Nearest-to-face catches an interior face point with no base point. + #[test] + fn line_snaps_to_box_face_nearest() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::NearestFace); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // Interior point of the top face (off the diagonal so exactly one + // mesh triangle owns it). + let target = glam::DVec3::new(2.0, 3.0, 5.0); + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box face"); + assert_eq!( + hit.snap_type, + SnapType::NearestFace, + "expected nearest-to-face, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-3, + "snap missed the face point: {:?} vs {target:?}", + hit.world + ); + } + + /// Perpendicular-to-face drops from an explicit base point: exercised + /// grey-box (real mesh, real helper) since it needs no event plumbing. + #[test] + fn face_perpendicular_needs_base_and_interior_foot() { + use acadrust::{entities::Solid3D, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + let _ = app.run_command_line("ZOOM EXTENTS"); + + let scene = &app.tabs[i].scene; + let bounds = iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)); + let cam = scene.camera.borrow(); + let view_rot = cam.view_proj_rte(bounds); + let eye = cam.eye(); + let foot = glam::DVec3::new(2.0, 3.0, 5.0); + let cursor = cam.project(foot, bounds).unwrap(); + drop(cam); + let cursor = Point::new(cursor.x, cursor.y); + + // Base below the face interior → foot straight above it. + let hit = app.tabs[i].scene.solid_face_snaps( + cursor, + view_rot, + eye, + bounds, + 15.0, + Some(glam::DVec3::new(2.0, 3.0, 0.0)), + true, + true, + ); + let hit = hit.expect("perpendicular foot missed"); + assert_eq!(hit.snap_type, crate::snap::SnapType::FacePerpendicular); + assert!( + (hit.world - foot).length() < 1e-3, + "foot off target: {:?} vs {foot:?}", + hit.world + ); + + // No base → perpendicular cannot fire (nearest still can). + let hit = app.tabs[i].scene.solid_face_snaps( + cursor, view_rot, eye, bounds, 15.0, None, true, false, + ); + assert!(hit.is_none(), "perp without a base must stay silent"); + + // Neither mode wanted → silence. + let hit = app.tabs[i].scene.solid_face_snaps( + cursor, + view_rot, + eye, + bounds, + 15.0, + Some(glam::DVec3::new(2.0, 3.0, 0.0)), + false, + false, + ); + assert!(hit.is_none(), "unwanted modes must stay silent"); + } + + /// Edge midpoints snap as their own 3D type at the segment centre. + #[test] + fn line_snaps_to_box_edge_midpoint() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::EdgeMidpoint); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + let h = app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // Midpoint of a top edge (both ends at max z): bottom edges project + // onto the same pixels, and coincident points resolve to the eye. + let target = { + let set = app.tabs[i].scene.meshes.get(&h).expect("box mesh"); + let (edges, lows) = set.geometry_edges(); + assert!(edges.len() >= 2, "box mesh has no B-rep edges"); + let at = |index: usize| { + let hi = edges[index]; + let lo = lows.get(index).copied().unwrap_or([0.0; 3]); + glam::DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + }; + let top = (0..edges.len()).map(at).map(|p| p.z).fold(f64::NEG_INFINITY, f64::max); + (0..edges.len() / 2) + .map(|k| (at(2 * k), at(2 * k + 1))) + .find(|(a, b)| { + (a.z - top).abs() < 1e-9 && (b.z - top).abs() < 1e-9 + }) + .map(|(a, b)| (a + b) * 0.5) + .expect("box mesh has no top edge") + }; + + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box edge"); + assert_eq!( + hit.snap_type, + SnapType::EdgeMidpoint, + "expected an edge midpoint, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the edge midpoint: {:?} vs {target:?}", + hit.world + ); + } } diff --git a/src/entities/spline.rs b/src/entities/spline.rs index e68c3b38a..5a8813561 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -67,6 +67,43 @@ pub(crate) fn nurbs3(spline: &Spline) -> Option { Some(curve.with_periodicity(spline.flags.closed || spline.flags.periodic)) } +/// NURBS knot locations on a control-point spline, evaluated on its own +/// curve: distinct interior knot values (the clamped ends already snap as +/// endpoints). Fit-method splines invent their knots at the fit nodes, which +/// already snap as endpoints, so they contribute nothing — re-offering them +/// would double every marker. +pub(crate) fn spline_knot_points(spline: &Spline) -> Vec { + const MAX_KNOT_SNAPS: usize = 1024; + if uses_fit_method(spline) { + return Vec::new(); + } + let Some(curve) = nurbs3(spline) else { + return Vec::new(); + }; + let (from, to) = curve.domain(); + if !(from.is_finite() && to.is_finite()) || !(to > from) { + return Vec::new(); + } + let span = to - from; + let mut knots: Vec = curve + .knots() + .iter() + .copied() + .filter(|k| k.is_finite() && *k > from + span * 1e-9 && *k < to - span * 1e-9) + .collect(); + knots.sort_by(f64::total_cmp); + knots.dedup(); + if knots.len() > MAX_KNOT_SNAPS { + let step = knots.len().div_ceil(MAX_KNOT_SNAPS); + knots = knots.into_iter().step_by(step).collect(); + } + knots + .into_iter() + .map(|k| glam::DVec3::from_array(curve.point_at_knot(k))) + .filter(|p| p.is_finite()) + .collect() +} + pub(crate) fn replace_with_nurbs(spline: &mut Spline, curve: &NurbsCurve3) { spline.degree = curve.degree() as i32; spline.knots = curve.knots().to_vec(); @@ -134,6 +171,11 @@ fn to_render(spl: &Spline) -> RenderEntity { crate::scene::model::wire_model::SnapHint::Endpoint, ) })); + snap_pts.extend( + spline_knot_points(spl) + .into_iter() + .map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)), + ); let key_vertices = if planar.is_some() { Vec::new() } else { @@ -179,6 +221,11 @@ fn to_render(spl: &Spline) -> RenderEntity { ) })); } + points.extend( + spline_knot_points(spl) + .into_iter() + .map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)), + ); (points, Vec::new()) } // A spline through points in space is not a planar curve, so the @@ -1120,6 +1167,45 @@ impl crate::entities::traits::Transformable for Spline { mod tests { use super::*; + #[test] + fn knot_points_evaluate_distinct_interior_knots() { + let mut spline = Spline::default(); + spline.degree = 3; + spline.control_points = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(1.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 3.0, 1.0), + acadrust::types::Vector3::new(5.0, 2.0, 0.0), + acadrust::types::Vector3::new(6.0, 0.0, 1.0), + ]; + spline.knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]; + let pts = spline_knot_points(&spline); + assert_eq!(pts.len(), 1, "one interior knot value: {pts:?}"); + let curve = nurbs3(&spline).expect("control-point spline builds"); + let expected = curve.point_at_knot(0.5); + let got = [pts[0].x, pts[0].y, pts[0].z]; + let err = ((got[0] - expected[0]).powi(2) + + (got[1] - expected[1]).powi(2) + + (got[2] - expected[2]).powi(2)) + .sqrt(); + assert!(err < 1e-9, "knot point off the curve: {err}"); + } + + #[test] + fn fit_method_splines_emit_no_knot_snaps() { + // Interpolated curves invent their own knots at the fit nodes, which + // already snap as endpoints — re-offering them as knots would double + // every marker. + let mut spline = Spline::default(); + spline.degree = 3; + spline.fit_points = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(4.0, 0.0, 1.0), + acadrust::types::Vector3::new(4.0, 3.0, 2.0), + ]; + assert!(spline_knot_points(&spline).is_empty()); + } + #[test] fn fit_bounds_accept_closed_curves_with_unset_tangents() { let mut spline = Spline::default(); diff --git a/src/scene/dimension_assoc_chain.rs b/src/scene/dimension_assoc_chain.rs index e0687760e..3a9146af0 100644 --- a/src/scene/dimension_assoc_chain.rs +++ b/src/scene/dimension_assoc_chain.rs @@ -36,9 +36,9 @@ pub(crate) mod osnap { /// Translate a live snap mode into the `AcDb::OsnapMode` we persist. /// -/// Modes with no ObjectARX equivalent (grid, object pick) map to -/// [`osnap::NONE`]; a reference carrying `NONE` resolves by marker/parameter -/// alone. +/// Modes with no ObjectARX equivalent (grid, object pick, 3D solid snaps) map +/// to [`osnap::NONE`]; a reference carrying `NONE` resolves by +/// marker/parameter alone. pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 { use crate::snap::SnapType as S; match snap { @@ -54,7 +54,9 @@ pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 { S::Nearest | S::Extension => osnap::NEAR, S::ApparentIntersection => osnap::APPARENT_INT, S::Parallel => osnap::PARA, - S::Grid | S::ObjectPick => osnap::NONE, + S::Grid | S::ObjectPick | S::Vertex | S::EdgeMidpoint | S::FaceCenter | S::Knot | S::FacePerpendicular | S::NearestFace => { + osnap::NONE + } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 51f432bc3..fc930b3a1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -163,6 +163,333 @@ pub(crate) fn tessellate_entity( wires } +impl Scene { + /// True for the modeler solid families whose wire path is an empty shell + /// (their geometry lives in the mesh pipeline): B-rep corners for those + /// come from the mesh edge list instead of wire points. + fn is_solid_shell_entity(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) + ) + } + + /// B-rep snap points for a solid: edge endpoints as `Vertex` hints and + /// edge centres as `EdgeMidpoint` hints (both from the tessellated edge + /// list), plus face centres as `FaceCenter` hints (from the kernel B-rep + /// faces). Empty for non-solids and for solids with no usable data. + pub(crate) fn solid_snap_points( + &self, + handle: Handle, + ) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> { + use crate::scene::model::wire_model::SnapHint; + let Some(entity) = self.document.get_entity(handle) else { + return Vec::new(); + }; + if !Self::is_solid_shell_entity(entity) { + return Vec::new(); + } + let mut out = Vec::new(); + if let Some(set) = self + .meshes + .get(&handle) + .or_else(|| self.block_meshes.get(&handle)) + { + let (edges, lows) = set.geometry_edges(); + let mut seen = HashSet::default(); + for (a, b) in + crate::scene::model::mesh_model::solid_edge_segments(edges, lows) + { + for (point, hint) in [(a, SnapHint::Vertex), (b, SnapHint::Vertex)] { + let key = [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()]; + if seen.insert(key) { + out.push((point, hint)); + } + } + out.push(((a + b) * 0.5, SnapHint::EdgeMidpoint)); + } + } + out.extend( + Self::solid_face_centers(entity, self.solid_models.get(&handle)) + .into_iter() + .map(|p| (p, SnapHint::FaceCenter)), + ); + out + } + + /// Face centres for a solid entity: kernel B-rep faces averaged over + /// their boundary loops. Prefers the cached kernel body (no re-parse); + /// falls back to parsing the entity's ACIS data, which shares the cost + /// profile of the mesh build at the same epoch. No body ⇒ no centres. + fn solid_face_centers( + entity: &EntityType, + cached: Option<&cadkernel::brep::Body>, + ) -> Vec { + use crate::scene::convert::solid3d_tess::{kernel_body, kernel_region_body}; + use crate::scene::model::solid_model::face_centers; + if let Some(body) = cached { + return face_centers(body) + .into_iter() + .map(glam::DVec3::from_array) + .collect(); + } + match entity { + EntityType::Solid3D(s) => kernel_body(s), + EntityType::Region(r) => kernel_region_body(r), + _ => None, + } + .map(|body| face_centers(&body)) + .unwrap_or_default() + .into_iter() + .map(glam::DVec3::from_array) + .collect() + } + + /// Attach solid B-rep snaps to the entity's shell wire (the empty wire + /// carrying only the insertion snap). History/construction wires keep + /// their own points and are left alone; non-solids are untouched. + /// Idempotent: a wire already carrying 3D snaps is skipped, so shared + /// memo entries can pass through every assembly path safely. + pub(crate) fn attach_solid_snaps(&self, handle: Handle, wires: &mut Vec) { + use crate::scene::model::wire_model::SnapHint; + let points = self.solid_snap_points(handle); + if points.is_empty() { + return; + } + for wire in wires.iter_mut() { + if !wire.points.is_empty() { + continue; + } + if wire.snap_pts.iter().any(|(_, hint)| { + matches!( + hint, + SnapHint::Vertex + | SnapHint::EdgeMidpoint + | SnapHint::FaceCenter + | SnapHint::Knot + ) + }) { + continue; + } + wire.snap_pts.extend(points.iter().copied()); + } + } + + /// Live solid-face snaps for a command point: Nearest-to-face (screen + /// nearest point on a mesh triangle) and Perpendicular-to-face (foot from + /// `base` onto a triangle). Mesh triangles carry no B-rep face grouping, + /// so faces are individual triangles here — the pre-baked `FaceCenter` + /// hints (true B-rep faces) are the separate discrete counterpart. + /// + /// Top-level model meshes only: block-local sets live in a different + /// space, and xclipped views are not honored (the wire channel the + /// pre-baked 3D hints ride is clipped; this direct mesh read is not). + /// Worst case in those views is a snap with no geometry behind it — + /// never a corrupt point. + pub(crate) fn solid_face_snaps( + &self, + cursor_screen: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + aperture_px: f32, + base: Option, + want_perp: bool, + want_nearest: bool, + ) -> Option { + use crate::snap::{ + closest_point_on_tri_2d, foot_on_triangle, snap_better, snap_priority, + snap_tier, SnapType, + }; + if !want_perp && !want_nearest { + return None; + } + if !(aperture_px.is_finite() && aperture_px > 0.0) { + return None; + } + let perp_base = want_perp.then(|| base).flatten(); + let project = |world: glam::DVec3| { + let ndc = view_rot.project_point3((world - eye).as_vec3()); + [ + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ] + }; + let finite_screen = |s: [f32; 2]| s[0].is_finite() && s[1].is_finite(); + let in_bounds = |s: [f32; 2]| { + s[0] >= 0.0 && s[0] <= bounds.width && s[1] >= 0.0 && s[1] <= bounds.height + }; + let radius2 = aperture_px * aperture_px; + let cursor = [cursor_screen.x, cursor_screen.y]; + // (tier, d2, sub, eye-depth², handle, hit): engine ordering, then + // nearer-to-eye (coincident faces share pixels — the top face must + // beat the bottom one), then the handle as a final deterministic + // tie-break (mesh iteration order is not stable). + let mut best: Option<(u8, f32, u8, f64, u64, crate::snap::SnapResult)> = None; + let mut consider = |snap_type: SnapType, + world: glam::DVec3, + screen: [f32; 2], + d2: f32, + handle: Handle| { + if !(d2 < radius2) || !in_bounds(screen) { + return; + } + let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type)); + let depth2 = (world - eye).length_squared(); + let better = match &best { + None => true, + Some((bt, bd2, bs, bdepth2, bh, _)) => { + snap_better(tier, d2, sub, (*bt, *bd2, *bs)) + || (tier == *bt + && (d2 - *bd2).abs() <= 1e-4 + && sub == *bs + && (depth2 < *bdepth2 + || (depth2 == *bdepth2 && handle.value() < *bh))) + } + }; + if better { + best = Some(( + tier, + d2, + sub, + depth2, + handle.value(), + crate::snap::SnapResult { + world, + screen: iced::Point::new(screen[0], screen[1]), + snap_type, + tangent_obj: None, + extension_base: None, + extension_base2: None, + extension_origin: None, + extension_dir: None, + viewport: None, + source: Some( + crate::command::DimensionAssociationSource::inferred(handle), + ), + secondary_source: None, + model_point: None, + }, + )); + } + }; + for (handle, set) in self.meshes.iter() { + let Some(lod) = set.geometry_lods().first() else { + continue; + }; + if lod.indices.len() < 3 { + continue; + } + // Screen-space cull from the mesh bounds before touching triangles. + let (mut x0, mut y0) = (f32::INFINITY, f32::INFINITY); + let (mut x1, mut y1) = (f32::NEG_INFINITY, f32::NEG_INFINITY); + for corner in [ + [set.world_aabb[0], set.world_aabb[1], set.z_aabb[0]], + [set.world_aabb[2], set.world_aabb[1], set.z_aabb[0]], + [set.world_aabb[0], set.world_aabb[3], set.z_aabb[0]], + [set.world_aabb[2], set.world_aabb[3], set.z_aabb[0]], + [set.world_aabb[0], set.world_aabb[1], set.z_aabb[1]], + [set.world_aabb[2], set.world_aabb[1], set.z_aabb[1]], + [set.world_aabb[0], set.world_aabb[3], set.z_aabb[1]], + [set.world_aabb[2], set.world_aabb[3], set.z_aabb[1]], + ] { + let s = project(glam::DVec3::new( + corner[0] as f64, + corner[1] as f64, + corner[2] as f64, + )); + if !finite_screen(s) { + continue; + } + x0 = x0.min(s[0]); + y0 = y0.min(s[1]); + x1 = x1.max(s[0]); + y1 = y1.max(s[1]); + } + if cursor[0] < x0 - aperture_px + || cursor[0] > x1 + aperture_px + || cursor[1] < y0 - aperture_px + || cursor[1] > y1 + aperture_px + { + continue; + } + for tri in lod.indices.chunks_exact(3) { + let mut v = [glam::DVec3::ZERO; 3]; + let mut ok = true; + for (slot, index) in v.iter_mut().zip(tri.iter()) { + let (Some(high), low) = ( + lod.verts.get(*index as usize), + lod.verts_low.get(*index as usize).copied().unwrap_or([0.0; 3]), + ) else { + ok = false; + break; + }; + *slot = glam::DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ); + if !slot.is_finite() { + ok = false; + break; + } + } + if !ok { + continue; + } + let [a, b, c] = v; + let (sa, sb, sc) = (project(a), project(b), project(c)); + if !(finite_screen(sa) && finite_screen(sb) && finite_screen(sc)) { + continue; + } + // Per-triangle screen reject before any 3D work. + let (tx0, ty0) = ( + sa[0].min(sb[0]).min(sc[0]), + sa[1].min(sb[1]).min(sc[1]), + ); + let (tx1, ty1) = ( + sa[0].max(sb[0]).max(sc[0]), + sa[1].max(sb[1]).max(sc[1]), + ); + if cursor[0] < tx0 - aperture_px + || cursor[0] > tx1 + aperture_px + || cursor[1] < ty0 - aperture_px + || cursor[1] > ty1 + aperture_px + { + continue; + } + if want_nearest { + let (q, d2, w) = closest_point_on_tri_2d(cursor, sa, sb, sc); + if d2 < radius2 { + let world = a * w[0] as f64 + b * w[1] as f64 + c * w[2] as f64; + consider(SnapType::NearestFace, world, q, d2, *handle); + } + } + if let Some(base3d) = perp_base { + if let Some(foot) = foot_on_triangle(base3d, a, b, c) { + let sf = project(foot); + if finite_screen(sf) { + let dx = sf[0] - cursor[0]; + let dy = sf[1] - cursor[1]; + consider( + SnapType::FacePerpendicular, + foot, + sf, + dx * dx + dy * dy, + *handle, + ); + } + } + } + } + } + best.map(|(_, _, _, _, _, hit)| hit) + } +} + /// Result of `Scene::entity_index()`. The wire path queries `tree` for /// view-rect candidates and also always emits `unbounded_handles` /// (entities with no usable bbox — legacy `UNBOUNDED_AABB` sentinel). @@ -5954,7 +6281,7 @@ impl Scene { continue; } visible_changed.insert(*h); - let raw = tessellate_entity( + let mut raw = tessellate_entity( &self.document, &empty_sel, style_viewport.or(self.active_viewport), @@ -5967,6 +6294,10 @@ impl Scene { None, anno_scale_override.is_some(), ); + // Solid B-rep corners snap via the mesh edge list (their wire + // path is an empty shell). Enrich before the memo/assembly split + // so both share the same snap-bearing wires. + self.attach_solid_snaps(*h, &mut raw); memo_updates.push((*h, Arc::new(raw.clone()))); let mut faded = raw; self.apply_refedit_fade(&mut faded, bg); @@ -9905,9 +10236,15 @@ impl Scene { let mut out = hit_wires; { let mut memo = memo_cell.borrow_mut(); - for (h, a) in &miss_pairs { + for (h, a) in miss_pairs { + // Uniquely owned (just built above): enrich solid shells + // with B-rep vertex snaps before the memo/assembly split. + let mut wires = + Arc::try_unwrap(a).unwrap_or_else(|a| a.as_ref().clone()); + self.attach_solid_snaps(h, &mut wires); + let a = Arc::new(wires); out.extend(a.iter().cloned()); - memo.insert(*h, Arc::clone(a)); + memo.insert(h, Arc::clone(&a)); } } materialize_ms = crate::perf::elapsed_ms(t_materialize); @@ -10874,7 +11211,7 @@ vis_index={:.1} visible_probe={:.1}", let (bg, anno, annotation_scale_handle, blk_cache, paper) = self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. - tessellate_entity( + let mut wires = tessellate_entity( &self.document, &self.selected, self.active_viewport, @@ -10886,7 +11223,11 @@ vis_index={:.1} visible_probe={:.1}", None, None, paper, - ) + ); + // Same solid-corner snaps as the resident paths, so one-off consumers + // (overlay changed-wires, edit previews) agree with hit-test/snap. + self.attach_solid_snaps(e.common().handle, &mut wires); + wires } fn model_space_block_handle(&self) -> Handle { diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index f59f91d77..36ea44f9c 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -318,3 +318,128 @@ impl MeshLodSet { }) } } + +/// Max B-rep edge pairs read from one solid for snapping. Curved-edge +/// tessellations fan out into thousands of joints; the cap keeps the +/// interaction index and the per-move snap scan bounded. +pub const MAX_SOLID_SNAP_EDGES: usize = 2048; + +/// Distinct B-rep edge segments from a high/low double-single line list +/// (mesh `edge_verts` pairs + `edge_verts_low`), feeding solid vertex +/// (endpoints) and edge-midpoint (centres) snaps. +/// +/// Degenerate (zero-length) and non-finite pairs are dropped, and identical +/// pairs collapse regardless of endpoint order. Past the cap the output is +/// stride-sampled so coverage degrades gracefully instead of dropping every +/// edge past some cutoff. +pub fn solid_edge_segments( + edge_verts: &[[f32; 3]], + edge_verts_low: &[[f32; 3]], +) -> Vec<(glam::DVec3, glam::DVec3)> { + fn point(high: [f32; 3], low: [f32; 3]) -> Option { + let p = glam::DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ); + p.is_finite().then_some(p) + } + fn key(p: &glam::DVec3) -> [u64; 3] { + [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()] + } + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for index in 0..edge_verts.len() / 2 { + let high = [edge_verts[2 * index], edge_verts[2 * index + 1]]; + let low = [ + edge_verts_low.get(2 * index).copied().unwrap_or([0.0; 3]), + edge_verts_low + .get(2 * index + 1) + .copied() + .unwrap_or([0.0; 3]), + ]; + let (Some(a), Some(b)) = (point(high[0], low[0]), point(high[1], low[1])) else { + continue; + }; + if a.distance_squared(b) <= 1e-12 { + continue; + } + let (ka, kb) = (key(&a), key(&b)); + let ordered = if ka <= kb { (ka, kb) } else { (kb, ka) }; + if !seen.insert(ordered) { + continue; + } + out.push((a, b)); + } + if out.len() > MAX_SOLID_SNAP_EDGES { + let step = out.len().div_ceil(MAX_SOLID_SNAP_EDGES); + out = out.into_iter().step_by(step).collect(); + } + out +} + +#[cfg(test)] +mod solid_snap_tests { + use super::solid_edge_segments; + + fn pt(x: f32, y: f32, z: f32) -> [f32; 3] { + [x, y, z] + } + + #[test] + fn shared_corners_keep_both_segments() { + // Two edges sharing the corner (1,0,0): both pairs survive, the + // corner itself dedups later at attach time, not here. + let edges = vec![ + pt(0.0, 0.0, 0.0), + pt(1.0, 0.0, 0.0), + pt(1.0, 0.0, 0.0), + pt(1.0, 1.0, 0.0), + ]; + let low = vec![[0.0f32; 3]; 4]; + let segs = solid_edge_segments(&edges, &low); + assert_eq!(segs.len(), 2, "shared corner collapsed segments: {segs:?}"); + } + + #[test] + fn degenerate_and_non_finite_pairs_are_dropped() { + let edges = vec![ + pt(0.0, 0.0, 0.0), + pt(f32::NAN, 0.0, 0.0), + pt(1.0, 0.0, 0.0), + pt(1.0, 0.0, 0.0), // degenerate: zero length + pt(2.0, 0.0, 0.0), + pt(3.0, 0.0, f32::INFINITY), + pt(4.0, 0.0, 0.0), + pt(5.0, 0.0, 0.0), + ]; + let low = vec![[0.0f32; 3]; 8]; + let segs = solid_edge_segments(&edges, &low); + assert_eq!(segs.len(), 1, "expected only the valid pair: {segs:?}"); + assert!(segs[0].0.is_finite() && segs[0].1.is_finite()); + } + + #[test] + fn output_is_capped_with_coverage() { + let mut edges = Vec::new(); + for k in 0..2200 { + let x = 100.0 + k as f32; + edges.push(pt(x, 0.0, 0.0)); + edges.push(pt(x, 1.0, 0.0)); + } + let low = vec![[0.0f32; 3]; 4400]; + let segs = solid_edge_segments(&edges, &low); + assert!( + segs.len() <= super::MAX_SOLID_SNAP_EDGES, + "cap exceeded: {}", + segs.len() + ); + assert!( + segs.len() >= super::MAX_SOLID_SNAP_EDGES / 2, + "stride sampling dropped too much coverage: {}", + segs.len() + ); + // The first pair survives sampling (index 0 is kept). + assert_eq!(segs[0].0, glam::DVec3::new(100.0, 0.0, 0.0)); + } +} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 1fa867915..3c74e2b6b 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -371,6 +371,45 @@ pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { Some(if face.forward { normal } else { -normal }.to_array()) } +/// Centre of every B-rep face: the average of its boundary-loop vertices. +/// Exact for planar faces; for curved faces the loop average floats slightly +/// inside the surface — a documented approximation the snap path must not pay +/// a surface evaluation for on every wire build. Capped like the edge snaps. +pub fn face_centers(body: &Body) -> Vec<[f64; 3]> { + const MAX_FACE_CENTERS: usize = 2048; + let mut out = Vec::new(); + for face in body.face_keys() { + let mut sum = [0.0; 3]; + let mut count = 0usize; + for coedge in body.face_coedges(face) { + let Some((va, vb)) = body.coedge_vertices(coedge) else { + continue; + }; + for vk in [va, vb] { + let Some(v) = body.vertices.get(vk) else { + continue; + }; + if !v.point.iter().all(|c| c.is_finite()) { + continue; + } + sum[0] += v.point[0]; + sum[1] += v.point[1]; + sum[2] += v.point[2]; + count += 1; + } + } + if count >= 3 { + let n = count as f64; + out.push([sum[0] / n, sum[1] / n, sum[2] / n]); + } + } + if out.len() > MAX_FACE_CENTERS { + let step = out.len().div_ceil(MAX_FACE_CENTERS); + out = out.into_iter().step_by(step).collect(); + } + out +} + // ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so @@ -601,6 +640,30 @@ mod tests { assert_eq!(display([2, 2], false), boundaries); } + #[test] + fn box_face_centers_are_six() { + let body = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap(); + let centers = face_centers(&body); + assert_eq!(centers.len(), 6, "a box has six faces: {centers:?}"); + for expected in [ + [5.0, 0.0, 0.0], + [-5.0, 0.0, 0.0], + [0.0, 5.0, 0.0], + [0.0, -5.0, 0.0], + [0.0, 0.0, 5.0], + [0.0, 0.0, -5.0], + ] { + assert!( + centers.iter().any(|c| { + (c[0] - expected[0]).abs() < 1e-9 + && (c[1] - expected[1]).abs() < 1e-9 + && (c[2] - expected[2]).abs() < 1e-9 + }), + "missing face center {expected:?} in {centers:?}" + ); + } + } + #[test] fn every_primitive_is_the_size_it_was_asked_for() { // Triangle counts say a mesh exists; the volume says it is the right diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index deec10db2..d1fc50bb8 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -22,6 +22,18 @@ pub enum SnapHint { /// arc's two ends would also produce a midpoint snap out on the chord, /// which is not on the geometry. Endpoint, + /// B-rep corner of a 3D solid. Maps to the separate 3D snap system + /// (`SnapType::Vertex`), never to the 2D modes. + Vertex, + /// Centre of a 3D solid B-rep edge. Maps to the separate 3D snap system + /// (`SnapType::EdgeMidpoint`), never to the 2D modes. + EdgeMidpoint, + /// Centre of a 3D solid B-rep face. Maps to the separate 3D snap system + /// (`SnapType::FaceCenter`), never to the 2D modes. + FaceCenter, + /// NURBS knot location on a spline entity. Maps to the separate 3D snap + /// system (`SnapType::Knot`), never to the 2D modes. + Knot, } /// Geometric primitive used by the tangent-snap engine. diff --git a/src/snap.rs b/src/snap.rs index 5d3b31518..728af19c3 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -43,6 +43,39 @@ pub enum SnapType { Grid, /// Object acquisition (domain-object pick, e.g. network structure) — orange marker. ObjectPick, + /// B-rep corner of a 3D solid. Part of the separate 3D object-snap + /// system (`Snapper::snap3d_enabled` + `enabled3d`): the 2D master toggle + /// and mode set never catch it. + Vertex, + /// Centre of a 3D solid B-rep edge. Same separate 3D system as `Vertex`. + EdgeMidpoint, + /// Centre of a 3D solid B-rep face (loop-vertex average; exact for + /// planar faces). Same separate 3D system. + FaceCenter, + /// NURBS knot location on a spline entity. Same separate 3D system. + Knot, + /// Foot of the perpendicular from the command base point onto a solid + /// face. Same separate 3D system. + FacePerpendicular, + /// Nearest point on a solid face to the cursor. Same separate 3D system. + NearestFace, +} + +impl SnapType { + /// True for the separate 3D object-snap system (F4 master + `enabled3d` + /// set). The single source of truth — every master-gating check must use + /// this rather than listing variants, so new 3D modes stay independent. + pub fn is_3d(self) -> bool { + matches!( + self, + SnapType::Vertex + | SnapType::EdgeMidpoint + | SnapType::FaceCenter + | SnapType::Knot + | SnapType::FacePerpendicular + | SnapType::NearestFace + ) + } } /// Ordered list used by the popup and snap engine. @@ -65,6 +98,20 @@ pub const ALL_SNAP_MODES: &[(SnapType, &str, &str)] = &[ // grid point; it is toggled on its own and handled directly in `snap()`. ]; +/// 3D object-snap modes: solid B-rep features with their own master toggle +/// (`Snapper::snap3d_enabled`, F4) and mode set (`Snapper::enabled3d`), +/// configured on the Drafting Settings "3D Object Snap" tab. Deliberately +/// absent from [`ALL_SNAP_MODES`] so the 2D Select All / Clear All, the snap +/// popup and `$OSMODE` persistence never touch them. +pub const ALL_3D_SNAP_MODES: &[(SnapType, &str, &str)] = &[ + (SnapType::Vertex, "◈", "Vertex"), + (SnapType::EdgeMidpoint, "▽", "Midpoint on edge"), + (SnapType::FaceCenter, "◉", "Center of face"), + (SnapType::Knot, "⬥", "Knot"), + (SnapType::FacePerpendicular, "⟂", "Perpendicular to face"), + (SnapType::NearestFace, "✦", "Nearest to face"), +]; + // ── Snap result ─────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy)] @@ -144,6 +191,11 @@ pub struct Snapper { pub snap_enabled: bool, /// Which snap modes are configured (used when `snap_enabled` is true). pub enabled: HashSet, + /// 3D object-snap master toggle (F4). Independent of the 2D master: + /// when false, solid B-rep snaps never fire but `enabled3d` is kept. + pub snap3d_enabled: bool, + /// Which 3D snap modes are configured (used when `snap3d_enabled`). + pub enabled3d: HashSet, /// Grid snap on/off — a system fully separate from object snap. When on, /// `snap()` can pick the nearest grid corner; object snap never does. pub grid_snap_on: bool, @@ -216,9 +268,20 @@ impl Default for Snapper { enabled.insert(SnapType::Quadrant); enabled.insert(SnapType::Intersection); enabled.insert(SnapType::Nearest); + let mut enabled3d = HashSet::default(); + enabled3d.insert(SnapType::Vertex); + enabled3d.insert(SnapType::EdgeMidpoint); + enabled3d.insert(SnapType::FaceCenter); + enabled3d.insert(SnapType::Knot); + enabled3d.insert(SnapType::FacePerpendicular); + // Nearest-to-face stays off by default: on dense curved solids it + // would catch nearly every cursor position, masking the discrete + // vertex/edge/face snaps. Opt in via the 3D Object Snap tab. Self { snap_enabled: false, enabled, + snap3d_enabled: true, + enabled3d, grid_snap_on: false, grid_spacing: 1.0, snap_spacing_x: 10.0, @@ -389,6 +452,28 @@ impl Snapper { self.grid_snap_on = !self.grid_snap_on; } + /// 3D object snap on/off (F4) — independent of the 2D object-snap master + /// and mode set. Only gates the solid B-rep snaps (`Vertex`, + /// `EdgeMidpoint`). + pub fn snap3d(&self) -> bool { + self.snap3d_enabled + } + + pub fn toggle_snap3d(&mut self) { + self.snap3d_enabled = !self.snap3d_enabled; + } + + /// Whether 3D snap mode `t` can fire: master on and mode configured. + pub fn is_on_3d(&self, t: SnapType) -> bool { + self.snap3d_enabled && self.enabled3d.contains(&t) + } + + pub fn toggle_3d(&mut self, t: SnapType) { + if !self.enabled3d.remove(&t) { + self.enabled3d.insert(t); + } + } + pub fn toggle(&mut self, t: SnapType) { if !self.enabled.remove(&t) { self.enabled.insert(t); @@ -1021,6 +1106,8 @@ impl Snapper { grid_spacing: self.grid_spacing, snap_spacing_x: self.snap_spacing_x, snap_spacing_y: self.snap_spacing_y, + snap3d_enabled: false, + enabled3d: HashSet::default(), osnap_radius_px: self.osnap_radius_px, otrack_enabled: false, tracking_points: Vec::new(), @@ -1154,9 +1241,10 @@ impl Snapper { } } - // Object snaps are gated by the object-snap master toggle. With it off - // only the grid result (if any) stands. - if !self.snap_enabled { + // The 2D object-snap master gates the 2D passes below. With it off, + // the grid result (if any) and the independent 3D system still stand. + // (`try_pt` drops 2D candidates itself when the master is off.) + if !self.snap_enabled && !self.snap3d_enabled { return best; } @@ -1245,6 +1333,11 @@ impl Snapper { snap_type: SnapType, src: Option, secondary: Option| { + // Masters are per-system: 2D candidates need the 2D master, 3D + // solid candidates answer only to the F4 master. + if !snap_type.is_3d() && !self.snap_enabled { + return; + } let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1258,7 +1351,20 @@ impl Snapper { return; } let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type)); - if snap_better(tier, d2, sub, (best_rank, best_d2, best_sub)) { + // Coincident 3D features share pixels (top and bottom face + // centres coincide in plan view): break exact ties by eye depth + // so the nearer one wins instead of whichever was evaluated + // first. 2D geometry is coplanar, so this never triggers there. + let depth_tie = snap_type.is_3d() + && tier == best_rank + && (d2 - best_d2).abs() <= 1e-4 + && sub == best_sub + && best.is_some_and(|prev| { + (world - eye).length_squared() < (prev.world - eye).length_squared() + }); + if depth_tie + || snap_better(tier, d2, sub, (best_rank, best_d2, best_sub)) + { best_rank = tier; best_sub = sub; best_d2 = d2; @@ -1283,15 +1389,27 @@ impl Snapper { let mut try_snap_hint = |world: DVec3, hint: SnapHint, src: Option| { - let snap_type = match hint { - SnapHint::Center => SnapType::Center, - SnapHint::Node => SnapType::Node, - SnapHint::Quadrant => SnapType::Quadrant, - SnapHint::Insertion => SnapType::Insertion, - SnapHint::Midpoint => SnapType::Midpoint, - SnapHint::Endpoint => SnapType::Endpoint, + // 3D hints run on the separate 3D master + mode set; everything + // else stays on the 2D master + mode set. + let (snap_type, on) = match hint { + SnapHint::Center => (SnapType::Center, self.is_on(SnapType::Center)), + SnapHint::Node => (SnapType::Node, self.is_on(SnapType::Node)), + SnapHint::Quadrant => (SnapType::Quadrant, self.is_on(SnapType::Quadrant)), + SnapHint::Insertion => (SnapType::Insertion, self.is_on(SnapType::Insertion)), + SnapHint::Midpoint => (SnapType::Midpoint, self.is_on(SnapType::Midpoint)), + SnapHint::Endpoint => (SnapType::Endpoint, self.is_on(SnapType::Endpoint)), + SnapHint::Vertex => (SnapType::Vertex, self.is_on_3d(SnapType::Vertex)), + SnapHint::EdgeMidpoint => ( + SnapType::EdgeMidpoint, + self.is_on_3d(SnapType::EdgeMidpoint), + ), + SnapHint::FaceCenter => ( + SnapType::FaceCenter, + self.is_on_3d(SnapType::FaceCenter), + ), + SnapHint::Knot => (SnapType::Knot, self.is_on_3d(SnapType::Knot)), }; - if self.is_on(snap_type) { + if on { try_pt(world, snap_type, src, None); } }; @@ -1857,7 +1975,9 @@ impl Snapper { // ── Tangent ──────────────────────────────────────────────────────── // Operates directly on tangent_geoms geometry — independent of the // wire.points rendering structure so polyline segments work correctly. - if self.is_on(SnapType::Tangent) { + // 2D master applies (the shared `try_pt` gate does not cover this + // direct-evaluation pass). + if self.snap_enabled && self.is_on(SnapType::Tangent) { let mut eval_tangent = |wire: &WireModel| { for tg in &wire.tangent_geoms { let (world_pt, d2) = match tg { @@ -2097,7 +2217,8 @@ impl Snapper { // cursor is near such a curve, offer its centre, ranked by how close // the cursor is to the curve. Runs here, after `try_pt`'s borrow ends, // so it can update the candidate state directly. (#152) - if self.is_on(SnapType::Center) { + // 2D master applies (this direct-evaluation pass bypasses `try_pt`). + if self.snap_enabled && self.is_on(SnapType::Center) { let mut offer = |wire: &WireModel, curve_d2: f32| { let Some(center) = wire .snap_pts @@ -2225,7 +2346,7 @@ impl Snapper { /// among them — a circle's Center must not mask its Quadrants just by rank /// (#420). Continuous snaps keep their individual lower tiers, preserving the /// #118 guarantee that they never suppress a discrete snap in the aperture. -fn snap_tier(t: SnapType) -> u8 { +pub(crate) fn snap_tier(t: SnapType) -> u8 { match t { SnapType::Endpoint | SnapType::Intersection @@ -2234,7 +2355,17 @@ fn snap_tier(t: SnapType) -> u8 { | SnapType::Center | SnapType::Node | SnapType::Quadrant - | SnapType::Insertion => 0, + | SnapType::Insertion + // Solid B-rep snaps are discrete like the 2D ones above. + | SnapType::Vertex + | SnapType::EdgeMidpoint + | SnapType::FaceCenter + | SnapType::Knot => 0, + // The face-continuous modes slot alongside their 2D analogues — + // without these arms the sub-priority fallback would rank them + // below Grid. + SnapType::FacePerpendicular => 9, + SnapType::NearestFace => 13, other => snap_priority(other), } } @@ -2266,7 +2397,7 @@ pub fn merge_snap( /// candidates are effectively equidistant (coincident features) the classic /// sub-priority — so an Endpoint still beats an Intersection sitting on the /// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. -fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { +pub(crate) fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { let (bt, bd2, bsub) = best; if tier != bt { return tier < bt; @@ -2277,7 +2408,7 @@ fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { sub < bsub } -fn snap_priority(t: SnapType) -> u8 { +pub(crate) fn snap_priority(t: SnapType) -> u8 { match t { SnapType::Endpoint => 0, SnapType::Intersection => 1, @@ -2294,6 +2425,15 @@ fn snap_priority(t: SnapType) -> u8 { SnapType::Extension => 12, SnapType::Nearest => 13, SnapType::Grid => 14, + // 3D discretes sort after the 2D ones so a coincident 2D feature wins + // the tie-break; distance still decides first (`snap_better`). The + // face-continuous modes slot alongside their 2D analogues. + SnapType::Vertex => 15, + SnapType::EdgeMidpoint => 16, + SnapType::FaceCenter => 17, + SnapType::Knot => 18, + SnapType::FacePerpendicular => 19, + SnapType::NearestFace => 20, } } @@ -3005,6 +3145,113 @@ fn dist2(a: Point, b: Point) -> f32 { dx * dx + dy * dy } +/// Closest point on the 2D triangle (a, b, c) to `p`: the point, its squared +/// distance, and its barycentric weights for (a, b, c) so callers can lift +/// the result back to 3D. Used to land Nearest-to-face on a projected mesh +/// triangle. +pub(crate) fn closest_point_on_tri_2d( + p: [f32; 2], + a: [f32; 2], + b: [f32; 2], + c: [f32; 2], +) -> ([f32; 2], f32, [f32; 3]) { + let close = |q: [f32; 2], w: [f32; 3]| { + let dx = p[0] - q[0]; + let dy = p[1] - q[1]; + (q, dx * dx + dy * dy, w) + }; + // Barycentric region tests (Real-Time Collision Detection §5.1.5). + let ab = [b[0] - a[0], b[1] - a[1]]; + let ac = [c[0] - a[0], c[1] - a[1]]; + let ap = [p[0] - a[0], p[1] - a[1]]; + let d1 = ab[0] * ap[0] + ab[1] * ap[1]; + let d2 = ac[0] * ap[0] + ac[1] * ap[1]; + if d1 <= 0.0 && d2 <= 0.0 { + return close(a, [1.0, 0.0, 0.0]); + } + let bp = [p[0] - b[0], p[1] - b[1]]; + let d3 = ab[0] * bp[0] + ab[1] * bp[1]; + let d4 = ac[0] * bp[0] + ac[1] * bp[1]; + if d3 >= 0.0 && d4 <= d3 { + return close(b, [0.0, 1.0, 0.0]); + } + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + let v = d1 / (d1 - d3); + return close([a[0] + v * ab[0], a[1] + v * ab[1]], [1.0 - v, v, 0.0]); + } + let cp = [p[0] - c[0], p[1] - c[1]]; + let d5 = ab[0] * cp[0] + ab[1] * cp[1]; + let d6 = ac[0] * cp[0] + ac[1] * cp[1]; + if d6 >= 0.0 && d5 <= d6 { + return close(c, [0.0, 0.0, 1.0]); + } + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + let w = d2 / (d2 - d6); + return close([a[0] + w * ac[0], a[1] + w * ac[1]], [1.0 - w, 0.0, w]); + } + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + let w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return close( + [b[0] + w * (c[0] - b[0]), b[1] + w * (c[1] - b[1])], + [0.0, 1.0 - w, w], + ); + } + // Inside: barycentric interpolation. + let denom = 1.0 / (va + vb + vc); + let v = vb * denom; + let w = vc * denom; + close( + [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w], + [1.0 - v - w, v, w], + ) +} + +/// Foot of the perpendicular from `base` onto the triangle (a, b, c): +/// `None` for degenerate triangles, a base already in the face plane +/// (perpendicular undefined), or a foot landing outside the triangle. +pub(crate) fn foot_on_triangle( + base: glam::DVec3, + a: glam::DVec3, + b: glam::DVec3, + c: glam::DVec3, +) -> Option { + let ab = b - a; + let ac = c - a; + let n = ab.cross(ac); + let n2 = n.length_squared(); + if !(n2 > 1e-24) { + return None; + } + let dist = (base - a).dot(n) / n2.sqrt(); + if dist.abs() <= 1e-9 { + return None; + } + let foot = base - n * ((base - a).dot(n) / n2); + // Inside test via barycentric areas (tolerant sliver at the rim). + let v0 = c - a; + let v1 = b - a; + let v2 = foot - a; + let d00 = v0.dot(v0); + let d01 = v0.dot(v1); + let d11 = v1.dot(v1); + let d20 = v2.dot(v0); + let d21 = v2.dot(v1); + let denom = d00 * d11 - d01 * d01; + if denom.abs() <= 1e-24 { + return None; + } + let v = (d11 * d20 - d01 * d21) / denom; + let w = (d00 * d21 - d01 * d20) / denom; + if v >= -1e-9 && w >= -1e-9 && v + w <= 1.0 + 1e-9 { + Some(foot) + } else { + None + } +} + /// The nearest line / polyline segment under the cursor as (unit direction, /// world point on it), within `aperture_px` in screen space, or None. /// Tessellated curves (circle / arc / ellipse) are skipped — they carry a @@ -3139,6 +3386,68 @@ fn t_on_segment(p: Point, a: Point, b: Point) -> f32 { mod ext_tests { use super::*; + #[test] + fn closest_point_on_triangle_2d_handles_inside_edge_and_vertex() { + let (a, b, c) = ([0.0f32, 0.0], [10.0, 0.0], [0.0, 10.0]); + // Interior → itself, with barycentric weights for (a, b, c). + let (q, d2, w) = closest_point_on_tri_2d([2.0, 2.0], a, b, c); + assert!((q[0] - 2.0).abs() < 1e-6 && (q[1] - 2.0).abs() < 1e-6); + assert!(d2 < 1e-12); + assert!((w[0] - 0.6).abs() < 1e-6 && (w[1] - 0.2).abs() < 1e-6 && (w[2] - 0.2).abs() < 1e-6); + // Outside near an edge → foot on the edge. + let (q, d2, w) = closest_point_on_tri_2d([5.0, -3.0], a, b, c); + assert!((q[0] - 5.0).abs() < 1e-6 && q[1].abs() < 1e-6); + assert!((d2 - 9.0).abs() < 1e-6); + assert!(w[2].abs() < 1e-6, "edge AB carries no C weight: {w:?}"); + // Outside near a vertex → the vertex. + let (q, d2, _) = closest_point_on_tri_2d([-4.0, -3.0], a, b, c); + assert!(q[0].abs() < 1e-6 && q[1].abs() < 1e-6); + assert!((d2 - 25.0).abs() < 1e-6); + } + + #[test] + fn foot_on_triangle_needs_off_plane_base_and_inside_landing() { + let (a, b, c) = ( + DVec3::new(0.0, 0.0, 5.0), + DVec3::new(10.0, 0.0, 5.0), + DVec3::new(0.0, 10.0, 5.0), + ); + // Base below the face interior → foot straight above it. + let foot = foot_on_triangle(DVec3::new(2.0, 3.0, 0.0), a, b, c) + .expect("interior foot"); + assert!((foot - DVec3::new(2.0, 3.0, 5.0)).length() < 1e-9); + // Base outside the triangle's span → the foot misses it. + assert!(foot_on_triangle(DVec3::new(9.0, 9.0, 0.0), a, b, c).is_none()); + // Base already in the face plane → perpendicular undefined. + assert!(foot_on_triangle(DVec3::new(2.0, 3.0, 5.0), a, b, c).is_none()); + } + + #[test] + fn snap3d_master_and_set_gate_independently_of_2d() { + let mut s = Snapper::default(); + // Defaults: master on; every 3D mode configured except + // Nearest-to-face, which would mask the discrete snaps. + assert!(s.is_on_3d(SnapType::Vertex)); + assert!(s.is_on_3d(SnapType::EdgeMidpoint)); + assert!(!s.is_on_3d(SnapType::NearestFace)); + // The 2D master has no say over 3D modes. + s.snap_enabled = false; + assert!(s.is_on_3d(SnapType::Vertex)); + // Per-mode toggle. + s.toggle_3d(SnapType::Vertex); + assert!(!s.is_on_3d(SnapType::Vertex)); + assert!(s.is_on_3d(SnapType::EdgeMidpoint)); + // Master toggle gates the whole 3D system, keeping the set. + s.toggle_snap3d(); + assert!(!s.is_on_3d(SnapType::EdgeMidpoint)); + assert!(s.enabled3d.contains(&SnapType::EdgeMidpoint)); + s.toggle_snap3d(); + assert!(s.is_on_3d(SnapType::EdgeMidpoint)); + // 3D modes never leak into the 2D set. + assert!(!s.is_on(SnapType::Vertex)); + assert!(!s.is_on(SnapType::EdgeMidpoint)); + } + #[test] fn tracking_active_covers_otrack_and_extension() { let mut s = Snapper::default(); diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 3164c4de8..883108e27 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -627,6 +627,14 @@ pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] { S::Grid => OSNAP_GRID, // Not shown in the snap menu; fall back to a neutral marker. S::ObjectPick => OSNAP_NEAREST, + // 3D modes have no dedicated menu glyphs yet; reuse the closest 2D + // shapes (hollow diamond ≈ vertex, hollow triangle ≈ edge midpoint). + S::Vertex => OSNAP_NODE, + S::EdgeMidpoint => OSNAP_MIDPOINT, + S::FaceCenter => OSNAP_CENTER, + S::Knot => OSNAP_NODE, + S::FacePerpendicular => OSNAP_PERPENDICULAR, + S::NearestFace => OSNAP_NEAREST, } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4a8806bf3..37274b4a8 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1580,6 +1580,68 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&h, stroke.clone()); frame.stroke(&v, stroke); } + SnapType::Vertex => { + // Filled square: the solid rhyme of Endpoint's hollow box. + let h = 5.0_f32; + frame.fill( + &canvas::Path::rectangle( + Point::new(sp.x - h, sp.y - h), + Size::new(h * 2.0, h * 2.0), + ), + marker, + ); + } + SnapType::EdgeMidpoint => { + // Filled triangle: the solid rhyme of Midpoint's outline. + let r = 6.0_f32; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x, sp.y - r)); + b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5)); + b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5)); + b.close(); + }); + frame.fill(&path, marker); + } + SnapType::FaceCenter => { + // Filled disc: the solid rhyme of Center's outline. + frame.fill(&canvas::Path::circle(sp, 5.5_f32), marker); + } + SnapType::Knot => { + // Filled diamond: distinct from Quadrant's outline. + let r = 5.5_f32; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x, sp.y - r)); + b.line_to(Point::new(sp.x + r, sp.y)); + b.line_to(Point::new(sp.x, sp.y + r)); + b.line_to(Point::new(sp.x - r, sp.y)); + b.close(); + }); + frame.fill(&path, marker); + } + SnapType::FacePerpendicular => { + // Right-angle hook like the 2D marker, plus a filled foot + // dot marking the face contact. + let r = 6.0_f32; + let p = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x - r, sp.y - r)); + b.line_to(Point::new(sp.x - r, sp.y + r)); + b.line_to(Point::new(sp.x + r, sp.y + r)); + }); + frame.stroke(&p, stroke.clone()); + frame.fill(&canvas::Path::circle(sp, 2.0_f32), marker); + } + SnapType::NearestFace => { + // Filled bowtie: the solid rhyme of Nearest's outline. + let r = 5.5_f32; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x - r, sp.y - r)); + b.line_to(Point::new(sp.x + r, sp.y - r)); + b.line_to(Point::new(sp.x - r, sp.y + r)); + b.line_to(Point::new(sp.x + r, sp.y + r)); + b.close(); + }); + frame.fill(&path, marker); + } } } diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index ae3ffb2fa..1e216b728 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -1,6 +1,6 @@ use crate::app::settings::IsoPlane; use crate::app::Message; -use crate::snap::{SnapType, ALL_SNAP_MODES}; +use crate::snap::{SnapType, ALL_3D_SNAP_MODES, ALL_SNAP_MODES}; use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Border, Element, Fill, Length, Theme}; use std::borrow::Cow; @@ -44,6 +44,7 @@ pub struct DraftingSettingsState { pub snap_modes: rustc_hash::FxHashSet, // 3D Object Snap pub osnap3d_on: bool, + pub snap3d_modes: rustc_hash::FxHashSet, // Dynamic Input pub dyn_input_on: bool, // Quick Properties @@ -71,6 +72,7 @@ impl DraftingSettingsState { || self.otrack_on != saved.otrack_on || self.snap_modes != saved.snap_modes || self.osnap3d_on != saved.osnap3d_on + || self.snap3d_modes != saved.snap3d_modes || self.dyn_input_on != saved.dyn_input_on || self.quick_props_on != saved.quick_props_on || self.selection_cycling_on != saved.selection_cycling_on @@ -471,44 +473,26 @@ pub fn view_window<'a>( Message::DraftingSettingsToggle3dOsnap, ); - let placeholder_group = group( - crate::t!("3D Object Snap modes"), - column![ - row![checkbox(false).size(14), text(crate::t!("Vertex")).size(11)] - .spacing(7) - .align_y(iced::Center), + // Implemented 3D modes follow the same checkbox pattern as the + // Object Snap tab, driven by ALL_3D_SNAP_MODES. + let mut modes_col = column![].spacing(6); + for &(snap_type, _, label) in ALL_3D_SNAP_MODES { + let is_checked = state.snap3d_modes.contains(&snap_type); + modes_col = modes_col.push( row![ - checkbox(false).size(14), - text(crate::t!("Midpoint on edge")).size(11), + checkbox(is_checked) + .on_toggle(move |_| Message::DraftingSettingsToggleSnapMode3d(snap_type)) + .size(14), + text(crate::t!(label)).size(11), ] .spacing(7) .align_y(iced::Center), - row![ - checkbox(false).size(14), - text(crate::t!("Center of face")).size(11), - ] - .spacing(7) - .align_y(iced::Center), - row![checkbox(false).size(14), text(crate::t!("Knot")).size(11)] - .spacing(7) - .align_y(iced::Center), - row![ - checkbox(false).size(14), - text(crate::t!("Perpendicular to face")).size(11), - ] - .spacing(7) - .align_y(iced::Center), - row![ - checkbox(false).size(14), - text(crate::t!("Nearest to face")).size(11), - ] - .spacing(7) - .align_y(iced::Center), - ] - .spacing(6), - ); + ); + } - column![osnap3d_toggle, Space::new().height(4), placeholder_group] + let modes_group = group(crate::t!("3D Object Snap modes"), modes_col); + + column![osnap3d_toggle, Space::new().height(4), modes_group] .spacing(10) .width(Fill) };