Export 3MF with B-rep face tags (#834)
3MF export carries each triangle's B-rep face tag.
This commit is contained in:
1 parent
ff7a347013
commit
5d661fa2af
10 files changed
+286
-47
No files matched your search
+115
-21
@@ -197,6 +197,10 @@ pub enum Format {
|
||||
Step,
|
||||
Iges,
|
||||
ThreeMf,
|
||||
/// 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis
|
||||
/// tools (docs/formats/3mf-face-tags.md). Chosen by name only; a
|
||||
/// `.3mf` path alone means [`Format::ThreeMf`].
|
||||
ThreeMfTagged,
|
||||
Design,
|
||||
}
|
||||
|
||||
@@ -211,6 +215,7 @@ impl Format {
|
||||
"step" | "stp" => Format::Step,
|
||||
"igs" | "iges" => Format::Iges,
|
||||
"3mf" => Format::ThreeMf,
|
||||
"3mf-tagged" => Format::ThreeMfTagged,
|
||||
"solidcraft" | "solvecraft" | "json" => Format::Design,
|
||||
_ => return Err(IoError::Format(ext)),
|
||||
})
|
||||
@@ -326,33 +331,46 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str)
|
||||
let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.igs"), ..Default::default() };
|
||||
solidcraft_kernel::iges_export_bodies(&bs, &header)?.into_bytes()
|
||||
}
|
||||
Format::ThreeMf => {
|
||||
let objs: Vec<MeshObject> = sel
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = export_mesh(&b.body)?;
|
||||
let eps = weld_eps(&b.body);
|
||||
// Faces with colours of their own tag their triangles.
|
||||
let paint = b.body.paint();
|
||||
let mut looks = Vec::new();
|
||||
let mut by_face = std::collections::HashMap::new();
|
||||
for f in paint.map(|p| p.faces.as_slice()).unwrap_or_default() {
|
||||
looks.push((f.color, 1.0 - f.opacity));
|
||||
by_face.insert(f.face, looks.len() as u32);
|
||||
}
|
||||
let tags: Vec<u32> = m.tri_face.iter().map(|f| by_face.get(&(*f as usize)).copied().unwrap_or(0)).collect();
|
||||
let (positions, triangles, tri_looks) = threemf::weld_tagged(&m.positions, &m.triangles, &tags, eps);
|
||||
let tri_looks = if looks.is_empty() { Vec::new() } else { tri_looks };
|
||||
let transparency = 1.0 - paint.map(|p| p.opacity).unwrap_or(1.0);
|
||||
Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color(), transparency, looks, tri_looks })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
Format::ThreeMf | Format::ThreeMfTagged => {
|
||||
let objs: Vec<MeshObject> = sel.iter().map(|b| mesh_object(b, format == Format::ThreeMfTagged)).collect::<Result<_>>()?;
|
||||
write_3mf(&objs)?
|
||||
}
|
||||
Format::Design => return Err(IoError::Format("solidcraft (save the document instead)".into())),
|
||||
})
|
||||
}
|
||||
|
||||
/// A body as a 3MF mesh: welded watertight (T-junctions split), coloured, and with `tagged`
|
||||
/// each triangle keeps the B-rep face it came from.
|
||||
fn mesh_object(b: &solidcraft_doc::ModelBody, tagged: bool) -> Result<MeshObject> {
|
||||
let m = export_mesh(&b.body)?;
|
||||
let eps = weld_eps(&b.body);
|
||||
// Faces with colours of their own tag their triangles.
|
||||
let paint = b.body.paint();
|
||||
let mut looks = Vec::new();
|
||||
let mut by_face = std::collections::HashMap::new();
|
||||
for f in paint.map(|p| p.faces.as_slice()).unwrap_or_default() {
|
||||
looks.push((f.color, 1.0 - f.opacity));
|
||||
by_face.insert(f.face, looks.len() as u32);
|
||||
}
|
||||
// Weld with each triangle's own index as its tag: the pieces of a split triangle keep it,
|
||||
// and its colour and face follow from it.
|
||||
let n = u32::try_from(m.triangles.len()).map_err(|_| IoError::Invalid("mesh too large".into()))?;
|
||||
let (positions, triangles, origin) = threemf::weld_tagged(&m.positions, &m.triangles, &(0..n).collect::<Vec<_>>(), eps);
|
||||
let face_of = |t: &u32| m.tri_face.get(*t as usize).copied().unwrap_or(0);
|
||||
let tri_looks =
|
||||
if looks.is_empty() { Vec::new() } else { origin.iter().map(|t| by_face.get(&(face_of(t) as usize)).copied().unwrap_or(0)).collect() };
|
||||
let (tri_faces, faces) = if tagged {
|
||||
let tri_faces: Vec<u32> = origin.iter().map(face_of).collect();
|
||||
let most = tri_faces.iter().max().map_or(0, |f| f.saturating_add(1));
|
||||
let faces = u32::try_from(b.body.face_count()).unwrap_or(u32::MAX).max(most);
|
||||
(tri_faces, faces)
|
||||
} else {
|
||||
(Vec::new(), 0)
|
||||
};
|
||||
let transparency = 1.0 - paint.map(|p| p.opacity).unwrap_or(1.0);
|
||||
Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color(), transparency, looks, tri_looks, tri_faces, faces })
|
||||
}
|
||||
|
||||
/// STEP assembly of a design with components: a product per component holding its bodies in
|
||||
/// the component's own frame (`local` is the model before placing occurrences), and an
|
||||
/// assembly occurrence per component occurrence.
|
||||
@@ -695,4 +713,80 @@ mod tests {
|
||||
assert!(mesh_import_feature(&[0u8; 84], "x.stl").is_err());
|
||||
assert!(is_mesh_path("a.3MF") && is_mesh_path("b.stl") && !is_mesh_path("c.step"));
|
||||
}
|
||||
|
||||
/// Face-tagged 3MF: every triangle carries its B-rep face, every coordinate is the
|
||||
/// tessellation's own `f64`, the bytes are deterministic, and the package stays plain 3MF.
|
||||
#[test]
|
||||
fn tagged_threemf_carries_faces_exactly() {
|
||||
use std::io::Read;
|
||||
let mut doc = Document::new("t");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(0.1, -0.3, 1.0 / 3.0)),
|
||||
length: "40".into(),
|
||||
width: "30".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(0.1, -0.3, 5.0 + 1.0 / 3.0)],
|
||||
radius: "4".into(),
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
fit: Default::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
let st = m.state();
|
||||
let body = &st.bodies[0].body;
|
||||
let faces = body.face_count();
|
||||
assert_eq!(faces, 7);
|
||||
let bytes = export(&st, &[], Format::ThreeMfTagged, "t").unwrap();
|
||||
assert_eq!(bytes, export(&st, &[], Format::ThreeMfTagged, "t").unwrap(), "deterministic bytes");
|
||||
assert_eq!(Format::from_name("3mf-tagged").unwrap(), Format::ThreeMfTagged);
|
||||
assert_eq!(Format::from_name("part.3mf").unwrap(), Format::ThreeMf);
|
||||
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("3D/3dmodel.model").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(xml.contains(&format!("xmlns:ft=\"{}\"", threemf::FACE_TAGS_NS)), "{}", &xml[..400]);
|
||||
assert!(xml.contains(&format!("ft:faces=\"{faces}\"")));
|
||||
let tris: Vec<&str> = xml.lines().filter(|l| l.trim_start().starts_with("<triangle ")).collect();
|
||||
assert!(!tris.is_empty() && tris.iter().all(|l| l.contains(" ft:face=\"")));
|
||||
let mut seen: Vec<usize> =
|
||||
tris.iter().map(|l| l.split("ft:face=\"").nth(1).and_then(|r| r.split('"').next()).unwrap().parse().unwrap()).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
assert_eq!(seen, (0..faces).collect::<Vec<_>>(), "every face is tagged");
|
||||
// Coordinates are the tessellation's own values, bit for bit.
|
||||
let exact: std::collections::HashSet<u64> =
|
||||
export_mesh(body).unwrap().positions.iter().flat_map(|p| [p.x, p.y, p.z]).map(|v| (v + 0.0).to_bits()).collect();
|
||||
let attr = |l: &str, k: &str| l.split(&format!(" {k}=\"")).nth(1).and_then(|r| r.split('"').next()).unwrap().parse::<f64>().unwrap();
|
||||
for l in xml.lines().filter(|l| l.trim_start().starts_with("<vertex ")) {
|
||||
for k in ["x", "y", "z"] {
|
||||
assert!(exact.contains(&attr(l, k).to_bits()), "{l}");
|
||||
}
|
||||
}
|
||||
// Any 3MF reader opens it: SolidCraft's own ignores the tags and gets a closed mesh.
|
||||
let f = mesh_import_feature(&bytes, "tagged.3mf").unwrap();
|
||||
let mut d2 = Document::new("m");
|
||||
let id = d2.add_feature(f.kind, Some(&f.name)).unwrap();
|
||||
let mut m2 = Model::new();
|
||||
m2.evaluate(&d2);
|
||||
assert!(m2.result(id).unwrap().error.is_none());
|
||||
assert!(m2.state().bodies[0].body.is_closed_mesh());
|
||||
// The plain export is unchanged: no tags.
|
||||
let plain = export(&st, &[], Format::ThreeMf, "t").unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(plain.as_slice())).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("3D/3dmodel.model").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(!xml.contains("ft:"));
|
||||
}
|
||||
}
|
||||
+58
-19
@@ -2,7 +2,10 @@
|
||||
//! `[Content_Types].xml`, `_rels/.rels` and the model part `3D/3dmodel.model`.
|
||||
//!
|
||||
//! Writing: one mesh object per body (shared vertices, outward winding), named after the body,
|
||||
//! one build item each, colours as base materials. Reading: every build item becomes one mesh
|
||||
//! one build item each, colours as base materials. Objects with face tags also carry, per
|
||||
//! triangle, the B-rep face it lies on (`ft:face`, SolidCraft's face-tag namespace; spec:
|
||||
//! docs/formats/3mf-face-tags.md) and are written at full `f64`
|
||||
//! precision so the receiver gets the tessellation exactly. Reading: every build item becomes one mesh
|
||||
//! (components flattened with their transforms), converted to millimetres. Packages are
|
||||
//! untrusted: sizes, entry counts, mesh sizes and component nesting are capped.
|
||||
|
||||
@@ -28,6 +31,9 @@ const MAX_XML_DEPTH: usize = 256;
|
||||
|
||||
const CORE_NS: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
|
||||
const MODEL_REL: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel";
|
||||
/// SolidCraft's face-tag namespace, version 1: `ft:face` on triangles, `ft:faces` on objects
|
||||
/// (docs/formats/3mf-face-tags.md).
|
||||
pub const FACE_TAGS_NS: &str = "https://github.com/bherbruck/solidcraft/formats/3mf-face-tags/2026/10";
|
||||
|
||||
/// A named triangle mesh (millimetres, counter-clockwise seen from outside).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -43,6 +49,17 @@ pub struct MeshObject {
|
||||
/// empty when no triangle has one.
|
||||
pub looks: Vec<([f32; 3], f32)>,
|
||||
pub tri_looks: Vec<u32>,
|
||||
/// The B-rep face each triangle lies on, or empty for no face tags. A tagged object is
|
||||
/// written with exact (`f64`) coordinates.
|
||||
pub tri_faces: Vec<u32>,
|
||||
/// Number of B-rep faces (written as `ft:faces` when the object is tagged).
|
||||
pub faces: u32,
|
||||
}
|
||||
|
||||
impl MeshObject {
|
||||
fn tagged(&self) -> bool {
|
||||
!self.tri_faces.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
@@ -61,14 +78,24 @@ fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// Shared vertices (merged at single precision, as written) and non-degenerate triangles.
|
||||
fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<([u32; 3], u32)>) {
|
||||
let mut map: HashMap<[u32; 3], u32> = HashMap::new();
|
||||
let mut verts: Vec<[f32; 3]> = Vec::new();
|
||||
/// A triangle as written: its vertices, its colour entry (0: the object's own) and its face.
|
||||
struct Tri {
|
||||
v: [u32; 3],
|
||||
look: u32,
|
||||
face: u32,
|
||||
}
|
||||
|
||||
/// Shared vertices, merged at the precision they are written in (single, or double for tagged
|
||||
/// objects), and non-degenerate triangles.
|
||||
fn indexed(m: &MeshObject) -> (Vec<[f64; 3]>, Vec<Tri>) {
|
||||
let exact = m.tagged();
|
||||
let mut map: HashMap<[u64; 3], u32> = HashMap::new();
|
||||
let mut verts: Vec<[f64; 3]> = Vec::new();
|
||||
let mut remap = Vec::with_capacity(m.positions.len());
|
||||
for p in &m.positions {
|
||||
let v = [p.x as f32 + 0.0, p.y as f32 + 0.0, p.z as f32 + 0.0];
|
||||
let key = v.map(f32::to_bits);
|
||||
// `+ 0.0` turns -0 into 0, so both merge.
|
||||
let v = if exact { [p.x + 0.0, p.y + 0.0, p.z + 0.0] } else { [p.x as f32 as f64 + 0.0, p.y as f32 as f64 + 0.0, p.z as f32 as f64 + 0.0] };
|
||||
let key = v.map(f64::to_bits);
|
||||
let next = u32::try_from(verts.len()).unwrap_or(u32::MAX);
|
||||
let i = *map.entry(key).or_insert_with(|| {
|
||||
verts.push(v);
|
||||
@@ -82,7 +109,11 @@ fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<([u32; 3], u32)>) {
|
||||
.enumerate()
|
||||
.filter_map(|(k, t)| {
|
||||
let (a, b, c) = (*remap.get(t[0] as usize)?, *remap.get(t[1] as usize)?, *remap.get(t[2] as usize)?);
|
||||
(a != b && b != c && c != a).then_some(([a, b, c], m.tri_looks.get(k).copied().unwrap_or(0)))
|
||||
(a != b && b != c && c != a).then(|| Tri {
|
||||
v: [a, b, c],
|
||||
look: m.tri_looks.get(k).copied().unwrap_or(0),
|
||||
face: m.tri_faces.get(k).copied().unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(verts, tris)
|
||||
@@ -210,7 +241,8 @@ pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps
|
||||
pub fn model_xml(objects: &[MeshObject]) -> String {
|
||||
let mut x = String::new();
|
||||
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\">");
|
||||
let ft = if objects.iter().any(MeshObject::tagged) { format!(" xmlns:ft=\"{FACE_TAGS_NS}\"") } else { String::new() };
|
||||
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\"{ft}>");
|
||||
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n <resources>\n");
|
||||
let colored = objects.iter().any(|o| o.color.is_some() || o.transparency > 0.0 || !o.looks.is_empty());
|
||||
// Base material group id 1 (each object's own colour, then its faces' colours); objects
|
||||
@@ -240,22 +272,29 @@ pub fn model_xml(objects: &[MeshObject]) -> String {
|
||||
let id = k + 2;
|
||||
let own = first.get(k).copied().unwrap_or(0);
|
||||
let mat = if colored { format!(" pid=\"1\" pindex=\"{own}\"") } else { String::new() };
|
||||
let _ = writeln!(x, " <object id=\"{id}\" type=\"model\" name=\"{}\"{mat}>", esc(&o.name));
|
||||
let faces = if o.tagged() { format!(" ft:faces=\"{}\"", o.faces.max(1)) } else { String::new() };
|
||||
let _ = writeln!(x, " <object id=\"{id}\" type=\"model\" name=\"{}\"{mat}{faces}>", esc(&o.name));
|
||||
x.push_str(" <mesh>\n <vertices>\n");
|
||||
let (verts, tris) = indexed(o);
|
||||
for v in &verts {
|
||||
let _ = writeln!(x, " <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>", v[0], v[1], v[2]);
|
||||
// Shortest round-trip form: f64 comes back exactly, f32 as written before.
|
||||
if o.tagged() {
|
||||
let _ = writeln!(x, " <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>", v[0], v[1], v[2]);
|
||||
} else {
|
||||
let _ = writeln!(x, " <vertex x=\"{}\" y=\"{}\" z=\"{}\"/>", v[0] as f32, v[1] as f32, v[2] as f32);
|
||||
}
|
||||
}
|
||||
x.push_str(" </vertices>\n <triangles>\n");
|
||||
for (t, look) in &tris {
|
||||
match look {
|
||||
l if colored && *l > 0 && (*l as usize) <= o.looks.len() => {
|
||||
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\" pid=\"1\" p1=\"{}\"/>", t[0], t[1], t[2], own + *l as usize);
|
||||
}
|
||||
_ => {
|
||||
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>", t[0], t[1], t[2]);
|
||||
}
|
||||
for t in &tris {
|
||||
let [a, b, c] = t.v;
|
||||
let _ = write!(x, " <triangle v1=\"{a}\" v2=\"{b}\" v3=\"{c}\"");
|
||||
if colored && t.look > 0 && (t.look as usize) <= o.looks.len() {
|
||||
let _ = write!(x, " pid=\"1\" p1=\"{}\"", own + t.look as usize);
|
||||
}
|
||||
if o.tagged() {
|
||||
let _ = write!(x, " ft:face=\"{}\"", t.face);
|
||||
}
|
||||
x.push_str("/>\n");
|
||||
}
|
||||
x.push_str(" </triangles>\n </mesh>\n </object>\n");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user