forked from 1b-agent/fieldcraft-upstream
file.open, geometry.open, geometry.import.mesh and reopening a model's geometry read through the io crate's file edge instead of SolidCraft's vfs, so in the browser they see the files the page holds (the same store saves write to). Natively it is the same capped regular-file read. fieldcraft-geometry drops its path-based readers (read_file, open_design_file, read_3mf_file): it works on bytes, and reading files is the io crate's job. Part of #119 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
164 lines
7.1 KiB
Rust
164 lines
7.1 KiB
Rust
//! Opening SolidCraft designs (`.solidcraft`): read the design, rebuild its parametric timeline
|
|
//! with SolidCraft, and hand back its bodies with persistent face names.
|
|
//!
|
|
//! The design is rebuilt from its authoritative inputs (sketches, features, parameters); cached
|
|
//! display meshes in the file are never used. [`open_design`] takes the file's bytes (reading
|
|
//! files is `fieldcraft_io::fs`'s job, which in the browser holds the page's files).
|
|
|
|
use solidcraft_doc::{Document, Model, naming};
|
|
use solidcraft_kernel::{KernelError, guard};
|
|
|
|
use crate::{Face, Result, Shape};
|
|
|
|
/// Largest design file opened (SolidCraft's own limit).
|
|
pub const MAX_DESIGN_BYTES: usize = solidcraft_io::MAX_DESIGN_BYTES;
|
|
|
|
/// A rebuilt design.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Design {
|
|
pub name: String,
|
|
/// 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.
|
|
pub errors: Vec<FeatureError>,
|
|
}
|
|
|
|
/// A feature of the design that failed to rebuild.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct FeatureError {
|
|
pub feature: String,
|
|
pub message: String,
|
|
}
|
|
|
|
impl Design {
|
|
pub fn body(&self, name: &str) -> Option<&Shape> {
|
|
self.bodies.iter().find(|b| b.name == name)
|
|
}
|
|
}
|
|
|
|
/// Open a design from its file bytes (a `.solidcraft` container or a JSON design) and rebuild it.
|
|
pub fn open_design(bytes: &[u8]) -> Result<Design> {
|
|
let doc = guard("read design", || solidcraft_io::read_design(bytes).map_err(|e| KernelError::Invalid(format!("not a readable design: {e}"))))?;
|
|
rebuild(&doc)
|
|
}
|
|
|
|
/// Rebuild an in-memory document (the design half of [`open_design`]).
|
|
pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
|
|
let model = guard("rebuild design", || {
|
|
let mut m = Model::new();
|
|
m.evaluate(doc);
|
|
Ok(m)
|
|
})?;
|
|
let errors = model
|
|
.results
|
|
.iter()
|
|
.filter(|r| !r.skipped)
|
|
.filter_map(|r| r.error.as_ref().map(|e| FeatureError { feature: r.name.clone(), message: e.clone() }))
|
|
.collect();
|
|
// 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", || {
|
|
let names = naming::face_names(b);
|
|
Ok(names.iter().enumerate().map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i) }).collect())
|
|
})?;
|
|
bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces });
|
|
}
|
|
Ok(Design { name: doc.name.clone(), bodies, errors })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::sample::{self, Plate};
|
|
|
|
fn face_areas(shape: &Shape) -> Vec<f64> {
|
|
let mesh = shape.tessellate(0.01).unwrap();
|
|
let mut areas = vec![0.0; shape.faces().len()];
|
|
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
|
let [a, b, c] = t.map(|i| {
|
|
let [x, y, z] = mesh.positions[i as usize];
|
|
solidcraft_geom::Vec3 { x, y, z }
|
|
});
|
|
areas[*f as usize] += 0.5 * (b - a).cross(c - a).len();
|
|
}
|
|
areas
|
|
}
|
|
|
|
#[test]
|
|
fn opens_rebuilds_and_tessellates_a_design() {
|
|
let plate = Plate::default();
|
|
let design = open_design(&sample::plate_design(&plate).unwrap()).unwrap();
|
|
assert!(design.errors.is_empty(), "{:?}", design.errors);
|
|
assert_eq!(design.bodies.len(), 1);
|
|
let body = &design.bodies[0];
|
|
let m = body.measure().unwrap();
|
|
let r = plate.hole_diameter / 2.0;
|
|
let expect = (plate.width * plate.depth - std::f64::consts::PI * r * r) * plate.thickness;
|
|
assert!((m.volume - expect).abs() < 1e-3 * expect, "{} vs {expect}", m.volume);
|
|
// Four sides, top, bottom and the hole (a cylinder is two half faces).
|
|
assert_eq!(m.faces, 8);
|
|
assert_eq!(body.faces().len(), 8);
|
|
let mesh = body.tessellate(0.01).unwrap();
|
|
let mut tags = mesh.tri_face.clone();
|
|
tags.sort_unstable();
|
|
tags.dedup();
|
|
assert_eq!(tags.len(), 8);
|
|
assert!(tags.iter().all(|&t| (t as usize) < body.faces().len()));
|
|
// Every face has its own name.
|
|
let mut names: Vec<&str> = body.faces().iter().map(|f| f.name.as_str()).collect();
|
|
names.sort_unstable();
|
|
names.dedup();
|
|
assert_eq!(names.len(), 8, "{names:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn face_names_survive_a_parameter_change() {
|
|
let narrow = Plate::default();
|
|
let wide = Plate { width: 70.0, thickness: 15.0, ..Plate::default() };
|
|
let a = open_design(&sample::plate_design(&narrow).unwrap()).unwrap();
|
|
let b = open_design(&sample::plate_design(&wide).unwrap()).unwrap();
|
|
let (a, b) = (&a.bodies[0], &b.bodies[0]);
|
|
let (area_a, area_b) = (face_areas(a), face_areas(b));
|
|
let pi = std::f64::consts::PI;
|
|
// Each half of the hole.
|
|
let hole = |p: &Plate| pi * p.hole_diameter * p.thickness / 2.0;
|
|
let cap = |p: &Plate| p.width * p.depth - pi * p.hole_diameter * p.hole_diameter / 4.0;
|
|
for face in a.faces() {
|
|
let i = a.face_index(&face.name).unwrap() as usize;
|
|
let j = b.face_index(&face.name).unwrap_or_else(|| panic!("{} is gone after the edit", face.name)) as usize;
|
|
// The same face by name: the hole stays the hole, a cap stays a cap, and a side keeps
|
|
// its own dimension (depth sides grow with the thickness only, width sides with both).
|
|
let (x, y) = (area_a[i], area_b[j]);
|
|
let close = |v: f64, e: f64| (v - e).abs() < 0.02 * e;
|
|
let kind = if close(x, hole(&narrow)) {
|
|
assert!(close(y, hole(&wide)), "{}: {y}", face.name);
|
|
"hole"
|
|
} else if close(x, cap(&narrow)) {
|
|
assert!(close(y, cap(&wide)), "{}: {y}", face.name);
|
|
"cap"
|
|
} else if close(x, narrow.width * narrow.thickness) {
|
|
assert!(close(y, wide.width * wide.thickness), "{}: {y}", face.name);
|
|
"width side"
|
|
} else {
|
|
assert!(close(x, narrow.depth * narrow.thickness), "{}: {x}", face.name);
|
|
assert!(close(y, wide.depth * wide.thickness), "{}: {y}", face.name);
|
|
"depth side"
|
|
};
|
|
assert!(face.exact, "{} ({kind}) is named from geometry", face.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_bytes_are_an_error() {
|
|
for bytes in [&b""[..], b"PK\x03\x04", b"{", b"{}", b"\xff\xfe\x00", b"[1,2,3]", br#"{"format":"solidcraft/9999","name":"x"}"#] {
|
|
assert!(open_design(bytes).is_err(), "{bytes:?}");
|
|
}
|
|
assert!(open_design(&vec![b' '; MAX_DESIGN_BYTES + 1]).is_err());
|
|
}
|
|
}
|