Export: ExportCommand/CLI --out/MCP export with .3mf write a 3MF Core package ([Content_Types].xml, _rels/.rels, 3D/3dmodel.model; millimetres), one mesh object and build item per body, named after it, vertices shared and welded watertight (T-junctions between faces split), base-material colours when bodies carry one (STEP colours now ride on bodies). Tessellation as for STL. File > Export 3MF. Import: doc.open/drag-drop/CLI/MCP open accept .3mf and .stl; Insert Mesh (ParaMeshInsertAlignCommand) adds them to a design as a MeshImport feature. Kernel mesh bodies render, measure exactly, move and export; solid features refuse them with a clear error. 3MF reading flattens components and build transforms, converts units, reads colours; sizes, entries, nesting and element counts are capped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
148 lines
5.9 KiB
Rust
148 lines
5.9 KiB
Rust
//! 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()));
|
||
}
|
||
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 })
|
||
}
|
||
|
||
/// Every triangle side is shared by exactly two triangles in opposite directions.
|
||
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 })
|
||
}
|
||
}
|