forked from 1b-agent/fieldcraft-upstream
Meshing test corpus built with SolidCraft; assembly placement in open_design (#11)
* geometry: build designs from SolidCraft command scripts (feature scripts) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * geometry: place component bodies through their occurrences when opening a design An assembly's instances were missing and moved components sat at their own origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * Meshing test corpus: 16 SolidCraft recipes, analytic shapes, loader and runner modelcraft-testkit holds the corpus (tests/corpus/<name>/recipe.json run through SolidCraft's engine, then opened as a design) and the runner binary behind cargo xtask corpus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: corpus results, test corpus architecture and the scripts feature; licences Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * corpus: measure each body once, check stage widths, simpler loader and runner 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:
49 files changed
+1770
-84
No files matched your search
@@ -18,7 +18,8 @@ pub const MAX_DESIGN_BYTES: usize = solidcraft_io::MAX_DESIGN_BYTES;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Design {
|
||||
pub name: String,
|
||||
/// The bodies at the end of the timeline, in the design's order.
|
||||
/// The bodies at the end of the timeline, in the design's order, placed in the world: bodies of
|
||||
/// components moved by their occurrences, one body per placement.
|
||||
pub bodies: Vec<Shape>,
|
||||
/// Features that failed to rebuild. The design still opens with the bodies the rest made,
|
||||
/// as it does in SolidCraft.
|
||||
@@ -63,7 +64,9 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
||||
.filter(|r| !r.skipped)
|
||||
.filter_map(|r| r.error.as_ref().map(|e| FeatureError { feature: r.name.clone(), message: e.clone() }))
|
||||
.collect();
|
||||
let state = model.state();
|
||||
// Placed in the world: a component's bodies through every occurrence of it (an instance is a
|
||||
// body of its own, named after its occurrence path), as SolidCraft displays and exports them.
|
||||
let state = guard("place components", || Ok(solidcraft_doc::world_state(doc, &model.state())))?;
|
||||
let mut bodies = Vec::with_capacity(state.bodies.len());
|
||||
for b in &state.bodies {
|
||||
let faces = guard("name faces", || {
|
||||
|
||||
+23
-11
@@ -19,9 +19,13 @@
|
||||
|
||||
mod design;
|
||||
pub mod sample;
|
||||
#[cfg(feature = "scripts")]
|
||||
mod script;
|
||||
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design, open_design_file};
|
||||
pub mod tagged_mesh;
|
||||
#[cfg(feature = "scripts")]
|
||||
pub use script::{MAX_SCRIPT_BYTES, script_design};
|
||||
use solidcraft_kernel::{Body, KernelError, guard};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
@@ -129,6 +133,23 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
impl SurfaceMesh {
|
||||
/// The volume the triangles enclose (divergence theorem: the sum of the signed tetrahedra each
|
||||
/// triangle spans with the origin). Equals the solid's volume for a closed, outward-oriented
|
||||
/// surface; a triangle with a vertex index out of range counts as nothing.
|
||||
pub fn enclosed_volume(&self) -> f64 {
|
||||
let six_v: f64 = self
|
||||
.triangles
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let [a, b, c] = [self.positions.get(t[0] as usize)?, self.positions.get(t[1] as usize)?, self.positions.get(t[2] as usize)?];
|
||||
Some(a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]))
|
||||
})
|
||||
.sum();
|
||||
six_v / 6.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -150,17 +171,8 @@ mod tests {
|
||||
faces.sort_unstable();
|
||||
faces.dedup();
|
||||
assert_eq!(faces.len(), 6);
|
||||
// Divergence theorem: the outward-oriented triangles enclose the block's volume.
|
||||
let p = |i: u32| mesh.positions[i as usize];
|
||||
let six_v: f64 = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let (a, b, c) = (p(t[0]), p(t[1]), p(t[2]));
|
||||
a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])
|
||||
})
|
||||
.sum();
|
||||
assert!((six_v / 6.0 - 24.0).abs() < 1e-9, "{}", six_v / 6.0);
|
||||
// The outward-oriented triangles enclose the block's volume.
|
||||
assert!((mesh.enclosed_volume() - 24.0).abs() < 1e-9, "{}", mesh.enclosed_volume());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Designs from SolidCraft command scripts (feature `scripts`): the recipes of the test corpus.
|
||||
//!
|
||||
//! A script is SolidCraft's own script form, a JSON array of `{"command", "params"}` or an object
|
||||
//! with a `commands` array, the same file `solidcraft-cli run` takes. [`script_design`] runs it in
|
||||
//! a SolidCraft session and saves the result as `.solidcraft` file bytes, which then open through
|
||||
//! [`crate::open_design`] like any design a person saved in SolidCraft.
|
||||
|
||||
use solidcraft_kernel::{KernelError, guard};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Largest script read (SolidCraft's design limit is far larger than any recipe needs).
|
||||
pub const MAX_SCRIPT_BYTES: usize = 4 << 20;
|
||||
|
||||
/// Run a SolidCraft command script and return the design it builds as `.solidcraft` file bytes.
|
||||
/// A command that fails stops the script and is the error.
|
||||
pub fn script_design(script: &str) -> Result<Vec<u8>> {
|
||||
if script.len() > MAX_SCRIPT_BYTES {
|
||||
return Err(Error::Invalid(format!("script is larger than {MAX_SCRIPT_BYTES} bytes")));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(script).map_err(|e| Error::Invalid(format!("script is not JSON: {e}")))?;
|
||||
// A failing command is the recipe's error; only a panic is the kernel's.
|
||||
let doc = guard("run script", || {
|
||||
let mut session = solidcraft_engine::Session::default();
|
||||
Ok(session.run_script(&value).map(|_| session.doc.clone()))
|
||||
})?
|
||||
.map_err(|e| Error::Invalid(format!("script: {e}")))?;
|
||||
guard("save design", || solidcraft_io::write_design_container(&doc).map_err(|e| KernelError::Internal(format!("save design: {e}"))))
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::open_design;
|
||||
|
||||
#[test]
|
||||
fn a_script_builds_a_design_that_opens() {
|
||||
let script = r#"{"commands": [
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 20, "height": 30, "body_name": "B"}}
|
||||
]}"#;
|
||||
let design = open_design(&script_design(script).unwrap()).unwrap();
|
||||
assert!(design.errors.is_empty(), "{:?}", design.errors);
|
||||
let m = design.body("B").unwrap().measure().unwrap();
|
||||
assert!((m.volume - 6000.0).abs() < 1e-6, "{}", m.volume);
|
||||
}
|
||||
|
||||
/// Bodies of components come out where the assembly places them: through every occurrence
|
||||
/// (instances are bodies of their own) and through rotations as well as translations.
|
||||
#[test]
|
||||
fn component_bodies_are_placed_by_their_occurrences() {
|
||||
let script = r#"{"commands": [
|
||||
{"command": "component.create", "params": {"name": "Block"}},
|
||||
{"command": "solid.box", "params": {"length": 10, "width": 4, "height": 2, "body_name": "B"}},
|
||||
{"command": "component.activate", "params": {"component": "root"}},
|
||||
{"command": "occurrence.copy", "params": {"component": "Block", "translate": [100, 0, 0]}},
|
||||
{"command": "occurrence.move", "params": {"occurrence": "Block:1", "axis": [0, 0, 1], "angle": "90 deg", "translate": [0, 50, 0]}}
|
||||
]}"#;
|
||||
let design = open_design(&script_design(script).unwrap()).unwrap();
|
||||
assert!(design.errors.is_empty(), "{:?}", design.errors);
|
||||
let mut boxes: Vec<([f64; 3], [f64; 3], f64)> = design
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = b.measure().unwrap();
|
||||
(m.bbox_min, m.bbox_max, m.volume)
|
||||
})
|
||||
.collect();
|
||||
boxes.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]));
|
||||
assert_eq!(boxes.len(), 2, "{:?}", design.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
|
||||
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
|
||||
// The first occurrence turned 90 degrees about Z, then moved 50 along Y.
|
||||
assert!(close(boxes[0].0, [-4.0, 50.0, 0.0]) && close(boxes[0].1, [0.0, 60.0, 2.0]), "{boxes:?}");
|
||||
// The instance, moved 100 along X.
|
||||
assert!(close(boxes[1].0, [100.0, 0.0, 0.0]) && close(boxes[1].1, [110.0, 4.0, 2.0]), "{boxes:?}");
|
||||
assert!(boxes.iter().all(|b| (b.2 - 80.0).abs() < 1e-6));
|
||||
// Placed bodies keep their persistent face names.
|
||||
assert!(design.bodies.iter().all(|b| b.faces().len() == 6), "{:?}", design.bodies.iter().map(|b| b.faces().len()).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_scripts_are_an_error() {
|
||||
for s in ["", "{", "[1]", "{}", r#"[{"command": "no.such"}]"#, r#"[{"command": "solid.box", "params": {"length": -1e400}}]"#] {
|
||||
assert!(script_design(s).is_err(), "{s}");
|
||||
}
|
||||
assert!(script_design(&" ".repeat(MAX_SCRIPT_BYTES + 1)).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user