Skip to main content

axiolid_mesh_compile/
exact.rs

1//! Reference graph-to-exact-B-rep compiler.
2//!
3//! Exact and mesh compilation are separate result domains. One exact batch owns
4//! a `NodeId -> Body` memo table, a `Body` being an `ExactBRep` or an empty
5//! result; a discrete value cannot enter that cache.
6//!
7//! # Empty results (#326)
8//!
9//! A clip or a boolean can leave nothing, and an `ExactBRep` cannot be
10//! empty. The compiler therefore holds such a node as `Body::Empty`, with
11//! the reason, and the booleans above it absorb it by the identities of
12//! `crate::empty` (`S - 0 = S`, `0 - T = 0`, `S & 0 = 0`, `S | 0 = S`); an
13//! instance of nothing is nothing. Only an empty root is refused, with
14//! `GeomError::Degenerate` giving the reason: an empty `ExactBRep` would
15//! change the representation for a value no consumer has asked for.
16//!
17//! It never falls back to mesh compilation. Extrusions (either way along
18//! the profile normal, #275), revolutions,
19//! booleans of sharp rectangle prisms along +z, booleans of placed
20//! extrusions, revolutions and booleans ([`boolean`], #228, #378), clips of those by half-spaces and
21//! intersections of half-spaces ([`clip`], #234, #326), disks swept along one segment or one arc, runs of sections at
22//! stations with a closed form (#247: a constant section along a line or a
23//! horizontal circle, a linear taper along a line; `crate::station`), and
24//! instances of any of these under a rigid transform are compiled
25//! exactly; every other family is refused with `GeomError::UnsupportedInput`
26//! naming it.
27//!
28//! An instance places its source's exact B-rep with
29//! [`ExactBRep::transformed`] (#223): a rotation, a reflection and a
30//! translation are exact, and a scale or shear is refused, never
31//! approximated. An instance at a station (#264) is placed the same way
32//! when its frame is exact (a line basis) and refused by name otherwise.
33//!
34//! [`ReferenceExactCompiler::compile_exact_with_report`] also says, per
35//! body, whether any boolean on the way read a feature within the
36//! tolerance (#236): an exact [`BooleanReport`] means the body is the exact
37//! result of its operands as given; otherwise it was built from operands
38//! moved by at most the report's magnitudes, within the tolerance.
39//!
40//! Only for a boolean's deviation report (#266, crate-internal), a
41//! compilation may flatten mesh operands: an authored mesh, or a B-rep
42//! of planes and warped faces without a surface, becomes the exact
43//! polyhedron of the triangles the mesh compiler makes of it
44//! (`polyhedral`), and the largest slab width a warped face was flattened
45//! by is reported with the body. That is the perturbed operand of
46//! ADR 0080, not the exact surface of a warped face, so the public
47//! compiler never does it and refuses meshes by name.
48
49use std::collections::{HashMap, HashSet};
50
51use axiolid_brep::{ExactBRep, TransformError};
52use axiolid_brep_boolean::BooleanReport;
53use axiolid_construct::extrude::extrude_profile_exact;
54use axiolid_construct::revolve_exact::revolve_profile_exact;
55use axiolid_construct::swept_disk_exact::{
56    swept_disk_along_arc_exact, swept_disk_along_line_exact,
57};
58use axiolid_contracts::{
59    Backend, BackendDescriptor, BackendId, ExecutionOptions, ExecutionTarget, GeomError,
60    GeomResult, Operation,
61};
62use axiolid_core::Scalar;
63use axiolid_exact_compile_contract::ExactCompiler;
64use axiolid_model::{GeometryGraph, GeometryNode, NodeId, SolidOperation};
65
66mod boolean;
67mod clip;
68pub(crate) mod polyhedral;
69
70/// Scalar reference implementation of the exact-compilation capability.
71#[derive(Debug, Clone, Copy, Default)]
72pub struct ReferenceExactCompiler;
73
74impl ReferenceExactCompiler {
75    /// Stable identity of this backend.
76    pub const ID: BackendId = BackendId::new("scalar-exact-compile");
77
78    /// Construct the reference exact compiler.
79    pub const fn new() -> Self {
80        Self
81    }
82
83    /// [`ExactCompiler::compile_exact`], with the within-tolerance
84    /// decisions the body's booleans took (#236), merged over every boolean
85    /// in its graph (each kind once, with its worst magnitude).
86    ///
87    /// An exact report ([`BooleanReport::is_exact`]) means no boolean read
88    /// anything within the tolerance: the body is the exact boolean of its
89    /// operands as given, and may be cited as exact. Otherwise it is the
90    /// exact result of operands moved by at most [`BooleanReport::linear`]
91    /// and turned by at most [`BooleanReport::angular`], and a consumer
92    /// must widen distances by that much. Bodies without booleans, and the
93    /// integer-exact prism path, always report exact.
94    ///
95    /// Either way, constructed points closer than
96    /// [`BooleanReport::rounding_floor`] count as one point and are not
97    /// reported, so a consumer widens distances by that floor too (#244).
98    /// The merged floor is the largest floor of any boolean or clip beneath
99    /// the body; an instance is rigid and keeps its source's floor, in
100    /// model units, unchanged. Zero means no general boolean or clip ran.
101    ///
102    /// # Errors
103    ///
104    /// As [`ExactCompiler::compile_exact`].
105    pub fn compile_exact_with_report(
106        &self,
107        graph: &GeometryGraph,
108        root: NodeId,
109        options: &ExecutionOptions,
110    ) -> GeomResult<(ExactBRep, BooleanReport)> {
111        let mut compilation = ExactCompilation::new(graph, options);
112        let body = compilation.compile(root)?;
113        Ok((body, compilation.report_of(root)))
114    }
115
116    /// [`Self::compile_exact_with_report`] for a boolean's deviation
117    /// report (#266): mesh operands (authored meshes, and B-reps whose
118    /// faces are all planes or warped faces without a surface) are taken
119    /// as the exact polyhedra of their triangle meshes, and a union or
120    /// intersection is taken when an operand is such a mesh. Also returns
121    /// the largest slab width any flattened operand beneath `root` was
122    /// moved by (zero when none was warped); the body is `None` when the
123    /// graph is empty, which an exact B-rep cannot be (#326). The public
124    /// compiler keeps
125    /// refusing meshes: their triangles are one reading of a warped face,
126    /// not its exact surface.
127    pub(crate) fn compile_flattened_with_report(
128        &self,
129        graph: &GeometryGraph,
130        root: NodeId,
131        options: &ExecutionOptions,
132    ) -> GeomResult<(Option<ExactBRep>, BooleanReport, Scalar)> {
133        let mut compilation = ExactCompilation::new(graph, options);
134        compilation.flatten = true;
135        let body = match compilation.body(root)? {
136            Body::Solid(solid) => Some(*solid),
137            Body::Empty(_) => None,
138        };
139        Ok((
140            body,
141            compilation.report_of(root),
142            compilation.flattened_of(root),
143        ))
144    }
145
146    /// [`Self::compile_exact_with_report`] for several roots in one
147    /// compilation, sharing its cache, in root order.
148    ///
149    /// # Errors
150    ///
151    /// As [`ExactCompiler::compile_exact_batch_into`].
152    pub fn compile_exact_batch_with_reports(
153        &self,
154        graph: &GeometryGraph,
155        roots: &[NodeId],
156        options: &ExecutionOptions,
157    ) -> GeomResult<Vec<(ExactBRep, BooleanReport)>> {
158        let mut compilation = ExactCompilation::new(graph, options);
159        let mut out = Vec::with_capacity(roots.len());
160        for &root in roots {
161            let body = compilation.compile(root)?;
162            out.push((body, compilation.report_of(root)));
163        }
164        Ok(out)
165    }
166}
167
168impl Backend for ReferenceExactCompiler {
169    fn descriptor(&self) -> BackendDescriptor {
170        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
171    }
172}
173
174impl ExactCompiler for ReferenceExactCompiler {
175    fn compile_exact(
176        &self,
177        graph: &GeometryGraph,
178        root: NodeId,
179        options: &ExecutionOptions,
180    ) -> GeomResult<ExactBRep> {
181        ExactCompilation::new(graph, options).compile(root)
182    }
183
184    fn compile_exact_batch_into(
185        &self,
186        graph: &GeometryGraph,
187        roots: &[NodeId],
188        options: &ExecutionOptions,
189        destination: &mut Vec<ExactBRep>,
190    ) -> GeomResult<()> {
191        destination.reserve(roots.len());
192        let mut compilation = ExactCompilation::new(graph, options);
193        for &root in roots {
194            destination.push(compilation.compile(root)?);
195        }
196        Ok(())
197    }
198}
199
200/// What a node compiles to: a solid, or nothing (#326). An exact B-rep
201/// cannot be empty, so an empty node is held here, absorbed by the
202/// identities of `crate::empty`, and refused only as a root.
203#[derive(Debug, Clone)]
204enum Body {
205    Solid(Box<ExactBRep>),
206    /// Why nothing is left.
207    Empty(&'static str),
208}
209
210impl Body {
211    fn new(solid: ExactBRep) -> Self {
212        Self::Solid(Box::new(solid))
213    }
214
215    /// The solid, or the emptiness as a refusal.
216    fn solid(self) -> GeomResult<ExactBRep> {
217        match self {
218            Self::Solid(solid) => Ok(*solid),
219            Self::Empty(why) => Err(GeomError::Degenerate(why.to_owned())),
220        }
221    }
222
223    fn is_empty(&self) -> bool {
224        matches!(self, Self::Empty(_))
225    }
226}
227
228struct ExactCompilation<'a> {
229    graph: &'a GeometryGraph,
230    options: &'a ExecutionOptions,
231    cache: HashMap<NodeId, Body>,
232    active: HashSet<NodeId>,
233    cache_hits: usize,
234    evaluated_nodes: usize,
235    /// Per compiled node with a boolean beneath it: what its booleans read
236    /// within tolerance (#236), and their rounding floor (#244). Absent
237    /// means exact, with no floor.
238    reports: HashMap<NodeId, BooleanReport>,
239    /// The report of the general boolean `compile_boolean` just ran.
240    boolean_report: Option<BooleanReport>,
241    /// Whether mesh operands are taken as the exact polyhedra of their
242    /// triangle meshes (#266,
243    /// [`ReferenceExactCompiler::compile_flattened_with_report`]).
244    flatten: bool,
245    /// Per compiled node with a warped mesh beneath it: the largest slab
246    /// width a flattened operand was moved by (#266). Absent means none.
247    flattened: HashMap<NodeId, Scalar>,
248    /// Which nodes are regions (#326), decided on demand.
249    regions: crate::regions::RegionMemo,
250}
251
252impl<'a> ExactCompilation<'a> {
253    fn new(graph: &'a GeometryGraph, options: &'a ExecutionOptions) -> Self {
254        Self {
255            graph,
256            options,
257            cache: HashMap::new(),
258            active: HashSet::new(),
259            cache_hits: 0,
260            evaluated_nodes: 0,
261            reports: HashMap::new(),
262            boolean_report: None,
263            flatten: false,
264            flattened: HashMap::new(),
265            regions: crate::regions::RegionMemo::default(),
266        }
267    }
268
269    /// The largest slab width of the meshes flattened beneath a node.
270    fn flattened_of(&self, node: NodeId) -> Scalar {
271        self.flattened.get(&node).copied().unwrap_or(0.0)
272    }
273
274    /// Record the largest slab width flattened beneath a node, if any.
275    fn set_flattened(&mut self, node: NodeId, width: Scalar) {
276        if width > 0.0 {
277            self.flattened.insert(node, width);
278        }
279    }
280
281    /// A mesh operand as the exact polyhedron of its triangle mesh, with
282    /// the slab width its warped faces were flattened by (#266).
283    fn flattened_mesh(&self, node: &GeometryNode) -> GeomResult<(ExactBRep, Scalar)> {
284        match node {
285            GeometryNode::PolygonMesh(mesh) => {
286                // A keyhole face's Steiner points off an axis plane (#283)
287                // move the mesh off the cover by at most their slab width,
288                // the same kind of bound as a warp: flattened by either.
289                let (triangles, warp, steiner_offset) =
290                    crate::compiler::compile_authored_polygons(mesh, self.options)?;
291                let width = warp.unwrap_or(0.0).max(steiner_offset.unwrap_or(0.0));
292                Ok((polyhedral::from_triangles(&triangles)?, width))
293            }
294            GeometryNode::TriMesh(mesh) => Ok((polyhedral::from_triangles(mesh)?, 0.0)),
295            GeometryNode::BRep(brep) => {
296                let (triangles, closure, deviation) = crate::brep::tessellate(
297                    brep,
298                    self.graph,
299                    self.options.tolerance(),
300                    crate::compiler::chord_error(self.options),
301                )?;
302                if closure != axiolid_mesh_compile_contract::MeshClosure::Solid {
303                    return Err(unsupported(
304                        "exact boolean over a B-rep operand that bounds no solid",
305                    ));
306                }
307                let warp = deviation.polyhedral_warp().ok_or_else(|| {
308                    unsupported(
309                        "exact boolean over a B-rep operand with a face that is neither a plane \
310                         with straight edges nor a warped face without a surface",
311                    )
312                })?;
313                Ok((polyhedral::from_triangles(&triangles)?, warp))
314            }
315            other => Err(unsupported(exact_input_family(other))),
316        }
317    }
318
319    /// What the booleans beneath a compiled node read within tolerance.
320    fn report_of(&self, node: NodeId) -> BooleanReport {
321        self.reports.get(&node).cloned().unwrap_or_default()
322    }
323
324    /// Record a node's report, if anything was read or a rounding floor
325    /// applied (#244).
326    fn set_report(&mut self, node: NodeId, report: BooleanReport) {
327        if !report.is_exact() || report.rounding_floor() > 0.0 {
328            self.reports.insert(node, report);
329        }
330    }
331
332    fn compile(&mut self, root: NodeId) -> GeomResult<ExactBRep> {
333        self.body(root)?.solid()
334    }
335
336    fn body(&mut self, root: NodeId) -> GeomResult<Body> {
337        if let Some(cached) = self.cache.get(&root) {
338            self.cache_hits += 1;
339            return Ok(cached.clone());
340        }
341        if !self.active.insert(root) {
342            return Err(GeomError::InvalidInput(format!(
343                "exact compilation cycle reached at node {root:?}"
344            )));
345        }
346
347        self.evaluated_nodes += 1;
348        let result = self.body_uncached(root);
349        self.active.remove(&root);
350        let exact = result?;
351        self.cache.insert(root, exact.clone());
352        Ok(exact)
353    }
354
355    fn body_uncached(&mut self, root: NodeId) -> GeomResult<Body> {
356        let node = self.graph.get(root).ok_or_else(|| {
357            GeomError::InvalidInput(format!("node {root:?} does not belong to this graph"))
358        })?;
359
360        match node {
361            GeometryNode::SolidOperation(SolidOperation::Extrusion {
362                profile,
363                direction,
364                depth,
365            }) => {
366                let profile_node = self.graph.get(*profile).ok_or_else(|| {
367                    GeomError::InvalidInput(format!(
368                        "extrusion profile {profile:?} does not belong to this graph"
369                    ))
370                })?;
371                let GeometryNode::Profile(profile) = profile_node else {
372                    return Err(unsupported("extrusion profile reference"));
373                };
374                extrude_profile_exact(profile, *direction, *depth, self.options.tolerance())
375                    .map(Body::new)
376                    .map_err(remap_construction_error)
377            }
378            GeometryNode::SolidOperation(SolidOperation::Revolution {
379                profile,
380                axis_origin,
381                axis_direction,
382                angle,
383            }) => {
384                let profile_node = self.graph.get(*profile).ok_or_else(|| {
385                    GeomError::InvalidInput(format!(
386                        "revolution profile {profile:?} does not belong to this graph"
387                    ))
388                })?;
389                let GeometryNode::Profile(profile) = profile_node else {
390                    return Err(unsupported("revolution profile reference"));
391                };
392                revolve_profile_exact(
393                    profile,
394                    *axis_origin,
395                    *axis_direction,
396                    *angle,
397                    self.options.tolerance(),
398                )
399                .map(Body::new)
400                .map_err(remap_construction_error)
401            }
402            GeometryNode::Instance(instance) => {
403                let instance = *instance;
404                let source = self.body(instance.source)?;
405                self.set_report(root, self.report_of(instance.source));
406                self.set_flattened(root, self.flattened_of(instance.source));
407                // Nothing placed is still nothing.
408                let Body::Solid(source) = source else {
409                    return Ok(source);
410                };
411                source
412                    .transformed(&instance.transform)
413                    .map(Body::new)
414                    .map_err(|error| match error {
415                        TransformError::NotRigid => {
416                            unsupported("exact instance under a scaled or sheared transform")
417                        }
418                        TransformError::Unsupported(what) => unsupported(what),
419                        other => GeomError::InvalidInput(format!("instance transform: {other}")),
420                    })
421            }
422            // An instance in a station's frame (#264): exact only where the
423            // frame is (a line basis); any other frame is read at a distance
424            // resolved by an estimate, which an exact body must not carry.
425            GeometryNode::InstanceAtStation(placed) => {
426                let source_id = placed.source;
427                let placement = crate::station::placement(self.graph, root)?;
428                if !placement.exact {
429                    return Err(unsupported(
430                        "exact instance at a station whose frame is not exact: the basis is \
431                         not a line, so its distance is read by an estimate",
432                    ));
433                }
434                let source = self.body(source_id)?;
435                self.set_report(root, self.report_of(source_id));
436                self.set_flattened(root, self.flattened_of(source_id));
437                let Body::Solid(source) = source else {
438                    return Ok(source);
439                };
440                source
441                    .transformed(&placement.transform)
442                    .map(Body::new)
443                    .map_err(|error| match error {
444                        TransformError::Unsupported(what) => unsupported(what),
445                        other => GeomError::InvalidInput(format!("instance at a station: {other}")),
446                    })
447            }
448            // A single segment or arc has no corners, so a fillet radius
449            // has nothing to round there; one with corners is refused.
450            GeometryNode::SolidOperation(SolidOperation::SweptDisk {
451                directrix,
452                radius,
453                inner_radius,
454                parameter_range,
455                fillet_radius: _,
456            }) => {
457                let tolerance = self.options.tolerance();
458                match crate::directrix::exact(
459                    self.graph,
460                    *directrix,
461                    *parameter_range,
462                    self.options,
463                    unsupported,
464                )? {
465                    crate::directrix::ExactDirectrix::Segment(start, end) => {
466                        swept_disk_along_line_exact(start, end, *radius, *inner_radius, tolerance)
467                    }
468                    crate::directrix::ExactDirectrix::Arc(circle, span) => {
469                        swept_disk_along_arc_exact(&circle, span, *radius, *inner_radius, tolerance)
470                    }
471                }
472                .map(Body::new)
473                .map_err(remap_construction_error)
474            }
475            GeometryNode::SolidOperation(SolidOperation::Boolean {
476                left,
477                right,
478                operator,
479            }) => {
480                let (left, right, operator) = (*left, *right, *operator);
481                let body = self.compile_boolean(left, right, operator)?;
482                let own = self.boolean_report.take().unwrap_or_default();
483                let report = self
484                    .report_of(left)
485                    .merged(&self.report_of(right))
486                    .merged(&own);
487                self.set_report(root, report);
488                let width = self.flattened_of(left).max(self.flattened_of(right));
489                self.set_flattened(root, width);
490                Ok(body)
491            }
492            // Only for a boolean's report (#266): a mesh operand flattened
493            // to the exact polyhedron of its triangles.
494            GeometryNode::PolygonMesh(_) | GeometryNode::TriMesh(_) | GeometryNode::BRep(_)
495                if self.flatten =>
496            {
497                let (body, width) = self.flattened_mesh(node)?;
498                self.set_flattened(root, width);
499                Ok(Body::new(body))
500            }
501            // A constant section along a line or a horizontal circle, and a
502            // linear taper along a line, have closed forms (#247).
503            GeometryNode::SolidOperation(SolidOperation::StationedSpine {
504                directrix,
505                sections,
506                frame,
507            }) => {
508                let sections: Vec<_> = sections.iter().copied().map(Into::into).collect();
509                crate::station::stationed_spine_exact(
510                    self.graph,
511                    *directrix,
512                    &sections,
513                    *frame,
514                    self.options,
515                    unsupported,
516                )
517                .map(Body::new)
518            }
519            GeometryNode::SolidOperation(SolidOperation::SectionsAtStations {
520                directrix,
521                sections,
522                frame,
523            }) => crate::station::stationed_spine_exact(
524                self.graph,
525                *directrix,
526                sections,
527                *frame,
528                self.options,
529                unsupported,
530            )
531            .map(Body::new),
532            _ => Err(unsupported(exact_input_family(node))),
533        }
534    }
535}
536
537fn remap_construction_error(error: GeomError) -> GeomError {
538    match error {
539        GeomError::UnsupportedInput { input, .. } => unsupported(input),
540        error => error,
541    }
542}
543
544/// Resolve a swept disk's directrix exactly as [`ReferenceExactCompiler`]
545/// reads it for `SolidOperation::SweptDisk` (#230).
546///
547/// `directrix` and `parameter_range` are the swept disk's own fields. The
548/// result is one straight segment or one circular arc, read with the same
549/// trim, sense and range conventions as the mesh compiler's sampled path, so
550/// an exact boundary built from it matches what both compilers produce.
551/// Accepted: a bounded line, a two-point polyline, a circle or a sub-range of
552/// one, a trim of a line or circle (across the seam included), and a
553/// one-segment composite. Refused with `GeomError::UnsupportedInput` naming
554/// the input: directrices with corners, other curve families and unbounded
555/// lines; invalid or empty ranges are `InvalidInput` or `Degenerate`.
556pub fn exact_directrix(
557    graph: &GeometryGraph,
558    directrix: NodeId,
559    parameter_range: Option<(Scalar, Scalar)>,
560    options: &ExecutionOptions,
561) -> GeomResult<crate::directrix::ExactDirectrix> {
562    crate::directrix::exact(graph, directrix, parameter_range, options, unsupported)
563}
564
565fn unsupported(input: &'static str) -> GeomError {
566    GeomError::UnsupportedInput {
567        backend: ReferenceExactCompiler::ID,
568        operation: Operation::GraphCompilation,
569        input,
570    }
571}
572
573fn exact_input_family(node: &GeometryNode) -> &'static str {
574    match node {
575        GeometryNode::Point2(_) => "2D point",
576        GeometryNode::Point3(_) => "3D point",
577        GeometryNode::Vector2(_) => "2D vector",
578        GeometryNode::Vector3(_) => "3D vector",
579        GeometryNode::Frame2(_) => "2D frame",
580        GeometryNode::Frame3(_) => "3D frame",
581        GeometryNode::Transform(_) => "transform",
582        GeometryNode::PointList2(_) => "2D point list",
583        GeometryNode::PointList3(_) => "3D point list",
584        GeometryNode::Primitive(_) => "primitive",
585        GeometryNode::Curve2(_) => "2D curve",
586        GeometryNode::Curve3(_) => "3D curve",
587        GeometryNode::CurveRelation(_) => "curve relation",
588        GeometryNode::PointOnCurve(_) => "point on curve",
589        GeometryNode::Surface(_) => "surface",
590        GeometryNode::SurfaceRelation(_) => "surface relation",
591        GeometryNode::PointOnSurface(_) => "point on surface",
592        GeometryNode::Profile(_) => "profile",
593        GeometryNode::OpenProfile(_) => "open profile",
594        GeometryNode::HalfSpace(_) => "half-space",
595        GeometryNode::BRep(_) => "B-rep",
596        GeometryNode::PolygonMesh(_) => "polygon mesh",
597        GeometryNode::TriMesh(_) => "triangle mesh",
598        GeometryNode::BoundingBox(_) => "bounding box",
599        GeometryNode::SolidOperation(operation) => solid_operation_family(operation),
600        GeometryNode::Instance(_) => "instance",
601        GeometryNode::Collection(_) => "collection",
602        GeometryNode::CurveStation(_) => "curve station",
603        GeometryNode::OrientedCurveStation(_) => "oriented curve station",
604        GeometryNode::InstanceAtStation(_) => "instance at a station",
605        _ => "unknown geometry node",
606    }
607}
608
609pub(crate) fn solid_operation_family(operation: &SolidOperation) -> &'static str {
610    match operation {
611        SolidOperation::Extrusion { .. } => "extrusion",
612        SolidOperation::TaperedExtrusion { .. } => "tapered extrusion",
613        SolidOperation::Revolution { .. } => "revolution",
614        SolidOperation::TaperedRevolution { .. } => "tapered revolution",
615        SolidOperation::SweptDisk { .. } => "swept disk",
616        SolidOperation::FixedReferenceSweep { .. } => "fixed-reference sweep",
617        SolidOperation::SurfaceCurveSweep { .. } => "surface-curve sweep",
618        SolidOperation::SectionedSpine { .. } => "sectioned spine",
619        SolidOperation::StationedSpine { .. } | SolidOperation::SectionsAtStations { .. } => {
620            "station-placed spine"
621        }
622        SolidOperation::Boolean { .. } => "boolean",
623        SolidOperation::BoundedHalfSpace { .. } => "bounded half-space",
624        SolidOperation::BoundedHalfSpaceAlongZ { .. } => "bounded half-space along z",
625        _ => "unknown solid operation",
626    }
627}
628
629#[cfg(test)]
630mod family_name_tests {
631    use super::{solid_operation_family, SOLID_FAMILY_NAMES};
632    use axiolid_core::Vec3;
633    use axiolid_model::{GeometryGraphBuilder, GeometryNode, SolidOperation};
634    use axiolid_profile::{Profile, RectangleProfile};
635
636    /// The exported table matches what the naming function actually returns.
637    ///
638    /// The table is hand-written, so it can drift from the match arm it
639    /// mirrors. Walking the real function for a constructed variant turns
640    /// drift into a failing test rather than a stale diagnostic, and the
641    /// count assertion catches a family added without a name.
642    #[test]
643    fn the_exported_table_matches_the_naming_function() {
644        assert_eq!(
645            SOLID_FAMILY_NAMES.len(),
646            12,
647            "the declared family count changed; update the table"
648        );
649        for name in SOLID_FAMILY_NAMES {
650            assert!(!name.is_empty());
651            assert_ne!(*name, "unknown solid operation");
652        }
653
654        // NodeId has no public constructor, so build a real one.
655        let mut builder = GeometryGraphBuilder::new();
656        let profile = builder
657            .push(GeometryNode::Profile(Profile::Rectangle(
658                RectangleProfile {
659                    x: 1.0,
660                    y: 1.0,
661                    thickness: None,
662                    outer_radius: None,
663                    inner_radius: None,
664                },
665            )))
666            .expect("a rectangle profile is a valid node");
667
668        let extrusion = SolidOperation::Extrusion {
669            profile,
670            direction: Vec3::Z,
671            depth: 1.0,
672        };
673        assert!(
674            SOLID_FAMILY_NAMES.contains(&solid_operation_family(&extrusion)),
675            "the function returned a name the table does not carry"
676        );
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use axiolid_core::{Tolerance, Vec3};
683    use axiolid_model::GeometryGraphBuilder;
684    use axiolid_profile::{Profile, RectangleProfile};
685
686    use super::*;
687
688    #[test]
689    fn duplicate_roots_hit_the_exact_result_cache() {
690        let mut builder = GeometryGraphBuilder::new();
691        let profile = builder
692            .push(GeometryNode::Profile(Profile::Rectangle(
693                RectangleProfile {
694                    x: 2.0,
695                    y: 1.0,
696                    thickness: None,
697                    outer_radius: None,
698                    inner_radius: None,
699                },
700            )))
701            .unwrap();
702        let root = builder
703            .push(GeometryNode::SolidOperation(SolidOperation::Extrusion {
704                profile,
705                direction: Vec3::Z,
706                depth: 1.0,
707            }))
708            .unwrap();
709        let graph = builder.finish(vec![root]).unwrap();
710        let options = ExecutionOptions::new(Tolerance::METRE);
711        let mut compilation = ExactCompilation::new(&graph, &options);
712
713        let first = compilation.compile(root).unwrap();
714        let second = compilation.compile(root).unwrap();
715
716        assert_eq!(first, second);
717        assert_eq!(compilation.evaluated_nodes, 1);
718        assert_eq!(compilation.cache_hits, 1);
719        assert_eq!(compilation.cache.len(), 1);
720    }
721}
722
723/// Every diagnostic name `solid_operation_family` can return.
724///
725/// Kept beside that function so the two cannot drift: a family added there
726/// without a name here fails the contract test. Exposed for that test rather
727/// than for callers, who receive the name inside a refusal.
728pub const SOLID_FAMILY_NAMES: &[&str] = &[
729    "extrusion",
730    "tapered extrusion",
731    "revolution",
732    "tapered revolution",
733    "swept disk",
734    "fixed-reference sweep",
735    "surface-curve sweep",
736    "sectioned spine",
737    "station-placed spine",
738    "boolean",
739    "bounded half-space",
740    "bounded half-space along z",
741];