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

+10 -1
View File
@@ -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));
+3
View File
@@ -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,
+11 -2
View File
@@ -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<i32> {
Some(match s {
SnapType::Endpoint => 1,
@@ -209,7 +211,14 @@ fn snap_bit(s: SnapType) -> Option<i32> {
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,
})
}
+3
View File
@@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap<String, String> {
("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,
+14
View File
@@ -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 {
+634 -3
View File
@@ -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<crate::snap::SnapType>) -> 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
);
}
}
+86
View File
@@ -67,6 +67,43 @@ pub(crate) fn nurbs3(spline: &Spline) -> Option<NurbsCurve3> {
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<glam::DVec3> {
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<f64> = 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();
+6 -4
View File
@@ -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
}
}
}
+346 -5
View File
@@ -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<glam::DVec3> {
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<WireModel>) {
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<glam::DVec3>,
want_perp: bool,
want_nearest: bool,
) -> Option<crate::snap::SnapResult> {
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 {
+125
View File
@@ -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<glam::DVec3> {
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));
}
}
+63
View File
@@ -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
+12
View File
@@ -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.
+327 -18
View File
@@ -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<SnapType>,
/// 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<SnapType>,
/// 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<DimensionAssociationSource>,
secondary: Option<DimensionAssociationSource>| {
// 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<DimensionAssociationSource>| {
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<glam::DVec3> {
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();
+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)
};