Files
solidcraft-upstream/crates/kernel/src/meshbody.rs
T
bherbruck c1d819f235 feat: preserve body history and placed modelling workflows
Record ownership transfers in the timeline so earlier Join/Cut membership,
world placement, machining data and styles survive component moves, edits,
independent copying, undo and reopening. Preserve pending positions in history
snapshots and keep constructor transfers ordered during feature redefinition.

Retain shallow sheet pockets and exact planar intersection islands through
unfold/refold, with depth-aware flat previews and DXF export. Store explicit
3D wire coordinate spaces and migrate known legacy provenance without moving
saved points or analytic fits.

Infer unique eligible cylindrical Emboss targets, align Revolve manipulators
with actual extent geometry, and discard obsolete preview errors after input
correction. Certify constructed and immutable imported face styles using exact
source metadata while preserving legacy face names. Upgrade designs to format10
with bounded validation and geometry-preserving legacy migration.
2026-10-11 03:59:12 -04:00

207 lines
7.8 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())),
coarse_missing: None,
mesh: Some(Arc::new(out)),
color: None,
paint: None,
splits: None,
face_sources: 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(),
coarse_missing: None,
mesh: Some(Arc::new(out)),
color: self.color,
paint: self.paint.clone(),
splits: self.splits.clone(),
face_sources: None,
})
}
}