mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
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:
+46
-127
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user