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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user