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

+1 -1
View File
@@ -47,7 +47,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | | M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | | M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
+1 -1
View File
@@ -45,7 +45,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.export", "Export", export) CommandSpec::new("file.export", "Export", export)
.icon("export") .icon("export")
.noundo() .noundo()
.params("path; format?: stl|stla|obj|step|3mf|json (default from extension); bodies?: [names] (JSON exports the complete design)"), .params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md); bodies?: [names] (JSON exports the complete design)"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"), CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
]; ];
+8
View File
@@ -1168,6 +1168,14 @@ fn threemf_export_open_insert() {
let p = dir.join("Parts.3MF"); let p = dir.join("Parts.3MF");
run(&mut s, "file.export", json!({"path": p.to_string_lossy()})); run(&mut s, "file.export", json!({"path": p.to_string_lossy()}));
let want = volume(&mut s); let want = volume(&mut s);
// With face tags it is still a 3MF any reader opens, with the same bodies.
let tagged = dir.join("tagged.3mf");
let r = run(&mut s, "file.export", json!({"path": tagged.to_string_lossy(), "format": "3mf-tagged"}));
assert_eq!(r["format"], "ThreeMfTagged", "{r}");
let mut t = Session::default();
let r = run(&mut t, "doc.open", json!({"path": tagged.to_string_lossy()}));
assert_eq!(r["bodies"].as_array().unwrap().len(), 2, "{r}");
assert!(rel(volume(&mut t), want) < 2e-3, "{} vs {want}", volume(&mut t));
let mut m = Session::default(); let mut m = Session::default();
let r = run(&mut m, "doc.open", json!({"path": p.to_string_lossy()})); let r = run(&mut m, "doc.open", json!({"path": p.to_string_lossy()}));
+115 -21
View File
@@ -197,6 +197,10 @@ pub enum Format {
Step, Step,
Iges, Iges,
ThreeMf, 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, Design,
} }
@@ -211,6 +215,7 @@ impl Format {
"step" | "stp" => Format::Step, "step" | "stp" => Format::Step,
"igs" | "iges" => Format::Iges, "igs" | "iges" => Format::Iges,
"3mf" => Format::ThreeMf, "3mf" => Format::ThreeMf,
"3mf-tagged" => Format::ThreeMfTagged,
"solidcraft" | "solvecraft" | "json" => Format::Design, "solidcraft" | "solvecraft" | "json" => Format::Design,
_ => return Err(IoError::Format(ext)), _ => 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() }; let header = solidcraft_kernel::StepHeader { file_name: format!("{name}.igs"), ..Default::default() };
solidcraft_kernel::iges_export_bodies(&bs, &header)?.into_bytes() solidcraft_kernel::iges_export_bodies(&bs, &header)?.into_bytes()
} }
Format::ThreeMf => { Format::ThreeMf | Format::ThreeMfTagged => {
let objs: Vec<MeshObject> = sel let objs: Vec<MeshObject> = sel.iter().map(|b| mesh_object(b, format == Format::ThreeMfTagged)).collect::<Result<_>>()?;
.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<_>>()?;
write_3mf(&objs)? write_3mf(&objs)?
} }
Format::Design => return Err(IoError::Format("solidcraft (save the document instead)".into())), 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 /// 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 /// the component's own frame (`local` is the model before placing occurrences), and an
/// assembly occurrence per component occurrence. /// assembly occurrence per component occurrence.
@@ -695,4 +713,80 @@ mod tests {
assert!(mesh_import_feature(&[0u8; 84], "x.stl").is_err()); 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")); 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
View File
@@ -2,7 +2,10 @@
//! `[Content_Types].xml`, `_rels/.rels` and the model part `3D/3dmodel.model`. //! `[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, //! 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 //! (components flattened with their transforms), converted to millimetres. Packages are
//! untrusted: sizes, entry counts, mesh sizes and component nesting are capped. //! 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 CORE_NS: &str = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02";
const MODEL_REL: &str = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"; 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). /// A named triangle mesh (millimetres, counter-clockwise seen from outside).
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
@@ -43,6 +49,17 @@ pub struct MeshObject {
/// empty when no triangle has one. /// empty when no triangle has one.
pub looks: Vec<([f32; 3], f32)>, pub looks: Vec<([f32; 3], f32)>,
pub tri_looks: Vec<u32>, 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 { fn esc(s: &str) -> String {
@@ -61,14 +78,24 @@ fn esc(s: &str) -> String {
o o
} }
/// Shared vertices (merged at single precision, as written) and non-degenerate triangles. /// A triangle as written: its vertices, its colour entry (0: the object's own) and its face.
fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<([u32; 3], u32)>) { struct Tri {
let mut map: HashMap<[u32; 3], u32> = HashMap::new(); v: [u32; 3],
let mut verts: Vec<[f32; 3]> = Vec::new(); 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()); let mut remap = Vec::with_capacity(m.positions.len());
for p in &m.positions { 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]; // `+ 0.0` turns -0 into 0, so both merge.
let key = v.map(f32::to_bits); 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 next = u32::try_from(verts.len()).unwrap_or(u32::MAX);
let i = *map.entry(key).or_insert_with(|| { let i = *map.entry(key).or_insert_with(|| {
verts.push(v); verts.push(v);
@@ -82,7 +109,11 @@ fn indexed(m: &MeshObject) -> (Vec<[f32; 3]>, Vec<([u32; 3], u32)>) {
.enumerate() .enumerate()
.filter_map(|(k, t)| { .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)?); 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(); .collect();
(verts, tris) (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 { pub fn model_xml(objects: &[MeshObject]) -> String {
let mut x = String::new(); let mut x = String::new();
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); 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"); 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()); 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 // 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 id = k + 2;
let own = first.get(k).copied().unwrap_or(0); let own = first.get(k).copied().unwrap_or(0);
let mat = if colored { format!(" pid=\"1\" pindex=\"{own}\"") } else { String::new() }; 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"); x.push_str(" <mesh>\n <vertices>\n");
let (verts, tris) = indexed(o); let (verts, tris) = indexed(o);
for v in &verts { 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"); x.push_str(" </vertices>\n <triangles>\n");
for (t, look) in &tris { for t in &tris {
match look { let [a, b, c] = t.v;
l if colored && *l > 0 && (*l as usize) <= o.looks.len() => { let _ = write!(x, " <triangle v1=\"{a}\" v2=\"{b}\" v3=\"{c}\"");
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\" pid=\"1\" p1=\"{}\"/>", t[0], t[1], t[2], own + *l as usize); if colored && t.look > 0 && (t.look as usize) <= o.looks.len() {
} let _ = write!(x, " pid=\"1\" p1=\"{}\"", own + t.look as usize);
_ => {
let _ = writeln!(x, " <triangle v1=\"{}\" v2=\"{}\" v3=\"{}\"/>", t[0], t[1], t[2]);
}
} }
if o.tagged() {
let _ = write!(x, " ft:face=\"{}\"", t.face);
}
x.push_str("/>\n");
} }
x.push_str(" </triangles>\n </mesh>\n </object>\n"); x.push_str(" </triangles>\n </mesh>\n </object>\n");
} }
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn tool_definitions() -> Vec<Value> {
obj( obj(
json!({ json!({
"path": string("Output file"), "path": string("Output file"),
"format": {"type": "string", "enum": ["step", "3mf", "stl", "stla", "obj"]}, "format": {"type": "string", "enum": ["step", "3mf", "3mf-tagged", "stl", "stla", "obj"]},
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Body names (default: all)"}, "bodies": {"type": "array", "items": {"type": "string"}, "description": "Body names (default: all)"},
}), }),
&["path"], &["path"],
+15 -3
View File
@@ -428,6 +428,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
export(app, "3mf"); export(app, "3mf");
close = true; close = true;
} }
if item_if(ui, "Export 3MF with Face Tags…", "", shown) {
export_as(app, "3mf", Some("3mf-tagged"));
close = true;
}
if item_if(ui, "Export OBJ…", "", shown) { if item_if(ui, "Export OBJ…", "", shown) {
export(app, "obj"); export(app, "obj");
close = true; close = true;
@@ -488,10 +492,18 @@ fn save_as_with_extension(app: &mut SolveApp, extension: &str) {
} }
fn export(app: &mut SolveApp, ext: &str) { fn export(app: &mut SolveApp, ext: &str) {
export_as(app, ext, None);
}
/// Export to a file with extension `ext`, in `format` when the extension alone doesn't name it.
fn export_as(app: &mut SolveApp, ext: &str, format: Option<&str>) {
let name = format!("{}.{ext}", app.session.doc.name); let name = format!("{}.{ext}", app.session.doc.name);
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext])) let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext])) else { return };
&& app.run("file.export", json!({"path": p})).is_ok() let params = match format {
{ Some(f) => json!({"path": p, "format": f}),
None => json!({"path": p}),
};
if app.run("file.export", params).is_ok() {
app.set_status(format!("Exported {p}"), false); app.set_status(format!("Exported {p}"), false);
} }
} }
+7
View File
@@ -7,6 +7,7 @@
| STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree | | STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree |
| IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours | | IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours |
| 3MF, STL, OBJ | mesh bodies | yes | | 3MF, STL, OBJ | mesh bodies | yes |
| 3MF with face tags | as 3MF | each triangle tagged with its B-rep face, exact coordinates |
| DXF, SVG | into a sketch | sketches and flat patterns as DXF | | DXF, SVG | into a sketch | sketches and flat patterns as DXF |
| PNG, JPEG | canvases and decals | | | PNG, JPEG | canvases and decals | |
@@ -28,6 +29,12 @@ tapers, stepped parts and organic scans are refused by this first reconstruction
method; it does not silently substitute a faceted solid. Conversion currently method; it does not silently substitute a faceted solid. Conversion currently
accepts at most 50,000 triangles and a bounded number of boundary segments. accepts at most 50,000 triangles and a bounded number of boundary segments.
**File → Export 3MF with Face Tags** (`file.export` with `format: "3mf-tagged"`) writes an
ordinary 3MF in which every triangle also names the B-rep face it lies on and every coordinate
is the tessellation's own double-precision value. Meshing and analysis tools can use the tags to
keep a model's faces (for loads, boundary conditions or feature edges); other 3MF readers ignore
them. The format is specified in [`docs/formats/3mf-face-tags.md`](https://github.com/bherbruck/solidcraft/blob/main/docs/formats/3mf-face-tags.md).
Saves are atomic: a crash at any moment leaves either the old file or the new one, and the Saves are atomic: a crash at any moment leaves either the old file or the new one, and the
replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder, replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder,
and the next launch offers to recover them. Older design files are upgraded on open, and files and the next launch offers to recover them. Older design files are upgraded on open, and files
+79
View File
@@ -0,0 +1,79 @@
# 3MF with B-rep face tags
Version 1 (2026-10). Namespace `https://github.com/bherbruck/solidcraft/formats/3mf-face-tags/2026/10`,
prefix `ft`.
A tessellated body loses its faces: a plain mesh says where the surface is, not which B-rep face
each triangle came from. Downstream tools that mesh or analyse a part need that back. They apply
loads and boundary conditions to faces, keep face boundaries as feature edges, and group
results by face. SolidCraft therefore exports a 3MF in which every triangle carries the index of
the B-rep face it lies on (**File → Export 3MF with Face Tags**, or `file.export` with
`format: "3mf-tagged"`).
## Why 3MF
The file is an ordinary **3MF package** (3MF Consortium, *3D Manufacturing Format Core
Specification* 1.3). The tags are one attribute in a namespace of SolidCraft's. The Core
specification allows that attribute on `<triangle>` and `<object>`, and every consumer ignores it
because the namespace is not listed in `requiredextensions`. Slicers, viewers and SolidCraft's
own mesh import open the file as plain 3MF. A tool that knows the namespace gets the faces too.
A purpose-built binary format would have needed its own specification and readers, and would
open nowhere else. Decimal coordinates lose nothing: written in shortest round-trip form, every
`f64` reads back bit for bit.
## Example
```xml
<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US"
xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
xmlns:ft="https://github.com/bherbruck/solidcraft/formats/3mf-face-tags/2026/10">
<metadata name="Application">SolidCraft</metadata>
<resources>
<object id="2" type="model" name="Body1" ft:faces="7">
<mesh>
<vertices>
<vertex x="0" y="0" z="0"/>
...
</vertices>
<triangles>
<triangle v1="0" v2="2" v3="1" ft:face="0"/>
...
</triangles>
</mesh>
</object>
</resources>
<build>
<item objectid="2"/>
</build>
</model>
```
## Attributes
| Attribute | On | Value | Meaning |
|---|---|---|---|
| `ft:faces` | `<object>` | integer ≥ 1 | Number of B-rep faces of the body. Every `ft:face` of the object is below it. Faces without triangles still count. |
| `ft:face` | `<triangle>` | integer ≥ 0 | The B-rep face (0-based, in the body's own face order) the triangle lies on. |
An object's triangles are either all tagged or none. A reader treats an object without tags as a
single face (tag 0).
## What SolidCraft writes
- `unit="millimeter"`, Z up. There is one `<object type="model">` with a `<mesh>` per body,
named after the body, and one `<item>` per object, with no transform and no components.
- The tessellation is the export mesh (chord tolerance 2·10⁻⁴ of the body size). It is welded
watertight: vertices within 10⁻⁶ of the body size are merged and T-junctions between faces are
split. Each piece of a split triangle keeps its face. Triangles are counter-clockwise seen from
outside, and degenerate triangles are dropped.
- Coordinates are the tessellation's own `f64` values in shortest round-trip form, and vertices
are shared by exact value.
- Colours use 3MF's own `basematerials` (`pid`/`p1`), as in the plain 3MF export, independent of
the tags.
- The bytes are deterministic: fixed part order, fixed zip timestamps, no hash-ordered output.
## Versions
A later version gets a new namespace (`…/3mf-face-tags/<year>/<month>`). A reader accepts the
versions it knows and treats an unknown face-tag namespace as untagged.
+1 -1
View File
@@ -65,7 +65,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | | `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). | | `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). |
| `export` | `path`, `format?` (step, 3mf, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. | | `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj), `bodies?` | STEP, 3MF or mesh export. |
| `undo`, `redo` | — | Undo / redo one change. | | `undo`, `redo` | — | Undo / redo one change. |
| `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). | | `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). |
| `new_design` | `name?` | Start an empty design. | | `new_design` | `name?` | Start an empty design. |