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:
authored and GitHub committed 2026-10-13 14:41:05 -04:00
1 parent 620ef6bc64
commit f36b1d9a69
13 files changed
+6290 -19

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
//! 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 bytes; [`open_design_file`]
//! reads a path through SolidCraft's file access, which in the browser is the in-memory files the
//! web host loaded.
use solidcraft_doc::{Document, Model, naming};
use solidcraft_kernel::{KernelError, guard};
use crate::{Error, 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.
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)
}
/// Read a design file (a regular file of at most [`MAX_DESIGN_BYTES`]) and rebuild it.
pub fn open_design_file(path: &str) -> Result<Design> {
let bytes = solidcraft_io::vfs::read(path, MAX_DESIGN_BYTES).map_err(|e| Error::Invalid(format!("cannot read `{path}`: {e}")))?;
open_design(&bytes)
}
/// 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();
let state = 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());
for path in ["", "/", "/dev/zero", "/no/such/design.solidcraft"] {
assert!(open_design_file(path).is_err(), "{path}");
}
}
}
+34 -1
View File
@@ -8,10 +8,19 @@
//! be replaced without touching the rest of the workspace. Only this crate may depend on
//! `solidcraft-*` (`cargo xtask layers` enforces it).
//!
//! Geometry enters two ways: shapes built in code (such as [`block`]) and SolidCraft designs
//! opened with [`open_design`], which rebuilds the design's parametric timeline and gives its
//! bodies with persistent face names (a boundary condition on a named face follows that face
//! through an edit of the design).
//!
//! Lengths are millimetres.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod design;
pub mod sample;
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design, open_design_file};
use solidcraft_kernel::{Body, KernelError, guard};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
@@ -38,6 +47,19 @@ impl From<KernelError> for Error {
pub struct Shape {
pub name: String,
body: Body,
/// By B-rep face index (the [`SurfaceMesh::tri_face`] tags); empty for shapes built in code.
faces: Vec<Face>,
}
/// A B-rep face's persistent identity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Face {
/// Stable across rebuilds of the design: named after the feature and sketch curve that made
/// the face (`F2:side:l4`, `F2:top`, …), not after its position in the body.
pub name: String,
/// The name follows the history SolidCraft recorded; `false` when it was worked out from the
/// geometry, which an upstream edit can fool.
pub exact: bool,
}
/// Mass properties and extent of a [`Shape`].
@@ -69,10 +91,21 @@ fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] {
pub fn block(a: [f64; 3], b: [f64; 3]) -> Result<Shape> {
let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] };
let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?;
Ok(Shape { name: "Block".into(), body })
Ok(Shape { name: "Block".into(), body, faces: Vec::new() })
}
impl Shape {
/// The persistent identities of the faces, by B-rep face index (empty for shapes built in code).
pub fn faces(&self) -> &[Face] {
&self.faces
}
/// The index of the face with this persistent name: the [`SurfaceMesh::tri_face`] tag its
/// triangles carry.
pub fn face_index(&self, name: &str) -> Option<u32> {
self.faces.iter().position(|f| f.name == name).and_then(|i| u32::try_from(i).ok())
}
pub fn measure(&self) -> Result<Measure> {
let m = guard("measure", || solidcraft_kernel::measure(&self.body))?;
Ok(Measure {
+119
View File
@@ -0,0 +1,119 @@
//! Sample SolidCraft designs built in code: test parts whose recipe is this source, so no design
//! file is committed (docs/adr/0001-libraries.md, test geometry).
use solidcraft_doc::sketch::{ConstraintKind, Sketch};
use solidcraft_doc::{Direction, Document, Extent, FeatureKind, Operation, PlaneRef, ProfileSel};
use solidcraft_geom::Vec2;
use crate::{Error, Result};
/// A rectangular plate with a round through hole, as a parametric design: the sketch's width is
/// driven by the design parameter `width` and the extrusion by `thickness`, so editing either
/// rebuilds the same faces at a new size.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Plate {
/// Along X, mm. At least `depth`.
pub width: f64,
/// Along Y, mm.
pub depth: f64,
/// Along Z, mm.
pub thickness: f64,
/// The hole is centred `depth / 2` from the corner at the origin, so it needs to be smaller
/// than `depth`.
pub hole_diameter: f64,
}
impl Default for Plate {
fn default() -> Self {
Plate { width: 40.0, depth: 30.0, thickness: 10.0, hole_diameter: 10.0 }
}
}
fn failed(e: impl std::fmt::Display) -> Error {
Error::Failed(format!("sample design: {e}"))
}
fn extrude(sketch: u64, distance: &str, operation: Operation) -> FeatureKind {
FeatureKind::Extrude {
sketch,
profiles: ProfileSel::All,
extent: Extent {
distance: distance.into(),
direction: Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation,
targets: vec![],
}
}
fn sketch_on_xy(doc: &mut Document, sketch: Sketch, name: &str) -> Result<u64> {
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some(name)).map_err(failed)
}
/// The plate as `.solidcraft` file bytes.
pub fn plate_design(p: &Plate) -> Result<Vec<u8>> {
let ok = |v: f64| v.is_finite() && v > 0.0 && v <= 1e6;
if !(ok(p.width) && ok(p.depth) && ok(p.thickness) && ok(p.hole_diameter)) || p.width < p.depth || p.hole_diameter >= p.depth {
return Err(Error::Invalid(format!("plate dimensions out of range: {p:?}")));
}
let mut doc = Document::new("Plate");
doc.set_param("width", &format!("{} mm", p.width), Some("mm"), None).map_err(failed)?;
doc.set_param("thickness", &format!("{} mm", p.thickness), Some("mm"), None).map_err(failed)?;
let mut sk = Sketch::new();
let corners = [Vec2::new(0.0, 0.0), Vec2::new(p.width, 0.0), Vec2::new(p.width, p.depth), Vec2::new(0.0, p.depth)];
let [p0, p1, p2, p3] = corners.map(|c| sk.add_point(c, None));
let (p0, p1, p2, p3) = (p0.map_err(failed)?, p1.map_err(failed)?, p2.map_err(failed)?, p3.map_err(failed)?);
let l0 = sk.add_line_pts(p0, p1, None).map_err(failed)?;
let l1 = sk.add_line_pts(p1, p2, None).map_err(failed)?;
let l2 = sk.add_line_pts(p2, p3, None).map_err(failed)?;
let l3 = sk.add_line_pts(p3, p0, None).map_err(failed)?;
use ConstraintKind::{Coincident, Horizontal, Vertical};
// Point 0 of a sketch is its origin.
for c in [Coincident { p: p0, q: 0 }, Horizontal { l: l0 }, Horizontal { l: l2 }, Vertical { l: l1 }, Vertical { l: l3 }] {
sk.add_constraint(c, None).map_err(failed)?;
}
let width = doc.new_model_param("width", "mm");
sk.add_constraint(ConstraintKind::Length { l: l0, value: p.width }, Some(width)).map_err(failed)?;
let depth = doc.new_model_param(&format!("{} mm", p.depth), "mm");
sk.add_constraint(ConstraintKind::Length { l: l1, value: p.depth }, Some(depth)).map_err(failed)?;
let outline = sketch_on_xy(&mut doc, sk, "Outline")?;
doc.add_feature(extrude(outline, "thickness", Operation::NewBody), Some("Plate")).map_err(failed)?;
let mut sk = Sketch::new();
let c = p.depth / 2.0;
sk.add_circle(Vec2::new(c, c), p.hole_diameter / 2.0, None, None).map_err(failed)?;
let hole = sketch_on_xy(&mut doc, sk, "Hole sketch")?;
// Past the top face, so the cut never ends on it.
doc.add_feature(extrude(hole, "thickness + 1 mm", Operation::Cut), Some("Hole")).map_err(failed)?;
solidcraft_io::write_design_container(&doc).map_err(failed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_dimensions_are_an_error() {
let base = Plate::default();
for p in [
Plate { width: f64::NAN, ..base },
Plate { width: -1.0, ..base },
Plate { thickness: 0.0, ..base },
Plate { thickness: f64::INFINITY, ..base },
Plate { width: 10.0, ..base },
Plate { hole_diameter: 30.0, ..base },
Plate { depth: 1e300, width: 1e300, ..base },
] {
assert!(plate_design(&p).is_err(), "{p:?}");
}
}
}