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 <noreply@anthropic.com> 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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user