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:
1 parent
3159fc0dd0
commit
9f88537927
14 files changed
+3925
-1
No files matched your search
Generated
+1
@@ -3375,6 +3375,7 @@ version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
|
||||
@@ -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(°ree) && 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user