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
);
}
}