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
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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()}));
|
||||
|
||||
+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");
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<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
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user