Files
OneCAD/crates/kernel/src/meshbody.rs
T
bherbruckandClaude Opus 5.5 ccde7a4837 Robustness runs and the kernel fixes they found
- solvecraft-cli fuzz / fuzz-one: seeded random modelling sessions (boxes, extruded profiles with arcs, revolves; joins and cuts of boxes, cylinders and spheres, fillets, chamfers, shells, holes, patterns) on a grid that makes coincident faces and tangencies common, every step checked (command ok, every body a sound solid via the new Body::validity, volume moving the right way), plus the boolean volume identity on random primitives; one process per seed with a time limit; failures minimised to scripts
- shell/offset: corners where a curved face meets another at an angle are refined onto the moved surfaces (Newton); edges that are neither lines nor arcs are rebuilt through their moved points; one shell per piece; faces that would cross say so (not supported yet) instead of leaving a cavity inside out; flat revolved faces count as planes; the unhealed body is tried when the healed one fails
- cylinders start their seams at an odd angle (grid-aligned seams broke booleans along them)
- partial revolves of profiles touching the axis build no zero-area face on the axis
- booleans on bodies in several pieces retry a piece at a time
- regression tests for each, and a fixed set of seeds in cargo test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00

191 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Mesh bodies: triangles without a B-rep (imported 3MF/STL). They render, measure, move and
//! export; B-rep features refuse them.
use std::collections::HashMap;
use std::sync::Arc;
use solvecraft_geom::{Mesh, Vec3};
use crate::body::Body;
use crate::{KernelError, Result};
/// Most triangles in one mesh body.
pub const MAX_TRIANGLES: usize = 20_000_000;
/// Indexed triangles with shared vertices, wound counter-clockwise seen from outside.
#[derive(Clone, Debug, Default)]
pub struct TriMesh {
pub positions: Vec<Vec3>,
pub triangles: Vec<[u32; 3]>,
}
impl TriMesh {
/// Display/measure mesh: corners split per triangle so normals stay flat.
pub(crate) fn to_mesh(&self) -> Mesh {
let mut out = Mesh::default();
out.positions.reserve(self.triangles.len() * 3);
for t in &self.triangles {
let (Some(a), Some(b), Some(c)) =
(self.positions.get(t[0] as usize), self.positions.get(t[1] as usize), self.positions.get(t[2] as usize))
else {
continue;
};
let n = (*b - *a).cross(*c - *a).normalized().unwrap_or(Vec3::Z);
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
out.positions.extend([*a, *b, *c]);
out.normals.extend([n, n, n]);
out.triangles.push([base, base.saturating_add(1), base.saturating_add(2)]);
out.tri_face.push(0);
}
out
}
/// Distinct undirected triangle sides.
pub(crate) fn edge_count(&self) -> usize {
let mut set = std::collections::HashSet::new();
for t in &self.triangles {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
set.insert((a.min(b), a.max(b)));
}
}
set.len()
}
/// Signed volume ×6.
fn volume6(&self) -> f64 {
self.triangles
.iter()
.filter_map(|t| Some((self.positions.get(t[0] as usize)?, self.positions.get(t[1] as usize)?, self.positions.get(t[2] as usize)?)))
.map(|(a, b, c)| a.dot(b.cross(*c)))
.sum()
}
}
/// A mesh body from triangles (`positions` in mm). Coincident vertices are merged, degenerate
/// triangles dropped, and a closed mesh with inward winding is turned outward.
pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
if triangles.len() > MAX_TRIANGLES {
return Err(KernelError::Invalid(format!("mesh with {} triangles (limit {MAX_TRIANGLES})", triangles.len())));
}
if positions.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
return Err(KernelError::Invalid("mesh with non-finite or huge coordinates".into()));
}
// Merge vertices that are exactly equal (bit pattern of the coordinates).
let mut map: HashMap<[u64; 3], u32> = HashMap::new();
let mut remap: Vec<u32> = Vec::with_capacity(positions.len());
let mut out = TriMesh::default();
for p in positions {
let key = [(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()];
let next = u32::try_from(out.positions.len()).map_err(|_| KernelError::Invalid("too many vertices".into()))?;
let i = *map.entry(key).or_insert_with(|| {
out.positions.push(*p);
next
});
remap.push(i);
}
for t in triangles {
let (Some(&a), Some(&b), Some(&c)) = (remap.get(t[0] as usize), remap.get(t[1] as usize), remap.get(t[2] as usize)) else {
return Err(KernelError::Invalid("mesh triangle refers to a missing vertex".into()));
};
if a == b || b == c || c == a {
continue;
}
out.triangles.push([a, b, c]);
}
if out.triangles.is_empty() {
return Err(KernelError::Invalid("mesh has no triangles".into()));
}
unify_winding(&mut out);
if out.volume6() < 0.0 && is_closed(&out) {
for t in &mut out.triangles {
t.swap(1, 2);
}
}
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None })
}
/// Wind every connected piece one way: a triangle that runs a side in the same direction as
/// its neighbour is turned over (files with stray inverted facets). Sides shared by more than
/// two triangles are left alone.
fn unify_winding(m: &mut TriMesh) {
let mut sides: HashMap<(u32, u32), Vec<usize>> = HashMap::new();
for (i, t) in m.triangles.iter().enumerate() {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
sides.entry((a.min(b), a.max(b))).or_default().push(i);
}
}
let runs = |t: &[u32; 3], a: u32, b: u32| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])].contains(&(a, b));
let mut done = vec![false; m.triangles.len()];
for start in 0..m.triangles.len() {
if done[start] {
continue;
}
done[start] = true;
let mut stack = vec![start];
while let Some(i) = stack.pop() {
let Some(t) = m.triangles.get(i).copied() else { continue };
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let Some(list) = sides.get(&(a.min(b), a.max(b))) else { continue };
if list.len() != 2 {
continue;
}
for &j in list {
if j == i || done[j] {
continue;
}
done[j] = true;
if let Some(u) = m.triangles.get_mut(j)
&& runs(u, a, b)
{
u.swap(1, 2);
}
stack.push(j);
}
}
}
}
}
/// Every triangle side is shared by exactly two triangles in opposite directions.
pub(crate) fn is_closed(m: &TriMesh) -> bool {
let mut count: HashMap<(u32, u32), i32> = HashMap::new();
for t in &m.triangles {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
*count.entry((a, b)).or_default() += 1;
}
}
count.iter().all(|(&(a, b), &n)| n == 1 && count.get(&(b, a)) == Some(&1))
}
impl Body {
/// Shared-vertex triangles of a mesh body.
pub fn triangle_mesh(&self) -> Option<(&[Vec3], &[[u32; 3]])> {
self.mesh.as_ref().map(|m| (m.positions.as_slice(), m.triangles.as_slice()))
}
/// Does a mesh body enclose a volume (every side shared by two triangles)?
pub fn is_closed_mesh(&self) -> bool {
self.mesh.as_ref().is_some_and(|m| is_closed(m))
}
/// A mesh body moved by a column-major affine matrix (reflections keep outward winding).
pub(crate) fn mesh_transformed(&self, m: &[[f64; 4]; 4]) -> Option<Body> {
let tm = self.mesh.as_ref()?;
let f = |p: Vec3| {
Vec3::new(
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1],
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2],
)
};
let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) - m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
let mut out = TriMesh { positions: tm.positions.iter().map(|p| f(*p)).collect(), triangles: tm.triangles.clone() };
if det < 0.0 {
for t in &mut out.triangles {
t.swap(1, 2);
}
}
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() })
}
}