From 739c9d8f5bdc69a86607e8a2f972779cc86500a0 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:17:16 -0400 Subject: [PATCH] Wave 161/509: kernel: planar source lineage on the record, coplanar faces joined by SMLib (and 3 more) 4 commits, squashed; the originals stay reachable from tag archive/merge-main-develop. - 585334a29 jyviko: kernel: planar source lineage on the record, coplanar faces joined by SMLib - 5c16e8dd1 jyviko: kernel: coplanar faces joined by SMLib's merge_faces, the record read from the body - c802458ef jyviko: solidcraft-smlib: Brep::face_points, a point inside each face (smlib_face_points) - 453a4dec7 jyviko: kernel: a trimmed piece's side is read at a point inside it, exactly Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ --- crates/kernel/src/planar_regularize.rs | 352 +++++++------------------ crates/kernel/src/planar_sources.rs | 236 ++++++++++++----- crates/kernel/src/splitface.rs | 17 +- crates/kernel/src/surface_trim.rs | 35 +-- crates/smlib/src/ffi/trim.rs | 18 +- crates/smlib/src/trim.rs | 8 + 6 files changed, 320 insertions(+), 346 deletions(-) diff --git a/crates/kernel/src/planar_regularize.rs b/crates/kernel/src/planar_regularize.rs index b899ff87f7..efd572c409 100644 --- a/crates/kernel/src/planar_regularize.rs +++ b/crates/kernel/src/planar_regularize.rs @@ -1,137 +1,28 @@ //! Exact topological removal of construction-induced planar subdivisions. -//! Shared edge identity and complete construction lineage select the groups. -use super::{Body, from_p3}; -use crate::history::FaceOrigin; -use crate::{Result, guard}; -use solidcraft_geom::Plane; -use std::collections::{BTreeMap, HashMap, HashSet}; +//! Shared edge identity and complete construction lineage select the edges; SMLib joins the +//! faces either side of them where they are topological (`solidcraft_smlib::Brep::merge_faces`, +//! every other edge kept by a point at its middle). +use super::Body; +use crate::{KernelError, Result, guard}; +use solidcraft_geom::Vec3; +use solidcraft_geom::brep::{self, eval::CurveEval}; use std::sync::Arc; -use truck_modeling as mt; -fn root(parent: &mut [usize], mut i: usize) -> usize { - while parent[i] != i { - parent[i] = parent[parent[i]]; - i = parent[i]; - } - i +/// Points along a record edge (ends and three inside). +fn edge_points(e: &brep::Edge) -> Vec { + let Ok(c) = CurveEval::new(&e.curve) else { return Vec::new() }; + let [t0, t1] = e.interval; + (0..=4).filter_map(|k| c.point(t0 + (t1 - t0) * k as f64 / 4.0)).collect() } -fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &HashSet) -> Option { - let mut counts = HashMap::::new(); - for i in members { - for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() { - // Whole linear supports, not a sampled plane fit. - let mt::Curve::Line(line) = edge.curve() else { return None }; - let (a, b) = (from_p3(line.0), from_p3(line.1)); - if !a.is_finite() || !b.is_finite() || a.len().max(b.len()) > 1e9 { - return None; - } - let front = from_p3(edge.absolute_front().point()); - let back = from_p3(edge.absolute_back().point()); - if !front.is_finite() || !back.is_finite() { - return None; - } - let error = 4096. * f64::EPSILON * (1. + a.len() + b.len() + plane.origin.len()); - if plane.height(a).abs() > error || plane.height(b).abs() > error || a.dist(front) > error || b.dist(back) > error { - return None; - } - let count = counts.entry(edge.id()).or_insert((0, edge.orientation())); - if count.0 == 1 && (!eligible.contains(&edge.id()) || count.1 == edge.orientation()) { - return None; - } - count.0 += 1; - if count.0 > 2 { - return None; - } - } - } - let mut boundary = Vec::new(); - for i in members { - for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() { - if counts.get(&edge.id()).is_some_and(|(n, _)| *n == 1) { - boundary.push(edge); - } - } - } - let mut next = HashMap::new(); - let mut incoming = HashMap::new(); - for (i, edge) in boundary.iter().enumerate() { - if next.insert(edge.front().id(), i).is_some() || incoming.insert(edge.back().id(), i).is_some() { - return None; - } - } - if next.len() != incoming.len() || next.keys().any(|id| !incoming.contains_key(id)) { - return None; - } - let mut used = vec![false; boundary.len()]; - let mut outer = None; - let mut holes = Vec::new(); - for first in 0..boundary.len() { - if used[first] { - continue; - } - let start = boundary.get(first)?.front().id(); - let origin = from_p3(boundary.get(first)?.front().point()); - let mut index = first; - let mut edges = Vec::new(); - let (mut area, mut magnitude, mut arithmetic_error) = (0., 0., 0.); - loop { - if *used.get(index)? { - return None; - } - used[index] = true; - let edge = boundary.get(index)?.clone(); - let da = from_p3(edge.front().point()) - origin; - let db = from_p3(edge.back().point()) - origin; - let a = solidcraft_geom::Vec2::new(da.dot(plane.x), da.dot(plane.y)); - let b = solidcraft_geom::Vec2::new(db.dot(plane.x), db.dot(plane.y)); - let term = a.cross(b); - area += term; - magnitude += term.abs(); - let coordinate_error = - 128. * f64::EPSILON * (1. + origin.len() + from_p3(edge.front().point()).len() + from_p3(edge.back().point()).len()); - arithmetic_error += coordinate_error * (da.len() + db.len() + coordinate_error) * 16.; - let back = edge.back().id(); - edges.push(edge); - if back == start { - break; - } - index = *next.get(&back)?; - } - if edges.len() < 3 || !area.is_finite() || area.abs() <= arithmetic_error + 64. * f64::EPSILON * (edges.len() as f64 + 1.) * magnitude.max(1.) - { - return None; - } - let wire = mt::Wire::from(edges); - if area > 0. { - if outer.replace(wire).is_some() { - return None; - } - } else { - holes.push(wire); - } - } - let mut wires = vec![outer?]; - wires.extend(holes); - mt::Face::try_new(wires, faces.get(*members.first()?)?.oriented_surface()).ok() +/// The point halfway along a record edge's interval. +fn edge_middle(e: &brep::Edge) -> Option { + let [t0, t1] = e.interval; + CurveEval::new(&e.curve).ok()?.point(0.5 * (t0 + t1)) } -/// `body` with its history: `input`'s faces with each output face made of the input faces -/// `members` lists for it (every edge of `body` one of `input`'s, by identity), composed with -/// the history of the operation that made `input`; none where `input` has none. -fn with_joined_history(body: Body, input: &Body, members: Vec>) -> Body { - let Some(inner) = input.history() else { return body }; - let faces = members.into_iter().map(|m| (m.iter().map(|&face| FaceOrigin::Face { operand: 0, face }).collect(), m.len() == 1)).collect(); - let joined = crate::record::traced( - body, - &crate::record::counts(&[input]), - Some(crate::record::regions(faces)), - Some(&crate::record::edge_origins(&input.solid, 0)), - ); - // Both steps are checked (`traced` against `input`, `through` composing over its checked - // history); the composed operands' counts are not known here, so it is set in place. - let history = crate::boolean_record::through(&joined, 0, Some(inner)); - Body { history: history.map(Arc::new), ..joined } +fn failed(e: impl std::fmt::Display) -> KernelError { + KernelError::Failed(format!("SMLib planar regularization: {e}")) } impl Body { @@ -173,29 +64,14 @@ impl Body { let split_points = self.split_keep().iter().try_fold(0usize, |sum, line| sum.checked_add(line.len())); let Some(split_points) = split_points.filter(|n| *n <= 65_536) else { return Ok(self) }; let result = guard("certified planar topology regularization", || { - let mut faces = Vec::new(); - let mut shell_owner = Vec::new(); - let mut use_budget = 65_536usize; - for (shell_index, shell) in self.solid.boundaries().iter().enumerate() { - for face in shell.face_iter() { - if faces.len() >= sources.len() { - return Ok(None); - } - for _ in face.edge_iter() { - let Some(left) = use_budget.checked_sub(1) else { return Ok(None) }; - use_budget = left; - } - faces.push(face); - shell_owner.push(shell_index); - } - } - if faces.len() != sources.len() { + if self.face_count() != sources.len() { return Ok(None); } - let valid: Vec<_> = faces + let actual = super::planar_sources::actual_planes(&self)?; + let valid: Vec<_> = actual .iter() .zip(sources) - .map(|(f, s)| { + .map(|(actual, s)| { s.as_ref() .and_then(|source| { if generated { @@ -207,29 +83,36 @@ impl Body { super::planar_sources::certified_plane(source) } }) - .filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual))) + .filter(|p| actual.is_some_and(|actual| super::planar_sources::same_support(p, &actual))) }) .collect(); - let mut adjacency = HashMap::>::new(); - for (i, face) in faces.iter().enumerate() { - for edge in face.boundary_iters().into_iter().flatten() { - adjacency.entry(edge.id()).or_default().push((i, edge)); + let record = self.record(); + let mut shell_of = vec![usize::MAX; record.faces.len()]; + for (k, shell) in record.shells.iter().enumerate() { + if let brep::Shell::Faces { faces, .. } = shell { + for o in faces { + if let Some(slot) = shell_of.get_mut(o.face as usize) { + *slot = k; + } + } } } - let mut eligible = HashSet::new(); - let mut parent: Vec<_> = (0..faces.len()).collect(); + // The edges between two faces of one shell on one certified plane (from one source, + // unless the faces are generated walls), off every authored split line. + let mut eligible = Vec::new(); let mut identity = 16_000_000usize; let mut work = 2_000_000usize; - for uses in adjacency.values() { - let [(a, ea), (b, eb)] = uses.as_slice() else { continue }; - if a == b || shell_owner[*a] != shell_owner[*b] || ea.orientation() == eb.orientation() { + for (k, e) in record.edges.iter().enumerate() { + let [ua, ub] = e.uses.as_slice() else { continue }; + let (a, b) = (ua.face as usize, ub.face as usize); + if a == b || shell_of.get(a) != shell_of.get(b) { continue; } - let (Some(pa), Some(pb)) = (valid[*a], valid[*b]) else { continue }; + let (Some(Some(pa)), Some(Some(pb))) = (valid.get(a), valid.get(b)) else { continue }; if pa != pb { continue; } - let (Some(sa), Some(sb)) = (sources[*a].as_ref(), sources[*b].as_ref()) else { continue }; + let (Some(Some(sa)), Some(Some(sb))) = (sources.get(a), sources.get(b)) else { continue }; let Some(cost) = super::planar_sources::descriptor_cost(sa) .and_then(|a| super::planar_sources::descriptor_cost(sb).and_then(|b| a.checked_add(b))) else { @@ -243,112 +126,71 @@ impl Body { let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) }; let Some(left) = work.checked_sub(cost) else { return Ok(None) }; work = left; - if crate::heal::on_split_line(&crate::heal::edge_points(ea), self.split_keep()) { + if crate::heal::on_split_line(&edge_points(e), self.split_keep()) { continue; } - eligible.insert(ea.id()); - let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b)); - parent[ra.max(rb)] = ra.min(rb); + eligible.push(k); } - let mut groups = BTreeMap::>::new(); - for i in 0..faces.len() { - groups.entry(root(&mut parent, i)).or_default().push(i); + if eligible.is_empty() { + return Ok(None); } - let mut replacements = HashMap::new(); - let mut changed = false; + // SMLib removes the topological edges and vertices but those under the points kept: + // every edge not chosen (at its middle) stays. + let keep: Vec<[f64; 3]> = record + .edges + .iter() + .enumerate() + .filter(|(k, _)| eligible.binary_search(k).is_err()) + .map(|(_, e)| edge_middle(e).map(|p| [p.x, p.y, p.z])) + .collect::>() + .ok_or_else(|| failed("an edge of the body has no middle point"))?; + if keep.len() > solidcraft_smlib::MAX_POINTS { + return Ok(None); + } + let (sb, ob) = crate::native::smlib_body_ordered(&self)?; + let ob = ob.ok_or_else(|| failed("SMLib numbers the body's edges unlike its record"))?; + let r = sb.tagged(0).map_err(failed)?.merge_faces(&keep, (self.size() * 1e-6).max(1e-9)).map_err(failed)?; + if r.counts().map_err(failed)?.first() == Some(&self.face_count()) { + return Ok(None); + } + let check = r.check(solidcraft_smlib::CheckLevel::All).map_err(failed)?; + if !check.is_valid() { + return Err(failed(format!("SMLib's check refuses the result: {check}"))); + } + let (body, or) = crate::native::body_from_smlib_ordered(&r)?; + let or = or.ok_or_else(|| failed("the result's record is numbered unlike SMLib's body"))?; + let h = r.history().map_err(failed)?.history(std::slice::from_ref(&ob), &or).map_err(failed)?; + // Each face keeps the source of the first face it is made of; a generated wall made + // of several is coalesced. let mut metadata = 2_000_000usize; - for (leader, members) in &groups { - if members.len() < 2 { - continue; + let mut output = Vec::with_capacity(h.faces.len()); + for f in &h.faces { + let members: Vec = + f.origins.iter().filter_map(|o| if let crate::history::FaceOrigin::Face { face, .. } = o { Some(*face) } else { None }).collect(); + let mut source = members.iter().min().and_then(|m| sources.get(*m)).cloned().flatten(); + if let Some(s) = &source { + let Some(cost) = super::planar_sources::descriptor_cost(s) else { return Ok(None) }; + let Some(left) = metadata.checked_sub(cost) else { return Ok(None) }; + metadata = left; } - let Some(face) = valid[*leader].and_then(|p| merged_face(&faces, members, p, &eligible)) else { continue }; - replacements.insert(*leader, face); - changed = true; - } - if !changed { - return Ok(None); - } - let mut shells = Vec::new(); - let mut output_sources = Vec::new(); - let mut output_ids = Vec::new(); - // The input faces each output face is made of, for its history. - let mut output_members: Vec> = Vec::new(); - let mut offset = 0; - for shell in self.solid.boundaries() { - let mut output = Vec::new(); - for face in shell.face_iter() { - let leader = root(&mut parent, offset); - if let Some(merged) = replacements.get(&leader) { - if offset == leader { - output.push(merged.clone()); - let Some(members) = groups.remove(&leader) else { return Ok(None) }; - output_members.push(members); - } else { - offset += 1; - continue; - } - } else { - output.push(face.clone()); - output_members.push(vec![offset]); - } - output_ids.push(output.last().ok_or_else(|| crate::KernelError::Failed("missing regularized face".into()))?.id()); - let source = sources[offset].as_ref(); - if let Some(source) = source { - let Some(cost) = super::planar_sources::descriptor_cost(source) else { return Ok(None) }; - let Some(left) = metadata.checked_sub(cost) else { return Ok(None) }; - metadata = left; - } - let mut source = source.cloned(); - if generated - && replacements.contains_key(&leader) - && let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source - { - *coalesced = true; - } - output_sources.push(source); - offset += 1; + if generated + && members.len() > 1 + && let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source + { + *coalesced = true; } - shells.push(mt::Shell::from(output)); + output.push(source); } - let solid = mt::Solid::try_new(shells).map_err(|e| crate::KernelError::Failed(format!("planar regularization does not close: {e}")))?; - let body = Body::new(solid)?.with_color(self.color()).with_paint(self.paint().cloned()).with_split_lines(self.split_lines().cloned()); - if !body.solid.face_iter().map(|f| f.id()).eq(output_ids) { - return Ok(None); - } - Ok(Some(with_joined_history(body.with_face_sources(Some(Arc::new(output_sources))), &self, output_members))) + let body = body + .with_color(self.color()) + .with_paint(self.paint().cloned()) + .with_split_lines(self.split_lines().cloned()) + .with_face_sources(Some(Arc::new(output))) + .with_checked_history(Some(h), &crate::record::counts(&[&self])); + // Over the operands of the operation that made `self`, as `self`'s history is. + let history = crate::boolean_record::through(&body, 0, self.history()); + Ok(Some(Body { history: history.map(Arc::new), ..body })) })?; Ok(result.unwrap_or(self)) } } - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn alternate_adjacency_path_cannot_remove_protected_internal_edge() { - let vertices: Vec<_> = - (0..9).map(|i| mt::builder::vertex(super::super::p3(solidcraft_geom::Vec3::new((i % 3) as f64, (i / 3) as f64, 0.)))).collect(); - let mut edges = HashMap::<(usize, usize), mt::Edge>::new(); - let mut faces = Vec::new(); - for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] { - let ids = [y * 3 + x, y * 3 + x + 1, (y + 1) * 3 + x + 1, (y + 1) * 3 + x]; - let wire: mt::Wire = (0..4) - .map(|i| { - let (a, b) = (ids[i], ids[(i + 1) % 4]); - let key = (a.min(b), a.max(b)); - let edge = edges.entry(key).or_insert_with(|| mt::builder::line(&vertices[key.0], &vertices[key.1])); - if a == key.0 { edge.clone() } else { edge.inverse() } - }) - .collect(); - faces.push(mt::builder::try_attach_plane(&[wire]).unwrap()); - } - let refs: Vec<_> = faces.iter().collect(); - let mut eligible: HashSet<_> = edges.values().map(|e| e.id()).collect(); - assert!(merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_some()); - eligible.remove(&edges[&(1, 4)].id()); - assert!( - merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_none(), - "remaining three internal joins cannot bypass authored split protection" - ); - } -} diff --git a/crates/kernel/src/planar_sources.rs b/crates/kernel/src/planar_sources.rs index 5e75700d72..5f4a103577 100644 --- a/crates/kernel/src/planar_sources.rs +++ b/crates/kernel/src/planar_sources.rs @@ -1,21 +1,43 @@ //! Exact oriented planar support lineage through a trim and an inside shell. //! No tessellated face names, centroids, or nearest-plane choices are used. -use super::{Body, FaceSource, from_p3}; +use super::{Body, FaceSource}; use crate::{KernelError, Result, guard}; +use solidcraft_geom::brep::{self, Surface}; use solidcraft_geom::{Plane, Vec3}; use std::sync::Arc; -use truck_modeling as mt; -pub(super) fn actual_plane(face: &mt::Face) -> Option { - let mt::Surface::Plane(p) = face.oriented_surface() else { return None }; - let u = p.u_axis(); - let v = p.v_axis(); - let u = Vec3::new(u.x, u.y, u.z); - let v = Vec3::new(v.x, v.y, v.z); - if !u.is_finite() || !v.is_finite() || u.cross(v).len() <= u.len() * v.len() * 1e-8 { - return None; +/// The oriented plane each face of `body` lies on (in face order), from its record: the +/// surface's plane, its normal turned with the face; none for a face on any other surface. +pub(super) fn actual_planes(body: &Body) -> Result>> { + let record = body.record(); + let mut forward = vec![true; record.faces.len()]; + for shell in &record.shells { + if let brep::Shell::Faces { faces, .. } = shell { + for o in faces { + if let Some(f) = forward.get_mut(o.face as usize) { + *f = o.forward; + } + } + } } - Plane::new(from_p3(p.origin()), u, v) + let v = |p: [f64; 3]| Vec3::new(p[0], p[1], p[2]); + Ok(record + .faces + .iter() + .zip(forward) + .map(|(face, forward)| { + let (o, u, w) = match face.surface { + Surface::Plane { origin, u_axis, v_axis } => (v(origin), v(u_axis), v(v_axis)), + Surface::PlaneThrough { origin, u_point, v_point } => (v(origin), v(u_point) - v(origin), v(v_point) - v(origin)), + _ => return None, + }; + if !o.is_finite() || !u.is_finite() || !w.is_finite() || u.cross(w).len() <= u.len() * w.len() * 1e-8 { + return None; + } + // A reversed face faces against its surface's normal. + if forward { Plane::new(o, u, w) } else { Plane::new(o, w, u) } + }) + .collect()) } /// How far apart the normals' x components of two planes `same_support` accepts can be, with @@ -157,13 +179,13 @@ impl Body { return Ok(self); } if inputs.len() > 2 - || inputs.iter().any(|input| input.solid.face_iter().count() > 16_384) - || self.solid.face_iter().count() > 16_384 - || blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384) + || inputs.iter().any(|input| input.face_count() > 16_384) + || self.face_count() > 16_384 + || blocker.is_some_and(|b| b.face_count() > 16_384) { return Err(KernelError::Invalid("planar source face budget exceeded".into())); } - if inputs.iter().any(|input| input.face_sources().is_some_and(|sources| sources.len() != input.solid.face_iter().count())) { + if inputs.iter().any(|input| input.face_sources().is_some_and(|sources| sources.len() != input.face_count())) { return Err(KernelError::Invalid("planar source alignment mismatch".into())); } if shell.is_some_and(|t| !t.is_finite() || t <= 0.) { @@ -175,7 +197,7 @@ impl Body { let opening_tolerance = if shell.is_some() { inputs.iter().map(|input| input.size()).fold(0., f64::max) * 1e-4 + 1e-6 } else { 0. }; let mapped = guard("exact planar support lineage", || { let mut competing = Vec::new(); - for plane in blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane) { + for plane in blocker.map(actual_planes).transpose()?.into_iter().flatten().flatten() { // A tool face blocks a face on its plane facing either way. let opposing = Plane::new(plane.origin, plane.x, -plane.y).ok_or_else(|| KernelError::Invalid("invalid opposing plane".into()))?; competing.extend([plane, opposing]); @@ -183,12 +205,14 @@ impl Body { competing.sort_by(|a, b| a.normal().x.total_cmp(&b.normal().x)); let mut candidates = Vec::new(); let mut unknown_supports = Vec::new(); - let faces = inputs.iter().flat_map(|input| { + let mut faces = Vec::new(); + for input in inputs { let sources = input.face_sources().unwrap_or_default(); - input.solid.face_iter().enumerate().map(move |(index, face)| (face, sources.get(index).and_then(Option::as_ref))) - }); - for (face, source) in faces { - let Some(actual) = actual_plane(face) else { continue }; + faces + .extend(actual_planes(input)?.into_iter().enumerate().map(|(index, plane)| (plane, sources.get(index).and_then(Option::as_ref)))); + } + for (actual, source) in faces { + let Some(actual) = actual else { continue }; let Some(source) = source else { unknown_supports.push(actual); continue; @@ -224,9 +248,9 @@ impl Body { let mut metadata = 2_000_000usize; let mut identity_bytes = 16_000_000usize; let mut result = Vec::new(); - for (index, face) in self.solid.face_iter().enumerate() { + for (index, actual) in actual_planes(&self)?.into_iter().enumerate() { let mut found = None; - if let Some(actual) = actual_plane(face) { + if let Some(actual) = actual { let mut blocked = false; let nx = actual.normal().x; for plane in normal_window(&unknown_supports, |p| p, nx) { @@ -336,6 +360,105 @@ mod tests { use super::*; use crate::{box_solid, fillet, shell, transform_matrix}; + /// The corner points of face `i` of `b`, from its record. + fn face_points(b: &Body, i: usize) -> Vec { + let record = b.record(); + let mut out = Vec::new(); + for l in &record.faces[i].loops { + match l { + brep::Loop::Edges { edges } => { + for e in edges { + for v in record.edges[e.edge as usize].vertices { + let p = record.vertices[v as usize].point; + out.push(Vec3::new(p[0], p[1], p[2])); + } + } + } + brep::Loop::Vertex { vertex } => { + let p = record.vertices[*vertex as usize].point; + out.push(Vec3::new(p[0], p[1], p[2])); + } + } + } + out + } + + /// The edges of face `i` of `b` as their end points, from its record. + fn face_edges(b: &Body, i: usize) -> Vec<(Vec3, Vec3)> { + let record = b.record(); + let at = |v: u32| { + let p = record.vertices[v as usize].point; + Vec3::new(p[0], p[1], p[2]) + }; + record.faces[i] + .loops + .iter() + .flat_map(|l| match l { + brep::Loop::Edges { edges } => edges.iter().map(|e| record.edges[e.edge as usize].vertices).map(|[a, b]| (at(a), at(b))).collect(), + brep::Loop::Vertex { .. } => Vec::new(), + }) + .collect() + } + + /// One body holding `a` and `b` side by side (their records' shells together, `b` wholly + /// beyond `a` along x), each face's source that of the face of its own operand on the same + /// plane (none from `b` when `b_sources` is false). + fn side_by_side(a: &Body, b: &Body, b_sources: bool) -> Body { + let (ra, rb) = (a.record(), b.record()); + let mut r = ra.clone(); + let (nf, ne, nv, ns) = (r.faces.len() as u32, r.edges.len() as u32, r.vertices.len() as u32, r.shells.len() as u32); + for mut face in rb.faces.clone() { + for l in &mut face.loops { + match l { + brep::Loop::Edges { edges } => edges.iter_mut().for_each(|e| e.edge += ne), + brep::Loop::Vertex { vertex } => *vertex += nv, + } + } + r.faces.push(face); + } + for mut e in rb.edges.clone() { + e.vertices = e.vertices.map(|v| v + nv); + e.uses.iter_mut().for_each(|u| u.face += nf); + r.edges.push(e); + } + r.vertices.extend(rb.vertices.iter().cloned()); + for mut sh in rb.shells.clone() { + if let brep::Shell::Faces { faces, wires } = &mut sh { + faces.iter_mut().for_each(|o| o.face += nf); + wires.iter_mut().for_each(|w| *w += ne); + } + r.shells.push(sh); + } + for mut region in rb.regions.clone().into_iter().filter(|g| !g.void) { + region.shells.iter_mut().for_each(|s| *s += ns); + r.regions.push(region); + } + let mut combined = Body::from_record(r).unwrap(); + let planes = |x: &Body| actual_planes(x).unwrap(); + let (pa, pb, pc) = (planes(a), planes(b), planes(&combined)); + let a_right = a.outline_bounds().max.x; + let sources: Vec> = pc + .iter() + .enumerate() + .map(|(i, p)| { + let p = (*p)?; + let find = |of: &Body, ps: &[Option]| { + ps.iter().position(|q| q.is_some_and(|q| same_support(&q, &p))).and_then(|i| of.face_sources()?[i].clone()) + }; + let in_a = face_points(&combined, i).iter().all(|q| q.x <= a_right + 1e-9); + if in_a { + find(a, &pa) + } else if b_sources { + find(b, &pb) + } else { + None + } + }) + .collect(); + combined.face_sources = Some(Arc::new(sources)); + combined + } + #[test] fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() { for (width, thickness) in [(80., 2.), (100., 3.)] { @@ -381,18 +504,16 @@ mod tests { assert!(moved.face_plane(right).unwrap().normal().dot(Vec3::Y) > 0.999999); let raw = cup.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(); let display = cup.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(); - let cylindrical: Vec<_> = cup - .solid - .face_iter() - .enumerate() - .filter_map(|(i, f)| { + let cylindrical: Vec<_> = (0..cup.face_count()) + .filter_map(|i| { let crate::FaceSurface::Cylinder { radius, .. } = crate::face_surface(&cup, i)? else { return None }; - let lo = f.vertex_iter().map(|v| v.point().z).fold(f64::INFINITY, f64::min); - let hi = f.vertex_iter().map(|v| v.point().z).fold(f64::NEG_INFINITY, f64::max); + let z: Vec = face_points(&cup, i).iter().map(|p| p.z).collect(); + let lo = z.iter().copied().fold(f64::INFINITY, f64::min); + let hi = z.iter().copied().fold(f64::NEG_INFINITY, f64::max); Some((i, radius, lo, hi)) }) .collect(); - eprintln!("roundedShell width{width} thickness{thickness} raw{} cylindrical roles{cylindrical:?}", cup.solid.face_iter().count()); + eprintln!("roundedShell width{width} thickness{thickness} raw{} cylindrical roles{cylindrical:?}", cup.face_count()); let mut corner_joins = 0; for (edge, adjacent) in raw.edge_faces.iter().enumerate() { let [a, b] = adjacent.as_slice() else { continue }; @@ -415,8 +536,7 @@ mod tests { for high in [false, true] { let matching: Vec<_> = sources.iter().enumerate().filter_map(|(i,s)| matches!(s,Some(FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),FaceSource::Box {axis:a,high:h,..} if *a==axis && *h==high)).then_some(i)).collect(); assert_eq!(matching.len(), 1, "each straight exterior wall is one actual face"); - let face = cup.solid.face_iter().nth(matching[0]).unwrap(); - let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect(); + let z: Vec<_> = face_points(&cup, matching[0]).iter().map(|p| p.z).collect(); assert!(z.iter().any(|z| z.abs() < 1e-9)); assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-9)); } @@ -437,13 +557,15 @@ mod tests { // STEP may additionally coalesce cylindrical corner patches. The // certified planar wall topology itself must survive unchanged. for (axis, coordinate) in [(Vec3::X, width / 2.), (-Vec3::X, width / 2.), (Vec3::Y, 30.), (-Vec3::Y, 30.)] { - let walls: Vec<_> = copy - .solid - .face_iter() - .filter(|f| actual_plane(f).is_some_and(|p| p.normal().dot(axis) > 0.999 && (p.origin.dot(axis) - coordinate).abs() < 1e-8)) + let walls: Vec = actual_planes(copy) + .unwrap() + .iter() + .enumerate() + .filter(|(_, p)| p.is_some_and(|p| p.normal().dot(axis) > 0.999 && (p.origin.dot(axis) - coordinate).abs() < 1e-8)) + .map(|(i, _)| i) .collect(); assert_eq!(walls.len(), 1); - let z: Vec<_> = walls[0].vertex_iter().map(|v| v.point().z).collect(); + let z: Vec<_> = face_points(copy, walls[0]).iter().map(|p| p.z).collect(); assert!(z.iter().any(|z| z.abs() < 1e-8)); assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-8)); } @@ -458,7 +580,7 @@ mod tests { let source = box_solid(Vec3::ZERO, Vec3::new(40., 30., 20.)).unwrap().with_box_certificate(1).unwrap(); let pending = shell(&source, &[Vec3::new(20., 15., 20.)], 2.).unwrap(); let cup = pending.clone().with_shell_certificate(2).unwrap(); - assert_eq!(cup.solid.face_iter().count(), 11); + assert_eq!(cup.face_count(), 11); let mesh = cup.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(); assert!((mesh.measure().volume - (40. * 30. * 20. - 36. * 26. * 18.)).abs() < 1e-6); assert!(mesh.contains(Vec3::new(20., 1., 1.))); @@ -467,7 +589,7 @@ mod tests { let imported = crate::step_import(&crate::step_export(&[&cup], "regularized shell").unwrap()).unwrap(); assert!(imported.warnings.is_empty()); let copy = &imported.bodies[0].body; - assert_eq!(copy.solid.face_iter().count(), 11); + assert_eq!(copy.face_count(), 11); assert!((copy.mesh(&crate::MeshTolerance::chord(0.01)).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6); } #[test] @@ -479,8 +601,8 @@ mod tests { let joined = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); let sources = joined.face_sources().unwrap(); let mut seen = Vec::new(); - for (i, face) in joined.solid.face_iter().enumerate() { - let plane = actual_plane(face).unwrap(); + for (i, plane) in actual_planes(&joined).unwrap().into_iter().enumerate() { + let plane = plane.unwrap(); let source = sources[i].as_ref(); let height = plane.origin.z; if plane.normal().z > 0.999 && (height - 10.).abs() < 1e-9 { @@ -502,30 +624,25 @@ mod tests { fn coincident_competing_source_planes_and_tool_planes_stay_unknown() { let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap(); let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap(); - let mut shells = a.solid.boundaries().to_vec(); - shells.extend(b.solid.boundaries().iter().cloned()); - let mut combined = Body::new(mt::Solid::try_new(shells).unwrap()).unwrap(); - combined.face_sources = Some(Arc::new(a.face_sources().unwrap().iter().chain(b.face_sources().unwrap()).cloned().collect())); + let combined = side_by_side(&a, &b, true); let result = a.clone().with_preserved_planar_sources(&combined).unwrap(); - for (i, face) in result.solid.face_iter().enumerate() { - if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 { + for (i, p) in actual_planes(&result).unwrap().into_iter().enumerate() { + if p.unwrap().normal().dot(Vec3::Z).abs() > 0.999 { assert!(result.face_sources().unwrap()[i].is_none()); } } - let mut mixed = combined.clone(); - mixed.face_sources = - Some(Arc::new(a.face_sources().unwrap().iter().cloned().chain(std::iter::repeat_n(None, b.solid.face_iter().count())).collect())); + let mixed = side_by_side(&a, &b, false); let guarded = a.clone().with_preserved_planar_sources(&mixed).unwrap(); - for (i, face) in guarded.solid.face_iter().enumerate() { - if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 { + for (i, p) in actual_planes(&guarded).unwrap().into_iter().enumerate() { + if p.unwrap().normal().dot(Vec3::Z).abs() > 0.999 { assert!(guarded.face_sources().unwrap()[i].is_none(), "uncertified competing support must not gain identity"); } } let cup = shell(&a, &[Vec3::new(5., 5., 10.)], 1.).unwrap().with_shell_certificate(3).unwrap(); let tool = box_solid(Vec3::new(2., 2., 1.), Vec3::new(4., 4., 3.)).unwrap(); let preserved = cup.clone().with_preserved_boolean_planar_sources(&cup, &tool, false).unwrap(); - for (i, face) in preserved.solid.face_iter().enumerate() { - let Some(p) = actual_plane(face) else { continue }; + for (i, p) in actual_planes(&preserved).unwrap().into_iter().enumerate() { + let Some(p) = p else { continue }; if p.normal().dot(Vec3::Z) > 0.999 && (p.origin.z - 1.).abs() < 1e-9 { assert!(preserved.face_sources().unwrap()[i].is_none()); } @@ -554,16 +671,15 @@ mod tests { let raw = pocket.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(); let display = pocket.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(); let sources = pocket.face_sources().unwrap(); - assert_eq!(pocket.solid.face_iter().count(), 11, "four outer walls, four inner walls, rim, floor, bottom"); + assert_eq!(pocket.face_count(), 11, "four outer walls, four inner walls, rim, floor, bottom"); let sides: Vec<_> = sources.iter().enumerate().filter_map(|(i, s)| matches!(s, Some(FaceSource::CertifiedExtrudeSide { .. })).then_some(i)).collect(); assert_eq!(sides.len(), 4); for i in sides { - let face = pocket.solid.face_iter().nth(i).unwrap(); - let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect(); + let z: Vec<_> = face_points(&pocket, i).iter().map(|p| p.z).collect(); assert!(z.iter().any(|z| z.abs() < 1e-9)); assert!(z.iter().any(|z| (*z - 10.).abs() < 1e-9)); - assert!(!face.edge_iter().any(|e| (e.front().point().z - 1.).abs() < 1e-9 && (e.back().point().z - 1.).abs() < 1e-9)); + assert!(!face_edges(&pocket, i).iter().any(|(a, b)| (a.z - 1.).abs() < 1e-9 && (b.z - 1.).abs() < 1e-9)); } assert!(display.contains(Vec3::new(0., 0., 0.5))); assert!(!display.contains(Vec3::new(0., 0., 5.))); @@ -574,7 +690,7 @@ mod tests { let imported = crate::step_import(&crate::step_export(&[&pocket], "regularized pocket").unwrap()).unwrap(); assert!(imported.warnings.is_empty()); let copy = &imported.bodies[0].body; - assert_eq!(copy.solid.face_iter().count(), 11); + assert_eq!(copy.face_count(), 11); let copy_mesh = copy.mesh(&crate::MeshTolerance::chord(0.01)).unwrap(); assert!((copy_mesh.measure().volume - raw.measure().volume).abs() < 1e-6); assert!(copy_mesh.contains(Vec3::new(0., 0., 0.5))); diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 89bbb6c421..ec6e4318c3 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -151,16 +151,9 @@ fn cutters(tool: &SplitTool, reach: &Aabb3) -> Result>> { /// where the surface is), facing out of the body. fn face_patch(body: &Body, face: usize) -> Result { body.require_brep("a split tool")?; - let record = crate::native::brep_record(body)?; + let record = body.record(); let f = record.faces.get(face).ok_or_else(|| invalid(format!("the tool body has no face {face}")))?; - let forward = record - .shells - .iter() - .find_map(|s| match s { - brep::Shell::Faces { faces, .. } => faces.iter().find(|o| o.face as usize == face).map(|o| o.forward), - _ => None, - }) - .ok_or_else(|| invalid(format!("face {face} of the tool body lies in no shell")))?; + let forward = body.face_forward(face); // The surface's own parameter box, unbounded where it is. let dom = SurfaceEval::new(&f.surface).map_err(|e| invalid(format!("the split tool's surface: {e}")))?.domain(); let range = |k: usize| dom.range[k].map_or(ALL, |(a, b)| [a, b]); @@ -440,7 +433,7 @@ pub(crate) fn face_sides(body: &Body, tool: &SplitTool) -> Result> { /// and how many there are. Faces are in one region when they meet at an edge the tool does not /// run along; an edge along the tool (a split line) parts them. pub(crate) fn regions(split: &Body, tool: &SplitTool) -> Result<(Vec, usize)> { - let record = crate::native::brep_record(split)?; + let record = split.record(); let fields: Vec = cutters(tool, &reach_of(split))? .iter() .map(|g| g.iter().map(Measured::new).collect::>>().map(ToolSide)) @@ -590,7 +583,7 @@ fn split(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<(Body, Vec Result>> { - let record = crate::native::brep_record(split)?; + let record = split.record(); let mut out = Vec::new(); for (e, prov) in record.edges.iter().zip(&h.edges) { if prov.origins.iter().any(|o| matches!(o, EdgeOrigin::Edge { .. })) { @@ -814,7 +807,7 @@ mod tests { let quarter = std::f64::consts::PI * 36.0 / 4.0; // (Face areas come from meshes.) assert!(rel(tops[0], quarter) < 1e-3 && rel(tops[1], 200.0 - quarter) < 1e-3, "{tops:?}"); - let record = crate::native::brep_record(&s).unwrap(); + let record = s.record(); assert!(record.edges.iter().any(|e| matches!(e.curve, brep::Curve::Circle { .. } | brep::Curve::RationalSpline { .. }))); // A listed face the tool misses is named: the plane z = 10 crosses the sides, not the top. let mid = Plane { origin: Vec3::new(0.0, 0.0, 10.0), ..Plane::XY }; diff --git a/crates/kernel/src/surface_trim.rs b/crates/kernel/src/surface_trim.rs index e81db0d991..c63423d68e 100644 --- a/crates/kernel/src/surface_trim.rs +++ b/crates/kernel/src/surface_trim.rs @@ -57,8 +57,8 @@ pub fn trim(body: &Body, tool: &SplitTool, keep: Vec3) -> Result { /// A surface body trimmed by a tool, keeping what `keep` says. Each kept face is the face it is /// a piece of (its history is over `[body]`). /// -/// The body is split where the tool meets it ([`crate::splitface::split_any`]) and the pieces -/// not kept are taken away ([`crate::remove_faces`]); both carry their history, so the kept +/// The body is split where the tool meets it ([`crate::splitface::split_any`]), each piece's side +/// read at a point inside it ([`face_sides`]), and the pieces not kept are taken away ([`crate::remove_faces`]); both carry their history, so the kept /// faces are the body's own, cut. pub fn trim_keep(body: &Body, tool: &SplitTool, keep: TrimKeep) -> Result { body.require_brep("trim")?; @@ -95,23 +95,24 @@ pub fn trim_keep(body: &Body, tool: &SplitTool, keep: TrimKeep) -> Result Ok(crate::record::after(trimmed, &split, &crate::record::counts(&[body]))) } -/// The side of the tool each face of `body` (in its face order) lies on: the tool's field at -/// the centre of the face's mesh triangle where it is largest (a face lies on one side once -/// the tool has cut it; on the tool's own surface the field is the rounding of zero). +/// The side of the tool each face of `body` (in its face order) lies on: the tool's field at a +/// point inside the face, on its surface and inside its trims (SMLib's +/// `CalculateAnInternalPoint`). Once the tool has cut the body each piece lies on one side, so +/// one such point says which. fn face_sides(body: &Body, side: &ToolSide) -> Result> { - let m = body.mesh(&crate::MeshTolerance::chord(body.size() * 2e-3))?; - let mut best = vec![0.0f64; body.face_count()]; - for (t, f) in m.triangles.iter().zip(&m.tri_face) { - let Some([a, b, c]) = m.tri(t) else { continue }; - let x = side.at((a + b + c) / 3.0); - if let Some(slot) = best.get_mut(*f as usize) - && x.is_finite() - && x.abs() > slot.abs() - { - *slot = x; - } + let (sm, order) = crate::native::smlib_body_ordered(body)?; + let order = order.ok_or_else(|| KernelError::Failed("trim: SMLib numbers the pieces' faces differently from their record".into()))?; + let points = sm.face_points().map_err(|e| KernelError::Failed(format!("SMLib trim: a point inside each piece: {e}")))?; + let mut sides = vec![f64::NAN; points.len()]; + for (i, p) in points.iter().enumerate() { + let face = if order.faces.is_empty() { i } else { order.faces.get(i).copied().unwrap_or(usize::MAX) }; + let slot = sides.get_mut(face).ok_or_else(|| KernelError::Failed("trim: a piece's face lies outside the body".into()))?; + *slot = side.at(Vec3::new(p[0], p[1], p[2])); } - Ok(best) + if sides.iter().any(|x| !x.is_finite()) { + return Err(KernelError::Failed("trim: the side of a piece can't be told".into())); + } + Ok(sides) } /// For each point, the face of `body` nearest it (over one tessellation). diff --git a/crates/smlib/src/ffi/trim.rs b/crates/smlib/src/ffi/trim.rs index f72439d9d6..a4fca195a0 100644 --- a/crates/smlib/src/ffi/trim.rs +++ b/crates/smlib/src/ffi/trim.rs @@ -1,6 +1,6 @@ //! The untrim and extend blocks of `capi/include/smlib.h` (`smlib_untrim_face`, -//! `smlib_extend_sheet`; the fork's `capi/src/smlib_untrim.cpp` and `smlib_extend.cpp`), declared -//! by hand. Part of the FFI boundary, like its parent: called only through [`crate::trim`]. +//! `smlib_extend_sheet`; the fork's `capi/src/smlib_untrim.cpp` and `smlib_extend.cpp`) and +//! `smlib_face_points` (`smlib_imprint.cpp`, the pieces a trim chooses from), declared by hand. Part of the FFI boundary, like its parent: called only through [`crate::trim`]. #![allow(unsafe_code)] @@ -29,6 +29,7 @@ unsafe extern "C" { in_place: i32, out: *mut *mut RawBrep, ) -> i32; + fn smlib_face_points(body: *const RawBrep, out: *mut f64, n_faces: i64) -> i32; fn smlib_extend_sheet(sheet: *const RawBrep, point: *const f64, tol: f64, distance: f64, mode: i32, out: *mut *mut RawBrep) -> i32; } @@ -56,3 +57,16 @@ pub fn untrim_face(body: &Handle, point: &[f64; 3], tol: f64, holes: Option<&[[f pub fn untrim_face(_: &Handle, _: &[f64; 3], _: f64, _: Option<&[[f64; 3]]>, _: bool, _: bool) -> Result { Err(NOT_BUILT) } + +#[cfg(feature = "native")] +pub fn face_points(body: &Handle, n_faces: usize) -> Result, i32> { + let n = i64::try_from(n_faces).map_err(|_| super::ERR_ARG)?; + let mut out = vec![[0.0f64; 3]; n_faces]; + let s = unsafe { smlib_face_points(body.raw(), out.as_mut_ptr().cast::(), n) }; + if s == super::OK { Ok(out) } else { Err(s) } +} + +#[cfg(not(feature = "native"))] +pub fn face_points(_: &Handle, _: usize) -> Result, i32> { + Err(NOT_BUILT) +} diff --git a/crates/smlib/src/trim.rs b/crates/smlib/src/trim.rs index 4ca3571c39..4c9948d967 100644 --- a/crates/smlib/src/trim.rs +++ b/crates/smlib/src/trim.rs @@ -56,4 +56,12 @@ impl Brep { s => error(s), }) } + + /// A point inside each face, in SMLib's face order: on the face's surface, inside its trims + /// (`SmFace::CalculateAnInternalPoint`). + pub fn face_points(&self) -> Result> { + init()?; + let [faces, _, _] = self.counts()?; + ffi::trim::face_points(&self.0, faces).map_err(error) + } }