Define a kernel-neutral native B-rep format and guarded adapter (#461)

A SolidCraft-owned portable B-rep schema (geometry schema 2 with analytic curves and surfaces, explicit frames and ranges, fitted intersection curves) and a versioned body envelope (SCBREP03) with its own metadata records, a bounded binary codec, a validator with resource budgets, and an adapter that refuses what it can't convert exactly. Pinned by a box-and-fillet golden fixture. Not yet written by application saves. Refs #303.
This commit is contained in:
selune authored and GitHub committed 2026-10-13 00:13:29 -04:00
1 parent 3159fc0dd0
commit 9f88537927
14 files changed
+3925 -1

No files matched your search

Generated
+1
View File
@@ -3375,6 +3375,7 @@ version = "0.7.0"
dependencies = [
"log",
"serde",
"serde_json",
"solidcraft-geom",
"thiserror 2.0.21",
"truck-meshalgo",
+409
View File
@@ -0,0 +1,409 @@
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
use serde::{Deserialize, Serialize};
pub mod envelope;
pub const VERSION: u32 = 2;
pub const MAX_ITEMS: usize = 100_000;
const MAX_DEPTH: usize = 16;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Brep {
pub version: u32,
pub shells: Vec<Shell>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Shell {
pub vertices: Vec<[f64; 3]>,
pub edges: Vec<Edge>,
pub faces: Vec<Face>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Edge {
pub vertices: [u32; 2],
pub curve: Curve,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Face {
pub loops: Vec<Vec<EdgeUse>>,
pub surface: Surface,
pub forward: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeUse {
pub edge: u32,
pub forward: bool,
}
/// Right-handed orthonormal frame; z = x_axis cross y_axis.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Frame {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Curve {
Line { start: [f64; 3], end: [f64; 3] },
CircleArc { frame: Frame, radius: f64, angle: [f64; 2] },
EllipseArc { frame: Frame, radii: [f64; 2], angle: [f64; 2] },
Polyline { points: Vec<[f64; 3]> },
Spline { knots: Vec<f64>, points: Vec<[f64; 3]> },
RationalSpline { knots: Vec<f64>, homogeneous: Vec<[f64; 4]> },
Intersection { knots: Vec<f64>, points: Vec<[f64; 3]>, fitting_tolerance: f64, faces: Option<[u32; 2]> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Surface {
Plane { origin: [f64; 3], u_axis: [f64; 3], v_axis: [f64; 3] },
Cylinder { frame: Frame, radius: f64, angle: [f64; 2], height: [f64; 2] },
Cone { frame: Frame, radius_at_origin: f64, half_angle: f64, angle: [f64; 2], height: [f64; 2] },
Sphere { frame: Frame, radius: f64, longitude: [f64; 2], latitude: [f64; 2] },
Torus { frame: Frame, major_radius: f64, minor_radius: f64, longitude: [f64; 2], meridian: [f64; 2] },
Spline { u_knots: Vec<f64>, v_knots: Vec<f64>, points: Vec<Vec<[f64; 3]>> },
RationalSpline { u_knots: Vec<f64>, v_knots: Vec<f64>, homogeneous: Vec<Vec<[f64; 4]>> },
Revolution { profile: Box<Curve>, origin: [f64; 3], axis: [f64; 3], angle: [f64; 2] },
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("portable B-rep: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
struct Budget(usize);
impl Budget {
fn add(&mut self, n: usize) -> Result<()> {
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
require(self.0 <= MAX_ITEMS, "item budget")
}
}
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= 1e12), "non-finite or excessive coordinate")
}
fn points<const N: usize>(values: &[[f64; N]], budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().flatten().copied())
}
fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().copied())?;
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
require((2..=4096).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
require(values.get(degree) < values.get(count), "empty spline domain")?;
// No knot may have multiplicity greater than degree + 1.
let mut run = 1;
for pair in values.windows(2) {
run = if pair.first() == pair.get(1) { run + 1 } else { 1 };
require(run <= degree + 1, "knot multiplicity")?;
}
Ok(())
}
fn weights(values: &[[f64; 4]]) -> Result<()> {
require(values.iter().all(|p| p.get(3).is_some_and(|w| *w >= 1e-12)), "non-positive rational weight")
}
fn grid<const N: usize>(u: &[f64], v: &[f64], rows: &[Vec<[f64; N]>], budget: &mut Budget) -> Result<()> {
let columns = rows.first().map_or(0, Vec::len);
knots(u, rows.len(), budget)?;
knots(v, columns, budget)?;
for row in rows {
require(row.len() == columns, "ragged surface")?;
points(row, budget)?;
}
Ok(())
}
fn vector(p: [f64; 3]) -> crate::Vec3 {
crate::Vec3::new(p[0], p[1], p[2])
}
fn frame(f: &Frame, budget: &mut Budget) -> Result<()> {
points(&[f.origin, f.x_axis, f.y_axis], budget)?;
let (x, y) = (vector(f.x_axis), vector(f.y_axis));
require((x.len() - 1.).abs() <= 1e-8 && (y.len() - 1.).abs() <= 1e-8 && x.dot(y).abs() <= 1e-8, "nonorthonormal frame")
}
fn range(r: &[f64; 2]) -> Result<()> {
numbers(*r)?;
require(r[0] < r[1], "empty parameter range")
}
fn angle(r: &[f64; 2]) -> Result<()> {
range(r)?;
require(r[1] - r[0] <= std::f64::consts::TAU + 1e-12, "angular range exceeds one turn")
}
fn radius(r: f64) -> Result<()> {
require(r.is_finite() && (1e-12..=1e12).contains(&r), "radius")
}
fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
require(depth <= MAX_DEPTH, "geometry depth")?;
budget.add(1)?;
match c {
Curve::Line { start, end } => {
points(&[*start, *end], budget)?;
require(start != end, "collapsed line")
}
Curve::CircleArc { frame: f, radius: r, angle: a } => {
frame(f, budget)?;
radius(*r)?;
angle(a)
}
Curve::EllipseArc { frame: f, radii, angle: a } => {
frame(f, budget)?;
for r in radii {
radius(*r)?;
}
angle(a)
}
Curve::Polyline { points: p } => {
require(p.len() >= 2, "empty polyline")?;
points(p, budget)?;
require(p.windows(2).all(|p| p.first() != p.get(1)), "collapsed polyline segment")
}
Curve::Spline { knots: k, points: p } => {
knots(k, p.len(), budget)?;
points(p, budget)
}
Curve::RationalSpline { knots: k, homogeneous: p } => {
knots(k, p.len(), budget)?;
points(p, budget)?;
weights(p)
}
Curve::Intersection { knots: k, points: p, fitting_tolerance, .. } => {
require((1e-12..=1.0).contains(fitting_tolerance), "intersection fitting tolerance")?;
knots(k, p.len(), budget)?;
points(p, budget)
}
}
}
fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
require(depth <= MAX_DEPTH, "geometry depth")?;
budget.add(1)?;
match s {
Surface::Plane { origin, u_axis, v_axis } => {
points(&[*origin, *u_axis, *v_axis], budget)?;
require(vector(*u_axis).cross(vector(*v_axis)).len() > 1e-12, "degenerate plane")
}
Surface::Cylinder { frame: f, radius: r, angle: a, height } => {
frame(f, budget)?;
radius(*r)?;
angle(a)?;
range(height)
}
Surface::Cone { frame: f, radius_at_origin, half_angle, angle: a, height } => {
frame(f, budget)?;
numbers([*radius_at_origin, *half_angle])?;
require(*radius_at_origin >= 0. && *half_angle > 0. && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")?;
angle(a)?;
range(height)?;
require(height.iter().all(|h| radius_at_origin + h * half_angle.tan() >= 0.), "negative cone radius")
}
Surface::Sphere { frame: f, radius: r, longitude, latitude } => {
frame(f, budget)?;
radius(*r)?;
angle(longitude)?;
range(latitude)?;
require(latitude[0] >= -std::f64::consts::FRAC_PI_2 && latitude[1] <= std::f64::consts::FRAC_PI_2, "sphere latitude")
}
Surface::Torus { frame: f, major_radius, minor_radius, longitude, meridian } => {
frame(f, budget)?;
radius(*major_radius)?;
radius(*minor_radius)?;
require(major_radius > minor_radius, "ring torus radii")?;
angle(longitude)?;
angle(meridian)
}
Surface::Spline { u_knots, v_knots, points: p } => grid(u_knots, v_knots, p, budget),
Surface::RationalSpline { u_knots, v_knots, homogeneous: p } => {
grid(u_knots, v_knots, p, budget)?;
for row in p {
weights(row)?;
}
Ok(())
}
Surface::Revolution { profile, origin, axis, angle: a } => {
points(&[*origin, *axis], budget)?;
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
angle(a)?;
curve(profile, depth + 1, budget)
}
}
}
impl Brep {
/// Validate portable structure without calling a kernel or evaluating geometry.
/// Does not certify watertightness, surface coincidence, or authoritative state.
pub fn validate(&self) -> Result<()> {
require(self.version == VERSION, "unsupported version")?;
require(!self.shells.is_empty() && self.shells.len() <= 1024, "shell count")?;
let mut budget = Budget(0);
for shell in &self.shells {
points(&shell.vertices, &mut budget)?;
budget.add(shell.edges.len())?;
budget.add(shell.faces.len())?;
require(!shell.faces.is_empty(), "empty shell")?;
for edge in &shell.edges {
require(edge.vertices.iter().all(|&v| (v as usize) < shell.vertices.len()), "edge vertices")?;
curve(&edge.curve, 0, &mut budget)?;
if let Curve::Intersection { faces: Some(hints), .. } = &edge.curve {
require(hints.iter().all(|&i| (i as usize) < shell.faces.len()), "intersection face hint")?;
}
}
for face in &shell.faces {
require(!face.loops.is_empty(), "face loops")?;
surface(&face.surface, 0, &mut budget)?;
for wire in &face.loops {
require(!wire.is_empty(), "empty wire")?;
budget.add(wire.len())?;
let mut first = None;
let mut last = None;
for use_ in wire {
let edge = shell.edges.get(use_.edge as usize).ok_or(Invalid("edge reference"))?;
let [a, b] = edge.vertices;
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
require(last.is_none_or(|v| v == start), "disconnected wire")?;
first.get_or_insert(start);
last = Some(end);
}
require(first == last, "open wire")?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn triangle() -> Brep {
let vertices = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let edges = (0..3)
.map(|i| Edge { vertices: [i as u32, ((i + 1) % 3) as u32], curve: Curve::Line { start: vertices[i], end: vertices[(i + 1) % 3] } })
.collect();
Brep {
version: VERSION,
shells: vec![Shell {
vertices,
edges,
faces: vec![Face {
loops: vec![(0..3).map(|edge| EdgeUse { edge, forward: true }).collect()],
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
forward: true,
}],
}],
}
}
#[test]
fn portable_open_face_round_trip_without_a_kernel() {
let body = triangle();
body.validate().unwrap();
let bytes = serde_json::to_vec(&body).unwrap();
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
restored.validate().unwrap();
assert_eq!(body, restored);
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
}
#[test]
fn topology_references_and_wire_orientation_are_checked() {
let mut b = triangle();
b.shells[0].faces[0].loops[0][0].edge = u32::MAX;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].faces[0].loops[0][1].forward = false;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].edges[0].vertices[0] = u32::MAX;
assert!(b.validate().is_err());
let mut b = triangle();
b.version += 1;
assert!(b.validate().is_err());
let mut b = triangle();
b.shells[0].vertices[0][0] = f64::NAN;
assert!(b.validate().is_err());
}
#[test]
fn closed_edge_can_share_its_endpoint_vertex() {
let mut body = triangle();
body.shells[0].vertices.truncate(1);
body.shells[0].edges =
vec![Edge { vertices: [0, 0], curve: Curve::Polyline { points: vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 0.]] } }];
body.shells[0].faces[0].loops = vec![vec![EdgeUse { edge: 0, forward: true }]];
body.validate().unwrap();
}
#[test]
fn spline_domains_weights_and_nesting_are_bounded() {
let mut budget = Budget(0);
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
}
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
assert!(curve(&c, 0, &mut Budget(0)).is_err());
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
assert!(surface(&p, 0, &mut Budget(0)).is_err());
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
assert!(Budget(MAX_ITEMS).add(1).is_err());
}
#[test]
fn analytic_records_have_independent_frames_and_domains() {
use std::f64::consts::{FRAC_PI_2, TAU};
let f = Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let surfaces = [
Surface::Cylinder { frame: f.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] },
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
Surface::Sphere { frame: f.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
Surface::Revolution {
profile: Box::new(Curve::Line { start: [2., 0., 0.], end: [2., 0., 3.] }),
origin: [0.; 3],
axis: [0., 0., 1.],
angle: [0., TAU],
},
];
for s in surfaces {
surface(&s, 0, &mut Budget(0)).unwrap();
let encoded = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
assert!(!encoded.contains("columns") && !encoded.contains("forward"));
}
for c in [
Curve::CircleArc { frame: f.clone(), radius: 2., angle: [0., FRAC_PI_2] },
Curve::EllipseArc { frame: f.clone(), radii: [2., 3.], angle: [0., TAU] },
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]], fitting_tolerance: 1e-6, faces: None },
] {
curve(&c, 0, &mut Budget(0)).unwrap();
let encoded = serde_json::to_string(&c).unwrap();
assert_eq!(serde_json::from_str::<Curve>(&encoded).unwrap(), c);
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
}
let mut bad = f;
bad.y_axis = bad.x_axis;
assert!(frame(&bad, &mut Budget(0)).is_err());
assert!(angle(&[0., TAU + 0.1]).is_err());
assert!(range(&[2., 1.]).is_err());
assert!(radius(f64::NAN).is_err());
let mut b = triangle();
b.shells[0].edges[0].curve =
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1., 0., 0.]], fitting_tolerance: 1e-6, faces: Some([0, 1]) };
assert!(b.validate().is_err());
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Stable version-one body metadata records. Runtime model types never define this wire contract.
use serde::{Deserialize, Serialize};
pub const VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Metadata {
pub version: u32,
pub color: Option<[f32; 3]>,
pub paint: Option<Paint>,
pub boundaries: Option<Vec<Vec<[f64; 3]>>>,
pub face_sources: Option<Vec<Option<FaceSource>>>,
pub history: Option<History>,
pub preserve_boundaries: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plane {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Segment {
Line { start: [f64; 2], end: [f64; 2] },
Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64 },
Cubic { points: [[f64; 2]; 4] },
Conic { points: [[f64; 2]; 3], middle_weight: f64 },
Bezier { points: Vec<[f64; 2]> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Region {
pub outer: Vec<Segment>,
pub holes: Vec<Vec<Segment>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceSource {
ExtrusionSide { segment: Segment, plane: Option<Plane>, coalesced: bool },
CertifiedExtrusionSide { producer: u64, sketch: u64, curve: String, plane: Option<Plane> },
ExtrusionCap { high: bool, region: Region, plane: Plane },
CertifiedExtrusionCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: Plane },
CylinderCap { producer: u64, high: bool, plane: Plane },
BoxFace { producer: Option<u64>, axis: u8, high: bool, plane: Plane },
PlanarShell { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Paint {
pub opacity: f32,
pub faces: Vec<FacePaint>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FacePaint {
pub face: u32,
pub color: [f32; 3],
pub opacity: f32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct History {
pub operands: u32,
pub faces: Vec<FaceHistory>,
pub edges: Vec<EdgeHistory>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceHistory {
pub origins: Vec<FaceOrigin>,
pub change: Change,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeHistory {
pub faces: Vec<u32>,
pub origins: Vec<EdgeOrigin>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceOrigin {
Face { operand: u32, face: u32 },
Profile { segment: u32 },
Cap { end: bool },
Edge { operand: u32, edge: u32 },
Offset { operand: u32, face: u32 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum EdgeOrigin {
Edge { operand: u32, edge: u32 },
Meeting { sides: [Vec<FaceOrigin>; 2] },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Change {
Kept,
Modified,
Split,
Merged,
Generated,
}
+2 -1
View File
@@ -3,11 +3,12 @@
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod brep;
pub mod bspline;
pub mod env;
pub mod fill;
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
solidcraft-geom = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
truck-modeling = { workspace = true }
+2
View File
@@ -43,6 +43,7 @@ mod mesh_convert;
mod meshbody;
#[cfg(test)]
mod modify_history_tests;
mod native;
mod numbered;
mod offset;
mod ops;
@@ -84,6 +85,7 @@ pub use lip::raise_band;
pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use native::{decode_native_body, encode_native_body};
pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart};
pub use polybool::{faceted_boolean, planar_boolean};
+305
View File
@@ -0,0 +1,305 @@
//! SolidCraft native, versioned binary geometry. No STEP conversion or display meshes.
//! A cache remains provisional until the document is rebuilt; hashes are not trust.
use crate::{Body, KernelError, Result, guard};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Number, Value};
use std::sync::Arc;
mod adapter;
mod binary;
mod metadata;
// 01/02 were development-only prototypes; independent metadata records start at 03.
const MAGIC: &[u8; 8] = b"SCBREP03";
const MAX_BYTES: usize = 64 << 20;
const MAX_ITEMS: usize = 100_000;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SavedBody {
solid: solidcraft_geom::brep::Brep,
metadata: solidcraft_geom::brep::envelope::Metadata,
}
fn invalid(detail: impl std::fmt::Display) -> KernelError {
KernelError::Invalid(format!("native B-rep: {detail}"))
}
/// Explicit topology, spatial geometry, construction certificates and history.
/// Procedural intersections use bounded fitting for provisional cache display.
/// Failure is a cache miss at the IO boundary, never a failed document save.
pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
guard("encode native B-rep", || {
for source in body.face_sources.iter().flat_map(|s| s.iter()).flatten() {
let mut at = source;
let mut depth = 0usize;
while let crate::FaceSource::ShellPlanar { source, .. } = at {
depth += 1;
if depth > 32 {
return Err(invalid("face-source nesting"));
}
at = source;
}
}
if body.face_count() > MAX_ITEMS
|| body.solid.edge_iter().take(MAX_ITEMS + 1).count() > MAX_ITEMS
|| body.mesh.as_ref().is_some_and(|m| m.positions.len() > MAX_ITEMS || m.triangles.len() > MAX_ITEMS)
{
return Err(invalid("native geometry budget"));
}
if body.mesh.is_some() {
return Err(invalid("mesh bodies are source blobs, not B-rep"));
}
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)? };
// Structural validation does not invoke another geometric reconstruction.
validate_topology(&saved)?;
let value = serde_json::to_value(saved).map_err(invalid)?;
validate_value(&value, 0)?;
if value_nodes(&value) > 2_000_000 {
return Err(invalid("value budget"));
}
let mut out = MAGIC.to_vec();
binary::write(&value, &mut out, 0)?;
Ok(out)
})
}
/// Decode bounded untrusted data. Validate indices before topology extraction.
/// Callers must not treat the decoded body as authoritative editing geometry.
pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
if bytes.len() > MAX_BYTES || bytes.get(..8) != Some(MAGIC.as_slice()) {
return Err(invalid("version or size"));
}
guard("decode native B-rep", || {
let mut input = bytes.get(8..).ok_or_else(|| invalid("header"))?;
let mut remaining = 2_000_000usize;
let value = binary::read(&mut input, 0, &mut remaining)?;
if !input.is_empty() {
return Err(invalid("trailing data"));
}
validate_value(&value, 0)?;
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
validate_topology(&saved)?;
let solid = adapter::decode(saved.solid)?;
let mut body = Body {
solid: Arc::new(solid),
coarse_missing: None,
mesh: None,
color: None,
paint: None,
splits: None,
face_sources: None,
history: None,
regularization_protected: false,
};
metadata::apply(&mut body, saved.metadata)?;
Ok(body)
})
}
fn validate_topology(saved: &SavedBody) -> Result<()> {
saved.solid.validate().map_err(invalid)?;
let faces: usize = saved.solid.shells.iter().map(|s| s.faces.len()).sum();
let edges: usize = saved.solid.shells.iter().map(|s| s.edges.len()).sum();
let saved = &saved.metadata;
if saved.version != solidcraft_geom::brep::envelope::VERSION {
return Err(invalid("metadata version"));
}
if saved.face_sources.as_ref().is_some_and(|s| s.len() != faces) {
return Err(invalid("face source count"));
}
if let Some(history) = &saved.history
&& (history.faces.len() != faces
|| history.edges.len() != edges
|| history.operands as usize > MAX_ITEMS
|| history.edges.iter().flat_map(|e| &e.faces).any(|&f| f as usize >= faces))
{
return Err(invalid("history topology"));
}
if saved.color.is_some_and(|c| c.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))) {
return Err(invalid("colour"));
}
if let Some(paint) = &saved.paint
&& (!paint.opacity.is_finite()
|| !(0.0..=1.0).contains(&paint.opacity)
|| paint.faces.iter().any(|p| {
p.face as usize >= faces
|| !p.opacity.is_finite()
|| !(0.0..=1.0).contains(&p.opacity)
|| p.color.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
}))
{
return Err(invalid("face paint"));
}
Ok(())
}
// The binary reader bounds allocation before deserialization. Geometry semantics
// are checked by the independent schema validator, not by backend serde internals.
fn validate_value(v: &Value, depth: usize) -> Result<()> {
if depth > 48 {
return Err(invalid("nesting"));
}
match v {
Value::String(s) if s.len() > 1 << 20 => return Err(invalid("string budget")),
Value::Number(n) if n.is_f64() && n.as_f64().is_none_or(|x| !x.is_finite() || x.abs() > 1e12) => return Err(invalid("non-finite metadata")),
Value::Array(a) => {
if a.len() > MAX_ITEMS {
return Err(invalid("array budget"));
}
for x in a {
validate_value(x, depth + 1)?;
}
}
Value::Object(o) => {
for x in o.values() {
validate_value(x, depth + 1)?;
}
}
_ => {}
}
Ok(())
}
fn value_nodes(v: &Value) -> usize {
match v {
Value::Array(a) => a.iter().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
Value::Object(o) => o.values().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
_ => 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
fn equal(a: &Body, b: &Body) {
assert_eq!(a.face_count(), b.face_count());
assert_eq!(a.face_sources, b.face_sources);
assert_eq!(a.history, b.history);
assert_eq!(a.paint, b.paint);
assert_eq!(a.color, b.color);
assert_eq!(a.splits, b.splits);
assert_eq!(a.regularization_protected, b.regularization_protected);
assert_eq!(encode_native_body(a).unwrap(), encode_native_body(b).unwrap());
let am = crate::measure(a).unwrap();
let bm = crate::measure(b).unwrap();
assert!((am.volume - bm.volume).abs() < 1e-7);
}
#[test]
fn native_solids_keep_geometry_metadata_and_later_modelling() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
let bore = crate::cylinder(Vec3::new(10., 10., -1.), Vec3::Z, 3., 12.).unwrap();
let cut = crate::boolean(&rounded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
for body in [cube, rounded, cut] {
let body = body.with_color(Some([0.1, 0.5, 0.9]));
let bytes = encode_native_body(&body).unwrap();
assert!(bytes.starts_with(MAGIC));
let decoded = decode_native_body(&bytes).unwrap();
equal(&body, &decoded);
let a = crate::boolean(&body, &bore, crate::BoolOp::Cut).unwrap().unwrap();
let b = crate::boolean(&decoded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
}
}
#[test]
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let surface = crate::copy_faces(&cube, &[Vec3::new(20., 15., 10.)]).unwrap();
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
let torus = crate::torus(Vec3::ZERO, 20., 4.).unwrap();
for body in [surface, sphere, torus] {
let encoded = encode_native_body(&body).unwrap();
let restored = decode_native_body(&encoded).unwrap();
assert_eq!(body.is_surface(), restored.is_surface());
assert_eq!(body.face_count(), restored.face_count());
assert_eq!(encoded, encode_native_body(&restored).unwrap());
if !body.is_surface() {
equal(&body, &restored);
}
}
}
#[test]
fn box_and_fillet_have_stable_wire_records() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
let records: Vec<_> = [cube, rounded]
.iter()
.map(|body| {
let bytes = encode_native_body(body).unwrap();
assert_eq!(&bytes[..8], b"SCBREP03");
let mut raw = &bytes[8..];
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
assert!(raw.is_empty());
value
})
.collect();
let actual = serde_json::json!(records);
let expected: Value = serde_json::from_str(include_str!("native/fixtures/box-fillet-v3.json")).unwrap();
compare_wire(&expected, &actual);
}
fn compare_wire(expected: &Value, actual: &Value) {
match (expected, actual) {
(Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
let (a, b) = (a.as_f64().unwrap(), b.as_f64().unwrap());
assert!((a - b).abs() <= 1e-10 * a.abs().max(1.), "wire number changed: {a} vs {b}");
}
(Value::Array(a), Value::Array(b)) => {
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
compare_wire(a, b);
}
}
(Value::Object(a), Value::Object(b)) => {
assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
for (key, a) in a {
compare_wire(a, &b[key]);
}
}
_ => assert_eq!(expected, actual),
}
}
#[test]
fn malformed_native_geometry_is_bounded_and_never_panics() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let bytes = encode_native_body(&body).unwrap();
for n in [0, 1, 7, 8, 9, bytes.len() - 1] {
assert!(decode_native_body(&bytes[..n]).is_err());
}
for i in (0..bytes.len()).step_by((bytes.len() / 256).max(1)) {
let mut bad = bytes.clone();
bad[i] ^= 0xff;
assert!(std::panic::catch_unwind(|| decode_native_body(&bad)).is_ok());
}
let mut raw = &bytes[8..];
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
for key in ["vertices", "index"] {
let mut v = value.clone();
if key == "vertices" {
v["solid"]["shells"][0]["edges"][0]["vertices"] = serde_json::json!([u64::MAX, 0]);
} else {
v["solid"]["shells"][0]["faces"][0]["loops"][0][0]["edge"] = serde_json::json!(u64::MAX);
}
let mut bad = MAGIC.to_vec();
binary::write(&v, &mut bad, 0).unwrap();
assert!(decode_native_body(&bad).is_err());
}
let mut huge = MAGIC.to_vec();
huge.push(7);
huge.extend(u32::MAX.to_le_bytes());
assert!(decode_native_body(&huge).is_err());
}
#[test]
fn native_preserves_face_appearance_and_authored_boundaries() {
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.))
.unwrap()
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6 }] }));
body.splits = Some(Arc::new(vec![vec![Vec3::new(1., 2., 3.), Vec3::new(11., 2., 3.)]]));
body.regularization_protected = true;
let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap();
equal(&body, &restored);
}
}
+369
View File
@@ -0,0 +1,369 @@
//! The truck adapter. Persisted data belongs to solidcraft_geom::brep, never truck.
use super::{Result, invalid};
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, Invertible, ParametricCurve, Transform, Transformed};
use solidcraft_geom::brep as b;
use truck_modeling as mt;
use truck_topology::compress as t;
type Packed = t::CompressedSolid<mt::Point3, mt::Curve, mt::Surface>;
#[path = "analytic.rs"]
mod analytic;
#[path = "fit.rs"]
mod fit;
use analytic::{arc, full_turn, load_analytic, revolved};
use fit::fit_intersection;
fn point(p: mt::Point3) -> [f64; 3] {
[p.x, p.y, p.z]
}
fn vector(p: mt::Vector3) -> [f64; 3] {
[p.x, p.y, p.z]
}
fn vector4(p: mt::Vector4) -> [f64; 4] {
[p.x, p.y, p.z, p.w]
}
fn p([x, y, z]: [f64; 3]) -> mt::Point3 {
mt::Point3::new(x, y, z)
}
fn v([x, y, z]: [f64; 3]) -> mt::Vector3 {
mt::Vector3::new(x, y, z)
}
fn v4([x, y, z, w]: [f64; 4]) -> mt::Vector4 {
mt::Vector4::new(x, y, z, w)
}
fn index(i: usize) -> Result<u32> {
u32::try_from(i).map_err(invalid)
}
fn depth(d: usize) -> Result<()> {
if d > 16 { Err(invalid("geometry nesting")) } else { Ok(()) }
}
// Truck evaluates the full knot interval. The portable domain is the standard
// active interval, so only coincident (clamped) domains can cross this adapter.
fn clamped(knots: &[f64], count: usize) -> Result<()> {
let degree = knots.len().checked_sub(count + 1).ok_or_else(|| invalid("spline degree"))?;
if knots.first() != knots.get(degree) || knots.last() != knots.get(count) {
return Err(invalid("unclamped spline domain unsupported by this adapter"));
}
Ok(())
}
fn compatible_curve(c: &b::Curve) -> Result<()> {
match c {
b::Curve::Spline { knots, points } => clamped(knots, points.len()),
b::Curve::RationalSpline { knots, homogeneous } => clamped(knots, homogeneous.len()),
b::Curve::Intersection { knots, points, .. } => clamped(knots, points.len()),
_ => Ok(()),
}
}
fn compatible_surface(s: &b::Surface) -> Result<()> {
match s {
b::Surface::Plane { origin, u_axis, v_axis } => {
let plane = mt::Plane::new(p(*origin), p(*origin) + v(*u_axis), p(*origin) + v(*v_axis));
if vector(plane.u_axis()) != *u_axis || vector(plane.v_axis()) != *v_axis {
return Err(invalid("plane axes cannot be reconstructed exactly"));
}
Ok(())
}
b::Surface::Spline { u_knots, v_knots, points } => {
clamped(u_knots, points.len())?;
clamped(v_knots, points.first().map_or(0, Vec::len))
}
b::Surface::RationalSpline { u_knots, v_knots, homogeneous } => {
clamped(u_knots, homogeneous.len())?;
clamped(v_knots, homogeneous.first().map_or(0, Vec::len))
}
b::Surface::Revolution { profile, angle, .. } => {
full_turn(angle)?;
compatible_curve(profile)
}
b::Surface::Cylinder { angle, .. } | b::Surface::Cone { angle, .. } => full_turn(angle),
b::Surface::Sphere { longitude, .. } | b::Surface::Torus { longitude, .. } => full_turn(longitude),
}
}
fn compatible(body: &b::Brep) -> Result<()> {
for shell in &body.shells {
for edge in &shell.edges {
compatible_curve(&edge.curve)?;
}
for face in &shell.faces {
compatible_surface(&face.surface)?;
}
}
Ok(())
}
pub(super) fn encode(solid: &mt::Solid) -> Result<b::Brep> {
let mut shells = Vec::new();
let mut fitting_budget = 4096usize;
for shell in solid.compress().boundaries {
let vertices = shell.vertices.into_iter().map(point).collect();
let edges = shell
.edges
.into_iter()
.map(|e| Ok(b::Edge { vertices: [index(e.vertices.0)?, index(e.vertices.1)?], curve: save_curve(&e.curve, 0, &mut fitting_budget)? }))
.collect::<Result<_>>()?;
let faces = shell
.faces
.into_iter()
.map(|f| {
let (surface, normal_flip) = save_surface(&f.surface, 0, &mut fitting_budget)?;
Ok(b::Face {
loops: f
.boundaries
.into_iter()
.map(|w| {
let mut uses = w
.into_iter()
.map(|e| Ok(b::EdgeUse { edge: index(e.index)?, forward: e.orientation }))
.collect::<Result<Vec<_>>>()?;
// Wire direction is explicit; Face.forward changes only the normal.
if !f.orientation {
reverse_wire(&mut uses);
}
Ok(uses)
})
.collect::<Result<_>>()?,
surface,
forward: f.orientation != normal_flip,
})
})
.collect::<Result<_>>()?;
shells.push(b::Shell { vertices, edges, faces });
}
let result = b::Brep { version: b::VERSION, shells };
result.validate().map_err(invalid)?;
compatible(&result)?;
Ok(result)
}
pub(super) fn decode(body: b::Brep) -> Result<mt::Solid> {
body.validate().map_err(invalid)?;
compatible(&body)?;
if body.shells.iter().flat_map(|s| &s.edges).any(|e| e.vertices[0] == e.vertices[1]) {
return Err(invalid("closed single-vertex edges are unsupported by this adapter"));
}
let packed = Packed {
boundaries: body
.shells
.into_iter()
.map(|s| {
Ok(t::CompressedShell {
vertices: s.vertices.into_iter().map(p).collect(),
edges: s
.edges
.into_iter()
.map(|e| Ok(t::CompressedEdge { vertices: (e.vertices[0] as usize, e.vertices[1] as usize), curve: load_curve(e.curve)? }))
.collect::<Result<_>>()?,
faces: s
.faces
.into_iter()
.map(|f| {
let mut loops = f.loops;
if !f.forward {
for wire in &mut loops {
reverse_wire(wire);
}
}
Ok(t::CompressedFace {
boundaries: loops
.into_iter()
.map(|w| {
w.into_iter().map(|e| t::CompressedEdgeIndex { index: e.edge as usize, orientation: e.forward }).collect()
})
.collect(),
orientation: f.forward,
surface: load_surface(f.surface)?,
})
})
.collect::<Result<_>>()?,
})
})
.collect::<Result<_>>()?,
};
let shells = packed.boundaries.into_iter().map(mt::Shell::extract).collect::<std::result::Result<Vec<_>, _>>().map_err(invalid)?;
if shells.iter().any(|s| s.shell_condition() == mt::ShellCondition::Irregular) {
return Err(invalid("non-manifold shell"));
}
Ok(mt::Solid::new_unchecked(shells))
}
fn reverse_wire(wire: &mut [b::EdgeUse]) {
wire.reverse();
for edge in wire {
edge.forward = !edge.forward;
}
}
fn save_curve(c: &mt::Curve, d: usize, fitting_budget: &mut usize) -> Result<b::Curve> {
depth(d)?;
Ok(match c {
mt::Curve::Line(c) => b::Curve::Line { start: point(c.0), end: point(c.1) },
mt::Curve::BSplineCurve(c) => {
b::Curve::Spline { knots: c.knot_vec().iter().copied().collect(), points: c.control_points().iter().copied().map(point).collect() }
}
mt::Curve::NurbsCurve(c) => b::Curve::RationalSpline {
knots: c.knot_vec().iter().copied().collect(),
homogeneous: c.control_points().iter().copied().map(vector4).collect(),
},
mt::Curve::IntersectionCurve(_) => fit_intersection(c, fitting_budget)?,
})
}
fn save_surface(s: &mt::Surface, d: usize, fitting_budget: &mut usize) -> Result<(b::Surface, bool)> {
depth(d)?;
let mut normal_flip = false;
let saved = match s {
mt::Surface::Plane(s) => b::Surface::Plane { origin: point(s.origin()), u_axis: vector(s.u_axis()), v_axis: vector(s.v_axis()) },
mt::Surface::BSplineSurface(s) => b::Surface::Spline {
u_knots: s.uknot_vec().iter().copied().collect(),
v_knots: s.vknot_vec().iter().copied().collect(),
points: s.control_points().iter().map(|r| r.iter().copied().map(point).collect()).collect(),
},
mt::Surface::NurbsSurface(s) => b::Surface::RationalSpline {
u_knots: s.uknot_vec().iter().copied().collect(),
v_knots: s.vknot_vec().iter().copied().collect(),
homogeneous: s.control_points().iter().map(|r| r.iter().copied().map(vector4).collect()).collect(),
},
mt::Surface::RevolutedCurve(s) => {
let m = s.transform();
let (x, y, z) = (m.x.truncate(), m.y.truncate(), m.z.truncate());
let scale = x.magnitude();
let tolerance = scale * 1e-10;
if !scale.is_finite()
|| scale <= 1e-12
|| (y.magnitude() - scale).abs() > tolerance
|| (z.magnitude() - scale).abs() > tolerance
|| x.dot(y).abs() > scale * tolerance
|| x.dot(z).abs() > scale * tolerance
|| y.dot(z).abs() > scale * tolerance
|| m.x.w != 0.
|| m.y.w != 0.
|| m.z.w != 0.
|| m.w.w != 1.
{
return Err(invalid("non-similarity revolution transform needs an exact spline conversion"));
}
// Bake a similarity into the geometry, never into persisted processor data.
// An improper rotation reverses the oriented axis in the rotation identity.
let sign = if x.dot(y.cross(z)) < 0. { -1. } else { 1. };
let axis = m.transform_vector(s.entity().axis()).normalize() * sign;
let profile = s.entity().entity_curve().transformed(*m);
normal_flip = !s.orientation();
b::Surface::Revolution {
profile: Box::new(save_curve(&profile, d + 1, fitting_budget)?),
origin: point(m.transform_point(s.entity().origin())),
axis: vector(axis),
angle: [0., std::f64::consts::TAU],
}
}
};
Ok((saved, normal_flip))
}
fn load_curve(c: b::Curve) -> Result<mt::Curve> {
Ok(match c {
b::Curve::Line { start, end } => mt::Curve::Line(mt::Line(p(start), p(end))),
b::Curve::CircleArc { frame, radius, angle } => arc(&frame, [radius, radius], angle)?,
b::Curve::EllipseArc { frame, radii, angle } => arc(&frame, radii, angle)?,
b::Curve::Spline { knots, points } => {
mt::Curve::BSplineCurve(mt::BSplineCurve::try_new(knots.into(), points.into_iter().map(p).collect()).map_err(invalid)?)
}
b::Curve::RationalSpline { knots, homogeneous } => mt::Curve::NurbsCurve(mt::NurbsCurve::new(
mt::BSplineCurve::try_new(knots.into(), homogeneous.into_iter().map(v4).collect()).map_err(invalid)?,
)),
b::Curve::Polyline { .. } => return Err(invalid("standalone polyline unsupported by this adapter")),
b::Curve::Intersection { knots, points, .. } => {
mt::Curve::BSplineCurve(mt::BSplineCurve::try_new(knots.into(), points.into_iter().map(p).collect()).map_err(invalid)?)
}
})
}
fn load_surface(s: b::Surface) -> Result<mt::Surface> {
Ok(match s {
b::Surface::Plane { origin, u_axis, v_axis } => mt::Surface::Plane(mt::Plane::new(p(origin), p(origin) + v(u_axis), p(origin) + v(v_axis))),
b::Surface::Spline { u_knots, v_knots, points } => mt::Surface::BSplineSurface(
mt::BSplineSurface::try_new((u_knots.into(), v_knots.into()), points.into_iter().map(|r| r.into_iter().map(p).collect()).collect())
.map_err(invalid)?,
),
b::Surface::RationalSpline { u_knots, v_knots, homogeneous } => mt::Surface::NurbsSurface(mt::NurbsSurface::new(
mt::BSplineSurface::try_new((u_knots.into(), v_knots.into()), homogeneous.into_iter().map(|r| r.into_iter().map(v4).collect()).collect())
.map_err(invalid)?,
)),
b::Surface::Revolution { profile, origin, axis, angle } => {
full_turn(&angle)?;
revolved(load_curve(*profile)?, p(origin), v(axis), false)
}
analytic => load_analytic(analytic)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuses_domain_or_axis_changes_instead_of_approximating() {
let curve = b::Curve::Spline { knots: vec![0., 1., 2., 3.], points: vec![[0.; 3], [1.; 3]] };
assert!(compatible_curve(&curve).is_err());
let plane = b::Surface::Plane { origin: [1e12; 3], u_axis: [1e-6, 0., 0.], v_axis: [0., 1., 0.] };
assert!(compatible_surface(&plane).is_err());
assert!(clamped(&[0., 0., 1., 1.], 2).is_ok());
}
#[test]
fn analytic_records_decode_as_their_mathematical_loci_and_normals() {
use mt::{MetricSpace, ParametricSurface, ParametricSurface3D};
use std::f64::consts::{FRAC_PI_2, TAU};
let frame = b::Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let circle = load_curve(b::Curve::CircleArc { frame: frame.clone(), radius: 2., angle: [0., FRAC_PI_2] }).unwrap();
assert!(circle.subs(0.).distance(p([3., 2., 3.])) < 1e-12);
assert!(circle.subs(1.).distance(p([1., 4., 3.])) < 1e-12);
let ellipse = load_curve(b::Curve::EllipseArc { frame: frame.clone(), radii: [2., 3.], angle: [0., FRAC_PI_2] }).unwrap();
assert!(ellipse.subs(1.).distance(p([1., 5., 3.])) < 1e-12);
let cases = [
(b::Surface::Cylinder { frame: frame.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] }, 0.5, [3., 2., 4.], [1., 0., 0.]),
(
b::Surface::Cone { frame: frame.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
0.5,
[3. + 1.5 * 0.2f64.tan(), 2., 4.5],
[1., 0., -0.2f64.tan()],
),
(
b::Surface::Sphere { frame: frame.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
0.5,
[3., 2., 3.],
[1., 0., 0.],
),
(
b::Surface::Torus { frame, major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
0.,
[6., 2., 3.],
[1., 0., 0.],
),
];
for (record, parameter, expected, normal) in cases {
let surface = load_surface(record).unwrap();
assert!(surface.subs(0., parameter).distance(p(expected)) < 1e-10);
assert!(surface.normal(0., parameter).normalize().dot(v(normal).normalize()) > 1. - 1e-10);
}
}
#[test]
fn intersection_fit_is_spatial_bounded_and_independent_of_surface_solvers() {
use mt::MetricSpace;
use solidcraft_geom::Vec3;
let cyl = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 40.).unwrap();
let block = crate::box_solid(Vec3::new(-20., -20., 20.), Vec3::new(20., 20., 60.)).unwrap();
let block = crate::transform(&block, Vec3::ZERO, Vec3::new(0., 0., 20.), Vec3::X, 0.4).unwrap();
let cut = crate::boolean(&cyl, &block, crate::BoolOp::Cut).unwrap().unwrap();
let curve = cut.solid.edge_iter().map(|e| e.curve()).find(|c| matches!(c, mt::Curve::IntersectionCurve(_))).unwrap();
assert!(save_curve(&curve, 0, &mut 0).is_err());
let record = save_curve(&curve, 0, &mut 4096).unwrap();
let encoded = serde_json::to_string(&record).unwrap();
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
let b::Curve::Intersection { fitting_tolerance, .. } = &record else { panic!("explicit fitted record required") };
let tolerance = *fitting_tolerance;
let restored = load_curve(record).unwrap();
let (a, b) = curve.range_tuple();
for i in 0..=512 {
let t = a + (b - a) * i as f64 / 512.;
assert!(curve.subs(t).distance(restored.subs(t)) <= tolerance * 1.1, "sample {i}: error {}", curve.subs(t).distance(restored.subs(t)));
}
let decoded = crate::decode_native_body(&crate::encode_native_body(&cut).unwrap()).unwrap();
let original = crate::measure(&cut).unwrap().volume;
assert!((crate::measure(&decoded).unwrap().volume - original).abs() < original * 1e-5);
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Analytic wire records are mathematical loci, independent of backend parameterizations.
use super::*;
pub(super) fn full_turn(angle: &[f64; 2]) -> Result<()> {
if (angle[1] - angle[0] - std::f64::consts::TAU).abs() > 1e-12 {
Err(invalid("partial angular surface domains need another adapter representation"))
} else {
Ok(())
}
}
/// Exact rational conic locus. The adapter reparameterizes angle by rational spans.
pub(super) fn arc(frame: &b::Frame, radii: [f64; 2], angle: [f64; 2]) -> Result<mt::Curve> {
let span = angle[1] - angle[0];
let count = (span / std::f64::consts::FRAC_PI_2).ceil().clamp(1., 4.) as usize;
let step = span / count as f64;
let weight = (step / 2.).cos();
let at = |t: f64, scale: f64| p(frame.origin) + v(frame.x_axis) * (radii[0] * t.cos() / scale) + v(frame.y_axis) * (radii[1] * t.sin() / scale);
let mut controls = vec![at(angle[0], 1.).to_homogeneous()];
let mut knots = vec![0.; 3];
for i in 0..count {
let start = angle[0] + i as f64 * step;
controls.push(at(start + step / 2., weight).to_homogeneous() * weight);
controls.push(at(start + step, 1.).to_homogeneous());
knots.extend(std::iter::repeat_n((i + 1) as f64 / count as f64, if i + 1 == count { 3 } else { 2 }));
}
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::try_new(knots.into(), controls).map_err(invalid)?)))
}
pub(super) fn revolved(profile: mt::Curve, origin: mt::Point3, axis: mt::Vector3, normal_flip: bool) -> mt::Surface {
let mut s = mt::Processor::new(mt::RevolutedCurve::by_revolution(profile, origin, axis));
// Internal parameter swapping is allowed; no such flag is persisted in a surface record.
if normal_flip {
s.invert();
}
mt::Surface::RevolutedCurve(s)
}
pub(super) fn load_analytic(surface: b::Surface) -> Result<mt::Surface> {
let axis = |f: &b::Frame| v(f.x_axis).cross(v(f.y_axis));
Ok(match surface {
b::Surface::Cylinder { frame, radius, angle, height } => {
full_turn(&angle)?;
let z = axis(&frame);
let at = |h| p(frame.origin) + v(frame.x_axis) * radius + z * h;
revolved(mt::Curve::Line(mt::Line(at(height[0]), at(height[1]))), p(frame.origin), z, true)
}
b::Surface::Cone { frame, radius_at_origin, half_angle, angle, height } => {
full_turn(&angle)?;
let z = axis(&frame);
let at = |h| p(frame.origin) + v(frame.x_axis) * (radius_at_origin + h * half_angle.tan()) + z * h;
revolved(mt::Curve::Line(mt::Line(at(height[0]), at(height[1]))), p(frame.origin), z, true)
}
b::Surface::Sphere { frame, radius, longitude, latitude } => {
full_turn(&longitude)?;
let z = axis(&frame);
let meridian = b::Frame { origin: frame.origin, x_axis: frame.x_axis, y_axis: vector(z) };
revolved(arc(&meridian, [radius; 2], latitude)?, p(frame.origin), z, true)
}
b::Surface::Torus { frame, major_radius, minor_radius, longitude, meridian } => {
full_turn(&longitude)?;
let z = axis(&frame);
let profile = b::Frame { origin: point(p(frame.origin) + v(frame.x_axis) * major_radius), x_axis: frame.x_axis, y_axis: vector(z) };
revolved(arc(&profile, [minor_radius; 2], meridian)?, p(frame.origin), z, true)
}
_ => return Err(invalid("expected analytic surface")),
})
}
+116
View File
@@ -0,0 +1,116 @@
//! Bounded tagged binary values: integer and f64 bits stay exact, no base64/text numbers.
use super::*;
fn bytes<'a>(input: &mut &'a [u8], n: usize) -> Result<&'a [u8]> {
let head = input.get(..n).ok_or_else(|| invalid("truncated value"))?;
*input = input.get(n..).ok_or_else(|| invalid("truncated value"))?;
Ok(head)
}
fn length(input: &mut &[u8]) -> Result<usize> {
let raw: [u8; 4] = bytes(input, 4)?.try_into().map_err(invalid)?;
usize::try_from(u32::from_le_bytes(raw)).map_err(invalid)
}
fn string(input: &mut &[u8]) -> Result<String> {
let n = length(input)?;
if n > 1 << 20 {
return Err(invalid("string budget"));
}
Ok(std::str::from_utf8(bytes(input, n)?).map_err(invalid)?.to_owned())
}
pub(super) fn read(input: &mut &[u8], depth: usize, budget: &mut usize) -> Result<Value> {
if depth > 48 || *budget == 0 {
return Err(invalid("value budget/depth"));
}
*budget -= 1;
let tag = *bytes(input, 1)?.first().ok_or_else(|| invalid("tag"))?;
Ok(match tag {
0 => Value::Null,
1 => Value::Bool(false),
2 => Value::Bool(true),
3..=5 => {
let raw: [u8; 8] = bytes(input, 8)?.try_into().map_err(invalid)?;
Value::Number(match tag {
3 => Number::from(i64::from_le_bytes(raw)),
4 => Number::from(u64::from_le_bytes(raw)),
_ => Number::from_f64(f64::from_le_bytes(raw)).ok_or_else(|| invalid("non-finite number"))?,
})
}
6 => Value::String(string(input)?),
7 | 8 => {
let n = length(input)?;
if n > MAX_ITEMS || n > input.len() || n > *budget {
return Err(invalid("collection budget"));
}
if tag == 7 {
let mut a = Vec::new();
for _ in 0..n {
a.push(read(input, depth + 1, budget)?);
}
Value::Array(a)
} else {
let mut map = Map::new();
for _ in 0..n {
let key = string(input)?;
if map.contains_key(&key) {
return Err(invalid("duplicate key"));
}
map.insert(key, read(input, depth + 1, budget)?);
}
Value::Object(map)
}
}
_ => return Err(invalid("unknown binary tag")),
})
}
fn count(n: usize, out: &mut Vec<u8>) -> Result<()> {
out.extend_from_slice(&u32::try_from(n).map_err(invalid)?.to_le_bytes());
Ok(())
}
fn text(s: &str, out: &mut Vec<u8>) -> Result<()> {
count(s.len(), out)?;
out.extend_from_slice(s.as_bytes());
Ok(())
}
pub(super) fn write(value: &Value, out: &mut Vec<u8>, depth: usize) -> Result<()> {
if depth > 48 || out.len() > MAX_BYTES {
return Err(invalid("encoding budget"));
}
match value {
Value::Null => out.push(0),
Value::Bool(b) => out.push(if *b { 2 } else { 1 }),
Value::Number(n) => {
if n.is_f64() {
out.push(5);
out.extend_from_slice(&n.as_f64().ok_or_else(|| invalid("number"))?.to_le_bytes());
} else if let Some(n) = n.as_u64() {
out.push(4);
out.extend_from_slice(&n.to_le_bytes());
} else {
out.push(3);
out.extend_from_slice(&n.as_i64().ok_or_else(|| invalid("integer"))?.to_le_bytes());
}
}
Value::String(s) => {
out.push(6);
text(s, out)?;
}
Value::Array(a) => {
out.push(7);
count(a.len(), out)?;
for v in a {
write(v, out, depth + 1)?;
}
}
Value::Object(map) => {
out.push(8);
count(map.len(), out)?;
for (k, v) in map {
text(k, out)?;
write(v, out, depth + 1)?;
}
}
}
if out.len() > MAX_BYTES {
return Err(invalid("encoding size"));
}
Ok(())
}
+85
View File
@@ -0,0 +1,85 @@
//! Bounded export of procedural intersections as ordinary spatial cubic splines.
//! The stored tolerance is the fitting target; the result is always provisional.
use super::*;
fn point_at(c: &mt::Curve, t: f64, budget: &mut usize) -> Result<mt::Point3> {
*budget = budget.checked_sub(1).ok_or_else(|| invalid("intersection fitting budget"))?;
let p = c.subs(t);
if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() {
return Err(invalid("intersection sample"));
}
Ok(p)
}
pub(super) fn fit_intersection(c: &mt::Curve, budget: &mut usize) -> Result<b::Curve> {
let tolerance = 1e-4;
let (start, end) = c.range_tuple();
if !start.is_finite() || !end.is_finite() || start >= end {
return Err(invalid("intersection range"));
}
// Fit each smooth parameter interval using spatial samples, without backend derivative conventions.
// Only the resulting spatial spline crosses the persisted format boundary.
let mt::Curve::IntersectionCurve(intersection) = c else {
return Err(invalid("expected intersection"));
};
let mut breaks = vec![start, end];
match intersection.leader() {
mt::Leader::Polyline(points) => {
if points.len() > 4096 {
return Err(invalid("intersection parameter budget"));
}
breaks.extend((0..points.len()).map(|i| i as f64).filter(|t| *t > start && *t < end));
}
mt::Leader::BSpline(curve) => {
if curve.knot_vec().len() > 4096 {
return Err(invalid("intersection parameter budget"));
}
breaks.extend(curve.knot_vec().iter().copied().filter(|t| *t > start && *t < end));
}
}
breaks.sort_by(f64::total_cmp);
breaks.dedup();
let mut stack = Vec::new();
for pair in breaks.windows(2).rev() {
let (a, b) = (pair[0], pair[1]);
stack.push((a, b, point_at(c, a, budget)?, point_at(c, b, budget)?, 0u8));
}
let mut pieces = Vec::new();
while let Some((a, b, pa, pb, depth)) = stack.pop() {
let p1 = point_at(c, a + (b - a) / 3., budget)?.to_vec();
let p2 = point_at(c, a + (b - a) * 2. / 3., budget)?.to_vec();
let x = p1 * 27. - pa.to_vec() * 8. - pb.to_vec();
let y = p2 * 27. - pa.to_vec() - pb.to_vec() * 8.;
let controls = [pa, mt::Point3::from_vec((x * 2. - y) / 18.), mt::Point3::from_vec((y * 2. - x) / 18.), pb];
let mut fits = true;
for fraction in [0.125, 0.25, 0.5, 0.75, 0.875] {
let actual = point_at(c, a + (b - a) * fraction, budget)?;
let u = 1. - fraction;
let fitted = controls[0].to_vec() * u * u * u
+ controls[1].to_vec() * 3. * u * u * fraction
+ controls[2].to_vec() * 3. * u * fraction * fraction
+ controls[3].to_vec() * fraction * fraction * fraction;
if (actual.to_vec() - fitted).magnitude() > tolerance * 0.1 {
fits = false;
}
}
if fits {
pieces.push((a, b, controls));
} else {
if depth >= 12 || pieces.len() + stack.len() >= 1023 {
return Err(invalid("intersection fit did not converge"));
}
let middle = a + (b - a) / 2.;
let pm = point_at(c, middle, budget)?;
stack.push((middle, b, pm, pb, depth + 1));
stack.push((a, middle, pa, pm, depth + 1));
}
}
let first = pieces.first().ok_or_else(|| invalid("empty intersection fit"))?;
let mut points = vec![point(first.2[0])];
let mut knots = vec![start; 4];
for (i, (_, b, controls)) in pieces.iter().enumerate() {
points.extend(controls.iter().skip(1).copied().map(point));
knots.extend(std::iter::repeat_n(*b, if i + 1 == pieces.len() { 4 } else { 3 }));
}
Ok(b::Curve::Intersection { knots, points, fitting_tolerance: tolerance, faces: None })
}
File diff suppressed because it is too large. Load diff
+349
View File
@@ -0,0 +1,349 @@
//! Explicit conversions between runtime metadata and the versioned wire records.
use super::{Result, invalid};
use crate::{Body, FaceSource, history as h};
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3, brep::envelope as w};
fn index(n: usize) -> Result<u32> {
u32::try_from(n).map_err(invalid)
}
fn point(p: Vec3) -> [f64; 3] {
[p.x, p.y, p.z]
}
fn point2(p: Vec2) -> [f64; 2] {
[p.x, p.y]
}
fn p([x, y, z]: [f64; 3]) -> Vec3 {
Vec3::new(x, y, z)
}
fn p2([x, y]: [f64; 2]) -> Vec2 {
Vec2::new(x, y)
}
fn plane(p: Plane) -> w::Plane {
w::Plane { origin: point(p.origin), x_axis: point(p.x), y_axis: point(p.y) }
}
fn load_plane(v: w::Plane) -> Plane {
Plane { origin: p(v.origin), x: p(v.x_axis), y: p(v.y_axis) }
}
fn segment(s: &Seg2) -> Result<w::Segment> {
Ok(match *s {
Seg2::Line { a, b } => w::Segment::Line { start: point2(a), end: point2(b) },
Seg2::Arc { center, radius, start, sweep } => w::Segment::Arc { center: point2(center), radius, start, sweep },
Seg2::Cubic { p0, p1, p2, p3 } => w::Segment::Cubic { points: [p0, p1, p2, p3].map(point2) },
Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w },
Seg2::Bezier { n, p } => {
let count = usize::from(n) + 1;
if !(5..=8).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) {
return Err(invalid("unsupported certificate Bezier layout"));
}
w::Segment::Bezier { points: p.get(..count).ok_or_else(|| invalid("Bezier count"))?.iter().copied().map(point2).collect() }
}
})
}
fn load_segment(s: w::Segment) -> Result<Seg2> {
Ok(match s {
w::Segment::Line { start, end } => Seg2::Line { a: p2(start), b: p2(end) },
w::Segment::Arc { center, radius, start, sweep } => Seg2::Arc { center: p2(center), radius, start, sweep },
w::Segment::Cubic { points: [a, b, c, d] } => Seg2::Cubic { p0: p2(a), p1: p2(b), p2: p2(c), p3: p2(d) },
w::Segment::Conic { points: [a, apex, b], middle_weight } => Seg2::Conic { a: p2(a), apex: p2(apex), b: p2(b), w: middle_weight },
w::Segment::Bezier { points } => {
if !(5..=8).contains(&points.len()) {
return Err(invalid("certificate Bezier degree"));
}
let mut p = [Vec2::ZERO; 8];
let n = (points.len() - 1) as u8;
for (slot, value) in p.iter_mut().zip(points) {
*slot = p2(value);
}
Seg2::Bezier { n, p }
}
})
}
fn region(r: &Region2) -> Result<w::Region> {
Ok(w::Region {
outer: r.outer.segs.iter().map(segment).collect::<Result<_>>()?,
holes: r.holes.iter().map(|l| l.segs.iter().map(segment).collect()).collect::<Result<_>>()?,
})
}
fn load_region(r: w::Region) -> Result<Region2> {
let loop_ = |s: Vec<w::Segment>| Ok(Loop2 { segs: s.into_iter().map(load_segment).collect::<Result<_>>()? });
Ok(Region2 { outer: loop_(r.outer)?, holes: r.holes.into_iter().map(loop_).collect::<Result<_>>()? })
}
fn source(s: &FaceSource, depth: usize) -> Result<w::FaceSource> {
if depth > 32 {
return Err(invalid("face-source nesting"));
}
Ok(match s {
FaceSource::ExtrudeSide { segment: s, plane: p, coalesced } => {
w::FaceSource::ExtrusionSide { segment: segment(s)?, plane: p.map(plane), coalesced: *coalesced }
}
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, plane: p } => {
w::FaceSource::CertifiedExtrusionSide { producer: *producer, sketch: *sketch, curve: curve.clone(), plane: p.map(plane) }
}
FaceSource::ExtrudeCap { high, region: r, plane: p } => w::FaceSource::ExtrusionCap { high: *high, region: region(r)?, plane: plane(*p) },
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane: p } => w::FaceSource::CertifiedExtrusionCap {
producer: *producer,
sketch: *sketch,
high: *high,
outer: outer.clone(),
holes: holes.clone(),
plane: plane(*p),
},
FaceSource::CylinderCap { producer, high, plane: p } => w::FaceSource::CylinderCap { producer: *producer, high: *high, plane: plane(*p) },
FaceSource::Box { producer, axis, high, plane: p } => {
w::FaceSource::BoxFace { producer: *producer, axis: *axis, high: *high, plane: plane(*p) }
}
FaceSource::ShellPlanar { producer, source: s, inner, plane: p } => {
w::FaceSource::PlanarShell { producer: *producer, source: Box::new(source(s, depth + 1)?), inner: *inner, plane: plane(*p) }
}
FaceSource::Imported { item, face, payload_sha256, producer } => {
w::FaceSource::Imported { item: *item, face: *face, payload_sha256: payload_sha256.clone(), producer: *producer }
}
})
}
fn load_source(s: w::FaceSource, depth: usize) -> Result<FaceSource> {
if depth > 32 {
return Err(invalid("face-source nesting"));
}
Ok(match s {
w::FaceSource::ExtrusionSide { segment, plane, coalesced } => {
FaceSource::ExtrudeSide { segment: load_segment(segment)?, plane: plane.map(load_plane), coalesced }
}
w::FaceSource::CertifiedExtrusionSide { producer, sketch, curve, plane } => {
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, plane: plane.map(load_plane) }
}
w::FaceSource::ExtrusionCap { high, region, plane } => {
FaceSource::ExtrudeCap { high, region: load_region(region)?, plane: load_plane(plane) }
}
w::FaceSource::CertifiedExtrusionCap { producer, sketch, high, outer, holes, plane } => {
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane: load_plane(plane) }
}
w::FaceSource::CylinderCap { producer, high, plane } => FaceSource::CylinderCap { producer, high, plane: load_plane(plane) },
w::FaceSource::BoxFace { producer, axis, high, plane } => FaceSource::Box { producer, axis, high, plane: load_plane(plane) },
w::FaceSource::PlanarShell { producer, source, inner, plane } => {
FaceSource::ShellPlanar { producer, source: Box::new(load_source(*source, depth + 1)?), inner, plane: load_plane(plane) }
}
w::FaceSource::Imported { item, face, payload_sha256, producer } => FaceSource::Imported { item, face, payload_sha256, producer },
})
}
fn origin(o: h::FaceOrigin) -> Result<w::FaceOrigin> {
Ok(match o {
h::FaceOrigin::Face { operand, face } => w::FaceOrigin::Face { operand: index(operand)?, face: index(face)? },
h::FaceOrigin::Profile { segment } => w::FaceOrigin::Profile { segment: index(segment)? },
h::FaceOrigin::Cap { end } => w::FaceOrigin::Cap { end },
h::FaceOrigin::Edge { operand, edge } => w::FaceOrigin::Edge { operand: index(operand)?, edge: index(edge)? },
h::FaceOrigin::Offset { operand, face } => w::FaceOrigin::Offset { operand: index(operand)?, face: index(face)? },
})
}
fn load_origin(o: w::FaceOrigin) -> h::FaceOrigin {
match o {
w::FaceOrigin::Face { operand, face } => h::FaceOrigin::Face { operand: operand as usize, face: face as usize },
w::FaceOrigin::Profile { segment } => h::FaceOrigin::Profile { segment: segment as usize },
w::FaceOrigin::Cap { end } => h::FaceOrigin::Cap { end },
w::FaceOrigin::Edge { operand, edge } => h::FaceOrigin::Edge { operand: operand as usize, edge: edge as usize },
w::FaceOrigin::Offset { operand, face } => h::FaceOrigin::Offset { operand: operand as usize, face: face as usize },
}
}
fn change(c: h::Change) -> w::Change {
match c {
h::Change::Kept => w::Change::Kept,
h::Change::Modified => w::Change::Modified,
h::Change::Split => w::Change::Split,
h::Change::Merged => w::Change::Merged,
h::Change::Generated => w::Change::Generated,
}
}
fn load_change(c: w::Change) -> h::Change {
match c {
w::Change::Kept => h::Change::Kept,
w::Change::Modified => h::Change::Modified,
w::Change::Split => h::Change::Split,
w::Change::Merged => h::Change::Merged,
w::Change::Generated => h::Change::Generated,
}
}
fn edge_origin(o: &h::EdgeOrigin) -> Result<w::EdgeOrigin> {
Ok(match o {
h::EdgeOrigin::Edge { operand, edge } => w::EdgeOrigin::Edge { operand: index(*operand)?, edge: index(*edge)? },
h::EdgeOrigin::Meet { a, b } => {
let mut sides = [
a.origins().iter().copied().map(origin).collect::<Result<Vec<_>>>()?,
b.origins().iter().copied().map(origin).collect::<Result<Vec<_>>>()?,
];
for side in &mut sides {
side.sort();
side.dedup();
}
sides.sort();
w::EdgeOrigin::Meeting { sides }
}
})
}
fn load_edge_origin(o: w::EdgeOrigin) -> Result<h::EdgeOrigin> {
Ok(match o {
w::EdgeOrigin::Edge { operand, edge } => h::EdgeOrigin::Edge { operand: operand as usize, edge: edge as usize },
w::EdgeOrigin::Meeting { sides: [a, b] } => {
if a > b || [&a, &b].iter().any(|s| s.is_empty() || s.windows(2).any(|pair| pair[0] >= pair[1])) {
return Err(invalid("noncanonical wire history meeting"));
}
let side = |o: Vec<w::FaceOrigin>| h::Side::new(o.into_iter().map(load_origin)).ok_or_else(|| invalid("empty history side"));
let (mut a, mut b) = (side(a)?, side(b)?);
// The runtime may order its variants differently from the wire schema.
if a > b {
std::mem::swap(&mut a, &mut b);
}
h::EdgeOrigin::Meet { a, b }
}
})
}
fn history(v: &h::History) -> Result<w::History> {
Ok(w::History {
operands: index(v.operands)?,
faces: v
.faces
.iter()
.map(|f| Ok(w::FaceHistory { origins: f.origins.iter().copied().map(origin).collect::<Result<_>>()?, change: change(f.change) }))
.collect::<Result<_>>()?,
edges: v
.edges
.iter()
.map(|e| {
Ok(w::EdgeHistory {
faces: e.faces.iter().copied().map(index).collect::<Result<_>>()?,
origins: e.origins.iter().map(edge_origin).collect::<Result<_>>()?,
})
})
.collect::<Result<_>>()?,
})
}
fn load_history(v: w::History) -> Result<h::History> {
Ok(h::History {
operands: v.operands as usize,
faces: v
.faces
.into_iter()
.map(|f| h::FaceProv { origins: f.origins.into_iter().map(load_origin).collect(), change: load_change(f.change) })
.collect(),
edges: v
.edges
.into_iter()
.map(|e| {
Ok(h::EdgeProv {
faces: e.faces.into_iter().map(|n| n as usize).collect(),
origins: e.origins.into_iter().map(load_edge_origin).collect::<Result<_>>()?,
})
})
.collect::<Result<_>>()?,
})
}
pub(super) fn encode(body: &Body) -> Result<w::Metadata> {
Ok(w::Metadata {
version: w::VERSION,
color: body.color,
paint: body
.paint
.as_ref()
.map(|p| {
Ok(w::Paint {
opacity: p.opacity,
faces: p
.faces
.iter()
.map(|f| Ok(w::FacePaint { face: index(f.face)?, color: f.color, opacity: f.opacity }))
.collect::<Result<_>>()?,
})
})
.transpose()?,
boundaries: body.splits.as_ref().map(|s| s.iter().map(|line| line.iter().copied().map(point).collect()).collect()),
face_sources: body
.face_sources
.as_ref()
.map(|s| s.iter().map(|s| s.as_ref().map(|s| source(s, 0)).transpose()).collect::<Result<_>>())
.transpose()?,
history: body.history.as_ref().map(|h| history(h)).transpose()?,
preserve_boundaries: body.regularization_protected,
})
}
pub(super) fn apply(body: &mut Body, saved: w::Metadata) -> Result<()> {
if saved.version != w::VERSION {
return Err(invalid("metadata version"));
}
body.color = saved.color;
body.paint = saved.paint.map(|p| {
std::sync::Arc::new(crate::Paint {
opacity: p.opacity,
faces: p.faces.into_iter().map(|f| crate::FacePaint { face: f.face as usize, color: f.color, opacity: f.opacity }).collect(),
})
});
body.splits = saved.boundaries.map(|s| std::sync::Arc::new(s.into_iter().map(|line| line.into_iter().map(p).collect()).collect()));
body.face_sources = saved
.face_sources
.map(|s| s.into_iter().map(|s| s.map(|s| load_source(s, 0)).transpose()).collect::<Result<Vec<_>>>())
.transpose()?
.map(std::sync::Arc::new);
body.history = saved.history.map(load_history).transpose()?.map(std::sync::Arc::new);
body.regularization_protected = saved.preserve_boundaries;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn independent_certificate_records_preserve_all_variants() {
let plane = w::Plane { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
let segments = vec![
w::Segment::Line { start: [0., 0.], end: [1., 2.] },
w::Segment::Arc { center: [1., 2.], radius: 3., start: 0.2, sweep: -0.7 },
w::Segment::Cubic { points: [[0., 0.], [1., 2.], [3., 4.], [5., 6.]] },
w::Segment::Conic { points: [[0., 0.], [1., 2.], [3., 4.]], middle_weight: 0.7 },
w::Segment::Bezier { points: vec![[1., 2.]; 5] },
w::Segment::Bezier { points: vec![[1., 2.]; 8] },
];
for value in &segments {
assert_eq!(*value, segment(&load_segment(value.clone()).unwrap()).unwrap());
}
let region = w::Region { outer: segments.clone(), holes: vec![segments.clone()] };
let mut sources: Vec<_> =
segments.into_iter().map(|segment| w::FaceSource::ExtrusionSide { segment, plane: Some(plane.clone()), coalesced: true }).collect();
sources.extend([
w::FaceSource::CertifiedExtrusionSide { producer: 5, sketch: 7, curve: "curve-9".into(), plane: None },
w::FaceSource::ExtrusionCap { high: true, region, plane: plane.clone() },
w::FaceSource::CertifiedExtrusionCap {
producer: 5,
sketch: 7,
high: false,
outer: vec!["a".into()],
holes: vec![vec!["b".into()]],
plane: plane.clone(),
},
w::FaceSource::CylinderCap { producer: 8, high: true, plane: plane.clone() },
w::FaceSource::BoxFace { producer: Some(5), axis: 2, high: true, plane: plane.clone() },
w::FaceSource::Imported { item: 2, face: 3, payload_sha256: Some("a".repeat(64)), producer: Some(5) },
]);
sources.push(w::FaceSource::PlanarShell { producer: Some(6), source: Box::new(sources[0].clone()), inner: true, plane });
for value in sources {
assert_eq!(value, source(&load_source(value.clone(), 0).unwrap(), 0).unwrap());
}
assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; 9] }).is_err());
}
#[test]
fn history_wire_order_is_explicit_and_meetings_round_trip() {
let origins = vec![
w::FaceOrigin::Face { operand: 0, face: 2 },
w::FaceOrigin::Profile { segment: 3 },
w::FaceOrigin::Cap { end: true },
w::FaceOrigin::Edge { operand: 1, edge: 4 },
w::FaceOrigin::Offset { operand: 0, face: 5 },
];
for value in &origins {
assert_eq!(*value, origin(load_origin(value.clone())).unwrap());
}
for value in [w::Change::Kept, w::Change::Modified, w::Change::Split, w::Change::Merged, w::Change::Generated] {
assert_eq!(value, change(load_change(value)));
}
let value = w::EdgeOrigin::Meeting { sides: [origins.clone(), vec![w::FaceOrigin::Offset { operand: 1, face: 2 }]] };
assert_eq!(value, edge_origin(&load_edge_origin(value.clone()).unwrap()).unwrap());
assert!(load_edge_origin(w::EdgeOrigin::Meeting { sides: [vec![], origins] }).is_err());
}
}
+138
View File
@@ -0,0 +1,138 @@
# Portable native B-rep, schema 2
This is SolidCraft's geometry interchange data model in `solidcraft_geom::brep`.
It has no modelling-backend dependency. It is the basis for #303's disposable
native cache; it is not an editable-session snapshot or a STEP replacement for
external interchange. The container reader, binary codec and kernel adapters are
separate layers. The schema is not yet emitted by application saves.
## Geometry and topology
Coordinates are IEEE-754 binary64, in millimetres, right-handed Z-up. Angles are
radians. Integer references are unsigned 32-bit shell-local indices, never kernel
addresses or runtime IDs. The schema version is an explicit unsigned 32-bit field.
A body contains ordered shells; a shell contains ordered vertices, edges and faces.
Order is retained for SolidCraft's face provenance and naming. Closed and open
shells are representable; material containment is not inferred from shell order.
An edge references its start/end vertices in its curve's forward direction. Each
face contains ordered closed loops of oriented edge uses. A reversed use swaps
edge endpoints. Loops record the physical face-oriented traversal. Face `forward` flips only the
surface normal; it never swaps surface parameters or reverses these stored loops.
Adapters must convert their internal boundary conventions separately. Shared edges are stored once per shell. Closed edges may reference the same endpoint vertex; individual adapters may refuse them.
The validator checks references and connected closed loops. It does not certify
watertightness, geometric coincidence, absence of self-intersections or trustworthy
modelling state; backend extraction has additional requirements.
Curves are tagged mathematical records:
- `line`: start/end points, linear parameter from 0 to 1.
- `polyline`: ordered points, segment i spans parameter i to i+1.
- `spline`: full nondecreasing knot vector and Cartesian control points. Degree is
knot count minus control-point count minus one.
- `rational_spline`: the same basis with homogeneous `[x*w,y*w,z*w,w]` coefficients,
retaining the original coefficients rather than normalizing/reapproximating them.
- `circle_arc` and `ellipse_arc`: an orthonormal frame, radius/radii and an
increasing angular range. Position is origin + x*rx*cos(t) + y*ry*sin(t).
- `intersection`: an explicit ordinary 3D polynomial spline (knots and Cartesian
control points), positive fitting tolerance in millimetres, and optional two
shell-local face references as hints. No supporting surfaces, leader, backend
solver or parameter-search convention is required to evaluate it.
Surfaces include planes (origin and independent parameter axes), polynomial and
rational tensor-product splines, and explicit analytic records. Analytic frames
store origin and orthonormal x/y axes; z is x cross y. Angular ranges are radians:
- cylinder: radius, angular range and axial height range;
- cone: radius at origin, half angle, angular range and height range; radius at
height h is radius_at_origin + h*tan(half_angle);
- sphere: radius, longitude and latitude ranges;
- torus: major/minor radii, longitude and meridian ranges;
- revolution: profile, origin, unit axis and angular range. Rotate the profile
around that axis using the right-hand rule. There is no persisted affine matrix
or backend orientation/parameter-swap flag.
Cylinder/cone positions are origin + h*z + r(h)*(cos(u)*x + sin(u)*y).
Sphere positions are origin + r*(cos(v)*(cos(u)*x + sin(u)*y) + sin(v)*z).
Torus positions are origin + (R+r*cos(v))*(cos(u)*x + sin(u)*y) + r*sin(v)*z.
Analytic normals point outward; face orientation can flip them. Each adapter
must preserve the geometric locus and explicit face-oriented boundaries, even
when its runtime uses a different parameterization.
## Validation and trust
Schema 2 caps 1,024 shells, 100,000 combined topology/geometry items, geometry
nesting at 16, spline degree at 16 and control points per spline dimension at
4,096. Numbers must be finite and bounded in magnitude by 1e12. Knot order, active
spline domain, multiplicities, rectangular grids, positive rational weights,
nondegenerate planes, orthonormal frames, unit revolution axes, positive radii,
valid angular/height ranges and intersection face references are checked before an adapter is called. These are resource and structural guards,
not a geometric validity certificate. The binary reader must additionally bound
bytes, allocation and nesting before creating these structures.
Schema tests build and validate an open triangular face without constructing or
linking a kernel body. Kernel-specific structures must appear only inside adapters;
they must never acquire authority over persisted field names or encodings. A
second backend still requires an implemented and tested adapter before claiming
cross-kernel interoperability.
Face-source certificates, operation history, appearances and persistent document
identities belong to a versioned SolidCraft body/state envelope around this geometry.
They must be preserved by the cache codec, not reconstructed from face counts or
floating-point comparisons. A decoded cache remains provisional until authoritative
re-evaluation finishes. The document and original blobs remain sufficient to reopen
when a cache, representation, producer or adapter is unsupported.
## Body binary envelope, version 3
`SCBREP03` is the eight-byte envelope signature. Geometry schema 2 is nested in
`solid`; metadata schema 1 is nested in `metadata`. These are separate version
numbers because each contract can evolve independently. Signatures 01 and 02
were development prototypes; 02 introduced the explicit geometry schema while
03 also separates metadata from runtime Rust types. Unsupported versions are
cache misses, not design-file migration failures.
The envelope uses its own explicitly versioned records for color, paint, authored
boundaries, face-source certificates, operation history and boundary protection.
Plane, region and segment records are also owned by the wire schema. Exhaustive
manual adapter conversions separate these from runtime FaceSource, History,
FaceProv, EdgeProv, Paint, Seg2, Region2 and Plane types. Their serde derives or
internal refactors cannot silently redefine the saved fields. No truck serde
record, pointer, tessellation or STEP string is written. Changing field semantics
requires a format version change, even if a Rust type keeps its name.
Values use tags: null=0, false=1, true=2, signed integer=3, unsigned integer=4,
binary64=5, UTF-8 string=6, array=7, object=8. Numbers use eight little-endian
bytes. Strings and collections use unsigned 32-bit little-endian lengths; object
keys are length-prefixed UTF-8, emitted lexicographically. Readers reject duplicate
keys, unknown tags, non-finite numbers and trailing bytes. Limits are 64 MiB,
2,000,000 values, depth 48, 100,000 entries per collection and 1 MiB per string.
The truck adapter converts these records into runtime topology only after
validation. It refuses single-vertex closed edges, standalone polylines,
unclamped spline domains, non-full angular carrier surfaces and conversions that
would change plane axes. Circle/ellipse arcs use exact rational quadratic loci;
the adapter may reparameterize them. Similarity transforms on revolutions are
baked into their profile, origin and axis (including reflections). Non-similarity
transforms are refused until an exact independent spline conversion exists.
Normal flips and backend boundary orientation are converted separately.
Procedural intersections are exported with a bounded spatial cubic fit: 1e-4 mm
fitting target, 4,096 evaluations shared across the body, at most 1,023 pieces and
12 subdivisions per source interval. The target is checked at sampled positions;
it is not a certified maximum error between samples. Failure to fit within these
limits refuses the cache. A decoded fitted curve is an ordinary spatial spline;
re-export may therefore use the `spline` record rather than retain its optional
intersection annotation. This approximation is suitable only for the provisional
cache, never authoritative modelling state.
Decoded bodies remain provisional; the application must never use an untrusted
cache as its editable model. Application cache integration is separate. A second
backend needs its own tested adapter before cross-kernel interoperability can be
claimed.
The box-and-fillet golden JSON fixture pins the decoded version-3 envelope,
field names, tags, topology, geometry and metadata. Integer and structural values
are exact; floating coefficients allow 1e-10 relative/absolute platform rounding.
The signature is asserted separately. This protects the wire contract against
accidental runtime-refactor changes.