kernel: a saved body is its record; SMLib bodies convert with no healer

Encoding writes the body's own record and decoding makes the body from it: the adapter to and
from the old kernel is gone. A record goes into SMLib as given (SMLib's own form); one SMLib
refuses is an error that names why, never sent through SMLib's healer. A body made by SMLib keeps
the SMLib body. The bounded preview of a decoded body is a coarse mesh from the planner, charged
to its triangle budget.

(not built: base in progress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 07:13:34 -04:00
parent e36f8d7774
commit f6590bcaa6
4 changed files with 66 additions and 1338 deletions
+46 -127
View File
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Number, Value};
use std::sync::Arc;
mod adapter;
mod binary;
mod metadata;
mod preview;
@@ -36,15 +35,16 @@ struct SavedBody {
struct Runtime {
/// [`Body::size`] as stored (0: not measured).
size: f64,
/// The orientation mesh's missing-face count at its tolerance.
/// Saved by earlier versions (an orientation mesh's missing-face count); read and ignored.
coarse_missing: Option<(f64, u64)>,
}
fn invalid(detail: impl std::fmt::Display) -> KernelError {
KernelError::Invalid(format!("native B-rep: {detail}"))
}
/// Explicit topology, spatial geometry, construction certificates and history, exactly.
/// Failure is a cache miss at the IO boundary, never a failed document save.
/// Explicit topology, spatial geometry, construction certificates and history, exactly: the
/// body's own record and what it carries. 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() {
@@ -58,17 +58,14 @@ pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
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 runtime = Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)) };
let saved = SavedBody { solid: adapter::encode(body)?, metadata: metadata::encode(body)?, runtime };
if body.face_count() > MAX_ITEMS || body.edge_count() > MAX_ITEMS {
return Err(invalid("native geometry budget"));
}
let runtime = Runtime { size: body.size, coarse_missing: None };
let saved = SavedBody { solid: body.record().clone(), metadata: metadata::encode(body)?, runtime };
// Structural validation does not invoke another geometric reconstruction.
validate_topology(&saved)?;
let value = serde_json::to_value(saved).map_err(invalid)?;
@@ -82,54 +79,18 @@ pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
})
}
/// The schema 4 record of a solid body, in canonical order with its pcurves: what another
/// backend (SMLib) builds the body from. Mesh bodies have none.
/// The schema 4 record of a solid body, in canonical order with its pcurves: the body itself.
/// Mesh bodies have none.
pub fn brep_record(body: &Body) -> Result<solidcraft_geom::brep::Brep> {
guard("B-rep record", || {
if body.mesh.is_some() {
return Err(invalid("mesh bodies are source blobs, not B-rep"));
}
adapter::encode(body)
})
if body.mesh.is_some() {
return Err(invalid("mesh bodies are source blobs, not B-rep"));
}
Ok(body.record().clone())
}
/// The body in SMLib, built from its schema 4 record. The record is trusted (SMLib's healer is
/// skipped): the kernel's records keep SMLib's rules, and healing moved a cylinder's volume by
/// 9e-7 where the record as given is exact to 1e-7.
#[cfg(feature = "smlib")]
/// The body in SMLib (see [`Body::smlib`]), as an owned copy.
pub fn smlib_body(body: &Body) -> Result<solidcraft_smlib::Brep> {
let record = tolerant_record(body)?;
solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))
}
/// [`brep_record`] for another kernel: where an edge lies a little farther from a face it bounds
/// than the body's working tolerance (truck's offsets leave edges some 3e-5 mm off their faces),
/// the record takes a tolerance ten, then a hundred times as large for the body, its edges and
/// vertices, as the kernel it goes to works to tolerances (SMLib's own are far larger). Past
/// that it is refused.
#[cfg(feature = "smlib")]
fn tolerant_record(body: &Body) -> Result<solidcraft_geom::brep::Brep> {
guard("B-rep record", || {
if body.mesh.is_some() {
return Err(invalid("mesh bodies are source blobs, not B-rep"));
}
let working = crate::fill::face_tolerance(body);
let mut last = None;
for factor in [1.0, 10.0, 100.0] {
match adapter::encode_within(body, working * factor)? {
Ok(record) => return Ok(record),
Err(e) => last = Some(e),
}
}
Err(last.unwrap_or_else(|| invalid("no record")))
})
}
/// The truck solid of a schema 4 record, as a saved body's is built: tests compare records
/// made elsewhere (the STEP reader's) with truck's own import.
#[cfg(test)]
pub(crate) fn solid_of_record(record: solidcraft_geom::brep::Brep) -> Result<truck_modeling::Solid> {
guard("decode a B-rep record", || adapter::decode(record))
body.smlib()?.try_clone().map_err(|e| KernelError::Failed(format!("SMLib: {e}")))
}
/// Decode bounded untrusted data. Validate indices before topology extraction.
@@ -148,106 +109,65 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
validate_value(&value, 0)?;
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
validate_topology(&saved)?;
let (solid, numbering) = adapter::decode_numbered(saved.solid, adapter::Producer::Own)?;
let Runtime { size, coarse_missing } = saved.runtime;
if !(size.is_finite() && size >= 0.0) || coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64) {
let Runtime { size, .. } = saved.runtime;
if !(size.is_finite() && size >= 0.0) {
return Err(invalid("runtime values"));
}
let body = Body {
solid: Arc::new(solid),
size,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: coarse_missing.map(|(tol, n)| (tol, n as usize)),
mesh: None,
color: None,
paint: None,
splits: None,
face_sources: None,
history: None,
display_of: None,
meshes: None,
phased: false,
tolerances: None,
regularization_protected: false,
};
// A body made by another kernel keeps the tolerances its record saved for it.
let tolerances = adapter::decoded_tolerances(&numbering, &body);
let mut body = body.with_tolerances(tolerances);
let mut body = Body::from_record(saved.solid)?;
if size > 0.0 {
body.size = size;
}
metadata::apply(&mut body, saved.metadata)?;
Ok(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::oriented`), never by meshing it.
#[cfg(feature = "smlib")]
/// An SMLib body as a kernel body: SMLib's export mapped by `solidcraft_smlib` (canonical order,
/// radians, pcurves) is the body's record, and the SMLib body is kept with it.
pub fn body_from_smlib(brep: &solidcraft_smlib::Brep) -> Result<Body> {
body_from_smlib_ordered(brep).map(|(body, _)| body)
}
/// [`body_from_smlib`], with where each of SMLib's faces and edges is in the body's face and
/// edge order ([`crate::history`]): `None` where the body did not keep the record's order (a
/// rebuild that walked a face's edges the other way).
#[cfg(feature = "smlib")]
/// edge order ([`crate::history`]).
pub(crate) fn body_from_smlib_ordered(brep: &solidcraft_smlib::Brep) -> Result<(Body, Option<solidcraft_smlib::history::Order>)> {
let (record, order) = brep.to_native_ordered().map_err(|e| KernelError::Failed(e.to_string()))?;
guard("decode SMLib B-rep", || {
let (solid, numbering) = adapter::decode_numbered(record, adapter::Producer::Foreign)?;
let body = Body::oriented(solid)?;
let kept = numbering.kept_by(&body.solid);
// SMLib's approximated edges lie farther from their faces than the kernel's working
// tolerance: the body keeps the tolerances the record gave them, so its own record does.
let tolerances = adapter::decoded_tolerances(&numbering, &body);
Ok((body.with_tolerances(tolerances), kept.then_some(order)))
})
let handle = brep.try_clone().map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?;
Ok((Body::from_smlib_record(handle, record)?, Some(order)))
}
/// `body` in SMLib ([`smlib_body`], healed where SMLib's check refuses it as given), with where
/// each of SMLib's faces and edges is in the body's face and edge order: SMLib's export of the
/// imported body, checked to number every face, edge and vertex as the body's record does.
/// `None` where it does not.
#[cfg(feature = "smlib")]
/// `body` in SMLib, with where each of SMLib's faces and edges is in the body's face and edge
/// order: SMLib's export of the imported body, checked to number every face, edge and vertex as
/// the body's record does. `None` where it does not.
pub(crate) fn smlib_body_ordered(body: &Body) -> Result<(solidcraft_smlib::Brep, Option<solidcraft_smlib::history::Order>)> {
smlib_record_ordered(&tolerant_record(body)?)
let brep = smlib_body(body)?;
Ok((brep.try_clone().map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?, numbering(body.record(), &brep)))
}
/// `record` in SMLib, healed where SMLib's check refuses it as given, with where each of SMLib's
/// faces and edges is in the record's order: `None` where SMLib's export of it does not number
/// every face, edge and vertex as the record does.
#[cfg(feature = "smlib")]
/// `record` in SMLib, imported as given (SMLib's own form; a record it refuses is an error that
/// names why), with where each of SMLib's faces and edges is in the record's order.
pub(crate) fn smlib_record_ordered(
record: &solidcraft_geom::brep::Brep,
) -> Result<(solidcraft_smlib::Brep, Option<solidcraft_smlib::history::Order>)> {
use solidcraft_smlib::{Brep, CheckLevel};
let import = |trusted: bool| Brep::from_record(record, trusted).map_err(|e| KernelError::Failed(format!("SMLib: {e}")));
let valid = |b: &Brep| b.check(CheckLevel::All).is_ok_and(|c| c.is_valid());
// A record SMLib's check refuses as given (a gap a little over the record's tolerance, a
// hole listed before its face's outer loop) goes through SMLib's healer; healed, it is kept
// only where SMLib then finds it valid.
// One SMLib refuses as given altogether goes through the healer too.
let brep = match import(true) {
Ok(trusted) if valid(&trusted) => trusted,
Ok(trusted) => match import(false) {
Ok(healed) if valid(&healed) => healed,
_ => trusted,
},
Err(e) => import(false).map_err(|_| e)?,
};
let order = match brep.to_native_ordered() {
let brep = solidcraft_smlib::Brep::from_record(record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?;
let order = numbering(record, &brep);
Ok((brep, order))
}
/// Where SMLib's faces and edges of `brep` (imported from `record`) are in the record's order:
/// `None` where SMLib's export does not number them as the record does.
fn numbering(record: &solidcraft_geom::brep::Brep, brep: &solidcraft_smlib::Brep) -> Option<solidcraft_smlib::history::Order> {
match brep.to_native_ordered() {
Ok((back, order)) => same_numbering(record, &back).then_some(order),
Err(e) => {
log::debug!(target: "smlib", "no order for an operand: {e}");
None
}
};
Ok((brep, order))
}
}
/// Whether two records of one body number its faces, edges and vertices alike: the same loops of
/// the same edges, the same edge ends, and each vertex where the other's is.
#[cfg(feature = "smlib")]
fn same_numbering(a: &solidcraft_geom::brep::Brep, b: &solidcraft_geom::brep::Brep) -> bool {
use solidcraft_geom::brep::Loop;
if a.faces.len() != b.faces.len() || a.edges.len() != b.edges.len() || a.vertices.len() != b.vertices.len() {
@@ -273,7 +193,6 @@ fn same_numbering(a: &solidcraft_geom::brep::Brep, b: &solidcraft_geom::brep::Br
/// opposite order with each edge the other way round. SMLib walks a face whose surface it turned
/// (a hole's wall) the other way, starting where it likes; its faces and edges are numbered
/// alike all the same.
#[cfg(feature = "smlib")]
fn same_cycle(x: &[solidcraft_geom::brep::OrientedEdge], y: &[solidcraft_geom::brep::OrientedEdge]) -> bool {
let n = x.len();
if n != y.len() {
@@ -358,7 +277,7 @@ fn value_nodes(v: &Value) -> usize {
_ => 1,
}
}
#[cfg(all(test, feature = "smlib"))]
#[cfg(test)]
mod smlib_import_tests;
#[cfg(test)]
-664
View File
@@ -1,664 +0,0 @@
//! The truck adapter. Persisted data belongs to solidcraft_geom::brep, never truck.
//!
//! Truck keeps a face's wires relative to its surface (`absolute_boundaries`) and flips the
//! face with an orientation flag, which is schema 4's convention: loops are written as truck
//! holds them and the flag becomes the oriented face's `forward`. Faces, edges and vertices are
//! numbered in canonical order, which is truck's own (`face_iter`, and first appearance through
//! `edge_iter`, which walks a reversed face's wires backwards), so every index the kernel hands
//! out (history, face sources, paint) means the same face or edge in the record.
use super::{Result, invalid};
use crate::Body;
use mt::{Invertible, ParametricCurve};
use solidcraft_geom::brep as b;
use std::collections::HashMap;
use truck_modeling as mt;
#[path = "analytic.rs"]
mod analytic;
#[cfg(test)]
use analytic::revolved;
use analytic::{arc, load_analytic};
fn point(p: mt::Point3) -> [f64; 3] {
[p.x, p.y, p.z]
}
fn vector(p: mt::Vector3) -> [f64; 3] {
[p.x, p.y, p.z]
}
fn vector4(p: mt::Vector4) -> [f64; 4] {
[p.x, p.y, p.z, p.w]
}
fn p([x, y, z]: [f64; 3]) -> mt::Point3 {
mt::Point3::new(x, y, z)
}
fn v([x, y, z]: [f64; 3]) -> mt::Vector3 {
mt::Vector3::new(x, y, z)
}
fn v4([x, y, z, w]: [f64; 4]) -> mt::Vector4 {
mt::Vector4::new(x, y, z, w)
}
fn index(i: usize) -> Result<u32> {
u32::try_from(i).map_err(invalid)
}
fn depth(d: usize) -> Result<()> {
if d > 16 { Err(invalid("geometry nesting")) } else { Ok(()) }
}
fn unsupported(what: &str) -> crate::KernelError {
crate::KernelError::Failed(format!("native B-rep: not supported yet: {what}"))
}
// 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()),
// The producer's own trace: its surfaces must load; its leader is kept as it was.
b::Curve::SurfaceIntersection { surfaces, .. } => surfaces.iter().try_for_each(|s| compatible_surface(s)),
_ => 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::PlacedRevolution { profile, .. } => compatible_curve(profile),
_ => Ok(()),
}
}
/// The body's working tolerance, mm: how far later operations take its faces to be from its
/// edges (`fill::face_tolerance`, 10⁻⁶ of its size, at least 10⁻⁵ mm). Truck's own edges sit
/// that far off their surfaces (a traced intersection, an imported edge), so a pcurve lifts
/// onto the edge within it and SMLib can check the edge against the face with it.
fn working_tolerance(body: &Body) -> f64 {
crate::fill::face_tolerance(body)
}
/// The record of `body`'s solid. Each face's pcurves come from the records written for it
/// (`brep::pcurve`, the routine every backend shares), within the body's working tolerance.
pub(super) fn encode(body: &Body) -> Result<b::Brep> {
encode_within(body, working_tolerance(body))?
}
/// The record of `body`'s solid with `tolerance` for the body, its edges and vertices, or for
/// an edge or vertex the larger tolerance the body keeps for it from the record it was decoded
/// from (`Body::tolerances`). `Ok(Err(_))`: some edge lies farther than its tolerance from a face
/// it bounds, so no pcurve fits it.
pub(super) fn encode_within(body: &Body, tolerance: f64) -> Result<Result<b::Brep>> {
let solid = &body.solid;
let shells = solid.boundaries();
let regions = regions(body)?;
let (edge_tol, vertex_tol) = kept_tolerances(body);
let edge_tolerance = |e: &mt::Edge| edge_tol.get(&e.id()).map_or(tolerance, |t| t.max(tolerance));
let vertex_tolerance = |v: &mt::Vertex| vertex_tol.get(&v.id()).map_or(tolerance, |t| t.max(tolerance));
// Faces in truck's order; edges and vertices in first appearance, each edge's curve record
// and interval written when it first appears.
let mut face_index = HashMap::new();
let mut edge_index = HashMap::new();
let mut edges: Vec<(mt::Edge, b::Curve, [f64; 2], f64)> = Vec::new();
let mut uses: Vec<Vec<b::EdgeUseRef>> = Vec::new();
let mut faces = Vec::new();
let mut shell_records = Vec::new();
for shell in shells {
let mut oriented = Vec::new();
for face in shell.face_iter() {
let fi = faces.len();
if face_index.insert(face.id(), fi).is_some() {
return Err(unsupported("a face in two shells"));
}
oriented.push(b::OrientedFace { face: index(fi)?, forward: face.orientation() });
let wires = face.absolute_boundaries();
// The canonical walk: wires in order, each backwards on a reversed face.
for (li, wire) in wires.iter().enumerate() {
let n = wire.len();
for k in 0..n {
let position = if face.orientation() { k } else { n - 1 - k };
let e = wire.get(position).ok_or_else(|| invalid("wire position"))?;
let i = match edge_index.get(&e.id()) {
Some(i) => *i,
None => {
let curve = e.curve();
let (t0, t1) = curve.try_range_tuple().ok_or_else(|| invalid("unbounded edge curve"))?;
edges.push((e.absolute_clone(), save_curve(&curve, 0)?, [t0, t1], edge_tolerance(e)));
uses.push(Vec::new());
edge_index.insert(e.id(), edges.len() - 1);
edges.len() - 1
}
};
let at = b::EdgeUseRef { face: index(fi)?, r#loop: index(li)?, position: index(position)?, upper: !e.orientation() };
uses.get_mut(i).ok_or_else(|| invalid("edge use"))?.push(at);
}
}
match face_record(face, wires, &edge_index, &edges, tolerance)? {
Ok(f) => faces.push(f),
Err(e) => return Ok(Err(e)),
}
}
shell_records.push(b::Shell::Faces { faces: oriented, wires: Vec::new() });
}
let mut vertex_index = HashMap::new();
let mut vertices = Vec::new();
let mut edge_records = Vec::with_capacity(edges.len());
for ((e, curve, interval, edge_tolerance), uses) in edges.into_iter().zip(uses) {
if uses.len() > 2 {
return Err(unsupported("the radial order of an edge with more than two uses"));
}
let mut vertex = |x: &mt::Vertex| {
*vertex_index.entry(x.id()).or_insert_with(|| {
vertices.push(b::Vertex { point: point(x.point()), tolerance: vertex_tolerance(x) });
vertices.len() - 1
})
};
let ends = [vertex(e.absolute_front()), vertex(e.absolute_back())];
edge_records.push(b::Edge { curve, interval, vertices: [index(ends[0])?, index(ends[1])?], tolerance: edge_tolerance, uses });
}
let result = b::Brep { version: b::VERSION, tolerance, regions, shells: shell_records, faces, edges: edge_records, vertices };
result.validate().map_err(invalid)?;
compatible(&result, Producer::Own)?;
Ok(Ok(result))
}
/// A face's record: its surface, its loops as truck holds them (relative to the surface) and
/// their pcurves from the surface and edge records, and the domain around the pcurves.
fn face_record(
face: &mt::Face,
wires: &[mt::Wire],
edge_index: &HashMap<mt::EdgeID, usize>,
edges: &[(mt::Edge, b::Curve, [f64; 2], f64)],
tolerance: f64,
) -> Result<Result<b::Face>> {
let surface = save_surface(&face.surface(), 0)?;
let mut uses = Vec::with_capacity(wires.len());
for wire in wires {
let l = wire
.edge_iter()
.map(|e| {
let i = *edge_index.get(&e.id()).ok_or_else(|| invalid("edge index"))?;
let (_, curve, interval, tolerance) = edges.get(i).ok_or_else(|| invalid("edge index"))?;
Ok(b::pcurve::EdgeUse { edge: index(i)?, curve, interval: *interval, forward: e.orientation(), tolerance: *tolerance, given: None })
})
.collect::<Result<Vec<_>>>()?;
uses.push(l);
}
let curves = match b::pcurve::pcurves(&surface, &uses, tolerance) {
Ok(c) => c,
Err(e) => return Ok(Err(pcurve_error(e))),
};
let loops = uses
.iter()
.zip(curves)
.map(|(l, pcs)| b::Loop::Edges {
edges: l.iter().zip(pcs).map(|(u, pcurve)| b::OrientedEdge { edge: u.edge, forward: u.forward, pcurve }).collect(),
})
.collect::<Vec<_>>();
let domain = domain(&surface, &loops)?;
Ok(Ok(b::Face { surface, domain, loops }))
}
/// `face`'s record on its own, its edges numbered in first appearance: what [`encode`] writes
/// for it, pcurves included.
#[cfg(test)]
pub(super) fn face_alone(face: &mt::Face, tolerance: f64) -> Result<b::Face> {
let wires = face.absolute_boundaries();
let (mut edge_index, mut edges) = (HashMap::new(), Vec::new());
for e in wires.iter().flat_map(|w| w.edge_iter()) {
if let std::collections::hash_map::Entry::Vacant(slot) = edge_index.entry(e.id()) {
let curve = e.curve();
let (t0, t1) = curve.try_range_tuple().ok_or_else(|| invalid("unbounded edge curve"))?;
edges.push((e.absolute_clone(), save_curve(&curve, 0)?, [t0, t1], tolerance));
slot.insert(edges.len() - 1);
}
}
face_record(face, wires, &edge_index, &edges, tolerance)?
}
/// The kernel's error for the pcurve routine's.
fn pcurve_error(e: b::pcurve::Error) -> crate::KernelError {
match e {
b::pcurve::Error::Invalid(m) => crate::KernelError::Invalid(m),
b::pcurve::Error::Failed(m) => crate::KernelError::Failed(m),
b::pcurve::Error::TooLarge(m) => crate::KernelError::TooLarge(m),
}
}
/// The rectangle around a face's pcurves (their control polygons, which hold them), clamped to
/// the surface's natural domain; at most one turn in a periodic direction, about its middle.
fn domain(surface: &b::Surface, loops: &[b::Loop]) -> Result<[[f64; 2]; 2]> {
let mut d = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
let mut add = |uv: [f64; 2]| {
for (r, x) in d.iter_mut().zip(uv) {
r[0] = r[0].min(x);
r[1] = r[1].max(x);
}
};
for l in loops {
let b::Loop::Edges { edges } = l else { continue };
for e in edges {
match &e.pcurve {
b::Curve2::Spline { points, .. } => points.iter().for_each(|q| add(*q)),
b::Curve2::RationalSpline { homogeneous, .. } => homogeneous.iter().for_each(|[x, y, w]| add([x / w, y / w])),
}
}
}
for (r, natural) in d.iter_mut().zip(surface.natural()) {
match natural {
b::Natural::Range(lo, hi) => *r = [r[0].max(lo), r[1].min(hi)],
b::Natural::Periodic if r[1] - r[0] > std::f64::consts::TAU => {
let middle = 0.5 * (r[0] + r[1]);
*r = [middle - std::f64::consts::PI, middle + std::f64::consts::PI];
}
b::Natural::Periodic => {}
}
if !(r[0].is_finite() && r[1].is_finite() && r[0] < r[1]) {
return Err(invalid("face domain"));
}
}
Ok(d)
}
/// The infinite region and the bounded ones, from the shells as truck holds them: an open shell
/// lies in the infinite region; a closed one bounds material when it faces out (each truck
/// solid's shells are oriented) and a void when it faces in, and lies in the region around it.
/// Bounded regions come in the order of their outer shells.
fn regions(body: &Body) -> Result<Vec<b::Region>> {
let shells = body.solid.boundaries();
let closed: Vec<bool> = shells.iter().map(|s| s.shell_condition() == mt::ShellCondition::Closed).collect();
let all: Vec<u32> = (0..shells.len()).map(index).collect::<Result<_>>()?;
let mut regions = Vec::new();
match closed.iter().filter(|c| **c).count() {
0 => regions.push(b::Region { shells: all, void: false }),
1 => {
regions.push(b::Region { shells: all, void: false });
let s = closed.iter().position(|c| *c).ok_or_else(|| invalid("closed shell"))?;
regions.push(b::Region { shells: vec![index(s)?], void: false });
}
n if n < shells.len() => return Err(unsupported("open shells beside several closed shells")),
_ => {
let (material, parents) = body.shell_nesting()?;
let inside =
|p: Option<usize>| -> Result<Vec<u32>> { parents.iter().enumerate().filter(|(_, q)| **q == p).map(|(i, _)| index(i)).collect() };
regions.push(b::Region { shells: inside(None)?, void: false });
for (s, m) in material.iter().enumerate() {
let mut shells = vec![index(s)?];
shells.extend(inside(Some(s))?);
regions.push(b::Region { shells, void: !m });
}
// The canonical shell order walks the regions; truck's must be the same.
let mut seen = Vec::new();
for s in regions.iter().flat_map(|r| &r.shells) {
if !seen.contains(s) {
seen.push(*s);
}
}
if seen != all {
return Err(unsupported("shells in an order other than canonical (a void before another body)"));
}
}
}
Ok(regions)
}
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| 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.
#[cfg(test)]
pub(super) fn decode(body: b::Brep) -> Result<mt::Solid> {
decode_from(body, Producer::Own)
}
#[cfg(test)]
pub(super) fn decode_from(body: b::Brep, producer: Producer) -> Result<mt::Solid> {
decode_numbered(body, producer).map(|(solid, _)| solid)
}
/// The identities of a decoded record's faces and edges, by record index: each face's
/// identity and those of the edges bounding it (a face rebuilt on the same boundary, as
/// `Body::oriented` rephases a revolution, keeps those).
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
pub(super) struct Numbering {
pub faces: Vec<(mt::FaceID, std::collections::HashSet<mt::EdgeID>)>,
pub edges: Vec<mt::EdgeID>,
/// Each record edge's and vertex's tolerance, by its identity in the solid.
pub edge_tolerances: Vec<(mt::EdgeID, f64)>,
pub vertex_tolerances: Vec<(mt::VertexID, f64)>,
}
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
impl Numbering {
/// Whether `solid` lists its faces and edges in the record's order.
pub fn kept_by(&self, solid: &mt::Solid) -> bool {
let faces_kept = solid.face_iter().count() == self.faces.len()
&& solid
.face_iter()
.zip(&self.faces)
.all(|(f, (id, edges))| f.id() == *id || f.edge_iter().map(|e| e.id()).collect::<std::collections::HashSet<_>>() == *edges);
faces_kept && crate::body::edge_order(solid.face_iter()).0.iter().map(|e| e.id()).eq(self.edges.iter().copied())
}
}
/// The tolerances a decoded record gave `body`'s edges and vertices, in the body's order, where
/// any is larger than the body's working tolerance (another kernel's approximated geometry);
/// `None` otherwise. An edge or vertex the record does not name (a face rebuilt on decode) takes
/// the largest the record gave.
pub(super) fn decoded_tolerances(numbering: &Numbering, body: &Body) -> Option<crate::body::Tolerances> {
let floor = working_tolerance(body);
let most = numbering.edge_tolerances.iter().map(|(_, t)| *t).chain(numbering.vertex_tolerances.iter().map(|(_, t)| *t)).fold(0.0, f64::max);
if !(most.is_finite() && most > floor) {
return None;
}
let edges: HashMap<mt::EdgeID, f64> = numbering.edge_tolerances.iter().copied().collect();
let vertices: HashMap<mt::VertexID, f64> = numbering.vertex_tolerances.iter().copied().collect();
Some(crate::body::Tolerances {
edges: Body::unique_edges(&body.solid).iter().map(|e| edges.get(&e.id()).copied().unwrap_or(most)).collect(),
vertices: Body::unique_vertices(&body.solid).iter().map(|v| vertices.get(&v.id()).copied().unwrap_or(most)).collect(),
})
}
/// The tolerances `body` keeps for its edges and vertices (`Body::tolerances`), by identity.
fn kept_tolerances(body: &Body) -> (HashMap<mt::EdgeID, f64>, HashMap<mt::VertexID, f64>) {
let Some(t) = body.kept_tolerances() else { return (HashMap::new(), HashMap::new()) };
let edges = Body::unique_edges(&body.solid).iter().map(mt::Edge::id).zip(t.edges.iter().copied()).collect();
let vertices = Body::unique_vertices(&body.solid).iter().map(mt::Vertex::id).zip(t.vertices.iter().copied()).collect();
(edges, vertices)
}
/// [`decode_from`], with each record face's and edge's identity in the solid: a caller holding
/// indices into the record (an SMLib history) checks with them that the solid's face and edge
/// order is the record's.
pub(super) fn decode_numbered(body: b::Brep, producer: Producer) -> Result<(mt::Solid, Numbering)> {
body.validate().map_err(invalid)?;
body.check_geometry().map_err(invalid)?;
compatible(&body, producer)?;
let b::Brep { shells, faces, edges, vertices, .. } = body;
let vertex_tolerances_in: Vec<f64> = vertices.iter().map(|v| v.tolerance).collect();
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"));
let edge_tolerances_in: Vec<f64> = edges.iter().map(|e| e.tolerance).collect();
let edges = edges
.into_iter()
.map(|e| Ok(mt::Edge::new_unchecked(vertex(e.vertices[0])?, vertex(e.vertices[1])?, load_edge_curve(e.curve, e.interval)?)))
.collect::<Result<Vec<_>>>()?;
let mut face_ids: Vec<Option<(mt::FaceID, std::collections::HashSet<mt::EdgeID>)>> = vec![None; faces.len()];
let mut faces: Vec<Option<b::Face>> = faces.into_iter().map(Some).collect();
let shells = shells
.into_iter()
.map(|s| match s {
b::Shell::Faces { faces: uses, wires } if wires.is_empty() => uses
.into_iter()
.map(|u| {
// A face in two shells (both its sides used) is refused here: truck has one.
let f = faces.get_mut(u.face as usize).and_then(Option::take).ok_or_else(|| unsupported("a face in two shells"))?;
let made = face(f, u.forward, &edges)?;
if let Some(slot) = face_ids.get_mut(u.face as usize) {
*slot = Some((made.id(), made.edge_iter().map(|e| e.id()).collect()));
}
Ok(made)
})
.collect::<Result<mt::Shell>>(),
b::Shell::Faces { .. } => Err(unsupported("wire edges embedded in a shell")),
b::Shell::Wire { .. } | b::Shell::Vertex { .. } => Err(unsupported("wire and vertex shells")),
})
.collect::<Result<Vec<_>>>()?;
if shells.iter().any(|s| s.shell_condition() == mt::ShellCondition::Irregular) {
return Err(invalid("non-manifold shell"));
}
let faces = face_ids.into_iter().collect::<Option<Vec<_>>>().ok_or_else(|| invalid("a face no shell uses"))?;
let numbering = Numbering {
faces,
edges: edges.iter().map(mt::Edge::id).collect(),
edge_tolerances: edges.iter().map(mt::Edge::id).zip(edge_tolerances_in).collect(),
vertex_tolerances: vertices.iter().map(mt::Vertex::id).zip(vertex_tolerances_in).collect(),
};
Ok((mt::Solid::new_unchecked(shells), numbering))
}
/// A face's topology as recorded, references already checked: built as the kernel built it,
/// without truck's checks for wires that touch themselves (a seam edge used twice, a closed edge
/// on one vertex), which imported and modelled faces have. Pcurves are not needed (truck has none).
fn face(f: b::Face, forward: bool, edges: &[mt::Edge]) -> Result<mt::Face> {
let wires = f
.loops
.into_iter()
.map(|l| match l {
b::Loop::Edges { edges: uses } => uses
.into_iter()
.map(|u| {
let e = edges.get(u.edge as usize).ok_or_else(|| invalid("edge reference"))?;
Ok(if u.forward { e.clone() } else { e.inverse() })
})
.collect::<Result<mt::Wire>>(),
b::Loop::Vertex { .. } => Err(unsupported("vertex loops")),
})
.collect::<Result<Vec<_>>>()?;
let mut face = mt::Face::new_unchecked(wires, load_surface(f.surface, f.domain)?);
if !forward {
face.invert();
}
Ok(face)
}
/// Exact: every record holds the producer's own numbers, so loading it gives back the same
/// curve, never a refit.
fn save_curve(c: &mt::Curve, d: usize) -> Result<b::Curve> {
depth(d)?;
Ok(match c {
mt::Curve::Line(c) => b::Curve::Line { start: point(c.0), end: point(c.1) },
mt::Curve::BSplineCurve(c) => {
b::Curve::Spline { knots: c.knot_vec().iter().copied().collect(), points: c.control_points().iter().copied().map(point).collect() }
}
mt::Curve::NurbsCurve(c) => b::Curve::RationalSpline {
knots: c.knot_vec().iter().copied().collect(),
homogeneous: c.control_points().iter().copied().map(vector4).collect(),
},
mt::Curve::IntersectionCurve(c) => {
let leader = match c.leader() {
mt::Leader::Polyline(p) => b::Curve::Polyline { points: p.0.iter().copied().map(point).collect() },
mt::Leader::BSpline(c) => b::Curve::Spline {
knots: c.knot_vec().iter().copied().collect(),
points: c.control_points().iter().copied().map(point).collect(),
},
};
b::Curve::SurfaceIntersection {
surfaces: [Box::new(save_surface(c.surface0(), d + 1)?), Box::new(save_surface(c.surface1(), d + 1)?)],
leader: Box::new(leader),
tolerance: c.tolerance(),
}
}
})
}
/// A plane as truck holds it: three points.
#[derive(serde::Serialize, serde::Deserialize)]
struct PlanePoints {
o: mt::Point3,
p: mt::Point3,
q: mt::Point3,
}
/// A revolution as truck holds it, built from its parts as they are (its constructors normalize
/// the axis and compose the placement with the identity, either of which can round).
#[derive(serde::Serialize)]
struct PlacedRevolution {
entity: Revolved,
transform: mt::Matrix4,
orientation: bool,
}
#[derive(serde::Serialize)]
struct Revolved {
curve: mt::Curve,
revolution: Axis,
}
#[derive(serde::Serialize)]
struct Axis {
origin: mt::Point3,
axis: mt::Vector3,
}
fn save_surface(s: &mt::Surface, d: usize) -> Result<b::Surface> {
depth(d)?;
Ok(match s {
mt::Surface::Plane(s) => {
let PlanePoints { o, p, q } = serde_json::from_value(serde_json::to_value(s).map_err(invalid)?).map_err(invalid)?;
b::Surface::PlaneThrough { origin: point(o), u_point: point(p), v_point: point(q) }
}
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();
b::Surface::PlacedRevolution {
profile: Box::new(save_curve(s.entity().entity_curve(), d + 1)?),
origin: point(s.entity().origin()),
axis: vector(s.entity().axis()),
matrix: [m.x, m.y, m.z, m.w].map(vector4),
forward: s.orientation(),
}
}
})
}
/// An edge's curve for truck, which bounds an edge by its curve's range: the record itself
/// where the interval is that range (all this adapter writes), else the piece on the interval:
/// in closed form for lines and conics, cut out of a spline by knot insertion (the same points
/// at the same parameters).
fn load_edge_curve(c: b::Curve, interval: [f64; 2]) -> Result<mt::Curve> {
match c {
b::Curve::Line { start, end } if interval != [0., 1.] => {
let at = |t: f64| p(start) + (p(end) - p(start)) * t;
Ok(mt::Curve::Line(mt::Line(at(interval[0]), at(interval[1]))))
}
b::Curve::Circle { frame, radius } => arc(&frame, [radius, radius], interval),
b::Curve::Ellipse { frame, radii } => arc(&frame, radii, interval),
b::Curve::Line { .. } => load_curve(c),
c => match c.natural() {
b::Natural::Range(a, z) if [a, z] == interval => load_curve(c),
b::Natural::Range(..) if matches!(c, b::Curve::Spline { .. } | b::Curve::RationalSpline { .. }) => {
load_curve(spline_piece(&c, interval)?)
}
_ => Err(unsupported("an intersection edge on part of its curve")),
},
}
}
/// A spline record's piece on `interval` (inside its domain), as a spline record of the same
/// kind whose domain is the interval ([`solidcraft_geom::bspline::segment`]).
fn spline_piece(c: &b::Curve, [a, z]: [f64; 2]) -> Result<b::Curve> {
use solidcraft_geom::bspline::{HPoint, segment};
let (knots, h): (&[f64], Vec<HPoint>) = match c {
b::Curve::Spline { knots, points } => (knots, points.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: 1.0 }).collect()),
b::Curve::RationalSpline { knots, homogeneous } => {
(knots, homogeneous.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect())
}
_ => return Err(invalid("not a spline")),
};
let degree = knots.len().checked_sub(h.len() + 1).ok_or_else(|| invalid("spline degree"))?;
let (knots, h) = segment(degree, knots, &h, a, z).map_err(invalid)?;
Ok(match c {
b::Curve::Spline { .. } => b::Curve::Spline {
knots,
points: h.iter().map(|q| q.project().map(|v| [v.x, v.y, v.z]).ok_or_else(|| invalid("spline piece"))).collect::<Result<_>>()?,
},
_ => b::Curve::RationalSpline { knots, homogeneous: h.iter().map(|q| [q.x, q.y, q.z, q.w]).collect() },
})
}
fn load_curve(c: b::Curve) -> Result<mt::Curve> {
Ok(match c {
b::Curve::Line { start, end } => mt::Curve::Line(mt::Line(p(start), p(end))),
b::Curve::Circle { frame, radius } => arc(&frame, [radius, radius], [0., std::f64::consts::TAU])?,
b::Curve::Ellipse { frame, radii } => arc(&frame, radii, [0., std::f64::consts::TAU])?,
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::SurfaceIntersection { surfaces: [s0, s1], leader, tolerance } => {
let leader = match *leader {
b::Curve::Polyline { points } => mt::Leader::Polyline(mt::PolylineCurve(points.into_iter().map(p).collect())),
b::Curve::Spline { knots, points } => {
mt::Leader::BSpline(mt::BSplineCurve::try_new(knots.into(), points.into_iter().map(p).collect()).map_err(invalid)?)
}
_ => return Err(invalid("intersection leader")),
};
let (s0, s1) = (load_surface(*s0, UNIT)?, load_surface(*s1, UNIT)?);
mt::Curve::IntersectionCurve(mt::IntersectionCurve::new_unchecked(Box::new(s0), Box::new(s1), leader, tolerance))
}
})
}
/// The domain an intersection's surfaces load with: they have no face of their own.
const UNIT: [[f64; 2]; 2] = [[0., 1.]; 2];
/// A surface for truck; `domain` (the face's) bounds the profile of an analytic surface,
/// which truck holds as a revolution.
fn load_surface(s: b::Surface, domain: [[f64; 2]; 2]) -> Result<mt::Surface> {
Ok(match s {
b::Surface::Plane { origin, u_axis, v_axis } => mt::Surface::Plane(mt::Plane::new(p(origin), p(origin) + v(u_axis), p(origin) + v(v_axis))),
b::Surface::Spline { u_knots, v_knots, points } => mt::Surface::BSplineSurface(
mt::BSplineSurface::try_new((u_knots.into(), v_knots.into()), points.into_iter().map(|r| r.into_iter().map(p).collect()).collect())
.map_err(invalid)?,
),
b::Surface::RationalSpline { u_knots, v_knots, homogeneous } => mt::Surface::NurbsSurface(mt::NurbsSurface::new(
mt::BSplineSurface::try_new((u_knots.into(), v_knots.into()), homogeneous.into_iter().map(|r| r.into_iter().map(v4).collect()).collect())
.map_err(invalid)?,
)),
b::Surface::PlaneThrough { origin, u_point, v_point } => mt::Surface::Plane(mt::Plane::new(p(origin), p(u_point), p(v_point))),
b::Surface::PlacedRevolution { profile, origin, axis, matrix: [x, y, z, w], forward } => {
let placed = PlacedRevolution {
entity: Revolved { curve: load_curve(*profile)?, revolution: Axis { origin: p(origin), axis: v(axis) } },
transform: mt::Matrix4::from_cols(v4(x), v4(y), v4(z), v4(w)),
orientation: forward,
};
mt::Surface::RevolutedCurve(serde_json::from_value(serde_json::to_value(placed).map_err(invalid)?).map_err(invalid)?)
}
analytic => load_analytic(analytic, domain[1])?,
})
}
#[cfg(test)]
#[path = "adapter_tests.rs"]
mod tests;
-421
View File
@@ -1,421 +0,0 @@
use super::*;
use mt::{BoundedCurve, InnerSpace, Transformed};
use solidcraft_geom::Vec3;
#[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());
let spline = b::Curve::Spline { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]] };
assert!(load_edge_curve(spline, [0., 1.]).is_ok());
}
/// A spline edge on part of its curve (SMLib bounds an intersection edge on a piece of its
/// spline) loads as the piece cut out by knot insertion, not a refit: the same points at the
/// same parameters, over exactly the interval.
#[test]
fn spline_edges_on_part_of_their_curve_load_as_that_piece() {
use mt::ParametricCurve;
let knots = vec![0., 0., 0., 0., 0.4, 0.4, 0.7, 1., 1., 1., 1.];
let points: Vec<[f64; 3]> = (0..7).map(|i| [f64::from(i), (f64::from(i) * 1.3).sin() * 4., f64::from(i * i) * 0.2]).collect();
let weights = [1., 0.8, 1.3, 0.9, 1.1, 0.7, 1.];
let rational = b::Curve::RationalSpline {
knots: knots.clone(),
homogeneous: points.iter().zip(weights).map(|(q, w)| [q[0] * w, q[1] * w, q[2] * w, w]).collect(),
};
for record in [b::Curve::Spline { knots, points }, rational] {
let exact = solidcraft_geom::brep::eval::CurveEval::new(&record).unwrap();
for interval in [[0.1, 0.35], [0.25, 0.9], [0., 0.4], [0.4, 1.]] {
let piece = load_edge_curve(record.clone(), interval).unwrap();
assert_eq!(piece.range_tuple(), (interval[0], interval[1]), "{interval:?}");
for k in 0..=20 {
let t = interval[0] + (interval[1] - interval[0]) * f64::from(k) / 20.;
let (q, r) = (piece.subs(t), exact.point(t).unwrap());
assert!((q - p([r.x, r.y, r.z])).magnitude() < 1e-12, "{interval:?} at {t}");
}
}
assert!(load_edge_curve(record, [0.5, 1.2]).is_err(), "beyond the curve's domain");
}
}
#[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_edge_curve(b::Curve::Circle { frame: frame.clone(), radius: 2. }, [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_edge_curve(b::Curve::Ellipse { frame: frame.clone(), radii: [2., 3.] }, [0., FRAC_PI_2]).unwrap();
assert!(ellipse.subs(1.).distance(p([1., 5., 3.])) < 1e-12);
// Past the seam: an arc from -π/2 to π/2.
let across = load_edge_curve(b::Curve::Circle { frame: frame.clone(), radius: 2. }, [-FRAC_PI_2, FRAC_PI_2]).unwrap();
assert!(across.subs(0.).distance(p([1., 0., 3.])) < 1e-12);
// A line edge on part of its line.
let line = load_edge_curve(b::Curve::Line { start: [0.; 3], end: [1., 0., 0.] }, [-1., 2.]).unwrap();
assert!(line.subs(0.).distance(p([-1., 0., 0.])) < 1e-12 && line.subs(1.).distance(p([2., 0., 0.])) < 1e-12);
let cases = [
(b::Surface::Cylinder { frame: frame.clone(), radius: 2. }, [-1., 3.], 0.5, [3., 2., 4.], [1., 0., 0.]),
(
b::Surface::Cone { frame: frame.clone(), radius_at_origin: 2., half_angle: 0.2 },
[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. }, [-FRAC_PI_2, FRAC_PI_2], 0.5, [3., 2., 3.], [1., 0., 0.]),
(b::Surface::Torus { frame, major_radius: 4., minor_radius: 1. }, [0., TAU], 0., [6., 2., 3.], [1., 0., 0.]),
];
for (record, height, parameter, expected, normal) in cases {
// Truck's revolution as truck makes its own: (u, v) = (the profile's parameter, the angle).
let surface = load_surface(record, [[0., TAU], height]).unwrap();
assert!(surface.subs(parameter, 0.).distance(p(expected)) < 1e-10);
assert!(surface.normal(parameter, 0.).normalize().dot(v(normal).normalize()) > 1. - 1e-10);
}
}
/// Every face, edge and vertex index of the record is the kernel's own: faces in `face_iter`
/// order, edges in `Body::edges` order (first appearance through `edge_iter`, which walks a
/// reversed face backwards).
fn same_indices(body: &Body, record: &b::Brep) {
assert_eq!(record.faces.len(), body.face_count());
let edges = Body::unique_edges(&body.solid);
assert_eq!(record.edges.len(), edges.len());
for (r, e) in record.edges.iter().zip(&edges) {
let ends = [e.absolute_front().point(), e.absolute_back().point()];
let recorded = r.vertices.map(|i| p(record.vertices[i as usize].point));
assert_eq!(recorded, ends);
}
for (r, f) in record.faces.iter().zip(body.solid.face_iter()) {
let b::Loop::Edges { edges } = &r.loops[0] else { panic!("an edge loop") };
assert_eq!(edges.len(), f.absolute_boundaries()[0].len());
}
let history = crate::history::edge_faces(&body.solid);
for (i, faces) in history.iter().enumerate() {
let mut recorded: Vec<usize> = record.edges[i].uses.iter().map(|u| u.face as usize).collect();
recorded.dedup();
assert_eq!(&recorded, faces, "edge {i}");
}
}
#[test]
fn planar_bodies_encode_in_the_kernels_order_and_decode_exactly() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
let slot = crate::box_solid(Vec3::new(10., -1., 4.), Vec3::new(20., 31., 11.)).unwrap();
let notched = crate::boolean(&cube, &slot, crate::BoolOp::Cut).unwrap().unwrap();
let sheet = crate::copy_faces(&cube, &[Vec3::new(20., 15., 10.)]).unwrap();
let inner = crate::box_solid(Vec3::new(5., 5., 2.), Vec3::new(10., 10., 6.)).unwrap();
let hollow = crate::boolean(&cube, &inner, crate::BoolOp::Cut).unwrap().unwrap();
let far = crate::box_solid(Vec3::new(60., 0., 0.), Vec3::new(70., 10., 10.)).unwrap();
let pair = crate::boolean(&cube, &far, crate::BoolOp::Union).unwrap().unwrap();
let bodies = [("cube", cube), ("notched", notched), ("sheet", sheet), ("hollow", hollow), ("pair", pair)];
assert!(bodies.iter().any(|(_, b)| b.solid.face_iter().any(|f| !f.orientation())), "reversed faces are walked");
for (name, body) in bodies {
let record = encode(&body).unwrap_or_else(|e| panic!("{name}: {e}"));
same_indices(&body, &record);
let decoded = decode(record.clone()).unwrap();
let again = Body::new(decoded).unwrap();
let record2 = encode(&again).unwrap();
assert_eq!(record, record2, "{name}: decoded body re-encodes to the same record");
match name {
"sheet" => assert_eq!(record.regions, vec![b::Region { shells: vec![0], void: false }]),
"hollow" => {
assert_eq!(record.shells.len(), 2);
assert_eq!(
record.regions,
vec![
b::Region { shells: vec![0], void: false },
b::Region { shells: vec![0, 1], void: false },
b::Region { shells: vec![1], void: true },
]
);
}
"pair" => assert_eq!(
record.regions,
vec![
b::Region { shells: vec![0, 1], void: false },
b::Region { shells: vec![0], void: false },
b::Region { shells: vec![1], void: false },
]
),
_ => assert_eq!(record.regions, vec![b::Region { shells: vec![0], void: false }, b::Region { shells: vec![0], void: false }]),
}
}
}
#[test]
fn records_truck_cannot_hold_are_refused() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
let record = encode(&cube).unwrap();
let mut wire = record.clone();
wire.shells.push(b::Shell::Vertex { vertex: 0 });
assert!(decode(wire).is_err());
let mut vertex_loop = record;
vertex_loop.faces[0].loops.push(b::Loop::Vertex { vertex: 0 });
assert!(decode(vertex_loop).is_err());
}
/// Truck builds edges and faces unchecked: a record whose geometry misses its topology is
/// refused before it gets there.
#[test]
fn records_whose_geometry_misses_their_topology_are_refused() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
let mut moved = encode(&cube).unwrap();
moved.vertices[0].point[0] += 0.1;
moved.validate().unwrap();
let error = decode(moved).err().unwrap().to_string();
assert!(error.contains("misses its"), "{error}");
}
#[test]
fn intersections_and_revolutions_reload_exactly() {
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();
let record = save_curve(&curve, 0).unwrap();
assert!(matches!(record, b::Curve::SurfaceIntersection { .. }), "the procedural curve itself, not a fit");
let (a, z) = curve.range_tuple();
let restored = load_edge_curve(record.clone(), [a, z]).unwrap();
for i in 0..=64 {
let t = a + (z - a) * i as f64 / 64.;
assert_eq!(curve.subs(t), restored.subs(t), "sample {i}");
}
assert_eq!(save_curve(&restored, 0).unwrap(), record);
// A revolution keeps its own placement, reversed normal included.
let line = mt::Curve::Line(mt::Line(mt::Point3::new(2., 0., 0.), mt::Point3::new(2.5, 0., 3.)));
let mt::Surface::RevolutedCurve(mut placed) = revolved(line, mt::Point3::new(0., 1., 0.), mt::Vector3::new(0., 0., 1.), true) else {
panic!("a revolution")
};
placed.transform_by(mt::Matrix4::from_translation(mt::Vector3::new(1., 2., 3.)) * mt::Matrix4::from_angle_x(mt::Rad(0.3)));
let placed = mt::Surface::RevolutedCurve(placed);
let record = save_surface(&placed, 0).unwrap();
assert!(matches!(record, b::Surface::PlacedRevolution { forward: false, .. }));
let restored = load_surface(record.clone(), UNIT).unwrap();
assert_eq!(save_surface(&restored, 0).unwrap(), record);
use mt::{ParametricSurface, ParametricSurface3D};
for (u, v) in [(0., 0.), (1.3, 0.4), (4., 1.)] {
assert_eq!(placed.subs(u, v), restored.subs(u, v));
assert_eq!(placed.normal(u, v), restored.normal(u, v));
}
// The whole body: the same records again, and the same volume to the last bit.
let bytes = crate::encode_native_body(&cut).unwrap();
let decoded = crate::decode_native_body(&bytes).unwrap();
assert_eq!(crate::encode_native_body(&decoded).unwrap(), bytes);
assert_eq!(crate::measure(&decoded).unwrap().volume, crate::measure(&cut).unwrap().volume);
}
/// Each oriented edge's pcurve, lifted onto its face's surface, is its edge: checked at the
/// ends and the middle of the edge's interval, on faces of either orientation.
#[test]
fn every_pcurve_lifts_onto_its_own_edge() {
use mt::{MetricSpace, ParametricCurve, ParametricSurface};
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 holed = crate::boolean(&rounded, &bore, crate::BoolOp::Cut).unwrap().unwrap();
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
for (name, body) in [("cube", cube), ("holed", holed), ("sphere", sphere)] {
let record = encode(&body).unwrap();
for (f, face) in record.faces.iter().zip(body.solid.face_iter()) {
let surface = face.surface();
for l in &f.loops {
let b::Loop::Edges { edges } = l else { continue };
for u in edges {
let e = &record.edges[u.edge as usize];
let curve = load_edge_curve(e.curve.clone(), e.interval).unwrap();
for s in [0., 0.5, 1.] {
let t = e.interval[0] + s * (e.interval[1] - e.interval[0]);
let [x, y] = u.pcurve.eval(t).unwrap();
let d = surface.subs(x, y).distance(curve.subs(t));
assert!(d <= e.tolerance, "{name}: edge {} at {s}: {d} mm off (forward face {})", u.edge, face.orientation());
}
}
}
}
}
}
/// 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}"
);
}
}
/// SMLib's fillet round a hole's rim is a rational spline closed on itself in u, bounded by its
/// seam used twice: the seam's pcurves lie a period apart, so the face meshes and the decoded
/// body has SMLib's volume (both seam uses on u = 0 left the face empty, 149 mm³ short).
#[cfg(feature = "smlib")]
#[test]
fn smlib_hole_rim_fillet_decodes_with_its_volume() {
use solidcraft_smlib::{BooleanOp, Brep as S};
let block = S::make_box([0.; 3], [20.; 3]).unwrap();
let pin = S::make_cylinder([10., 10., -1.], 4., 22.).unwrap();
let holed = block.boolean(&pin, BooleanOp::Subtract).unwrap().unwrap();
let rim = holed.fillet(&[[10., 14., 20.]], 0.1, 1.).unwrap();
let record = rim.to_native().unwrap();
assert!(
record
.faces
.iter()
.any(|f| matches!(&f.surface, b::Surface::RationalSpline { homogeneous, .. } if homogeneous.first() == homogeneous.last())),
"the fillet is a spline closed in u"
);
record.check_geometry().unwrap();
let body = crate::native::body_from_smlib(&rim).unwrap();
let mesh = body.mesh(&crate::MeshTolerance::chord(0.02)).unwrap();
for f in 0..body.face_count() {
assert!(mesh.tri_face.iter().any(|&t| t as usize == f), "face {f} meshes");
}
let smlib = rim.mass_properties(solidcraft_smlib::MEASURE_ACCURACY).unwrap().volume;
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - smlib).abs() < 1e-3 * smlib, "decoded volume {ours}, SMLib's {smlib}");
}
/// SMLib's sphere cut by a box keeps its meridian seam from pole to pole: whole beside a cap
/// cut away from it, split where the cap crosses it. Decoded, each body is oriented as its
/// record says (never by truck's coarse mesh, which leaves the seam-bounded sphere out and
/// turned one inside out), the sphere keeps its wrap on its seam and every face meshes: the
/// volume is the sphere's less the cap, within 1e-3 (it came out −V, or short by the sphere).
#[cfg(feature = "smlib")]
#[test]
fn smlib_spheres_cut_beside_or_across_their_seam_decode_with_their_volume() {
use solidcraft_smlib::{BooleanOp, Brep as S};
use std::f64::consts::PI;
let (r, h) = (5., 2.);
let exact = 4. / 3. * PI * r * r * r - PI * h * h * (3. * r - h) / 3.;
// The seam is the meridian on +x: a cap on −x or +y leaves it whole, one on +x splits it.
let caps =
[("−x", [-10., -10., -10.], [h - r, 10., 10.]), ("+x", [r - h, -10., -10.], [10., 10., 10.]), ("+y", [-10., r - h, -10.], [10., 10., 10.])];
for (name, lo, hi) in caps {
let ball = S::make_sphere([0.; 3], r).unwrap();
let tool = S::make_box(lo, [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]).unwrap();
let cut = ball.boolean(&tool, BooleanOp::Subtract).unwrap().unwrap();
let record = cut.to_native().unwrap();
record.check_geometry().unwrap();
crate::body::ORIENTED.with(|n| n.set(0));
let body = crate::native::body_from_smlib(&cut).unwrap();
assert_eq!(crate::body::ORIENTED.with(|n| n.get()), 0, "{name}: the decode meshed the body to orient it");
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 mesh = body.mesh(&crate::MeshTolerance::chord(0.02)).unwrap();
for f in 0..body.face_count() {
assert!(mesh.tri_face.iter().any(|&t| t as usize == f), "{name}: face {f} meshes");
}
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - exact).abs() < 1e-3 * exact, "{name}: decoded volume {ours}, exact {exact}");
}
// A bar bored into the sphere's side away from its seam: truck's turning of revolved faces
// away from their wrap ran the wrap through the hole, and the face meshed as nothing. The
// volume the bar takes, by midpoint rule over its disc of the chord inside the ball.
let (c, big, y0, z0, x0, x1, rb) = ([25., 17.5, 2.5], 10., 22.5, 10., 12.5, 30., 2.5);
let n = 400;
let mut taken = 0.;
for i in 0..n {
let rho = rb * (f64::from(i) + 0.5) / f64::from(n);
for j in 0..n {
let t = 2. * PI * (f64::from(j) + 0.5) / f64::from(n);
let (y, z) = (y0 + rho * t.cos(), z0 + rho * t.sin());
let d = big * big - (y - c[1]).powi(2) - (z - c[2]).powi(2);
if d > 0. {
taken += ((c[0] + d.sqrt()).min(x1) - (c[0] - d.sqrt()).max(x0)).max(0.) * rho;
}
}
}
let exact = 4. / 3. * PI * big.powi(3) - taken * rb / f64::from(n) * 2. * PI / f64::from(n);
let ball = S::make_sphere(c, big).unwrap();
let bar = crate::cylinder(Vec3::new(x0, y0, z0), Vec3::X, rb, x1 - x0).unwrap();
let cut = ball.boolean(&crate::native::smlib_body(&bar).unwrap(), BooleanOp::Subtract).unwrap().unwrap();
let ours = crate::measure(&crate::native::body_from_smlib(&cut).unwrap()).unwrap().volume;
assert!((ours - exact).abs() < 1e-3 * exact, "bored: decoded volume {ours}, exact {exact}");
}
/// SMLib's torus is one face bounded by its two seam circles, each used both ways: the display
/// plan treats the closed profile as periodic, so the torus meshes and, beside a sphere bounded
/// by its seam alone (which only the plan meshes), the body keeps both (the sphere was lost).
/// The decode never meshes the body (it took half a second, meshing a torus to orient it).
#[cfg(feature = "smlib")]
#[test]
fn smlib_torus_decodes_without_meshing_and_plans_with_a_sphere_beside_it() {
use solidcraft_smlib::{BooleanOp, Brep as S};
use std::f64::consts::PI;
let ring = S::make_torus([0., 30., 15.], 7.5, 2.5).unwrap();
let ball = S::make_sphere([2.5, 15., 7.5], 2.5).unwrap();
let (torus, sphere) = (2. * PI * PI * 7.5 * 2.5 * 2.5, 4. / 3. * PI * 2.5 * 2.5 * 2.5);
let both = ring.boolean(&ball, BooleanOp::Union).unwrap().unwrap();
for (name, s, exact) in [("torus", ring, torus), ("torus and sphere", both, torus + sphere)] {
crate::body::ORIENTED.with(|n| n.set(0));
let body = crate::native::body_from_smlib(&s).unwrap();
assert_eq!(crate::body::ORIENTED.with(|n| n.get()), 0, "{name}: the decode meshed the body to orient it");
let plan = body.mesh_plan(&crate::MeshTolerance::chord(0.05)).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(plan.faces.len(), body.face_count(), "{name}");
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - exact).abs() < 1e-3 * exact, "{name}: decoded volume {ours}, exact {exact}");
}
}
/// SMLib's intersection of a block and a cylinder, both sent as truck's splines, decodes with
/// the cylinder's volume inside the block, π r² h. (SMLib used to bound the top disc with spline
/// edges on part of their curves, which the decode refused; since the fork keeps a split face's
/// surface parameterization it no longer does, and
/// `spline_edges_on_part_of_their_curve_load_as_that_piece` covers that decode directly.)
#[cfg(feature = "smlib")]
#[test]
fn smlib_intersection_of_truck_splines_decodes_with_its_volume() {
use solidcraft_smlib::BooleanOp;
let block = crate::box_solid(Vec3::new(7.5, 15., 0.), Vec3::new(25., 32.5, 27.5)).unwrap();
let post = crate::cylinder(Vec3::new(12.5, 20., 10.), Vec3::Z, 5., 30.).unwrap();
// Both as truck holds them, the post a rational spline and the block's sides bilinear
// patches (the import otherwise sends SMLib's cylinder and planes, whose cut leaves circles).
let keeping = |body: &Body| solidcraft_smlib::Brep::from_record_keeping_splines(&crate::brep_record(body).unwrap(), true).unwrap();
let common = keeping(&block).boolean(&keeping(&post), BooleanOp::Intersect).unwrap().unwrap();
let body = crate::native::body_from_smlib(&common).unwrap();
let exact = std::f64::consts::PI * 25. * 17.5;
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - exact).abs() < 1e-3 * exact, "decoded volume {ours}, exact {exact}");
}
+20 -126
View File
@@ -1,137 +1,31 @@
//! Bounded provisional tessellation. Fixed sampling avoids untrusted adaptive
//! subdivision before output budgets can be checked; editing uses the normal mesh.
//! Bounded provisional display of a decoded body, before its edit mesh. The budget is taken
//! before meshing (at least one triangle per face) and charged with what the mesh used; the
//! planner bounds its own work per face, so hostile records cost a bounded amount.
use super::{Body, Result, invalid};
use mt::{BoundedCurve, ParametricCurve};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_geom::Mesh;
use truck_modeling as mt;
fn curve_safe(c: &mt::Curve, depth: usize) -> bool {
if depth > 4 {
return false;
}
match c {
mt::Curve::Line(_) => true,
mt::Curve::BSplineCurve(c) => c.control_points().len() <= 256,
mt::Curve::NurbsCurve(c) => c.control_points().len() <= 256,
mt::Curve::IntersectionCurve(c) => {
let leader = match c.leader() {
mt::Leader::Polyline(p) => p.len() <= 256,
mt::Leader::BSpline(p) => p.control_points().len() <= 256,
};
leader && surface_safe(c.surface0(), depth + 1) && surface_safe(c.surface1(), depth + 1)
}
}
}
fn surface_safe(s: &mt::Surface, depth: usize) -> bool {
if depth > 4 {
return false;
}
match s {
mt::Surface::Plane(_) => true,
mt::Surface::BSplineSurface(s) => s.control_points().iter().map(Vec::len).sum::<usize>() <= 256,
mt::Surface::NurbsSurface(s) => s.control_points().iter().map(Vec::len).sum::<usize>() <= 256,
mt::Surface::RevolutedCurve(s) => {
!matches!(s.entity().entity_curve(), mt::Curve::IntersectionCurve(_)) && curve_safe(s.entity().entity_curve(), depth + 1)
}
}
}
/// Approximate read-only display. Boundary and grid sampling are fixed before
/// triangulation, and the complete body's upper bound is checked before projection.
/// A coarse mesh of `body` (finer with `detail`, 1–8) charged to `remaining` triangles.
pub fn native_preview_mesh(body: &Body, detail: usize, remaining: &mut usize) -> Result<Mesh> {
crate::guard("bounded native preview", || {
if body.mesh.is_some() {
return Err(invalid("native preview requires B-rep"));
}
let detail = detail.clamp(1, 8);
let mut prepared = Vec::new();
let mut estimate = 0usize;
for face in body.solid.face_iter() {
let surface = face.surface();
let grid = if matches!(surface, mt::Surface::Plane(_)) { 1 } else { detail * 2 };
if !surface_safe(&surface, 0) {
return Err(invalid("preview surface complexity"));
}
let mut loops = Vec::new();
let mut count = 0usize;
for wire in face.absolute_boundaries() {
let mut points = Vec::new();
for edge in wire {
let curve = edge.oriented_curve();
if !curve_safe(&curve, 0) {
return Err(invalid("preview curve complexity"));
}
let n = if matches!(curve, mt::Curve::Line(_)) { 1 } else { detail * 4 };
count = count.saturating_add(n);
if count > 4096 {
return Err(invalid("preview boundary budget"));
}
let (a, b) = curve.range_tuple();
for i in 0..n {
let t = a * (1. - i as f64 / n as f64) + b * (i as f64 / n as f64);
let point = match &curve {
mt::Curve::IntersectionCurve(c) => c.leader().subs(t),
_ => curve.subs(t),
};
if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite() {
return Err(invalid("preview sample"));
}
points.push(point);
}
}
let first = points.first().copied().ok_or_else(|| invalid("empty preview wire"))?;
points.push(first);
count = count.saturating_add(1);
if count > 4096 {
return Err(invalid("preview boundary budget"));
}
loops.push(points);
}
// Projection duplicates at most each input point, seams add eight
// fixed polylines, and a planar triangulation has fewer than 2V faces.
estimate = estimate.saturating_add(2 * (2 * count + 8 * (grid + 1) + (grid + 1).pow(2)) + 16);
if estimate > *remaining {
return Err(invalid("preview allocation budget"));
}
prepared.push((surface, face.orientation(), loops, grid));
}
*remaining -= estimate;
let mut out = Mesh::default();
for (surface, forward, loops, grid) in prepared {
let mesh = truck_meshalgo::tessellation::preview_face(&surface, &loops, grid).ok_or_else(|| invalid("preview projection"))?;
let mut count = 0;
for triangle in mesh.faces().triangle_iter() {
if out.triangles.len() >= estimate {
return Err(invalid("preview output budget"));
}
let mut indices = [0u32; 3];
for (slot, index) in indices.iter_mut().zip(triangle) {
let p = mesh.positions().get(index.pos).ok_or_else(|| invalid("preview vertex"))?;
if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() {
return Err(invalid("preview vertex"));
}
*slot = u32::try_from(out.positions.len()).map_err(invalid)?;
out.positions.push(Vec3::new(p.x, p.y, p.z));
}
if !forward {
indices.swap(1, 2);
}
out.triangles.push(indices);
count += 1;
}
if count == 0 {
return Err(invalid("missing preview face"));
}
}
Ok(out)
})
if body.mesh.is_some() {
return Err(invalid("native preview requires B-rep"));
}
if body.face_count() > *remaining {
return Err(invalid("preview budget"));
}
let detail = detail.clamp(1, 8) as f64;
let chord = (body.size() * 0.01 / detail).max(1e-3);
let mesh = body.mesh(&crate::MeshTolerance::display(chord))?;
if mesh.triangles.len() > *remaining || mesh.positions.iter().any(|p| !p.is_finite()) {
return Err(invalid("preview budget"));
}
*remaining -= mesh.triangles.len();
Ok(mesh)
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
#[test]
fn native_preview_is_finite_and_budgeted_before_meshing() {
for body in [