feat(snap): independent 3D object-snap system with six modes

Grid snap locks to SNAPUNIT instead of magnetizing; snapped 3D points keep elevation instead of flattening to z=0. New SnapTypes Vertex/EdgeMidpoint/FaceCenter/Knot/FacePerpendicular/NearestFace with their own F4 master toggle and mode set, tiers, and markers, fully independent of the 2D master. Solid B-rep corners/edge-midpoints/face-centers from mesh edges and kernel faces, spline knot points, and live perpendicular/nearest-to-face on mesh triangles merged priority-aware in move/click paths. Drafting Settings 3D Object Snap tab is fully live (was dead placeholders); Nearest-to-face off by default. TDD: behavioral LINE-to-solid tests plus engine/scene unit tests.
This commit is contained in:
Karim Jerbi committed 2026-09-16 16:43:37 +01:00
1 parent d894b8eb05
commit ca0ee37d26
16 files changed
+1728 -67

No files matched your search

+8
View File
@@ -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,
}
}
+62
View File
@@ -1580,6 +1580,68 @@ impl canvas::Program<Message> 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);
}
}
}
+18 -34
View File
@@ -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<SnapType>,
// 3D Object Snap
pub osnap3d_on: bool,
pub snap3d_modes: rustc_hash::FxHashSet<SnapType>,
// 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)
};