forked from 1b-agent/fieldcraft-upstream
engine: mesh.export.stl writes the outer surface or a set's faces
Binary by default, ASCII on request; in the Mesh tab's Verify panel. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
87710a83ab
commit
1d71a9f8af
12 files changed
+166
-5
No files matched your search
@@ -92,6 +92,7 @@ is committed and listed here with its exports.
|
||||
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||
<title>mesh.export.stl</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path d="M3 9 L9 5 L15 9 L9 13 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 9 L9 21 M9 13 L15 17" fill="none" stroke="#dde2ea" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 817 B |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"shapes": [{"d": "M3 9 L9 5 L15 9 L9 13 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 9 L3 17 L9 21 L9 13 M9 21 L15 17 L15 9", "stroke": "line"}, {"d": "M3 9 L9 21 M9 13 L15 17", "stroke": "line", "width": 1}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
|
||||
@@ -399,6 +399,7 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
|
||||
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
|
||||
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
|
||||
ready("mesh.export.stl", "Export STL", Mesh, "Verify", "Write the mesh's outer surface (or a set's faces) as binary STL"),
|
||||
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
|
||||
// ---- Properties ----
|
||||
ready_more(
|
||||
|
||||
@@ -1704,6 +1704,12 @@ pub const DECK: &[Field] = &[
|
||||
references("steps", "Steps", C::Step).help("The steps to write; all when empty.").hint("all"),
|
||||
];
|
||||
|
||||
pub const EXPORT_STL: &[Field] = &[
|
||||
path("path", "File", true).help("Where to write the STL file.").req(),
|
||||
reference("set", "Set", C::Set).help("Write only this set's element faces; the whole outer surface when empty.").hint("the outer surface"),
|
||||
choice("format", "Format", &[("binary", "Binary"), ("ascii", "ASCII")]).help("Binary is compact; ASCII is readable text.").def("\"binary\""),
|
||||
];
|
||||
|
||||
// ---- Solving ----
|
||||
|
||||
const STEP: Field = reference("step", "Step", C::Step).help("The step to solve; the only one when empty.").hint("the only one");
|
||||
|
||||
@@ -67,6 +67,16 @@ pub const QUALITY: CommandSpec = CommandSpec {
|
||||
run: quality_report,
|
||||
};
|
||||
|
||||
pub const EXPORT_STL: CommandSpec = CommandSpec {
|
||||
id: "mesh.export.stl",
|
||||
label: "Export STL",
|
||||
params: r#"{"path": "skin.stl", "set": "top", "format": "binary|ascii"} — writes the mesh's outer surface as STL: every solid element face no other solid shares, and the top side (face 1) of every shell; `set` (optional) writes only that set's element faces instead; `format` defaults to binary. Quadrilaterals are split in two, normals point out of the elements, and coordinates are rounded to single precision (STL stores f32). Replies {path, triangles, bytes}"#,
|
||||
form: Some(super::forms::EXPORT_STL),
|
||||
kind: CommandKind::Session,
|
||||
requires: &[Requirement::Mesh],
|
||||
run: export_stl,
|
||||
};
|
||||
|
||||
pub const DELAUNAY_POINTS: CommandSpec = CommandSpec {
|
||||
id: "mesh.delaunay.points",
|
||||
label: "Delaunay of Points",
|
||||
@@ -553,6 +563,53 @@ fn number(v: f64) -> Value {
|
||||
if v.is_finite() { json!(v) } else { json!(if v > 0.0 { "inf" } else { "-inf" }) }
|
||||
}
|
||||
|
||||
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let set: Option<String> = super::opt(p, "set")?;
|
||||
let format: Option<String> = super::opt(p, "format")?;
|
||||
let ascii = match format.as_deref() {
|
||||
None | Some("binary") => false,
|
||||
Some("ascii") => true,
|
||||
Some(other) => {
|
||||
let shown: String = other.chars().take(32).collect();
|
||||
return Err(EngineError::Params(format!("unknown STL format `{shown}`; known: binary, ascii")));
|
||||
}
|
||||
};
|
||||
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
|
||||
let m = &s.model;
|
||||
if m.elements().is_empty() {
|
||||
return Err(EngineError::Params("the mesh has no elements".into()));
|
||||
}
|
||||
let faces = match &set {
|
||||
Some(name) => {
|
||||
let shown: String = name.chars().take(64).collect();
|
||||
if !m.sets().contains_key(name) {
|
||||
return Err(EngineError::Params(format!("no set `{shown}`")));
|
||||
}
|
||||
let faces = m.resolve_set(name)?.faces;
|
||||
if faces.is_empty() {
|
||||
return Err(EngineError::Params(format!("set `{shown}` has no element faces")));
|
||||
}
|
||||
faces
|
||||
}
|
||||
None => {
|
||||
let faces = m.skin_faces();
|
||||
if faces.is_empty() {
|
||||
return Err(EngineError::Params("the mesh has only line elements: no surface to write".into()));
|
||||
}
|
||||
faces
|
||||
}
|
||||
};
|
||||
let bytes = if ascii {
|
||||
let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
fieldcraft_io::stl::write_ascii(m, &faces, &name)?.into_bytes()
|
||||
} else {
|
||||
fieldcraft_io::stl::write_binary(m, &faces)?
|
||||
};
|
||||
let triangles = fieldcraft_io::stl::triangles(m, &faces)?.len();
|
||||
fieldcraft_io::fs::write_atomic(std::path::Path::new(&path), &bytes, false)?;
|
||||
Ok(json!({"path": path, "triangles": triangles, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bins = match p.get("bins") {
|
||||
None => 10,
|
||||
@@ -671,6 +728,63 @@ fn surface_check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn export_stl_writes_the_skin_or_a_set() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-stl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("skin.stl");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("mesh.export.stl", &json!({"path": path_str})).is_err(), "no mesh");
|
||||
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||
let journal = s.journal.len();
|
||||
let out = s.execute("mesh.export.stl", &json!({"path": path_str})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(!bytes.starts_with(b"solid"));
|
||||
let skin = fieldcraft_io::stl::triangles(&s.model, &s.model.skin_faces()).unwrap().len();
|
||||
assert!(skin >= s.model.skin_faces().len() && skin > 0);
|
||||
assert_eq!(out["triangles"], skin);
|
||||
assert_eq!(bytes.len(), 84 + 50 * skin);
|
||||
assert_eq!(out["bytes"], bytes.len());
|
||||
assert_eq!(s.journal.len(), journal, "exporting does not change the model");
|
||||
|
||||
// A set with faces writes only those; one without faces, or an unknown one, is refused.
|
||||
let faces = s.model.skin_faces();
|
||||
let two: Vec<serde_json::Value> = faces.iter().take(2).map(|f| json!({"element": f.element, "face": f.face})).collect();
|
||||
let first = *s.model.elements().keys().next().unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "stl_faces", "faces": two})).unwrap();
|
||||
s.execute("model.set.create", &json!({"name": "stl_elements", "elements": [first]})).unwrap();
|
||||
let out = s.execute("mesh.export.stl", &json!({"path": path_str, "set": "stl_faces"})).unwrap();
|
||||
assert_eq!(out["triangles"], fieldcraft_io::stl::triangles(&s.model, &faces[..2]).unwrap().len());
|
||||
let other = dir.join("other.stl").to_string_lossy().into_owned();
|
||||
let e = s.execute("mesh.export.stl", &json!({"path": other, "set": "nope"})).unwrap_err().to_string();
|
||||
assert!(e.contains("no set `nope`"), "{e}");
|
||||
let e = s.execute("mesh.export.stl", &json!({"path": other, "set": "stl_elements"})).unwrap_err().to_string();
|
||||
assert!(e.contains("has no element faces"), "{e}");
|
||||
let e = s.execute("mesh.export.stl", &json!({"path": other, "format": "obj"})).unwrap_err().to_string();
|
||||
assert!(e.contains("known: binary, ascii"), "{e}");
|
||||
assert!(!dir.join("other.stl").exists());
|
||||
|
||||
let out = s.execute("mesh.export.stl", &json!({"path": path_str, "format": "ascii"})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.starts_with("solid skin\n"));
|
||||
assert_eq!(text.matches("facet normal").count(), skin);
|
||||
assert_eq!(out["triangles"], skin);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_stl_refuses_a_mesh_of_lines() {
|
||||
let mut s = Session::default();
|
||||
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
|
||||
s.execute("mesh.node.add", &json!({"label": 2, "xyz": [1, 0, 0]})).unwrap();
|
||||
s.execute("mesh.element.add", &json!({"label": 1, "kind": "rod2", "nodes": [1, 2]})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("fieldcraft-stl-lines-{}.stl", std::process::id()));
|
||||
let e = s.execute("mesh.export.stl", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("only line elements"), "{e}");
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -74,6 +74,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::ELEMENT_REMOVE,
|
||||
replace::CLEAR,
|
||||
mesh::QUALITY,
|
||||
mesh::EXPORT_STL,
|
||||
mesh::DELAUNAY_POINTS,
|
||||
mesh::SURFACE_CHECK,
|
||||
mesh::PLANAR,
|
||||
|
||||
@@ -545,6 +545,10 @@ mod tests {
|
||||
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
|
||||
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
|
||||
json!({"path": 7}),
|
||||
json!({"path": "x\u{0}.stl"}),
|
||||
json!({"path": "/no/such/dir/skin.stl", "set": 7}),
|
||||
json!({"path": "/no/such/dir/skin.stl", "set": "x".repeat(100_000)}),
|
||||
json!({"path": "/no/such/dir/skin.stl", "format": ["ascii"]}),
|
||||
// (`max_points` and `max_passes` keep mesh.tet.generate's quality stage small.)
|
||||
json!({"body": "Body1", "tolerance": 0.05, "max_points": 3000, "max_passes": 1}),
|
||||
json!({"body": null, "tolerance": "fine"}),
|
||||
|
||||
+12
-1
@@ -176,7 +176,18 @@ mod tests {
|
||||
#[test]
|
||||
fn tet10_and_wedge6_close_with_outward_normals() {
|
||||
let mut t = Model::default();
|
||||
let p = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.], [0., 0., 0.5], [0.5, 0., 0.5], [0., 0.5, 0.5]];
|
||||
let p = [
|
||||
[0., 0., 0.],
|
||||
[1., 0., 0.],
|
||||
[0., 1., 0.],
|
||||
[0., 0., 1.],
|
||||
[0.5, 0., 0.],
|
||||
[0.5, 0.5, 0.],
|
||||
[0., 0.5, 0.],
|
||||
[0., 0., 0.5],
|
||||
[0.5, 0., 0.5],
|
||||
[0., 0.5, 0.5],
|
||||
];
|
||||
for (i, xyz) in p.iter().enumerate() {
|
||||
t.set_node(i as u32 + 1, *xyz).unwrap();
|
||||
}
|
||||
|
||||
@@ -403,9 +403,20 @@ mod tests {
|
||||
assert_eq!(m.skin_faces().len(), 10, "two hex8 sharing a face");
|
||||
|
||||
let mut t = Model::default();
|
||||
for (i, p) in [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0.5, 0., 0.], [0.5, 0.5, 0.], [0., 0.5, 0.], [0., 0., 0.5], [0.5, 0., 0.5], [0., 0.5, 0.5]]
|
||||
.iter()
|
||||
.enumerate()
|
||||
for (i, p) in [
|
||||
[0., 0., 0.],
|
||||
[1., 0., 0.],
|
||||
[0., 1., 0.],
|
||||
[0., 0., 1.],
|
||||
[0.5, 0., 0.],
|
||||
[0.5, 0.5, 0.],
|
||||
[0., 0.5, 0.],
|
||||
[0., 0., 0.5],
|
||||
[0.5, 0., 0.5],
|
||||
[0., 0.5, 0.5],
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
t.set_node(i as u32 + 1, *p).unwrap();
|
||||
}
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
|
||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
|
||||
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||
|
||||
73 ready, 39 coming soon.
|
||||
74 ready, 39 coming soon.
|
||||
|
||||
## Geometry
|
||||
|
||||
@@ -57,6 +57,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||
| Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | |
|
||||
| Verify | Quality — Quality metrics and the certified validity check, failing elements listed | ready | `mesh.quality` |
|
||||
| Verify | Verify Set — Quality and validity per set | coming soon (M2) | |
|
||||
| Verify | Export STL — Write the mesh's outer surface (or a set's faces) as binary STL | ready | `mesh.export.stl` |
|
||||
| Verify | Tag Geometry — Tag elements, faces, edges and nodes with the geometry they mesh | ready | `mesh.tag`, `mesh.tag.clear` |
|
||||
|
||||
## Properties
|
||||
|
||||
Reference in new issue
Block a user