forked from 1b-agent/fieldcraft-upstream
Open .solidcraft designs: rebuild, persistent face names, geometry commands (#4)
* geometry: open .solidcraft designs, rebuild them and keep persistent face names open_design(bytes) reads a design (container or JSON) with solidcraft-io, rebuilds its timeline with solidcraft-doc and returns its bodies; each Shape carries SolidCraft's persistent face names by B-rep face index, so a face keeps its name through a parameter edit. sample::plate_design builds a parametric test plate in code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: geometry.open, geometry.bodies and geometry.tessellate The session holds the open design (shared, restored on a failed command, journalled when it changes). Hostile-params coverage includes non-files, missing files, oversized paths and extreme tolerances against an open design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: opening SolidCraft designs in the architecture and roadmap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * geometry: open_design_file reads through SolidCraft's bounded file access; simplify The engine's hand-rolled capped read is replaced by open_design_file, which uses solidcraft_io::vfs::read (refuses non-regular files before opening, works on the browser's in-memory files). Drops the duplicate size check, simplifies the sample sketch and the design-change test in Session::execute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
13 files changed
+6290
-19
No files matched your search
@@ -0,0 +1,79 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::{Shape, open_design_file};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const OPEN: CommandSpec = CommandSpec {
|
||||
id: "geometry.open",
|
||||
label: "Open Design",
|
||||
params: r#"{"path": "part.solidcraft"} — rebuilds the SolidCraft design and makes its bodies the geometry; opening it again after an edit picks up the change"#,
|
||||
run: open,
|
||||
};
|
||||
|
||||
pub const BODIES: CommandSpec = CommandSpec {
|
||||
id: "geometry.bodies",
|
||||
label: "Bodies",
|
||||
params: "{} — the open design's bodies: measures and persistent face names (by face index)",
|
||||
run: bodies,
|
||||
};
|
||||
|
||||
pub const TESSELLATE: CommandSpec = CommandSpec {
|
||||
id: "geometry.tessellate",
|
||||
label: "Tessellate Body",
|
||||
params: r#"{"body": "Body1", "tolerance": 0.05} — triangulates the body's boundary within the chordal tolerance (mm); reports triangles per face"#,
|
||||
run: tessellate,
|
||||
};
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| EngineError::Params("`path` must be the design file's path".into()))?;
|
||||
let design = open_design_file(path)?;
|
||||
let out = json!({
|
||||
"name": design.name,
|
||||
"bodies": design.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"errors": design.errors.iter().map(|e| json!({"feature": e.feature, "message": e.message})).collect::<Vec<_>>(),
|
||||
});
|
||||
s.design = Some(Arc::new(design));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn bodies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(design) = &s.design else { return Ok(json!([])) };
|
||||
let mut out = Vec::with_capacity(design.bodies.len());
|
||||
for b in &design.bodies {
|
||||
let m = b.measure()?;
|
||||
out.push(json!({
|
||||
"name": b.name,
|
||||
"volume": m.volume,
|
||||
"area": m.area,
|
||||
"centroid": m.centroid,
|
||||
"bbox": [m.bbox_min, m.bbox_max],
|
||||
"faces": b.faces().iter().map(|f| json!({"name": f.name, "exact": f.exact})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
fn body<'a>(s: &'a Session, p: &Value) -> Result<&'a Shape> {
|
||||
let name = p.get("body").and_then(Value::as_str).ok_or_else(|| EngineError::Params("`body` must be a body name".into()))?;
|
||||
s.design.as_deref().and_then(|d| d.body(name)).ok_or_else(|| EngineError::Params(format!("no body `{name}` (open a design first)")))
|
||||
}
|
||||
|
||||
fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let shape = body(s, p)?;
|
||||
let tolerance = p.get("tolerance").and_then(Value::as_f64).ok_or_else(|| EngineError::Params("`tolerance` must be a number (mm)".into()))?;
|
||||
let mesh = shape.tessellate(tolerance)?;
|
||||
let mut per_face = vec![0usize; shape.faces().len()];
|
||||
for &f in &mesh.tri_face {
|
||||
if let Some(n) = per_face.get_mut(f as usize) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
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}))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
mod geometry;
|
||||
mod mesh;
|
||||
mod model;
|
||||
|
||||
@@ -7,7 +8,16 @@ use serde_json::Value;
|
||||
|
||||
use crate::{CommandSpec, EngineError, Result};
|
||||
|
||||
pub static ALL: &[CommandSpec] = &[model::INFO, mesh::NODE_ADD, mesh::NODE_REMOVE, mesh::ELEMENT_ADD, mesh::ELEMENT_REMOVE];
|
||||
pub static ALL: &[CommandSpec] = &[
|
||||
model::INFO,
|
||||
geometry::OPEN,
|
||||
geometry::BODIES,
|
||||
geometry::TESSELLATE,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
mesh::ELEMENT_REMOVE,
|
||||
];
|
||||
|
||||
/// A required label parameter (1..=u32::MAX; the model checks its own range).
|
||||
pub(crate) fn label(p: &Value, key: &str) -> Result<u32> {
|
||||
|
||||
+103
-4
@@ -9,6 +9,9 @@
|
||||
|
||||
pub mod cmd;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use modelcraft_geometry::Design;
|
||||
use modelcraft_model::Model;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -20,6 +23,8 @@ pub enum EngineError {
|
||||
Params(String),
|
||||
#[error(transparent)]
|
||||
Model(#[from] modelcraft_model::Error),
|
||||
#[error(transparent)]
|
||||
Geometry(#[from] modelcraft_geometry::Error),
|
||||
/// A command panicked; the model is unchanged.
|
||||
#[error("internal error in {command}: {message}")]
|
||||
Internal { command: String, message: String },
|
||||
@@ -46,19 +51,22 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
commands().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The model being edited and the commands that built it.
|
||||
/// The model being edited, the geometry it is built on and the commands that built it.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Session {
|
||||
pub model: Model,
|
||||
/// The open SolidCraft design (`geometry.open`); shared, so a snapshot costs nothing.
|
||||
pub design: Option<Arc<Design>>,
|
||||
/// 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 is left as it was.
|
||||
/// Run a command. On an error or a panic the model and the design 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 outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let result = match outcome {
|
||||
Ok(r) => r,
|
||||
@@ -68,10 +76,14 @@ impl Session {
|
||||
Err(EngineError::Internal { command: id.to_string(), message })
|
||||
}
|
||||
};
|
||||
let design_changed = self.design.as_ref().map(Arc::as_ptr) != design_before.as_ref().map(Arc::as_ptr);
|
||||
match &result {
|
||||
Ok(_) if self.model != before => self.journal.push((id.to_string(), params.clone())),
|
||||
Ok(_) if self.model != before || design_changed => self.journal.push((id.to_string(), params.clone())),
|
||||
Ok(_) => {}
|
||||
Err(_) => self.model = before,
|
||||
Err(_) => {
|
||||
self.model = before;
|
||||
self.design = design_before;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -91,6 +103,7 @@ impl Session {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use modelcraft_geometry::sample::{Plate, plate_design};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -143,6 +156,14 @@ mod tests {
|
||||
json!({"label": 1, "kind": "nope", "nodes": [1]}),
|
||||
json!({"label": 1, "kind": "tet4", "nodes": [1, 1, 1, 1]}),
|
||||
json!({"label": 1, "kind": "hex20", "nodes": vec![u32::MAX; 20]}),
|
||||
json!({"path": ""}),
|
||||
json!({"path": "/"}),
|
||||
json!({"path": "/dev/zero"}),
|
||||
json!({"path": "/no/such/design.solidcraft"}),
|
||||
json!({"path": "x".repeat(100_000)}),
|
||||
json!({"path": 7}),
|
||||
json!({"body": "Body1", "tolerance": 0.05}),
|
||||
json!({"body": null, "tolerance": "fine"}),
|
||||
];
|
||||
for spec in commands() {
|
||||
for p in &hostile {
|
||||
@@ -151,5 +172,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert!(Session::default().execute("no.such", &json!({})).is_err());
|
||||
|
||||
// The same against an open design.
|
||||
let (_dir, path) = sample_design_file(&Plate::default());
|
||||
let mut opened = Session::default();
|
||||
opened.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let tess = [
|
||||
json!({"body": "Body1"}),
|
||||
json!({"body": "Body1", "tolerance": 0}),
|
||||
json!({"body": "Body1", "tolerance": -1}),
|
||||
json!({"body": "Body1", "tolerance": 1e-300}),
|
||||
json!({"body": "Body1", "tolerance": 1e300}),
|
||||
json!({"body": "Body2", "tolerance": 0.1}),
|
||||
];
|
||||
for p in tess.iter().chain(&hostile) {
|
||||
for spec in commands() {
|
||||
let mut s = opened.clone();
|
||||
let _ = s.execute(spec.id, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A design file in a fresh temporary directory (removed when the guard drops).
|
||||
fn sample_design_file(plate: &Plate) -> (TempDir, String) {
|
||||
let dir = TempDir::new();
|
||||
let path = dir.0.join("plate.solidcraft");
|
||||
std::fs::write(&path, plate_design(plate).unwrap()).unwrap();
|
||||
(dir, path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("modelcraft-engine-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TempDir(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_a_design_list_its_bodies_and_tessellate_one() {
|
||||
let (_dir, path) = sample_design_file(&Plate::default());
|
||||
let mut s = Session::default();
|
||||
let opened = s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(opened["bodies"], json!(["Body1"]));
|
||||
assert_eq!(opened["errors"], json!([]));
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let volume = bodies[0]["volume"].as_f64().unwrap();
|
||||
let expect = (40.0 * 30.0 - std::f64::consts::PI * 25.0) * 10.0;
|
||||
assert!((volume - expect).abs() < 1e-3 * expect, "{volume}");
|
||||
let faces = bodies[0]["faces"].as_array().unwrap();
|
||||
assert_eq!(faces.len(), 8);
|
||||
|
||||
let t = s.execute("geometry.tessellate", &json!({"body": "Body1", "tolerance": 0.05})).unwrap();
|
||||
let per_face: Vec<u64> = t["faces"].as_array().unwrap().iter().map(|f| f["triangles"].as_u64().unwrap()).collect();
|
||||
assert!(per_face.iter().all(|&n| n > 0), "{t}");
|
||||
assert_eq!(per_face.iter().sum::<u64>(), t["triangles"].as_u64().unwrap());
|
||||
|
||||
// A failed open keeps the design that was open.
|
||||
assert!(s.execute("geometry.open", &json!({"path": "/no/such/design.solidcraft"})).is_err());
|
||||
assert!(s.design.is_some());
|
||||
assert_eq!(s.journal.len(), 1);
|
||||
|
||||
// Re-opening the edited design rebuilds it with the same face names.
|
||||
std::fs::write(&path, plate_design(&Plate { width: 60.0, ..Plate::default() }).unwrap()).unwrap();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
let wider = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
assert!(wider[0]["volume"].as_f64().unwrap() > volume);
|
||||
assert_eq!(wider[0]["faces"], bodies[0]["faces"]);
|
||||
assert_eq!(s.journal.len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user