kernel: booleans through SMLib behind SOLIDCRAFT_BOOLEANS, with SMLib's history

With the `smlib` feature, SOLIDCRAFT_BOOLEANS=smlib (read once per process, default truck)
sends every kernel::boolean through SMLib: each operand's schema 4 record imported and tagged
as operand 0 or 1, SmBrep's boolean, SMLib's check at every level, the result exported and
decoded (with its geometry check). A result either check refuses is an error. The setting
naming SMLib in a build without it, or anything unknown, fails every boolean instead of
quietly running truck, so a comparison never compares truck with itself.

The history is SMLib's tags renumbered into Body::history's index spaces. The export now
reports where each SMLib face and edge lands in canonical order (its arrays follow
SmBrep::GetFaces/GetEdges, the order the tags use). The result's order is checked against the
decoded solid by face and edge identity, the operands' by re-exporting the imported body and
comparing its numbering with the record's; where either differs the result has no history
rather than a wrong one. The result gets the operands' regularization protection and planar
roles as truck's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 05:07:50 -04:00
parent f7336497f9
commit 5238840bc8
8 changed files with 320 additions and 10 deletions
+110
View File
@@ -0,0 +1,110 @@
//! Which kernel runs [`crate::boolean`]: truck (the default) or SMLib.
//!
//! The setting is the environment variable `SOLIDCRAFT_BOOLEANS`, read once per process:
//! `truck` (or unset) or `smlib`. SMLib needs a build with the kernel's `smlib` feature; set
//! without it, or set to anything else, every boolean fails with an error that says so rather
//! than quietly running truck, so a comparison run can never compare truck with itself.
//!
//! Through SMLib a boolean is: each operand's schema 4 record built in SMLib and tagged as
//! operand 0 or 1, `SmBrep` boolean, the result exported to schema 4 and decoded as a kernel
//! body. The result's history is SMLib's own (its tags carried through the operation),
//! renumbered from SMLib's face and edge order into the operands' and the result's ([`History`]'s
//! contract). A result SMLib's own check or the record's geometry check refuses is an error,
//! never a body.
use crate::{KernelError, Result};
/// The environment variable that picks the boolean kernel.
pub const BOOLEANS_ENV: &str = "SOLIDCRAFT_BOOLEANS";
/// A kernel that runs booleans.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BooleanBackend {
Truck,
Smlib,
}
impl BooleanBackend {
/// The backend a setting names; `None` (unset) is truck.
pub fn parse(setting: Option<&str>) -> std::result::Result<BooleanBackend, String> {
match setting.map(str::trim) {
None | Some("") | Some("truck") => Ok(BooleanBackend::Truck),
Some("smlib") if cfg!(feature = "smlib") => Ok(BooleanBackend::Smlib),
Some("smlib") => Err(format!("{BOOLEANS_ENV}=smlib: not supported yet: this build has no SMLib (feature `smlib`)")),
Some(other) => Err(format!("{BOOLEANS_ENV}={other}: expected `truck` or `smlib`")),
}
}
}
/// The boolean kernel this process uses ([`BOOLEANS_ENV`], read on first use).
pub fn boolean_backend() -> Result<BooleanBackend> {
static SETTING: std::sync::OnceLock<std::result::Result<BooleanBackend, String>> = std::sync::OnceLock::new();
SETTING.get_or_init(|| BooleanBackend::parse(std::env::var(BOOLEANS_ENV).ok().as_deref())).clone().map_err(KernelError::Invalid)
}
#[cfg(feature = "smlib")]
pub(crate) use smlib::boolean as smlib_boolean;
#[cfg(feature = "smlib")]
mod smlib {
use solidcraft_smlib::{BooleanOp, CheckLevel};
use crate::body::Body;
use crate::history::History;
use crate::native::{body_from_smlib_ordered, smlib_body_ordered};
use crate::ops::BoolOp;
use crate::{KernelError, Result, record};
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib boolean: {what}: {e}"))
}
/// `a` and `b` combined by `op` in SMLib, with the history SMLib's tags give (none where a
/// body's face and edge order cannot be matched to SMLib's). `None`: the result is empty.
pub(crate) fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let (sa, oa) = smlib_body_ordered(a)?;
let (sb, ob) = smlib_body_ordered(b)?;
let ta = sa.tagged(0).map_err(|e| failed("tag", e))?;
let tb = sb.tagged(1).map_err(|e| failed("tag", e))?;
let kind = match op {
BoolOp::Union => BooleanOp::Union,
BoolOp::Cut => BooleanOp::Subtract,
BoolOp::Intersect => BooleanOp::Intersect,
};
let Some(r) = ta.boolean(&tb, kind).map_err(|e| failed("operation", e))? else { return Ok(None) };
let check = r.check(CheckLevel::All).map_err(|e| failed("check", e))?;
if !check.is_valid() {
return Err(failed("SMLib's check refuses the result", check));
}
let (body, or) = body_from_smlib_ordered(&r)?;
let history = match (oa, ob, or) {
(Some(oa), Some(ob), Some(or)) => r.history().map_err(|e| failed("history", e))?.history(&[oa, ob], &or).map(Some).unwrap_or_else(|e| {
log::debug!(target: "smlib", "boolean history: {e}");
None
}),
_ => None,
};
Ok(Some(with_history(body, history, a, b)))
}
/// `body` with `history` where it describes `body` made from `a` and `b`.
fn with_history(body: Body, history: Option<History>, a: &Body, b: &Body) -> Body {
body.with_checked_history(history, &record::counts(&[a, b]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_setting_names_a_backend_or_is_refused() {
assert_eq!(BooleanBackend::parse(None), Ok(BooleanBackend::Truck));
assert_eq!(BooleanBackend::parse(Some("truck")), Ok(BooleanBackend::Truck));
assert_eq!(BooleanBackend::parse(Some(" ")), Ok(BooleanBackend::Truck));
assert_eq!(BooleanBackend::parse(Some("smlib")).is_ok(), cfg!(feature = "smlib"));
assert!(BooleanBackend::parse(Some("occt")).is_err());
}
}
@@ -525,3 +525,50 @@ fn stacked_pieces_of_one_cylinder_keep_their_walls() {
}
}
}
/// The same booleans through SMLib: its tags, renumbered into the operands' and the result's
/// face and edge order, give a history that holds against the geometry.
#[cfg(feature = "smlib")]
mod smlib {
use super::*;
use crate::boolean_backend::smlib_boolean;
fn through_smlib(a: &Body, b: &Body, op: BoolOp) -> Body {
smlib_boolean(a, b, op).unwrap().expect("a result")
}
#[test]
fn operands_number_as_their_records() {
let bodies = [boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0), crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap()];
for (i, b) in bodies.iter().enumerate() {
let (_, order) = crate::native::smlib_body_ordered(b).unwrap();
assert!(order.is_some(), "body {i}");
}
}
#[test]
fn box_minus_cylinder() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let r = through_smlib(&a, &b, BoolOp::Cut);
let h = verified(&r, &[&a, &b], 0.01);
assert!(from(h, face(0, 0)).len() + from(h, face(0, 1)).len() >= 1, "{}", show(h));
}
#[test]
fn box_union_box() {
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
let b = boxed(5.0, 2.0, 2.0, 15.0, 8.0, 8.0);
let r = through_smlib(&a, &b, BoolOp::Union);
let h = verified(&r, &[&a, &b], 0.02);
assert_eq!(h.faces.len(), 11, "{}", show(h));
assert_eq!(meets(h).len(), 4, "{}", show(h));
}
#[test]
fn disjoint_intersection_is_empty() {
let a = boxed(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let b = boxed(5.0, 5.0, 5.0, 6.0, 6.0, 6.0);
assert!(smlib_boolean(&a, &b, BoolOp::Intersect).unwrap().is_none());
}
}
+2
View File
@@ -20,6 +20,7 @@ mod blend_vertex;
#[cfg(test)]
mod blend_wrong_way_tests;
mod body;
mod boolean_backend;
mod boolean_diagnostics;
mod boolean_guard;
mod boolean_record;
@@ -160,6 +161,7 @@ pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet
pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_backend::{BOOLEANS_ENV, BooleanBackend, boolean_backend};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use boundary::{Boundary, boundary_patch};
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
+59 -2
View File
@@ -157,8 +157,65 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
/// says (`Body::oriented`), never by meshing it.
#[cfg(feature = "smlib")]
pub fn body_from_smlib(brep: &solidcraft_smlib::Brep) -> Result<Body> {
let record = brep.to_native().map_err(|e| KernelError::Failed(e.to_string()))?;
guard("decode SMLib B-rep", || Body::oriented(adapter::decode_from(record, adapter::Producer::Foreign)?))
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")]
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 faces_kept = body.solid.face_iter().map(|f| f.id()).eq(numbering.faces.iter().copied());
let edges_kept = crate::body::edge_order(body.solid.face_iter()).0.iter().map(|e| e.id()).eq(numbering.edges.iter().copied());
Ok((body, (faces_kept && edges_kept).then_some(order)))
})
}
/// `body` in SMLib ([`smlib_body`]), 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")]
pub(crate) fn smlib_body_ordered(body: &Body) -> Result<(solidcraft_smlib::Brep, Option<solidcraft_smlib::history::Order>)> {
let record = brep_record(body)?;
let brep = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?;
let 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() {
return false;
}
let loops_alike = a.faces.iter().zip(&b.faces).all(|(fa, fb)| {
fa.loops.len() == fb.loops.len()
&& fa.loops.iter().zip(&fb.loops).all(|pair| match pair {
(Loop::Edges { edges: x }, Loop::Edges { edges: y }) => {
x.len() == y.len() && x.iter().zip(y).all(|(u, w)| u.edge == w.edge && u.forward == w.forward)
}
(Loop::Vertex { vertex: x }, Loop::Vertex { vertex: y }) => x == y,
_ => false,
})
});
let ends_alike = a.edges.iter().zip(&b.edges).all(|(x, y)| x.vertices == y.vertices);
let at_same_place = a.vertices.iter().zip(&b.vertices).all(|(x, y)| {
let d = (0..3).map(|k| (x.point[k] - y.point[k]).powi(2)).sum::<f64>().sqrt();
d <= x.tolerance + y.tolerance + a.tolerance + b.tolerance
});
loops_alike && ends_alike && at_same_place
}
fn validate_topology(saved: &SavedBody) -> Result<()> {
+23 -2
View File
@@ -330,6 +330,20 @@ pub(super) fn decode(body: b::Brep) -> Result<mt::Solid> {
}
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.
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
pub(super) struct Numbering {
pub faces: Vec<mt::FaceID>,
pub edges: Vec<mt::EdgeID>,
}
/// [`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)?;
@@ -340,6 +354,7 @@ pub(super) fn decode_from(body: b::Brep, producer: Producer) -> Result<mt::Solid
.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>> = vec![None; faces.len()];
let mut faces: Vec<Option<b::Face>> = faces.into_iter().map(Some).collect();
let shells = shells
.into_iter()
@@ -349,7 +364,11 @@ pub(super) fn decode_from(body: b::Brep, producer: Producer) -> Result<mt::Solid
.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"))?;
face(f, u.forward, &edges)
let made = face(f, u.forward, &edges)?;
if let Some(slot) = face_ids.get_mut(u.face as usize) {
*slot = Some(made.id());
}
Ok(made)
})
.collect::<Result<mt::Shell>>(),
b::Shell::Faces { .. } => Err(unsupported("wire edges embedded in a shell")),
@@ -359,7 +378,9 @@ pub(super) fn decode_from(body: b::Brep, producer: Producer) -> Result<mt::Solid
if shells.iter().any(|s| s.shell_condition() == mt::ShellCondition::Irregular) {
return Err(invalid("non-manifold shell"));
}
Ok(mt::Solid::new_unchecked(shells))
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() };
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
+32 -2
View File
@@ -917,8 +917,38 @@ fn take_attempt() -> bool {
///
/// The result carries its exact history ([`Body::history`]) over the operands `[a, b]`,
/// whichever way it was made, or none where a step it took does not report one.
///
/// Truck runs it unless [`crate::BOOLEANS_ENV`] picks SMLib ([`crate::boolean_backend`]).
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
crate::profile::timed(crate::profile::Kind::Boolean, || boolean_untimed(a, b, op))
crate::profile::timed(crate::profile::Kind::Boolean, || match crate::boolean_backend()? {
crate::BooleanBackend::Truck => boolean_untimed(a, b, op),
crate::BooleanBackend::Smlib => smlib_boolean(a, b, op),
})
}
/// [`boolean`] through SMLib, its result given the operands' roles as truck's is.
#[cfg(feature = "smlib")]
fn smlib_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let r = guard("SMLib boolean", || crate::boolean_backend::smlib_boolean(a, b, op));
with_operand_roles(a, b, op, r)
}
#[cfg(not(feature = "smlib"))]
fn smlib_boolean(_: &Body, _: &Body, _: BoolOp) -> Result<Option<Body>> {
Err(KernelError::Invalid("not supported yet: this build has no SMLib".into()))
}
/// A boolean's result with what its operands' faces stood for carried over: regularization
/// protection and the planar construction and shell roles on supports it kept.
fn with_operand_roles(a: &Body, b: &Body, op: BoolOp, r: Result<Option<Body>>) -> Result<Option<Body>> {
let r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
match op {
BoolOp::Cut => r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()),
BoolOp::Union => r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()),
// Every face of an intersection lies on one operand's face, as a union's does: the same
// roles carry over (#565).
BoolOp::Intersect => r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()),
}
}
fn boolean_untimed(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
@@ -1012,7 +1042,7 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool
)),
};
}
r = finished(a, b, op, r);
r = with_operand_roles(a, b, op, r);
// A retry's result is held to what the operation can do at all; one that fails is refused
// rather than returned wrong (issue #616).
if (!diagnostics.events.is_empty() || diagnostics.truncated)
+24 -4
View File
@@ -34,6 +34,7 @@ use solidcraft_geom::Vec3;
use solidcraft_geom::brep as b;
use crate::flat::{self, *};
use crate::history::Order;
use crate::import::Arrays;
use crate::{Brep, Result, error, ffi, init};
@@ -86,6 +87,15 @@ impl Brep {
let arrays = self.arrays().map_err(ExportError::Smlib)?;
native(&arrays)
}
/// [`Brep::to_native`], with where each of SMLib's faces and edges went in the record's
/// canonical order: what [`crate::history::Raw::history`] renumbers this body's history with.
/// SMLib's export lists faces and edges in the body's own order (`SmBrep::GetFaces`,
/// `GetEdges`), the order its history uses.
pub fn to_native_ordered(&self) -> std::result::Result<(b::Brep, Order), ExportError> {
let arrays = self.arrays().map_err(ExportError::Smlib)?;
native_ordered(&arrays).map(|(brep, _, order)| (brep, order))
}
}
/// What the mapping could not put in its own kind, or turned.
@@ -576,6 +586,11 @@ struct Edge {
/// The schema 4 B-rep of SMLib's flat arrays: mapped, renumbered canonically and validated.
pub fn native(arrays: &Arrays) -> Res<(b::Brep, Report)> {
native_ordered(arrays).map(|(brep, report, _)| (brep, report))
}
/// [`native`], with the canonical place of each array face and edge.
fn native_ordered(arrays: &Arrays) -> Res<(b::Brep, Report, Order)> {
let flat = View(arrays);
let mut report = Report::default();
if arrays.ints.values().map(Vec::len).chain(arrays.reals.values().map(Vec::len)).any(|n| n > flat::MAX_LEN) {
@@ -787,7 +802,7 @@ pub fn native(arrays: &Arrays) -> Res<(b::Brep, Report)> {
}
let shells: Vec<Vec<(u32, bool)>> = order.iter().filter_map(|s| shells.get(*s as usize).cloned()).collect();
canonical(Parts { faces, edges, shells, regions }, n_vertices, flat, tolerance).map(|brep| (brep, report))
canonical(Parts { faces, edges, shells, regions }, n_vertices, flat, tolerance).map(|(brep, order)| (brep, report, order))
}
/// The mapped records before renumbering.
@@ -800,8 +815,9 @@ struct Parts {
/// The record in canonical order: faces in first appearance through the shells, edges through
/// the faces (a face whose first oriented face is reversed walked backwards), vertices through
/// the edges and then the vertex loops, with each face's pcurves; validated.
fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res<b::Brep> {
/// the edges and then the vertex loops, with each face's pcurves; validated. With it, the
/// canonical index of each array face and edge.
fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res<(b::Brep, Order)> {
let Parts { faces, edges, shells, regions } = parts;
let mut face_new: Vec<Option<u32>> = vec![None; faces.len()];
let (mut face_order, mut direction) = (Vec::new(), Vec::new());
@@ -933,7 +949,11 @@ fn canonical(parts: Parts, n_vertices: usize, flat: View, tolerance: f64) -> Res
.collect::<Res<Vec<_>>>()?;
let brep = b::Brep { version: b::VERSION, tolerance, regions, shells, faces: face_records, edges: edge_records, vertices };
brep.validate().map_err(|e| invalid(format!("the mapped B-rep does not validate: {e}")))?;
Ok(brep)
let placed = |new: &[Option<u32>]| {
new.iter().map(|i| i.map(|i| i as usize).ok_or_else(|| invalid("an entity outside the canonical walk"))).collect::<Res<Vec<_>>>()
};
let order = Order { faces: placed(&face_new)?, edges: placed(&edge_new)? };
Ok((brep, order))
}
/// A face's pcurves, edge loop by edge loop, from the plain-record routine on the face's
+23
View File
@@ -58,6 +58,29 @@ mod native_tests {
}
}
/// A tagged body's own history, renumbered with its export's order on both sides, is the
/// identity in canonical order: the order says where each SMLib face and edge went.
#[test]
fn the_export_order_renumbers_a_history_into_the_record() {
for (name, s, _) in primitives() {
let t = s.tagged(0).unwrap();
let (record, order) = t.to_native_ordered().unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((order.faces.len(), order.edges.len()), (record.faces.len(), record.edges.len()), "{name}");
let h = t.history().unwrap().history(std::slice::from_ref(&order), &order).unwrap();
for (i, f) in h.faces.iter().enumerate() {
assert_eq!(f.origins, vec![solidcraft_geom::brep::history::FaceOrigin::Face { operand: 0, face: i }], "{name}: face {i}");
}
for (i, e) in h.edges.iter().enumerate() {
assert_eq!(e.origins, vec![solidcraft_geom::brep::history::EdgeOrigin::Edge { operand: 0, edge: i }], "{name}: edge {i}");
// The faces it bounds, as the record's uses say.
let mut faces: Vec<usize> = record.edges[i].uses.iter().map(|u| u.face as usize).collect();
faces.sort();
faces.dedup();
assert_eq!(e.faces, faces, "{name}: edge {i}");
}
}
}
#[test]
fn recognised_edges_are_on_smlibs_curves() {
let cyl = Brep::make_cylinder([0.0; 3], 5.0, 10.0).unwrap().to_native().unwrap();