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