forked from 1b-agent/opencadstudio-upstream
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:
1 parent
d894b8eb05
commit
ca0ee37d26
16 files changed
+1728
-67
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user