diff --git a/Cargo.lock b/Cargo.lock index 7bb68c4a..a20cfac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3375,6 +3375,7 @@ version = "0.7.0" dependencies = [ "log", "serde", + "serde_json", "solidcraft-geom", "thiserror 2.0.21", "truck-meshalgo", diff --git a/crates/geom/src/brep.rs b/crates/geom/src/brep.rs new file mode 100644 index 00000000..2e01e680 --- /dev/null +++ b/crates/geom/src/brep.rs @@ -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, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Shell { + pub vertices: Vec<[f64; 3]>, + pub edges: Vec, + pub faces: Vec, +} + +#[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>, + 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, points: Vec<[f64; 3]> }, + RationalSpline { knots: Vec, homogeneous: Vec<[f64; 4]> }, + Intersection { knots: Vec, 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, v_knots: Vec, points: Vec> }, + RationalSpline { u_knots: Vec, v_knots: Vec, homogeneous: Vec> }, + Revolution { profile: Box, 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 = std::result::Result; + +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) -> Result<()> { + require(values.into_iter().all(|v| v.is_finite() && v.abs() <= 1e12), "non-finite or excessive coordinate") +} +fn points(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(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::(&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::(&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()); + } +} diff --git a/crates/geom/src/brep/envelope.rs b/crates/geom/src/brep/envelope.rs new file mode 100644 index 00000000..2483992c --- /dev/null +++ b/crates/geom/src/brep/envelope.rs @@ -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, + pub boundaries: Option>>, + pub face_sources: Option>>, + pub history: Option, + 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, + pub holes: Vec>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum FaceSource { + ExtrusionSide { segment: Segment, plane: Option, coalesced: bool }, + CertifiedExtrusionSide { producer: u64, sketch: u64, curve: String, plane: Option }, + ExtrusionCap { high: bool, region: Region, plane: Plane }, + CertifiedExtrusionCap { producer: u64, sketch: u64, high: bool, outer: Vec, holes: Vec>, plane: Plane }, + CylinderCap { producer: u64, high: bool, plane: Plane }, + BoxFace { producer: Option, axis: u8, high: bool, plane: Plane }, + PlanarShell { producer: Option, source: Box, inner: bool, plane: Plane }, + Imported { item: u64, face: u64, payload_sha256: Option, producer: Option }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Paint { + pub opacity: f32, + pub faces: Vec, +} + +#[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, + pub edges: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceHistory { + pub origins: Vec, + pub change: Change, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EdgeHistory { + pub faces: Vec, + pub origins: Vec, +} + +#[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; 2] }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Change { + Kept, + Modified, + Split, + Merged, + Generated, +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index d09a07d0..8ca68e3c 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 5bcb302b..fe69d82c 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -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 } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 7defd097..367bc6f3 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs new file mode 100644 index 00000000..e4887d43 --- /dev/null +++ b/crates/kernel/src/native.rs @@ -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> { + 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 { + 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::>(), b.keys().collect::>()); + 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); + } +} diff --git a/crates/kernel/src/native/adapter.rs b/crates/kernel/src/native/adapter.rs new file mode 100644 index 00000000..ba661ec8 --- /dev/null +++ b/crates/kernel/src/native/adapter.rs @@ -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; +#[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::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 { + 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::>()?; + 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::>>()?; + // Wire direction is explicit; Face.forward changes only the normal. + if !f.orientation { + reverse_wire(&mut uses); + } + Ok(uses) + }) + .collect::>()?, + surface, + forward: f.orientation != normal_flip, + }) + }) + .collect::>()?; + 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 { + 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::>()?, + 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::>()?, + }) + }) + .collect::>()?, + }; + let shells = packed.boundaries.into_iter().map(mt::Shell::extract).collect::, _>>().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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/kernel/src/native/analytic.rs b/crates/kernel/src/native/analytic.rs new file mode 100644 index 00000000..2de6b1a9 --- /dev/null +++ b/crates/kernel/src/native/analytic.rs @@ -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 { + 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 { + 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")), + }) +} diff --git a/crates/kernel/src/native/binary.rs b/crates/kernel/src/native/binary.rs new file mode 100644 index 00000000..a5e85592 --- /dev/null +++ b/crates/kernel/src/native/binary.rs @@ -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 { + 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 { + 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 { + 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) -> Result<()> { + out.extend_from_slice(&u32::try_from(n).map_err(invalid)?.to_le_bytes()); + Ok(()) +} +fn text(s: &str, out: &mut Vec) -> Result<()> { + count(s.len(), out)?; + out.extend_from_slice(s.as_bytes()); + Ok(()) +} +pub(super) fn write(value: &Value, out: &mut Vec, 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(()) +} diff --git a/crates/kernel/src/native/fit.rs b/crates/kernel/src/native/fit.rs new file mode 100644 index 00000000..f51c08b6 --- /dev/null +++ b/crates/kernel/src/native/fit.rs @@ -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 { + *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 { + 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 }) +} diff --git a/crates/kernel/src/native/fixtures/box-fillet-v3.json b/crates/kernel/src/native/fixtures/box-fillet-v3.json new file mode 100644 index 00000000..fa1d6aed --- /dev/null +++ b/crates/kernel/src/native/fixtures/box-fillet-v3.json @@ -0,0 +1,1962 @@ +[ + { + "metadata": { + "boundaries": null, + "color": null, + "face_sources": [ + { + "axis": 2, + "high": false, + "kind": "box_face", + "plane": { + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_axis": [ + 1.0, + 0.0, + 0.0 + ], + "y_axis": [ + -0.0, + -1.0, + -0.0 + ] + }, + "producer": null + }, + { + "axis": 1, + "high": false, + "kind": "box_face", + "plane": { + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_axis": [ + 1.0, + 0.0, + 0.0 + ], + "y_axis": [ + 0.0, + 0.0, + 1.0 + ] + }, + "producer": null + }, + { + "axis": 0, + "high": true, + "kind": "box_face", + "plane": { + "origin": [ + 40.0, + 0.0, + 0.0 + ], + "x_axis": [ + 0.0, + 1.0, + 0.0 + ], + "y_axis": [ + 0.0, + 0.0, + 1.0 + ] + }, + "producer": null + }, + { + "axis": 1, + "high": true, + "kind": "box_face", + "plane": { + "origin": [ + 0.0, + 30.0, + 0.0 + ], + "x_axis": [ + 1.0, + 0.0, + 0.0 + ], + "y_axis": [ + -0.0, + -0.0, + -1.0 + ] + }, + "producer": null + }, + { + "axis": 0, + "high": false, + "kind": "box_face", + "plane": { + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_axis": [ + 0.0, + 1.0, + 0.0 + ], + "y_axis": [ + -0.0, + -0.0, + -1.0 + ] + }, + "producer": null + }, + { + "axis": 2, + "high": true, + "kind": "box_face", + "plane": { + "origin": [ + 0.0, + 0.0, + 10.0 + ], + "x_axis": [ + 1.0, + 0.0, + 0.0 + ], + "y_axis": [ + 0.0, + 1.0, + 0.0 + ] + }, + "producer": null + } + ], + "history": { + "edges": [ + { + "faces": [ + 0, + 4 + ], + "origins": [] + }, + { + "faces": [ + 0, + 3 + ], + "origins": [] + }, + { + "faces": [ + 0, + 2 + ], + "origins": [] + }, + { + "faces": [ + 0, + 1 + ], + "origins": [] + }, + { + "faces": [ + 1, + 2 + ], + "origins": [] + }, + { + "faces": [ + 1, + 5 + ], + "origins": [] + }, + { + "faces": [ + 1, + 4 + ], + "origins": [] + }, + { + "faces": [ + 2, + 3 + ], + "origins": [] + }, + { + "faces": [ + 2, + 5 + ], + "origins": [] + }, + { + "faces": [ + 3, + 4 + ], + "origins": [] + }, + { + "faces": [ + 3, + 5 + ], + "origins": [] + }, + { + "faces": [ + 4, + 5 + ], + "origins": [] + } + ], + "faces": [ + { + "change": "generated", + "origins": [ + { + "end": false, + "kind": "cap" + } + ] + }, + { + "change": "generated", + "origins": [ + { + "kind": "profile", + "segment": 0 + } + ] + }, + { + "change": "generated", + "origins": [ + { + "kind": "profile", + "segment": 1 + } + ] + }, + { + "change": "generated", + "origins": [ + { + "kind": "profile", + "segment": 2 + } + ] + }, + { + "change": "generated", + "origins": [ + { + "kind": "profile", + "segment": 3 + } + ] + }, + { + "change": "generated", + "origins": [ + { + "end": true, + "kind": "cap" + } + ] + } + ], + "operands": 0 + }, + "paint": null, + "preserve_boundaries": false, + "version": 1 + }, + "solid": { + "shells": [ + { + "edges": [ + { + "curve": { + "end": [ + 40.0, + 0.0, + 0.0 + ], + "kind": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 0, + 1 + ] + }, + { + "curve": { + "end": [ + 40.0, + 30.0, + 0.0 + ], + "kind": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 1, + 2 + ] + }, + { + "curve": { + "end": [ + 0.0, + 30.0, + 0.0 + ], + "kind": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 2, + 3 + ] + }, + { + "curve": { + "end": [ + 0.0, + 0.0, + 0.0 + ], + "kind": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 3, + 0 + ] + }, + { + "curve": { + "end": [ + 40.0, + 0.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 1, + 4 + ] + }, + { + "curve": { + "end": [ + 40.0, + 0.0, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 0.0, + 10.0 + ] + }, + "vertices": [ + 5, + 4 + ] + }, + { + "curve": { + "end": [ + 0.0, + 0.0, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 0, + 5 + ] + }, + { + "curve": { + "end": [ + 40.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 2, + 6 + ] + }, + { + "curve": { + "end": [ + 40.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 0.0, + 10.0 + ] + }, + "vertices": [ + 4, + 6 + ] + }, + { + "curve": { + "end": [ + 0.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 3, + 7 + ] + }, + { + "curve": { + "end": [ + 0.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 30.0, + 10.0 + ] + }, + "vertices": [ + 6, + 7 + ] + }, + { + "curve": { + "end": [ + 0.0, + 0.0, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 30.0, + 10.0 + ] + }, + "vertices": [ + 7, + 5 + ] + } + ], + "faces": [ + { + "forward": false, + "loops": [ + [ + { + "edge": 3, + "forward": false + }, + { + "edge": 2, + "forward": false + }, + { + "edge": 1, + "forward": false + }, + { + "edge": 0, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 30.0, + 0.0 + ], + "u_axis": [ + -40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + -30.0, + 0.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 0, + "forward": true + }, + { + "edge": 4, + "forward": true + }, + { + "edge": 5, + "forward": false + }, + { + "edge": 6, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "u_axis": [ + 40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 1, + "forward": true + }, + { + "edge": 7, + "forward": true + }, + { + "edge": 8, + "forward": false + }, + { + "edge": 4, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 0.0, + 0.0 + ], + "u_axis": [ + 0.0, + 30.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 2, + "forward": true + }, + { + "edge": 9, + "forward": true + }, + { + "edge": 10, + "forward": false + }, + { + "edge": 7, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 30.0, + 0.0 + ], + "u_axis": [ + -40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 3, + "forward": true + }, + { + "edge": 6, + "forward": true + }, + { + "edge": 11, + "forward": false + }, + { + "edge": 9, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 0.0, + 30.0, + 0.0 + ], + "u_axis": [ + 0.0, + -30.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 5, + "forward": true + }, + { + "edge": 8, + "forward": true + }, + { + "edge": 10, + "forward": true + }, + { + "edge": 11, + "forward": true + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 30.0, + 10.0 + ], + "u_axis": [ + -40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + -30.0, + 0.0 + ] + } + } + ], + "vertices": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 40.0, + 0.0, + 0.0 + ], + [ + 40.0, + 30.0, + 0.0 + ], + [ + 0.0, + 30.0, + 0.0 + ], + [ + 40.0, + 0.0, + 10.0 + ], + [ + 0.0, + 0.0, + 10.0 + ], + [ + 40.0, + 30.0, + 10.0 + ], + [ + 0.0, + 30.0, + 10.0 + ] + ] + } + ], + "version": 2 + } + }, + { + "metadata": { + "boundaries": null, + "color": null, + "face_sources": null, + "history": { + "edges": [ + { + "faces": [ + 0, + 4 + ], + "origins": [ + { + "edge": 0, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 0, + 3 + ], + "origins": [ + { + "edge": 1, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 0, + 2 + ], + "origins": [ + { + "edge": 2, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 0, + 1 + ], + "origins": [ + { + "edge": 3, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 1, + 2 + ], + "origins": [ + { + "edge": 4, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 1, + 6 + ], + "origins": [ + { + "kind": "meeting", + "sides": [ + [ + { + "face": 1, + "kind": "face", + "operand": 0 + } + ], + [ + { + "edge": 5, + "kind": "edge", + "operand": 0 + } + ] + ] + } + ] + }, + { + "faces": [ + 1, + 4 + ], + "origins": [ + { + "edge": 6, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 2, + 3 + ], + "origins": [ + { + "edge": 7, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 2, + 5 + ], + "origins": [ + { + "edge": 8, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 2, + 6 + ], + "origins": [ + { + "kind": "meeting", + "sides": [ + [ + { + "face": 2, + "kind": "face", + "operand": 0 + } + ], + [ + { + "edge": 5, + "kind": "edge", + "operand": 0 + } + ] + ] + } + ] + }, + { + "faces": [ + 3, + 4 + ], + "origins": [ + { + "edge": 9, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 3, + 5 + ], + "origins": [ + { + "edge": 10, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 4, + 6 + ], + "origins": [ + { + "kind": "meeting", + "sides": [ + [ + { + "face": 4, + "kind": "face", + "operand": 0 + } + ], + [ + { + "edge": 5, + "kind": "edge", + "operand": 0 + } + ] + ] + } + ] + }, + { + "faces": [ + 4, + 5 + ], + "origins": [ + { + "edge": 11, + "kind": "edge", + "operand": 0 + } + ] + }, + { + "faces": [ + 5, + 6 + ], + "origins": [ + { + "kind": "meeting", + "sides": [ + [ + { + "face": 5, + "kind": "face", + "operand": 0 + } + ], + [ + { + "edge": 5, + "kind": "edge", + "operand": 0 + } + ] + ] + } + ] + } + ], + "faces": [ + { + "change": "kept", + "origins": [ + { + "face": 0, + "kind": "face", + "operand": 0 + } + ] + }, + { + "change": "modified", + "origins": [ + { + "face": 1, + "kind": "face", + "operand": 0 + } + ] + }, + { + "change": "modified", + "origins": [ + { + "face": 2, + "kind": "face", + "operand": 0 + } + ] + }, + { + "change": "kept", + "origins": [ + { + "face": 3, + "kind": "face", + "operand": 0 + } + ] + }, + { + "change": "modified", + "origins": [ + { + "face": 4, + "kind": "face", + "operand": 0 + } + ] + }, + { + "change": "modified", + "origins": [ + { + "face": 5, + "kind": "face", + "operand": 0 + } + ] + }, + { + "change": "generated", + "origins": [ + { + "edge": 5, + "kind": "edge", + "operand": 0 + } + ] + } + ], + "operands": 1 + }, + "paint": null, + "preserve_boundaries": false, + "version": 1 + }, + "solid": { + "shells": [ + { + "edges": [ + { + "curve": { + "end": [ + 40.0, + 0.0, + 0.0 + ], + "kind": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 0, + 1 + ] + }, + { + "curve": { + "end": [ + 40.0, + 30.0, + 0.0 + ], + "kind": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 1, + 2 + ] + }, + { + "curve": { + "end": [ + 0.0, + 30.0, + 0.0 + ], + "kind": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 2, + 3 + ] + }, + { + "curve": { + "end": [ + 0.0, + 0.0, + 0.0 + ], + "kind": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 3, + 0 + ] + }, + { + "curve": { + "end": [ + 40.0, + 0.0, + 8.0 + ], + "kind": "line", + "start": [ + 40.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 1, + 4 + ] + }, + { + "curve": { + "end": [ + 40.0, + 0.0, + 8.0 + ], + "kind": "line", + "start": [ + 0.0, + 0.0, + 8.0 + ] + }, + "vertices": [ + 5, + 4 + ] + }, + { + "curve": { + "end": [ + 0.0, + 0.0, + 8.0 + ], + "kind": "line", + "start": [ + 0.0, + 0.0, + 0.0 + ] + }, + "vertices": [ + 0, + 5 + ] + }, + { + "curve": { + "end": [ + 40.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 2, + 6 + ] + }, + { + "curve": { + "end": [ + 40.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 2.0000000000000004, + 10.0 + ] + }, + "vertices": [ + 7, + 6 + ] + }, + { + "curve": { + "homogeneous": [ + [ + 40.0, + 0.0, + 8.0, + 1.0 + ], + [ + 37.07106781186547, + -2.220446049250313e-16, + 7.767766952966368, + 0.9267766952966368 + ], + [ + 34.142135623730944, + 0.2499999999999991, + 7.785533905932737, + 0.8535533905932736 + ], + [ + 34.142135623730944, + 0.7499999999999988, + 8.285533905932736, + 0.8535533905932736 + ], + [ + 37.07106781186547, + 1.4999999999999982, + 9.267766952966369, + 0.9267766952966368 + ], + [ + 40.0, + 1.9999999999999984, + 10.0, + 1.0 + ] + ], + "kind": "rational_spline", + "knots": [ + 0.0, + 0.0, + 0.0, + 0.25, + 0.5, + 0.75, + 1.0, + 1.0, + 1.0 + ] + }, + "vertices": [ + 4, + 7 + ] + }, + { + "curve": { + "end": [ + 0.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 30.0, + 0.0 + ] + }, + "vertices": [ + 3, + 8 + ] + }, + { + "curve": { + "end": [ + 0.0, + 30.0, + 10.0 + ], + "kind": "line", + "start": [ + 40.0, + 30.0, + 10.0 + ] + }, + "vertices": [ + 6, + 8 + ] + }, + { + "curve": { + "homogeneous": [ + [ + 0.0, + 0.0, + 8.0, + 1.0 + ], + [ + 0.0, + -2.220446049250313e-16, + 7.767766952966368, + 0.9267766952966368 + ], + [ + 0.0, + 0.2499999999999991, + 7.785533905932737, + 0.8535533905932736 + ], + [ + 0.0, + 0.7499999999999988, + 8.285533905932736, + 0.8535533905932736 + ], + [ + 0.0, + 1.4999999999999982, + 9.267766952966369, + 0.9267766952966368 + ], + [ + 0.0, + 1.9999999999999984, + 10.0, + 1.0 + ] + ], + "kind": "rational_spline", + "knots": [ + 0.0, + 0.0, + 0.0, + 0.25, + 0.5, + 0.75, + 1.0, + 1.0, + 1.0 + ] + }, + "vertices": [ + 5, + 9 + ] + }, + { + "curve": { + "end": [ + 0.0, + 2.0000000000000004, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 30.0, + 10.0 + ] + }, + "vertices": [ + 8, + 9 + ] + }, + { + "curve": { + "end": [ + 40.0, + 2.0000000000000004, + 10.0 + ], + "kind": "line", + "start": [ + 0.0, + 2.0000000000000004, + 10.0 + ] + }, + "vertices": [ + 9, + 7 + ] + } + ], + "faces": [ + { + "forward": false, + "loops": [ + [ + { + "edge": 3, + "forward": false + }, + { + "edge": 2, + "forward": false + }, + { + "edge": 1, + "forward": false + }, + { + "edge": 0, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 30.0, + 0.0 + ], + "u_axis": [ + -40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + -30.0, + 0.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 0, + "forward": true + }, + { + "edge": 4, + "forward": true + }, + { + "edge": 5, + "forward": false + }, + { + "edge": 6, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "u_axis": [ + 40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 1, + "forward": true + }, + { + "edge": 7, + "forward": true + }, + { + "edge": 8, + "forward": false + }, + { + "edge": 9, + "forward": false + }, + { + "edge": 4, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 0.0, + 0.0 + ], + "u_axis": [ + 0.0, + 30.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 2, + "forward": true + }, + { + "edge": 10, + "forward": true + }, + { + "edge": 11, + "forward": false + }, + { + "edge": 7, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 30.0, + 0.0 + ], + "u_axis": [ + -40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 3, + "forward": true + }, + { + "edge": 6, + "forward": true + }, + { + "edge": 12, + "forward": true + }, + { + "edge": 13, + "forward": false + }, + { + "edge": 10, + "forward": false + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 0.0, + 30.0, + 0.0 + ], + "u_axis": [ + 0.0, + -30.0, + 0.0 + ], + "v_axis": [ + 0.0, + 0.0, + 10.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 14, + "forward": true + }, + { + "edge": 8, + "forward": true + }, + { + "edge": 11, + "forward": true + }, + { + "edge": 13, + "forward": true + } + ] + ], + "surface": { + "kind": "plane", + "origin": [ + 40.0, + 30.0, + 10.0 + ], + "u_axis": [ + -40.0, + 0.0, + 0.0 + ], + "v_axis": [ + 0.0, + -30.0, + 0.0 + ] + } + }, + { + "forward": true, + "loops": [ + [ + { + "edge": 5, + "forward": true + }, + { + "edge": 9, + "forward": true + }, + { + "edge": 14, + "forward": false + }, + { + "edge": 12, + "forward": false + } + ] + ], + "surface": { + "homogeneous": [ + [ + [ + -2.0, + 0.0, + 8.0, + 1.0 + ], + [ + -1.8535533905932735, + -2.220446049250313e-16, + 7.767766952966368, + 0.9267766952966368 + ], + [ + -1.7071067811865472, + 0.2499999999999991, + 7.785533905932737, + 0.8535533905932736 + ], + [ + -1.7071067811865472, + 0.7499999999999988, + 8.285533905932736, + 0.8535533905932736 + ], + [ + -1.8535533905932735, + 1.4999999999999982, + 9.267766952966369, + 0.9267766952966368 + ], + [ + -2.0, + 1.9999999999999984, + 10.0, + 1.0 + ] + ], + [ + [ + 42.0, + 0.0, + 8.0, + 1.0 + ], + [ + 38.92462120245874, + -2.220446049250313e-16, + 7.767766952966368, + 0.9267766952966368 + ], + [ + 35.84924240491749, + 0.2499999999999991, + 7.785533905932737, + 0.8535533905932736 + ], + [ + 35.84924240491749, + 0.7499999999999988, + 8.285533905932736, + 0.8535533905932736 + ], + [ + 38.92462120245874, + 1.4999999999999982, + 9.267766952966369, + 0.9267766952966368 + ], + [ + 42.0, + 1.9999999999999984, + 10.0, + 1.0 + ] + ] + ], + "kind": "rational_spline", + "u_knots": [ + 0.0, + 0.0, + 1.0, + 1.0 + ], + "v_knots": [ + 0.0, + 0.0, + 0.0, + 0.25, + 0.5, + 0.75, + 1.0, + 1.0, + 1.0 + ] + } + } + ], + "vertices": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 40.0, + 0.0, + 0.0 + ], + [ + 40.0, + 30.0, + 0.0 + ], + [ + 0.0, + 30.0, + 0.0 + ], + [ + 40.0, + 0.0, + 8.0 + ], + [ + 0.0, + 0.0, + 8.0 + ], + [ + 40.0, + 30.0, + 10.0 + ], + [ + 40.0, + 2.0000000000000004, + 10.0 + ], + [ + 0.0, + 30.0, + 10.0 + ], + [ + 0.0, + 2.0000000000000004, + 10.0 + ] + ] + } + ], + "version": 2 + } + } +] diff --git a/crates/kernel/src/native/metadata.rs b/crates/kernel/src/native/metadata.rs new file mode 100644 index 00000000..1dccec7f --- /dev/null +++ b/crates/kernel/src/native/metadata.rs @@ -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::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 { + 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 { + 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 { + Ok(w::Region { + outer: r.outer.segs.iter().map(segment).collect::>()?, + holes: r.holes.iter().map(|l| l.segs.iter().map(segment).collect()).collect::>()?, + }) +} +fn load_region(r: w::Region) -> Result { + let loop_ = |s: Vec| Ok(Loop2 { segs: s.into_iter().map(load_segment).collect::>()? }); + Ok(Region2 { outer: loop_(r.outer)?, holes: r.holes.into_iter().map(loop_).collect::>()? }) +} +fn source(s: &FaceSource, depth: usize) -> Result { + 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 { + 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 { + 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 { + 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::>>()?, + b.origins().iter().copied().map(origin).collect::>>()?, + ]; + for side in &mut sides { + side.sort(); + side.dedup(); + } + sides.sort(); + w::EdgeOrigin::Meeting { sides } + } + }) +} +fn load_edge_origin(o: w::EdgeOrigin) -> Result { + 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| 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 { + Ok(w::History { + operands: index(v.operands)?, + faces: v + .faces + .iter() + .map(|f| Ok(w::FaceHistory { origins: f.origins.iter().copied().map(origin).collect::>()?, change: change(f.change) })) + .collect::>()?, + edges: v + .edges + .iter() + .map(|e| { + Ok(w::EdgeHistory { + faces: e.faces.iter().copied().map(index).collect::>()?, + origins: e.origins.iter().map(edge_origin).collect::>()?, + }) + }) + .collect::>()?, + }) +} +fn load_history(v: w::History) -> Result { + 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::>()?, + }) + }) + .collect::>()?, + }) +} +pub(super) fn encode(body: &Body) -> Result { + 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::>()?, + }) + }) + .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::>()) + .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::>>()) + .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()); + } +} diff --git a/docs/native-brep.md b/docs/native-brep.md new file mode 100644 index 00000000..5442e266 --- /dev/null +++ b/docs/native-brep.md @@ -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.