Define a kernel-neutral native B-rep format and guarded adapter (#461)
A SolidCraft-owned portable B-rep schema (geometry schema 2 with analytic curves and surfaces, explicit frames and ranges, fitted intersection curves) and a versioned body envelope (SCBREP03) with its own metadata records, a bounded binary codec, a validator with resource budgets, and an adapter that refuses what it can't convert exactly. Pinned by a box-and-fillet golden fixture. Not yet written by application saves. Refs #303.
This commit is contained in:
1 parent
3159fc0dd0
commit
9f88537927
14 files changed
+3925
-1
No files matched your search
@@ -0,0 +1,305 @@
|
||||
//! SolidCraft native, versioned binary geometry. No STEP conversion or display meshes.
|
||||
//! A cache remains provisional until the document is rebuilt; hashes are not trust.
|
||||
use crate::{Body, KernelError, Result, guard};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod adapter;
|
||||
mod binary;
|
||||
mod metadata;
|
||||
|
||||
// 01/02 were development-only prototypes; independent metadata records start at 03.
|
||||
const MAGIC: &[u8; 8] = b"SCBREP03";
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
const MAX_ITEMS: usize = 100_000;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SavedBody {
|
||||
solid: solidcraft_geom::brep::Brep,
|
||||
metadata: solidcraft_geom::brep::envelope::Metadata,
|
||||
}
|
||||
fn invalid(detail: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Invalid(format!("native B-rep: {detail}"))
|
||||
}
|
||||
|
||||
/// Explicit topology, spatial geometry, construction certificates and history.
|
||||
/// Procedural intersections use bounded fitting for provisional cache display.
|
||||
/// Failure is a cache miss at the IO boundary, never a failed document save.
|
||||
pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
|
||||
guard("encode native B-rep", || {
|
||||
for source in body.face_sources.iter().flat_map(|s| s.iter()).flatten() {
|
||||
let mut at = source;
|
||||
let mut depth = 0usize;
|
||||
while let crate::FaceSource::ShellPlanar { source, .. } = at {
|
||||
depth += 1;
|
||||
if depth > 32 {
|
||||
return Err(invalid("face-source nesting"));
|
||||
}
|
||||
at = source;
|
||||
}
|
||||
}
|
||||
if body.face_count() > MAX_ITEMS
|
||||
|| body.solid.edge_iter().take(MAX_ITEMS + 1).count() > MAX_ITEMS
|
||||
|| body.mesh.as_ref().is_some_and(|m| m.positions.len() > MAX_ITEMS || m.triangles.len() > MAX_ITEMS)
|
||||
{
|
||||
return Err(invalid("native geometry budget"));
|
||||
}
|
||||
if body.mesh.is_some() {
|
||||
return Err(invalid("mesh bodies are source blobs, not B-rep"));
|
||||
}
|
||||
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)? };
|
||||
// Structural validation does not invoke another geometric reconstruction.
|
||||
validate_topology(&saved)?;
|
||||
let value = serde_json::to_value(saved).map_err(invalid)?;
|
||||
validate_value(&value, 0)?;
|
||||
if value_nodes(&value) > 2_000_000 {
|
||||
return Err(invalid("value budget"));
|
||||
}
|
||||
let mut out = MAGIC.to_vec();
|
||||
binary::write(&value, &mut out, 0)?;
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode bounded untrusted data. Validate indices before topology extraction.
|
||||
/// Callers must not treat the decoded body as authoritative editing geometry.
|
||||
pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
if bytes.len() > MAX_BYTES || bytes.get(..8) != Some(MAGIC.as_slice()) {
|
||||
return Err(invalid("version or size"));
|
||||
}
|
||||
guard("decode native B-rep", || {
|
||||
let mut input = bytes.get(8..).ok_or_else(|| invalid("header"))?;
|
||||
let mut remaining = 2_000_000usize;
|
||||
let value = binary::read(&mut input, 0, &mut remaining)?;
|
||||
if !input.is_empty() {
|
||||
return Err(invalid("trailing data"));
|
||||
}
|
||||
validate_value(&value, 0)?;
|
||||
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
|
||||
validate_topology(&saved)?;
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
history: None,
|
||||
regularization_protected: false,
|
||||
};
|
||||
metadata::apply(&mut body, saved.metadata)?;
|
||||
Ok(body)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_topology(saved: &SavedBody) -> Result<()> {
|
||||
saved.solid.validate().map_err(invalid)?;
|
||||
let faces: usize = saved.solid.shells.iter().map(|s| s.faces.len()).sum();
|
||||
let edges: usize = saved.solid.shells.iter().map(|s| s.edges.len()).sum();
|
||||
let saved = &saved.metadata;
|
||||
if saved.version != solidcraft_geom::brep::envelope::VERSION {
|
||||
return Err(invalid("metadata version"));
|
||||
}
|
||||
if saved.face_sources.as_ref().is_some_and(|s| s.len() != faces) {
|
||||
return Err(invalid("face source count"));
|
||||
}
|
||||
if let Some(history) = &saved.history
|
||||
&& (history.faces.len() != faces
|
||||
|| history.edges.len() != edges
|
||||
|| history.operands as usize > MAX_ITEMS
|
||||
|| history.edges.iter().flat_map(|e| &e.faces).any(|&f| f as usize >= faces))
|
||||
{
|
||||
return Err(invalid("history topology"));
|
||||
}
|
||||
if saved.color.is_some_and(|c| c.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))) {
|
||||
return Err(invalid("colour"));
|
||||
}
|
||||
if let Some(paint) = &saved.paint
|
||||
&& (!paint.opacity.is_finite()
|
||||
|| !(0.0..=1.0).contains(&paint.opacity)
|
||||
|| paint.faces.iter().any(|p| {
|
||||
p.face as usize >= faces
|
||||
|| !p.opacity.is_finite()
|
||||
|| !(0.0..=1.0).contains(&p.opacity)
|
||||
|| p.color.iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
|
||||
}))
|
||||
{
|
||||
return Err(invalid("face paint"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// The binary reader bounds allocation before deserialization. Geometry semantics
|
||||
// are checked by the independent schema validator, not by backend serde internals.
|
||||
fn validate_value(v: &Value, depth: usize) -> Result<()> {
|
||||
if depth > 48 {
|
||||
return Err(invalid("nesting"));
|
||||
}
|
||||
match v {
|
||||
Value::String(s) if s.len() > 1 << 20 => return Err(invalid("string budget")),
|
||||
Value::Number(n) if n.is_f64() && n.as_f64().is_none_or(|x| !x.is_finite() || x.abs() > 1e12) => return Err(invalid("non-finite metadata")),
|
||||
Value::Array(a) => {
|
||||
if a.len() > MAX_ITEMS {
|
||||
return Err(invalid("array budget"));
|
||||
}
|
||||
for x in a {
|
||||
validate_value(x, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
for x in o.values() {
|
||||
validate_value(x, depth + 1)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value_nodes(v: &Value) -> usize {
|
||||
match v {
|
||||
Value::Array(a) => a.iter().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
|
||||
Value::Object(o) => o.values().fold(1usize, |n, v| n.saturating_add(value_nodes(v))),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
fn equal(a: &Body, b: &Body) {
|
||||
assert_eq!(a.face_count(), b.face_count());
|
||||
assert_eq!(a.face_sources, b.face_sources);
|
||||
assert_eq!(a.history, b.history);
|
||||
assert_eq!(a.paint, b.paint);
|
||||
assert_eq!(a.color, b.color);
|
||||
assert_eq!(a.splits, b.splits);
|
||||
assert_eq!(a.regularization_protected, b.regularization_protected);
|
||||
assert_eq!(encode_native_body(a).unwrap(), encode_native_body(b).unwrap());
|
||||
let am = crate::measure(a).unwrap();
|
||||
let bm = crate::measure(b).unwrap();
|
||||
assert!((am.volume - bm.volume).abs() < 1e-7);
|
||||
}
|
||||
#[test]
|
||||
fn native_solids_keep_geometry_metadata_and_later_modelling() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
|
||||
let bore = crate::cylinder(Vec3::new(10., 10., -1.), Vec3::Z, 3., 12.).unwrap();
|
||||
let cut = crate::boolean(&rounded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
for body in [cube, rounded, cut] {
|
||||
let body = body.with_color(Some([0.1, 0.5, 0.9]));
|
||||
let bytes = encode_native_body(&body).unwrap();
|
||||
assert!(bytes.starts_with(MAGIC));
|
||||
let decoded = decode_native_body(&bytes).unwrap();
|
||||
equal(&body, &decoded);
|
||||
let a = crate::boolean(&body, &bore, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let b = crate::boolean(&decoded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let surface = crate::copy_faces(&cube, &[Vec3::new(20., 15., 10.)]).unwrap();
|
||||
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
|
||||
let torus = crate::torus(Vec3::ZERO, 20., 4.).unwrap();
|
||||
for body in [surface, sphere, torus] {
|
||||
let encoded = encode_native_body(&body).unwrap();
|
||||
let restored = decode_native_body(&encoded).unwrap();
|
||||
assert_eq!(body.is_surface(), restored.is_surface());
|
||||
assert_eq!(body.face_count(), restored.face_count());
|
||||
assert_eq!(encoded, encode_native_body(&restored).unwrap());
|
||||
if !body.is_surface() {
|
||||
equal(&body, &restored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_and_fillet_have_stable_wire_records() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap();
|
||||
let records: Vec<_> = [cube, rounded]
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let bytes = encode_native_body(body).unwrap();
|
||||
assert_eq!(&bytes[..8], b"SCBREP03");
|
||||
let mut raw = &bytes[8..];
|
||||
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
|
||||
assert!(raw.is_empty());
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let actual = serde_json::json!(records);
|
||||
let expected: Value = serde_json::from_str(include_str!("native/fixtures/box-fillet-v3.json")).unwrap();
|
||||
compare_wire(&expected, &actual);
|
||||
}
|
||||
|
||||
fn compare_wire(expected: &Value, actual: &Value) {
|
||||
match (expected, actual) {
|
||||
(Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
|
||||
let (a, b) = (a.as_f64().unwrap(), b.as_f64().unwrap());
|
||||
assert!((a - b).abs() <= 1e-10 * a.abs().max(1.), "wire number changed: {a} vs {b}");
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
compare_wire(a, b);
|
||||
}
|
||||
}
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
assert_eq!(a.keys().collect::<Vec<_>>(), b.keys().collect::<Vec<_>>());
|
||||
for (key, a) in a {
|
||||
compare_wire(a, &b[key]);
|
||||
}
|
||||
}
|
||||
_ => assert_eq!(expected, actual),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_native_geometry_is_bounded_and_never_panics() {
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
let bytes = encode_native_body(&body).unwrap();
|
||||
for n in [0, 1, 7, 8, 9, bytes.len() - 1] {
|
||||
assert!(decode_native_body(&bytes[..n]).is_err());
|
||||
}
|
||||
for i in (0..bytes.len()).step_by((bytes.len() / 256).max(1)) {
|
||||
let mut bad = bytes.clone();
|
||||
bad[i] ^= 0xff;
|
||||
assert!(std::panic::catch_unwind(|| decode_native_body(&bad)).is_ok());
|
||||
}
|
||||
let mut raw = &bytes[8..];
|
||||
let value = binary::read(&mut raw, 0, &mut 2_000_000).unwrap();
|
||||
for key in ["vertices", "index"] {
|
||||
let mut v = value.clone();
|
||||
if key == "vertices" {
|
||||
v["solid"]["shells"][0]["edges"][0]["vertices"] = serde_json::json!([u64::MAX, 0]);
|
||||
} else {
|
||||
v["solid"]["shells"][0]["faces"][0]["loops"][0][0]["edge"] = serde_json::json!(u64::MAX);
|
||||
}
|
||||
let mut bad = MAGIC.to_vec();
|
||||
binary::write(&v, &mut bad, 0).unwrap();
|
||||
assert!(decode_native_body(&bad).is_err());
|
||||
}
|
||||
let mut huge = MAGIC.to_vec();
|
||||
huge.push(7);
|
||||
huge.extend(u32::MAX.to_le_bytes());
|
||||
assert!(decode_native_body(&huge).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_preserves_face_appearance_and_authored_boundaries() {
|
||||
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.))
|
||||
.unwrap()
|
||||
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6 }] }));
|
||||
body.splits = Some(Arc::new(vec![vec![Vec3::new(1., 2., 3.), Vec3::new(11., 2., 3.)]]));
|
||||
body.regularization_protected = true;
|
||||
let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap();
|
||||
equal(&body, &restored);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user