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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user