Export 3MF with B-rep face tags (#834)

3MF export carries each triangle's B-rep face tag.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 16:29:09 -04:00
1 parent ff7a347013
commit 5d661fa2af
10 files changed
+286 -47

No files matched your search

+115 -21
View File
@@ -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:"));
}
}