solidcraft-smlib: SMLib bodies into SolidCraft through schema 4

SMLib B-reps leave the C API as its flat arrays, read through the crate's
one flat wrapper (crate::flat::Flat, copied out as import::Arrays), and
become schema 4 records: shell pairs merged, sheets collapsed, radial cycles
normalised, analytic surfaces as their kinds (narrowing cones kept), loops
reversed where SMLib's surface is inside out, every pcurve from
brep::pcurve, canonical order, then validate() and check_geometry(). Edges
arrive as their own kinds; NURBS edges that are lines or circular arcs (the
primitives' circles) are recognised as a fallback and counted. With the
kernel's `smlib` feature, body_from_smlib() decodes them into a Body.

Adds the cylinder, sphere, cone and torus primitives. Each primitive passes
SMLib's own check at every level, its volume from the exact mass properties
matches the closed form, and it goes round SMLib -> schema 4 -> SMLib with
its counts, volume and a clean check. Truck now meshes a whole sphere
bounded only by its seam (SMLib's sphere) through the display plan.

SMLIB_REV moves to the fork's capi-primitives (the primitives on capi-flat
40bb855).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 01:12:36 -04:00
parent 2a22f4641b
commit 144b569246
12 changed files with 1384 additions and 14 deletions
+8 -8
View File
@@ -1278,14 +1278,14 @@ impl Body {
if let Some(m) = &self.mesh {
return Ok(m.to_mesh());
}
// Two kinds of face truck cannot mesh in its surface's parameters: one trimmed by a
// helix, which turns more than once around its cylinder and comes out with triangles
// across the solid (#1289), and one that runs across the join of a B-spline surface that
// closes on itself, which comes out folded over itself or with a hole in it.
// Such a body is meshed from its display plan, which cuts and unwraps those faces.
let planned =
guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid) || crate::tess::seam::closes_on_itself(&self.solid)));
if planned.unwrap_or(false) {
// A face trimmed by a helix (a modelled thread from another CAD system) turns more than
// once around its cylinder; truck meshes it with triangles across the solid (#1289).
// Such a body is meshed from its display plan, which cuts the face along a seam.
// So is one bounded by a single seam from pole to pole (a sphere from SMLib), which truck
// meshes as nothing.
if guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid) || crate::tess::has_pole_to_pole_seam(&self.solid)))
.unwrap_or(false)
{
let turn = (angle.is_finite() && angle > 0.0)
.then(|| crate::tess::size::Turn { angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol)) {
+1 -1
View File
@@ -191,7 +191,7 @@ pub use measure::{BodyMeasure, MassMeasure, measure, measure_mass};
pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body};
#[cfg(feature = "smlib")]
pub use native::smlib_body;
pub use native::{body_from_smlib, smlib_body};
pub use native::{brep_record, decode_native_body, encode_native_body, native_preview_mesh};
pub use offset::with_same_surface;
pub use ops::{
+10
View File
@@ -144,6 +144,16 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
})
}
/// An SMLib body as a kernel body, through its schema 4 record: SMLib's export mapped by
/// `solidcraft_smlib` (canonical order, radians, pcurves), then this adapter's decode of a
/// foreign record (analytic surfaces as truck's revolutions). The body is oriented as the record
/// says; `Body::new` only measures it.
#[cfg(feature = "smlib")]
pub fn body_from_smlib(brep: &solidcraft_smlib::Brep) -> Result<Body> {
let record = brep.to_native().map_err(|e| KernelError::Failed(e.to_string()))?;
guard("decode SMLib B-rep", || Body::new(adapter::decode_from(record, adapter::Producer::Foreign)?))
}
fn validate_topology(saved: &SavedBody) -> Result<()> {
saved.solid.validate().map_err(invalid)?;
let (faces, edges) = (saved.solid.faces.len(), saved.solid.edges.len());
+23 -4
View File
@@ -162,7 +162,7 @@ pub(super) fn encode(body: &Body) -> Result<b::Brep> {
}
let result = b::Brep { version: b::VERSION, tolerance, regions, shells: shell_records, faces, edges: edge_records, vertices };
result.validate().map_err(invalid)?;
compatible(&result)?;
compatible(&result, Producer::Own)?;
Ok(result)
}
@@ -303,17 +303,36 @@ fn regions(body: &Body) -> Result<Vec<b::Region>> {
Ok(regions)
}
fn compatible(body: &b::Brep) -> Result<()> {
fn compatible(body: &b::Brep, producer: Producer) -> Result<()> {
body.edges.iter().try_for_each(|e| compatible_curve(&e.curve))?;
body.faces.iter().try_for_each(|f| compatible_surface(&f.surface))
body.faces.iter().try_for_each(|f| match (&f.surface, producer) {
// Truck holds a plane by three points: another kernel's axes load rounded once.
(b::Surface::Plane { .. }, Producer::Foreign) => Ok(()),
(s, _) => compatible_surface(s),
})
}
/// Who wrote a record, which sets how exactly it must load.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Producer {
/// This adapter: the record loads back number for number, or is refused.
Own,
/// Another kernel (SMLib): its records load as truck holds them, analytic surfaces as
/// revolutions (as for our own) and planes through three points rounded once.
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
Foreign,
}
/// The solid of a record: validated, its geometry checked against its topology (truck builds
/// edges and faces from it unchecked), then built as the kernel built it.
pub(super) fn decode(body: b::Brep) -> Result<mt::Solid> {
decode_from(body, Producer::Own)
}
pub(super) fn decode_from(body: b::Brep, producer: Producer) -> Result<mt::Solid> {
body.validate().map_err(invalid)?;
body.check_geometry().map_err(invalid)?;
compatible(&body)?;
compatible(&body, producer)?;
let b::Brep { shells, faces, edges, vertices, .. } = body;
let vertices: Vec<mt::Vertex> = vertices.into_iter().map(|v| mt::Vertex::new(p(v.point))).collect();
let vertex = |i: u32| vertices.get(i as usize).ok_or_else(|| invalid("edge vertices"));
+39
View File
@@ -212,3 +212,42 @@ fn every_pcurve_lifts_onto_its_own_edge() {
}
}
}
/// SMLib's primitives load through their schema 4 records as bodies of SMLib's volume, oriented
/// as the records say (outward without `Body::new` turning them).
#[cfg(feature = "smlib")]
#[test]
fn smlib_primitives_decode_with_their_volume_and_orientation() {
use solidcraft_smlib::Brep as S;
let cases = [
("box", S::make_box([1., 2., 3.], [10., 20., 30.]).unwrap()),
("cylinder", S::make_cylinder([1., 2., 3.], 5., 10.).unwrap()),
("sphere", S::make_sphere([1., 2., 3.], 5.).unwrap()),
("frustum", S::make_cone([0.; 3], 5., 2., 10.).unwrap()),
("widening frustum", S::make_cone([0.; 3], 2., 5., 10.).unwrap()),
("cone", S::make_cone([0.; 3], 5., 0., 10.).unwrap()),
("torus", S::make_torus([0.; 3], 10., 3.).unwrap()),
];
for (name, s) in cases {
let record = s.to_native().unwrap();
let body = crate::native::body_from_smlib(&s).unwrap_or_else(|e| panic!("{name}: {e}"));
let forward: Vec<bool> = record
.shells
.iter()
.flat_map(|sh| match sh {
b::Shell::Faces { faces, .. } => faces.iter().map(|f| f.forward).collect(),
_ => Vec::new(),
})
.collect();
let loaded: Vec<bool> = body.solid.boundaries().iter().flat_map(|sh| sh.face_iter().map(|f| f.orientation()).collect::<Vec<_>>()).collect();
assert_eq!(loaded, forward, "{name}: the body was turned inside out");
let smlib = s.mass_properties(solidcraft_smlib::MEASURE_ACCURACY).unwrap().volume;
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - smlib).abs() < 2e-3 * smlib, "{name}: decoded volume {ours}, SMLib's {smlib}");
assert_eq!(
(body.face_count(), body.edge_count(), body.vertex_count()),
(record.faces.len(), record.edges.len(), record.vertices.len()),
"{name}"
);
}
}
+61
View File
@@ -468,6 +468,60 @@ fn off_surface(rules: &size::Rules) -> f64 {
/// One face: boundary loops in 3D (shared edge nodes) to parameters and triangles.
/// `size`: the face's target side length (mm) and the surface's mean speed along u and v.
/// Whether a face of `solid` is bounded by one seam edge alone, used both ways between two
/// distinct vertices (poles): a whole sphere as one face, as SMLib writes it. Truck's mesher
/// projects both uses onto one parameter line and meshes nothing; the display plan opens the
/// loop across the period ([`open_pole_to_pole`]).
pub(crate) fn has_pole_to_pole_seam(solid: &Solid) -> bool {
solid.face_iter().any(|f| {
f.boundaries().iter().any(|w| {
let edges: Vec<&mt::Edge> = w.edge_iter().collect();
matches!(edges.as_slice(), [a, b] if a.id() == b.id() && a.front() != a.back())
})
})
}
/// Twice the signed area of a parameter polygon.
fn area2(uv: &[[f64; 2]]) -> f64 {
let n = uv.len();
(0..n).map(|k| uv.get(k).zip(uv.get((k + 1) % n)).map_or(0.0, |(a, b)| a[0] * b[1] - b[0] * a[1])).sum()
}
/// A loop that runs from one pole to the other and back along the same parameter line (both
/// uses of a seam joined only at the poles) encloses nothing once projected: the run after the
/// second pole is moved one period along the periodic direction, which way gives the loop a
/// positive area, so the face spans the period between its two seam copies.
fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
let n = l.uv.len();
let poles: Vec<usize> = (0..n).filter(|&k| l.uv.get(k).is_some_and(|q| (0..2).any(|d| uv::degenerate(s, *q, d)))).collect();
let [p0, p1] = poles.as_slice() else { return };
let (lo, hi) = l.uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q[0].min(q[1])), b.max(q[0].max(q[1]))));
let extent = (hi - lo).abs().max(1e-12);
if area2(&l.uv).abs() > 1e-9 * extent * extent {
return;
}
for d in 0..2 {
let Some(per) = dom.period[d] else { continue };
// Along the seam the periodic parameter is constant between the poles.
let second: Vec<usize> = (0..n).filter(|k| (k > p1 || k < p0) && *k != *p0 && *k != *p1).collect();
if second.is_empty() {
continue;
}
for sign in [1.0, -1.0] {
let mut moved = l.uv.clone();
for &k in &second {
if let Some(q) = moved.get_mut(k) {
q[d] += sign * per;
}
}
if area2(&moved) > 1e-9 * extent * extent {
l.uv = moved;
return;
}
}
}
}
fn plan_face(
fi: usize,
s: &EvalSurface,
@@ -533,6 +587,13 @@ fn plan_face(
}
_ => return Err(fail("boundary winds around the surface both ways")),
}
// A loop from pole to pole and back along one seam (a whole sphere as one face, as another
// kernel writes it) projects onto one parameter line: its second run is a period along.
if !seamed {
for l in projected.iter_mut() {
open_pole_to_pole(s, &dom, l);
}
}
// Split poles.
let mut fixed: Vec<Vec3> = Vec::new();
let mut uvs: Vec<[f64; 2]> = Vec::new();
+1 -1
View File
@@ -1 +1 @@
40bb855d15391428697d086fd122409c10e5ffee
0a48843fd764b0b4a6e8998716e800a91518fe7a
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
use super::*;
#[cfg(feature = "native")]
mod native_tests {
use super::*;
use crate::{CheckLevel, MEASURE_ACCURACY};
use std::f64::consts::PI;
/// SMLib's primitives with their closed-form volumes.
#[allow(clippy::unwrap_used)] // test fixture
fn primitives() -> Vec<(&'static str, Brep, f64)> {
vec![
("box", Brep::make_box([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]).unwrap(), 6000.0),
("cylinder", Brep::make_cylinder([1.0, 2.0, 3.0], 5.0, 10.0).unwrap(), PI * 25.0 * 10.0),
("sphere", Brep::make_sphere([1.0, 2.0, 3.0], 5.0).unwrap(), 4.0 / 3.0 * PI * 125.0),
("frustum", Brep::make_cone([0.0; 3], 5.0, 2.0, 10.0).unwrap(), PI * 10.0 / 3.0 * (25.0 + 10.0 + 4.0)),
("widening frustum", Brep::make_cone([0.0; 3], 2.0, 5.0, 10.0).unwrap(), PI * 10.0 / 3.0 * (25.0 + 10.0 + 4.0)),
("cone", Brep::make_cone([0.0; 3], 5.0, 0.0, 10.0).unwrap(), PI * 10.0 / 3.0 * 25.0),
("torus", Brep::make_torus([0.0; 3], 10.0, 3.0).unwrap(), 2.0 * PI * PI * 10.0 * 9.0),
]
}
#[test]
fn every_primitive_exports_validates_and_keeps_its_volume() {
for (name, b, exact) in primitives() {
let check = b.check(CheckLevel::All).unwrap();
assert!(check.is_valid(), "{name}: {check}");
let v = b.mass_properties(MEASURE_ACCURACY).unwrap().volume;
assert!((v - exact).abs() < 1e-6 * exact, "{name}: SMLib volume {v}, closed form {exact}");
let (native, report) = b.to_native_report().unwrap_or_else(|e| panic!("{name}: {e}"));
native.validate().unwrap();
native.check_geometry().unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(
report,
Report { recognised_edges: report.recognised_edges, ..Report::default() },
"{name}: every edge a line or circle, every face agrees"
);
let [f, e, n] = b.counts().unwrap();
assert_eq!((native.faces.len(), native.edges.len(), native.vertices.len()), (f, e, n), "{name}");
// One closed shell bounding one material region; no NURBS edges left.
assert_eq!(native.shells.len(), 1, "{name}");
assert_eq!(native.regions.len(), 2, "{name}");
assert!(!native.regions[0].void && !native.regions[1].void, "{name}");
assert!(native.edges.iter().all(|e| matches!(e.curve, b::Curve::Line { .. } | b::Curve::Circle { .. })), "{name}");
}
}
#[test]
fn every_primitive_goes_round_through_schema_4_unchanged() {
for (name, s, _) in primitives() {
let record = s.to_native().unwrap_or_else(|e| panic!("{name}: {e}"));
let back = Brep::from_record(&record, true).unwrap_or_else(|e| panic!("{name}: {e}"));
let check = back.check(CheckLevel::All).unwrap();
assert!(check.is_valid(), "{name}: {check}");
assert_eq!(back.counts().unwrap(), s.counts().unwrap(), "{name}");
let (v0, v1) = (s.mass_properties(MEASURE_ACCURACY).unwrap().volume, back.mass_properties(MEASURE_ACCURACY).unwrap().volume);
assert!((v1 - v0).abs() < 1e-7 * v0, "{name}: volume {v0} -> {v1}");
}
}
#[test]
fn recognised_edges_are_on_smlibs_curves() {
let cyl = Brep::make_cylinder([0.0; 3], 5.0, 10.0).unwrap().to_native().unwrap();
let side = cyl.faces.iter().find(|f| matches!(f.surface, b::Surface::Cylinder { .. })).unwrap();
assert!((side.domain[0][1] - side.domain[0][0] - TAU).abs() < 1e-12);
assert_eq!(side.domain[1], [0.0, 10.0]);
for e in &cyl.edges {
let eval = b::eval::CurveEval::new(&e.curve).unwrap();
let [a, z] = e.interval;
// Ends on the edge's vertices.
for (t, v) in [(a, e.vertices[0]), (z, e.vertices[1])] {
let p = eval.point(t).unwrap();
let q = cyl.vertices[v as usize].point;
assert!((p - Vec3::new(q[0], q[1], q[2])).len() < 1e-9, "{:?} at {t}", e.curve);
}
if let b::Curve::Circle { radius, .. } = e.curve {
assert!((radius - 5.0).abs() < 1e-12 && (z - a - TAU).abs() < 1e-9);
}
}
// The narrowing cone keeps SMLib's negative half angle.
let cone = Brep::make_cone([0.0; 3], 5.0, 0.0, 10.0).unwrap().to_native().unwrap();
let half = cone.faces.iter().find_map(|f| if let b::Surface::Cone { half_angle, .. } = f.surface { Some(half_angle) } else { None });
assert!((half.unwrap() + 0.5f64.atan()).abs() < 1e-12);
}
#[test]
fn hostile_arrays_are_errors_never_panics() {
let good = Brep::make_box([0.0; 3], [1.0, 2.0, 3.0]).unwrap().arrays().unwrap();
assert!(native(&good).is_ok());
assert!(native(&Arrays::default()).is_err());
let ids: Vec<i32> = good.ints.keys().copied().collect();
for id in ids {
for edit in 0..4 {
let mut f = good.clone();
let a = f.ints.get_mut(&id).unwrap();
match edit {
0 => a.clear(),
1 => a.iter_mut().for_each(|x| *x = 1 << 40),
2 => a.iter_mut().for_each(|x| *x = -1),
_ => a.push(7),
}
let _ = native(&f);
}
}
let ids: Vec<i32> = good.reals.keys().copied().collect();
for id in ids {
for x in [f64::NAN, f64::INFINITY, 1e300, 0.0] {
let mut f = good.clone();
f.reals.get_mut(&id).unwrap().iter_mut().for_each(|v| *v = x);
let _ = native(&f);
}
}
}
}
#[test]
fn nurbs_lines_and_arcs_are_recognised_exactly() {
let line = Nurbs { knots: vec![2.0, 2.0, 4.0, 4.0], points: vec![[0.0; 3], [1.0, 2.0, 3.0]], weights: vec![1.0, 1.0] };
let (c, r) = as_line(&line, [2.0, 4.0]).unwrap_or_else(|| panic!("line"));
assert_eq!(c, b::Curve::Line { start: [0.0; 3], end: [1.0, 2.0, 3.0] });
assert_eq!(r, [0.0, 1.0]);
// A quarter circle of radius 2 about (1, 1, 0) as a rational quadratic Bézier.
let w = std::f64::consts::FRAC_1_SQRT_2;
let arc = Nurbs {
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
points: vec![[3.0, 1.0, 0.0], [3.0, 3.0, 0.0], [1.0, 3.0, 0.0]],
weights: vec![1.0, w, 1.0],
};
let (c, r) = as_circle(&arc, [0.0, 1.0]).unwrap_or_else(|| panic!("arc"));
let b::Curve::Circle { frame, radius } = c else { panic!("not a circle") };
assert!((radius - 2.0).abs() < 1e-12 && (r[0]).abs() < 1e-12 && (r[1] - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "{r:?}");
assert!((frame.origin[0] - 1.0).abs() < 1e-12 && (frame.origin[1] - 1.0).abs() < 1e-12);
// A parabola-like quadratic is neither.
let not = Nurbs { knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], points: vec![[0.0; 3], [1.0, 2.0, 0.0], [2.0, 0.0, 0.0]], weights: vec![1.0; 3] };
assert!(as_circle(&not, [0.0, 1.0]).is_none() && as_line(&not, [0.0, 1.0]).is_none());
}
+49
View File
@@ -197,3 +197,52 @@ extern "C" fn trampoline(_: *mut c_void, level: i32, line: *const c_char) {
sink(level, text);
}
}
// ---- Primitives (smlib_primitives.cpp) -----------------------------------------------------
#[cfg(feature = "native")]
unsafe extern "C" {
fn smlib_make_cylinder(origin: *const f64, radius: f64, height: f64, out: *mut *mut RawBrep) -> i32;
fn smlib_make_sphere(center: *const f64, radius: f64, out: *mut *mut RawBrep) -> i32;
fn smlib_make_cone(origin: *const f64, base_radius: f64, top_radius: f64, height: f64, out: *mut *mut RawBrep) -> i32;
fn smlib_make_torus(center: *const f64, major_radius: f64, minor_radius: f64, out: *mut *mut RawBrep) -> i32;
}
#[cfg(feature = "native")]
mod primitive_calls {
use super::*;
pub fn make_cylinder(origin: &[f64; 3], radius: f64, height: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_cylinder(origin.as_ptr(), radius, height, out) })
}
pub fn make_sphere(center: &[f64; 3], radius: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_sphere(center.as_ptr(), radius, out) })
}
pub fn make_cone(origin: &[f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_cone(origin.as_ptr(), base_radius, top_radius, height, out) })
}
pub fn make_torus(center: &[f64; 3], major_radius: f64, minor_radius: f64) -> Result<Handle, i32> {
new_brep(|out| unsafe { smlib_make_torus(center.as_ptr(), major_radius, minor_radius, out) })
}
}
/// The same calls when SMLib is not built: each one fails with [`NOT_BUILT`].
#[cfg(not(feature = "native"))]
mod primitive_calls {
use super::*;
pub fn make_cylinder(_: &[f64; 3], _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_sphere(_: &[f64; 3], _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_cone(_: &[f64; 3], _: f64, _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
pub fn make_torus(_: &[f64; 3], _: f64, _: f64) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
}
pub use primitive_calls::*;
+2
View File
@@ -100,6 +100,8 @@ pub const FACE_TORUS_AXIS: i32 = 160;
pub const FACE_TORUS_REF_DIRECTION: i32 = 161;
pub const FACE_TORUS_MAJOR_RADIUS: i32 = 162;
pub const FACE_TORUS_MINOR_RADIUS: i32 = 163;
/// `SMLIB_FLAT_MAX_LEN`: no array is longer.
pub const MAX_LEN: usize = 1 << 27;
pub const SURFACE_NURBS: u8 = 0;
pub const SURFACE_PLANE: u8 = 1;
pub const SURFACE_CYLINDER: u8 = 2;
+1
View File
@@ -16,6 +16,7 @@ use std::sync::Once;
use std::sync::atomic::{AtomicI32, Ordering};
mod check;
pub mod export;
mod ffi;
pub mod flat;
pub mod history;