From 5d661fa2af7a3f23ea225c09a1790604f70e38f9 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Tue, 13 Oct 2026 16:29:09 -0400 Subject: [PATCH] Export 3MF with B-rep face tags (#834) 3MF export carries each triangle's B-rep face tag. --- ROADMAP.md | 2 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/tests.rs | 8 ++ crates/io/src/lib.rs | 136 ++++++++++++++++++++++++++++------ crates/io/src/threemf.rs | 77 ++++++++++++++----- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/src/toolbar.rs | 18 ++++- docs/book/src/guide/files.md | 7 ++ docs/formats/3mf-face-tags.md | 79 ++++++++++++++++++++ docs/mcp.md | 2 +- 10 files changed, 286 insertions(+), 47 deletions(-) create mode 100644 docs/formats/3mf-face-tags.md diff --git a/ROADMAP.md b/ROADMAP.md index 043c4943..089e0255 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | | 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 | -| 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 | | 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 | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 107c135a..3a3dc474 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -45,7 +45,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("file.export", "Export", export) .icon("export") .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"), ]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0fd01055..99522a98 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1168,6 +1168,14 @@ fn threemf_export_open_insert() { let p = dir.join("Parts.3MF"); run(&mut s, "file.export", json!({"path": p.to_string_lossy()})); 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 r = run(&mut m, "doc.open", json!({"path": p.to_string_lossy()})); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e7f299d1..d5f7b831 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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 = 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 = 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::>()?; + Format::ThreeMf | Format::ThreeMfTagged => { + let objs: Vec = sel.iter().map(|b| mesh_object(b, format == Format::ThreeMfTagged)).collect::>()?; 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 { + 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::>(), 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 = 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(" = + 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::>(), "every face is tagged"); + // Coordinates are the tessellation's own values, bit for bit. + let exact: std::collections::HashSet = + 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::().unwrap(); + for l in xml.lines().filter(|l| l.trim_start().starts_with(", pub tri_looks: Vec, + /// 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, + /// 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) { + 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("\n"); - let _ = writeln!(x, ""); + let ft = if objects.iter().any(MeshObject::tagged) { format!(" xmlns:ft=\"{FACE_TAGS_NS}\"") } else { String::new() }; + let _ = writeln!(x, ""); x.push_str(" SolidCraft\n \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, " ", esc(&o.name)); + let faces = if o.tagged() { format!(" ft:faces=\"{}\"", o.faces.max(1)) } else { String::new() }; + let _ = writeln!(x, " ", esc(&o.name)); x.push_str(" \n \n"); let (verts, tris) = indexed(o); for v in &verts { - let _ = writeln!(x, " ", v[0], v[1], v[2]); + // Shortest round-trip form: f64 comes back exactly, f32 as written before. + if o.tagged() { + let _ = writeln!(x, " ", v[0], v[1], v[2]); + } else { + let _ = writeln!(x, " ", v[0] as f32, v[1] as f32, v[2] as f32); + } } x.push_str(" \n \n"); - for (t, look) in &tris { - match look { - l if colored && *l > 0 && (*l as usize) <= o.looks.len() => { - let _ = writeln!(x, " ", t[0], t[1], t[2], own + *l as usize); - } - _ => { - let _ = writeln!(x, " ", t[0], t[1], t[2]); - } + for t in &tris { + let [a, b, c] = t.v; + let _ = write!(x, " 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(" \n \n \n"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6d849019..1b054dff 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -181,7 +181,7 @@ pub fn tool_definitions() -> Vec { obj( json!({ "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)"}, }), &["path"], diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 19dcd528..e91b4cd2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -428,6 +428,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { export(app, "3mf"); 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) { export(app, "obj"); close = true; @@ -488,10 +492,18 @@ fn save_as_with_extension(app: &mut SolveApp, extension: &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); - if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext])) - && app.run("file.export", json!({"path": p})).is_ok() - { + let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext])) else { return }; + 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); } } diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index a412bceb..87800ba9 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -7,6 +7,7 @@ | 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 | | 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 | | 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 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 replaced version is kept as `.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 diff --git a/docs/formats/3mf-face-tags.md b/docs/formats/3mf-face-tags.md new file mode 100644 index 00000000..aeb5486f --- /dev/null +++ b/docs/formats/3mf-face-tags.md @@ -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 `` and ``, 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 + + + SolidCraft + + + + + + ... + + + + ... + + + + + + + + +``` + +## Attributes + +| Attribute | On | Value | Meaning | +|---|---|---|---| +| `ft:faces` | `` | 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` | `` | 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 `` with a `` per body, + named after the body, and one `` 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//`). A reader accepts the +versions it knows and treats an unknown face-tag namespace as untagged. diff --git a/docs/mcp.md b/docs/mcp.md index a875c7fb..c9ef50f0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. | | `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). | -| `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. | | `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. |