forked from 1b-agent/fieldcraft-upstream
Face-tagged 3MF exchange: format spec, reader, geometry.import.mesh
docs/formats/tagged-mesh.md defines the exchange: 3MF Core with an optional ft:face attribute per triangle (and ft:faces per object) in a ModelCraft namespace. modelcraft_geometry::tagged_mesh::read_3mf reads it from bytes (wasm), capping every size and count and range-checking every index and coordinate. geometry.import.mesh adds the surfaces to the session, which keeps them beside the open design and restores them when a command fails. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
f36b1d9a69
commit
f181c98b75
13 files changed
+791
-6
No files matched your search
@@ -1,8 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::tagged_mesh::{self, MAX_FILE_BYTES};
|
||||
use modelcraft_geometry::{Shape, open_design_file};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::read_capped;
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
@@ -26,6 +28,13 @@ pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
pub const IMPORT_MESH: CommandSpec = CommandSpec {
|
||||
id: "geometry.import.mesh",
|
||||
label: "Import Tagged Mesh",
|
||||
params: r#"{"path": "part.3mf"} — adds each build item of a 3MF package as a surface; triangles keep their B-rep face tags (docs/formats/tagged-mesh.md)"#,
|
||||
run: import_mesh,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
@@ -77,3 +86,64 @@ fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let faces: Vec<Value> = shape.faces().iter().zip(per_face).map(|(f, n)| json!({"name": f.name, "triangles": n})).collect();
|
||||
Ok(json!({"body": shape.name, "vertices": mesh.positions.len(), "triangles": mesh.triangles.len(), "faces": faces}))
|
||||
}
|
||||
|
||||
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|x| !x.trim().is_empty() && x.len() < 4096)
|
||||
.ok_or_else(|| EngineError::Params("`path` must name a .3mf file".into()))?;
|
||||
let surfaces = tagged_mesh::read_3mf(&read_capped(path, MAX_FILE_BYTES)?)?;
|
||||
let first = s.surfaces.len();
|
||||
let added: Vec<Value> = surfaces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, t)| json!({"index": first + k, "name": t.name, "vertices": t.mesh.positions.len(), "triangles": t.mesh.triangles.len(), "faces": t.faces}))
|
||||
.collect();
|
||||
s.surfaces.extend(surfaces.into_iter().map(Arc::new));
|
||||
Ok(json!({"path": path, "surfaces": added}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn package(model: &str) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(model.as_bytes()).unwrap();
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_adds_tagged_surfaces_and_failures_change_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("modelcraft-import-mesh-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let model = format!(
|
||||
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:ft="{}"><resources>
|
||||
<object id="1" name="Tet" ft:faces="2"><mesh><vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/><vertex x="0" y="0" z="1"/></vertices>
|
||||
<triangles><triangle v1="0" v2="2" v3="1" ft:face="0"/><triangle v1="0" v2="1" v3="3" ft:face="1"/><triangle v1="1" v2="2" v3="3" ft:face="1"/><triangle v1="0" v2="3" v3="2" ft:face="1"/></triangles></mesh></object>
|
||||
</resources><build><item objectid="1"/></build></model>"#,
|
||||
tagged_mesh::FACE_TAGS_NS
|
||||
);
|
||||
let good = dir.join("tet.3mf");
|
||||
std::fs::write(&good, package(&model)).unwrap();
|
||||
let junk = dir.join("junk.3mf");
|
||||
std::fs::write(&junk, b"PK\x03\x04 not really").unwrap();
|
||||
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("geometry.import.mesh", &json!({"path": good.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["surfaces"][0], json!({"index": 0, "name": "Tet", "vertices": 4, "triangles": 4, "faces": 2}));
|
||||
assert_eq!(s.surfaces[0].mesh.tri_face, vec![0, 1, 1, 1]);
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
|
||||
for p in [json!({"path": junk.to_str().unwrap()}), json!({"path": dir.join("missing.3mf").to_str().unwrap()}), json!({"path": ""})] {
|
||||
assert!(s.execute("geometry.import.mesh", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.surfaces.len(), 1);
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
geometry::OPEN,
|
||||
geometry::BODIES,
|
||||
geometry::TESSELLATE,
|
||||
geometry::IMPORT_MESH,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
@@ -37,3 +38,17 @@ pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A file's bytes, refusing anything over `cap`.
|
||||
pub(crate) fn read_capped(path: &str, cap: usize) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let err = |e: std::io::Error| EngineError::File(format!("{path}: {e}"));
|
||||
let f = std::fs::File::open(path).map_err(err)?;
|
||||
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0).min(cap));
|
||||
f.take(cap as u64 + 1).read_to_end(&mut bytes).map_err(err)?;
|
||||
if bytes.len() > cap {
|
||||
return Err(EngineError::File(format!("{path}: larger than {} MB", cap >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! Everything a user can do is a command ([`CommandSpec`]) with a JSON form. The desktop UI,
|
||||
//! scripts, the CLI and the control channel all run the same commands through
|
||||
//! [`Session::execute`], which never lets a failure or a panic damage the model: the model is
|
||||
//! restored and an [`EngineError`] comes back.
|
||||
//! [`Session::execute`], which never lets a failure or a panic damage the session: the model and
|
||||
//! the surfaces are restored and an [`EngineError`] comes back.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod cmd;
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::Design;
|
||||
use modelcraft_geometry::tagged_mesh::TaggedSurface;
|
||||
use modelcraft_model::Model;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -25,6 +26,9 @@ pub enum EngineError {
|
||||
Model(#[from] modelcraft_model::Error),
|
||||
#[error(transparent)]
|
||||
Geometry(#[from] modelcraft_geometry::Error),
|
||||
/// A file could not be read or written.
|
||||
#[error("{0}")]
|
||||
File(String),
|
||||
/// A command panicked; the model is unchanged.
|
||||
#[error("internal error in {command}: {message}")]
|
||||
Internal { command: String, message: String },
|
||||
@@ -57,16 +61,21 @@ pub struct Session {
|
||||
pub model: Model,
|
||||
/// The open SolidCraft design (`geometry.open`); shared, so a snapshot costs nothing.
|
||||
pub design: Option<Arc<Design>>,
|
||||
/// Face-tagged boundary surfaces (`geometry.import.mesh`): the input to volume meshing.
|
||||
/// Each is shared, so the per-command snapshot is cheap.
|
||||
pub surfaces: Vec<Arc<TaggedSurface>>,
|
||||
/// Commands that changed the model, in order (a script that rebuilds it).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Run a command. On an error or a panic the model and the design are left as they were.
|
||||
/// Run a command. On an error or a panic the model, the design and the surfaces are left as
|
||||
/// they were.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let before = self.model.clone();
|
||||
let design_before = self.design.clone();
|
||||
let surfaces_before = self.surfaces.clone();
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let result = match outcome {
|
||||
Ok(r) => r,
|
||||
@@ -77,12 +86,15 @@ impl Session {
|
||||
}
|
||||
};
|
||||
let design_changed = self.design.as_ref().map(Arc::as_ptr) != design_before.as_ref().map(Arc::as_ptr);
|
||||
let surfaces_changed =
|
||||
self.surfaces.len() != surfaces_before.len() || self.surfaces.iter().zip(&surfaces_before).any(|(a, b)| !Arc::ptr_eq(a, b));
|
||||
match &result {
|
||||
Ok(_) if self.model != before || design_changed => self.journal.push((id.to_string(), params.clone())),
|
||||
Ok(_) if self.model != before || design_changed || surfaces_changed => self.journal.push((id.to_string(), params.clone())),
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
self.model = before;
|
||||
self.design = design_before;
|
||||
self.surfaces = surfaces_before;
|
||||
}
|
||||
}
|
||||
result
|
||||
|
||||
Reference in new issue
Block a user