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

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

No files matched your search

+125
View File
@@ -318,3 +318,128 @@ impl MeshLodSet {
})
}
}
/// Max B-rep edge pairs read from one solid for snapping. Curved-edge
/// tessellations fan out into thousands of joints; the cap keeps the
/// interaction index and the per-move snap scan bounded.
pub const MAX_SOLID_SNAP_EDGES: usize = 2048;
/// Distinct B-rep edge segments from a high/low double-single line list
/// (mesh `edge_verts` pairs + `edge_verts_low`), feeding solid vertex
/// (endpoints) and edge-midpoint (centres) snaps.
///
/// Degenerate (zero-length) and non-finite pairs are dropped, and identical
/// pairs collapse regardless of endpoint order. Past the cap the output is
/// stride-sampled so coverage degrades gracefully instead of dropping every
/// edge past some cutoff.
pub fn solid_edge_segments(
edge_verts: &[[f32; 3]],
edge_verts_low: &[[f32; 3]],
) -> Vec<(glam::DVec3, glam::DVec3)> {
fn point(high: [f32; 3], low: [f32; 3]) -> Option<glam::DVec3> {
let p = glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
);
p.is_finite().then_some(p)
}
fn key(p: &glam::DVec3) -> [u64; 3] {
[p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
}
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for index in 0..edge_verts.len() / 2 {
let high = [edge_verts[2 * index], edge_verts[2 * index + 1]];
let low = [
edge_verts_low.get(2 * index).copied().unwrap_or([0.0; 3]),
edge_verts_low
.get(2 * index + 1)
.copied()
.unwrap_or([0.0; 3]),
];
let (Some(a), Some(b)) = (point(high[0], low[0]), point(high[1], low[1])) else {
continue;
};
if a.distance_squared(b) <= 1e-12 {
continue;
}
let (ka, kb) = (key(&a), key(&b));
let ordered = if ka <= kb { (ka, kb) } else { (kb, ka) };
if !seen.insert(ordered) {
continue;
}
out.push((a, b));
}
if out.len() > MAX_SOLID_SNAP_EDGES {
let step = out.len().div_ceil(MAX_SOLID_SNAP_EDGES);
out = out.into_iter().step_by(step).collect();
}
out
}
#[cfg(test)]
mod solid_snap_tests {
use super::solid_edge_segments;
fn pt(x: f32, y: f32, z: f32) -> [f32; 3] {
[x, y, z]
}
#[test]
fn shared_corners_keep_both_segments() {
// Two edges sharing the corner (1,0,0): both pairs survive, the
// corner itself dedups later at attach time, not here.
let edges = vec![
pt(0.0, 0.0, 0.0),
pt(1.0, 0.0, 0.0),
pt(1.0, 0.0, 0.0),
pt(1.0, 1.0, 0.0),
];
let low = vec![[0.0f32; 3]; 4];
let segs = solid_edge_segments(&edges, &low);
assert_eq!(segs.len(), 2, "shared corner collapsed segments: {segs:?}");
}
#[test]
fn degenerate_and_non_finite_pairs_are_dropped() {
let edges = vec![
pt(0.0, 0.0, 0.0),
pt(f32::NAN, 0.0, 0.0),
pt(1.0, 0.0, 0.0),
pt(1.0, 0.0, 0.0), // degenerate: zero length
pt(2.0, 0.0, 0.0),
pt(3.0, 0.0, f32::INFINITY),
pt(4.0, 0.0, 0.0),
pt(5.0, 0.0, 0.0),
];
let low = vec![[0.0f32; 3]; 8];
let segs = solid_edge_segments(&edges, &low);
assert_eq!(segs.len(), 1, "expected only the valid pair: {segs:?}");
assert!(segs[0].0.is_finite() && segs[0].1.is_finite());
}
#[test]
fn output_is_capped_with_coverage() {
let mut edges = Vec::new();
for k in 0..2200 {
let x = 100.0 + k as f32;
edges.push(pt(x, 0.0, 0.0));
edges.push(pt(x, 1.0, 0.0));
}
let low = vec![[0.0f32; 3]; 4400];
let segs = solid_edge_segments(&edges, &low);
assert!(
segs.len() <= super::MAX_SOLID_SNAP_EDGES,
"cap exceeded: {}",
segs.len()
);
assert!(
segs.len() >= super::MAX_SOLID_SNAP_EDGES / 2,
"stride sampling dropped too much coverage: {}",
segs.len()
);
// The first pair survives sampling (index 0 is kept).
assert_eq!(segs[0].0, glam::DVec3::new(100.0, 0.0, 0.0));
}
}
+63
View File
@@ -371,6 +371,45 @@ pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> {
Some(if face.forward { normal } else { -normal }.to_array())
}
/// Centre of every B-rep face: the average of its boundary-loop vertices.
/// Exact for planar faces; for curved faces the loop average floats slightly
/// inside the surface — a documented approximation the snap path must not pay
/// a surface evaluation for on every wire build. Capped like the edge snaps.
pub fn face_centers(body: &Body) -> Vec<[f64; 3]> {
const MAX_FACE_CENTERS: usize = 2048;
let mut out = Vec::new();
for face in body.face_keys() {
let mut sum = [0.0; 3];
let mut count = 0usize;
for coedge in body.face_coedges(face) {
let Some((va, vb)) = body.coedge_vertices(coedge) else {
continue;
};
for vk in [va, vb] {
let Some(v) = body.vertices.get(vk) else {
continue;
};
if !v.point.iter().all(|c| c.is_finite()) {
continue;
}
sum[0] += v.point[0];
sum[1] += v.point[1];
sum[2] += v.point[2];
count += 1;
}
}
if count >= 3 {
let n = count as f64;
out.push([sum[0] / n, sum[1] / n, sum[2] / n]);
}
}
if out.len() > MAX_FACE_CENTERS {
let step = out.len().div_ceil(MAX_FACE_CENTERS);
out = out.into_iter().step_by(step).collect();
}
out
}
// ── Boolean operations ──────────────────────────────────────────────────────
/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so
@@ -601,6 +640,30 @@ mod tests {
assert_eq!(display([2, 2], false), boundaries);
}
#[test]
fn box_face_centers_are_six() {
let body = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap();
let centers = face_centers(&body);
assert_eq!(centers.len(), 6, "a box has six faces: {centers:?}");
for expected in [
[5.0, 0.0, 0.0],
[-5.0, 0.0, 0.0],
[0.0, 5.0, 0.0],
[0.0, -5.0, 0.0],
[0.0, 0.0, 5.0],
[0.0, 0.0, -5.0],
] {
assert!(
centers.iter().any(|c| {
(c[0] - expected[0]).abs() < 1e-9
&& (c[1] - expected[1]).abs() < 1e-9
&& (c[2] - expected[2]).abs() < 1e-9
}),
"missing face center {expected:?} in {centers:?}"
);
}
}
#[test]
fn every_primitive_is_the_size_it_was_asked_for() {
// Triangle counts say a mesh exists; the volume says it is the right
+12
View File
@@ -22,6 +22,18 @@ pub enum SnapHint {
/// arc's two ends would also produce a midpoint snap out on the chord,
/// which is not on the geometry.
Endpoint,
/// B-rep corner of a 3D solid. Maps to the separate 3D snap system
/// (`SnapType::Vertex`), never to the 2D modes.
Vertex,
/// Centre of a 3D solid B-rep edge. Maps to the separate 3D snap system
/// (`SnapType::EdgeMidpoint`), never to the 2D modes.
EdgeMidpoint,
/// Centre of a 3D solid B-rep face. Maps to the separate 3D snap system
/// (`SnapType::FaceCenter`), never to the 2D modes.
FaceCenter,
/// NURBS knot location on a spline entity. Maps to the separate 3D snap
/// system (`SnapType::Knot`), never to the 2D modes.
Knot,
}
/// Geometric primitive used by the tangent-snap engine.