axiolid_mesh_compile/
compiler.rs

1//! The `MeshCompiler` implementation: graph in, meshes out.
2//!
3//! Evaluation is iterative. The graph forbids non-prior references,
4//! so cycles are structurally impossible, but depth is unbounded and
5//! recursion would risk a stack overflow on adversarial input.
6
7use axiolid_contracts::{
8    Backend, BackendDescriptor, BackendId, ExecutionOptions, ExecutionTarget, GeomError,
9    GeomResult, Operation, ScratchRequirement,
10};
11use axiolid_core::{PlaneFrame, Point3, Scalar, Tolerance, Transform3};
12use axiolid_mesh::TriMesh;
13use axiolid_mesh_boolean_contract::MeshBoolean;
14use axiolid_mesh_compile_contract::{CompileOutcome, MeshCompiler};
15use axiolid_model::{GeometryGraph, GeometryNode, NodeId, SolidOperation};
16
17use axiolid_construct::extrude::extrude_profile;
18use axiolid_construct::profile::profile_rings;
19
20use crate::channels::{self, Built};
21
22/// Scalar reference compiler.
23///
24/// Generic over the boolean provider so this crate never depends on a
25/// particular one: `axiolid-mesh-boolean-boolmesh` is an adapter, and a different provider
26/// swaps in without touching this code.
27#[derive(Debug, Clone)]
28pub struct ReferenceMeshCompiler<B> {
29    boolean: B,
30}
31
32impl<B> ReferenceMeshCompiler<B> {
33    /// Bind a boolean provider.
34    pub const fn new(boolean: B) -> Self {
35        Self { boolean }
36    }
37
38    /// The bound provider.
39    pub const fn boolean(&self) -> &B {
40        &self.boolean
41    }
42}
43
44impl<B: MeshBoolean> Backend for ReferenceMeshCompiler<B> {
45    fn descriptor(&self) -> BackendDescriptor {
46        BackendDescriptor::new(
47            BackendId::new("scalar-compile"),
48            ExecutionTarget::PortableCpu,
49        )
50    }
51}
52
53/// One entry in the explicit evaluation stack.
54///
55/// `Enter` schedules dependency discovery; `Exit` runs after every dependency
56/// already has a mesh, which is what replaces the recursive call.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58struct EvalKey {
59    id: NodeId,
60    linear_bits: u64,
61    angular_bits: u64,
62    /// The chord budget in this node's local space (#165). Part of the
63    /// key because an instance scales it with its transform, and two
64    /// budgets tessellate one node differently.
65    chord_bits: u64,
66}
67
68/// The evaluation budgets that vary per node: the tolerance, and the chord
69/// budget curved geometry is flattened to (#165). Both rescale together
70/// through an instance transform.
71#[derive(Debug, Clone, Copy, PartialEq)]
72struct Budget {
73    tolerance: Tolerance,
74    chord: Scalar,
75}
76
77impl Budget {
78    fn of(options: &ExecutionOptions) -> Self {
79        let tolerance = options.tolerance();
80        Self {
81            tolerance,
82            chord: options.chord_error().unwrap_or(tolerance.linear()),
83        }
84    }
85}
86
87impl EvalKey {
88    fn new(id: NodeId, budget: Budget) -> Self {
89        let bits = |value: Scalar| if value == 0.0 { 0 } else { value.to_bits() };
90        Self {
91            id,
92            linear_bits: bits(budget.tolerance.linear()),
93            angular_bits: bits(budget.tolerance.angular()),
94            chord_bits: bits(budget.chord),
95        }
96    }
97
98    fn budget(self) -> Budget {
99        Budget {
100            tolerance: self.tolerance(),
101            chord: Scalar::from_bits(self.chord_bits),
102        }
103    }
104
105    fn tolerance(self) -> Tolerance {
106        Tolerance::new(
107            Scalar::from_bits(self.linear_bits),
108            Scalar::from_bits(self.angular_bits),
109        )
110        .expect("evaluation keys only contain validated tolerances")
111    }
112}
113
114#[derive(Debug, Clone, Copy)]
115enum Step {
116    Enter(EvalKey),
117    Exit(EvalKey),
118}
119
120/// Cached meshes keyed by node and effective local tolerance.
121///
122/// A transformed instance changes the local chord budget. Keying only by node
123/// would incorrectly reuse a coarse source mesh for a larger instance.
124type Cache = std::collections::HashMap<EvalKey, Built>;
125
126impl<B: MeshBoolean> ReferenceMeshCompiler<B> {
127    /// Resolve a node handle, blaming the graph rather than panicking.
128    /// Resolve a closed 2D boundary curve into rings.
129    ///
130    /// Only a closed polyline boundary is handled: its points ARE the ring.
131    /// An analytic boundary needs the curve evaluator, and an open one does
132    /// not bound anything, so both are refused rather than guessed at.
133    fn boundary_rings(
134        &self,
135        graph: &GeometryGraph,
136        id: NodeId,
137    ) -> GeomResult<axiolid_construct::profile::Rings> {
138        let node = self.node(graph, id)?;
139        let GeometryNode::Curve2(curve) = node else {
140            return Err(GeomError::InvalidInput(format!(
141                "half-space boundary {id:?} is not a Curve2 node"
142            )));
143        };
144        match curve {
145            axiolid_curve::Curve2::Polyline(p) => {
146                let mut pts = p.points.clone();
147                // A closed polyline may or may not repeat its first point.
148                // Dropping the duplicate keeps the ring's edge count honest.
149                if pts.len() >= 2 && pts[0] == pts[pts.len() - 1] {
150                    pts.pop();
151                }
152                if pts.len() < 3 {
153                    return Err(GeomError::InvalidInput(
154                        "half-space boundary needs at least 3 distinct points".to_owned(),
155                    ));
156                }
157                Ok(axiolid_construct::profile::Rings {
158                    outer: pts,
159                    holes: Vec::new(),
160                })
161            }
162            _ => Err(GeomError::Unsupported {
163                backend: self.descriptor().id,
164                operation: Operation::CurveEvaluation,
165            }),
166        }
167    }
168
169    /// Resolve a node that must be a profile, into flattened rings.
170    /// Resolve a node that must be a surface.
171    ///
172    /// Reported by node id rather than by position so a malformed graph
173    /// names the offending node instead of the operation that reached it.
174    fn surface_of(
175        graph: &GeometryGraph,
176        id: axiolid_model::NodeId,
177    ) -> GeomResult<&axiolid_surface::Surface> {
178        match graph.get(id) {
179            Some(GeometryNode::Surface(surface)) => Ok(surface),
180            Some(_) => Err(GeomError::InvalidInput(format!(
181                "reference surface {id:?} is not a Surface node"
182            ))),
183            None => Err(GeomError::InvalidInput(format!(
184                "reference surface {id:?} does not belong to this graph"
185            ))),
186        }
187    }
188
189    fn surface_normals(
190        &self,
191        graph: &GeometryGraph,
192        id: NodeId,
193        path: &[Point3],
194        options: &ExecutionOptions,
195    ) -> GeomResult<Vec<axiolid_core::Vec3>> {
196        if let Some(GeometryNode::SurfaceRelation(
197            axiolid_model::SurfaceRelation::LinearExtrusion { direction, .. },
198        )) = graph.get(id)
199        {
200            return axiolid_construct::sweep::linear_extrusion_normals(path, *direction);
201        }
202        let surface = Self::surface_of(graph, id)?;
203        path.iter()
204            .map(|point| {
205                let (u, v) =
206                    axiolid_reference::surface::invert(surface, *point, options.tolerance())?;
207                axiolid_reference::surface::normal(surface, u, v)
208            })
209            .collect()
210    }
211
212    fn rings_of(
213        &self,
214        graph: &GeometryGraph,
215        id: NodeId,
216        options: &ExecutionOptions,
217        what: &str,
218    ) -> GeomResult<axiolid_construct::profile::Rings> {
219        let node = self.node(graph, id)?;
220        let GeometryNode::Profile(shape) = node else {
221            return Err(GeomError::InvalidInput(format!(
222                "{what} {id:?} is not a Profile node"
223            )));
224        };
225        profile_rings(shape, chord_error(options), options.tolerance())
226    }
227
228    /// Sample a directrix curve into a polyline.
229    ///
230    /// Only a polyline directrix is handled here: its points ARE the samples,
231    /// so no evaluator is involved. An analytic directrix needs the curve
232    /// provider, which the compiler does not yet hold, and is refused with the
233    /// named capability rather than silently approximated by its control
234    /// points.
235    fn directrix_points(
236        &self,
237        graph: &GeometryGraph,
238        id: NodeId,
239        range: Option<(Scalar, Scalar)>,
240        options: &ExecutionOptions,
241    ) -> GeomResult<Vec<Point3>> {
242        crate::directrix::points(graph, id, range, options)
243    }
244
245    fn node<'g>(&self, graph: &'g GeometryGraph, id: NodeId) -> GeomResult<&'g GeometryNode> {
246        graph.get(id).ok_or_else(|| {
247            GeomError::InvalidInput(format!("node {id:?} does not belong to this graph"))
248        })
249    }
250
251    /// Nodes that must have meshes before `id` can be evaluated.
252    ///
253    /// Only mesh-producing dependencies are listed. A profile referenced by an
254    /// extrusion is consumed as 2D data, not as a mesh, so it is deliberately
255    /// absent: compiling it standalone would be meaningless.
256    fn mesh_dependencies(
257        &self,
258        graph: &GeometryGraph,
259        node: &GeometryNode,
260        key: EvalKey,
261    ) -> GeomResult<Vec<EvalKey>> {
262        let budget = key.budget();
263        let same = |id| EvalKey::new(id, budget);
264        Ok(match node {
265            GeometryNode::Instance(instance) => vec![EvalKey::new(
266                instance.source,
267                instance_local_budget(instance.transform, budget)?,
268            )],
269            GeometryNode::Collection(members) => members.iter().copied().map(same).collect(),
270            GeometryNode::SolidOperation(
271                operation @ SolidOperation::Boolean { left, right, .. },
272            ) => {
273                if is_subject_bounded_half_space_boolean(graph, operation) {
274                    vec![same(*left)]
275                } else {
276                    vec![same(*left), same(*right)]
277                }
278            }
279            _ => Vec::new(),
280        })
281    }
282
283    /// Iterative post-order evaluation of one root.
284    fn evaluate(
285        &self,
286        graph: &GeometryGraph,
287        root: NodeId,
288        options: &ExecutionOptions,
289        cache: &mut Cache,
290    ) -> GeomResult<Built> {
291        let root_key = EvalKey::new(root, Budget::of(options));
292        let mut stack = vec![Step::Enter(root_key)];
293        while let Some(step) = stack.pop() {
294            match step {
295                Step::Enter(key) => {
296                    if cache.contains_key(&key) {
297                        continue;
298                    }
299                    let node = self.node(graph, key.id)?;
300                    let deps = self.mesh_dependencies(graph, node, key)?;
301                    // Exit runs after every dependency, so push it first.
302                    stack.push(Step::Exit(key));
303                    for dep in deps {
304                        if !cache.contains_key(&dep) {
305                            stack.push(Step::Enter(dep));
306                        }
307                    }
308                }
309                Step::Exit(key) => {
310                    if cache.contains_key(&key) {
311                        continue;
312                    }
313                    let budget = key.budget();
314                    let local_options = options
315                        .clone()
316                        .with_tolerance(budget.tolerance)
317                        .with_chord_error(budget.chord)
318                        .ok_or_else(|| {
319                            GeomError::InvalidInput(format!(
320                                "chord budget {} is not positive and finite",
321                                budget.chord
322                            ))
323                        })?;
324                    let built = self.build(graph, key, &local_options, cache)?;
325                    cache.insert(key, built);
326                }
327            }
328        }
329        cache
330            .get(&root_key)
331            .cloned()
332            .ok_or_else(|| GeomError::InvalidInput(format!("root {root:?} produced no mesh")))
333    }
334
335    /// Convert an authored polygon mesh to triangles (#160).
336    ///
337    /// A plain triangle keeps its exact corner order. Any other face -- an
338    /// n-gon, a concave face, a face with holes (IFC4
339    /// `IfcIndexedPolygonalFaceWithVoids`) -- is triangulated in its own
340    /// plane by [`crate::planar::triangulate_polygon`]. Positions are kept
341    /// as authored and shared, so the output welds exactly where the input
342    /// did; no corner is moved or added.
343    ///
344    /// A face that is not planar within the linear tolerance, has no area,
345    /// or whose rings cross is refused with an error naming its index: a
346    /// non-planar n-gon has no unique triangulation, so picking one would
347    /// invent geometry.
348    fn compile_authored_polygons(
349        &self,
350        mesh: &axiolid_mesh::PolygonMesh,
351        options: &ExecutionOptions,
352    ) -> GeomResult<TriMesh> {
353        let position_count = mesh.positions.len();
354        if let Some(index) = mesh
355            .faces
356            .iter()
357            .flat_map(|face| face_rings(face).flatten().copied())
358            .find(|&index| index as usize >= position_count)
359        {
360            return Err(GeomError::InvalidInput(format!(
361                "authored polygon index {index} exceeds position count {position_count}"
362            )));
363        }
364
365        let mut positions = Vec::new();
366        positions
367            .try_reserve_exact(position_count)
368            .map_err(|_| GeomError::BudgetExceeded {
369                resource: "authored polygon positions",
370            })?;
371        positions.extend_from_slice(&mesh.positions);
372
373        // A polygon with k corners (holes included) gives at most
374        // k + 2 * holes - 2 triangles; bound by corner count times three.
375        let index_bound = mesh
376            .faces
377            .iter()
378            .try_fold(0_usize, |total, face| {
379                let corners = face_rings(face).map(Vec::len).sum::<usize>();
380                corners
381                    .checked_add(2 * face.holes.len())
382                    .and_then(|triangles| triangles.checked_mul(3))
383                    .and_then(|indices| total.checked_add(indices))
384            })
385            .ok_or(GeomError::BudgetExceeded {
386                resource: "authored polygon indices",
387            })?;
388        let mut indices = Vec::new();
389        indices
390            .try_reserve_exact(index_bound)
391            .map_err(|_| GeomError::BudgetExceeded {
392                resource: "authored polygon indices",
393            })?;
394
395        let linear = options.tolerance().linear();
396        for (face_index, face) in mesh.faces.iter().enumerate() {
397            if face.outer.len() == 3 && face.holes.is_empty() {
398                indices.extend_from_slice(&face.outer);
399                continue;
400            }
401            // A repeated corner (an exporter's closing point, a doubled
402            // corner) needs no handling here: earcut drops coincident
403            // consecutive points itself (`a_repeated_closing_corner_is_not_a_triangle`).
404            let rings: Vec<&Vec<u32>> = face_rings(face).collect();
405            let points: Vec<Vec<axiolid_core::Point3>> = rings
406                .iter()
407                .map(|ring| ring.iter().map(|&i| mesh.positions[i as usize]).collect())
408                .collect();
409            let views: Vec<&[axiolid_core::Point3]> = points.iter().map(Vec::as_slice).collect();
410            let local = crate::planar::triangulate_polygon(&views, linear)
411                .map_err(|refusal| crate::planar::face_error(face_index, refusal))?;
412            let corners: Vec<u32> = rings.into_iter().flatten().copied().collect();
413            indices.extend(local.into_iter().map(|corner| corners[corner]));
414        }
415
416        let triangles = TriMesh::new(positions, indices);
417        triangles.validate_structure().map_err(|error| {
418            GeomError::InvalidInput(format!("invalid authored polygon mesh: {error}"))
419        })?;
420        Ok(triangles)
421    }
422
423    /// Build one node, assuming its mesh dependencies are already cached.
424    fn build(
425        &self,
426        graph: &GeometryGraph,
427        key: EvalKey,
428        options: &ExecutionOptions,
429        cache: &Cache,
430    ) -> GeomResult<Built> {
431        let id = key.id;
432        let node = self.node(graph, id)?;
433        match node {
434            GeometryNode::TriMesh(mesh) => Ok(authored_mesh(mesh.clone(), options)),
435            GeometryNode::PolygonMesh(mesh) => self
436                .compile_authored_polygons(mesh, options)
437                .map(|mesh| authored_mesh(mesh, options)),
438            GeometryNode::Instance(instance) => {
439                let source_budget = instance_local_budget(instance.transform, Budget::of(options))?;
440                let source = self.cached(cache, instance.source, source_budget)?;
441                Ok(channels::transform(source, instance.transform))
442            }
443            GeometryNode::Collection(members) => {
444                let members = members
445                    .iter()
446                    .map(|&member| self.cached(cache, member, Budget::of(options)))
447                    .collect::<GeomResult<Vec<_>>>()?;
448                Ok(channels::merge(&members))
449            }
450            GeometryNode::SolidOperation(SolidOperation::Boolean {
451                left,
452                right,
453                operator,
454            }) => self.build_boolean(graph, *left, *right, *operator, options, cache),
455            GeometryNode::SolidOperation(operation) => {
456                self.build_solid(graph, operation, options).map(Built::leaf)
457            }
458            GeometryNode::BRep(brep) => {
459                crate::brep::tessellate(brep, graph, options.tolerance(), chord_error(options))
460                    .map(|(mesh, closure)| Built::with_closure(mesh, closure))
461            }
462            // A plane trimmed by boundary curves: a surface (#192).
463            GeometryNode::SurfaceRelation(axiolid_model::SurfaceRelation::CurveBounded {
464                basis,
465                boundaries,
466                implicit_outer,
467            }) => crate::bounded::curve_bounded(
468                self.descriptor().id,
469                graph,
470                *basis,
471                boundaries,
472                *implicit_outer,
473                chord_error(options),
474                options.tolerance(),
475            )
476            .map(|mesh| {
477                Built::with_closure(mesh, axiolid_mesh_compile_contract::MeshClosure::Surface)
478            }),
479            // CSG primitives are analytic solids: no surface evaluation,
480            // no trim curves, just a closed mesh at the caller's tolerance.
481            GeometryNode::Primitive(primitive) => {
482                axiolid_reference::primitive::tessellate_primitive(
483                    primitive,
484                    chord_tolerance(options)?,
485                )
486                .map(Built::leaf)
487            }
488            other => Err(GeomError::Unsupported {
489                backend: self.descriptor().id,
490                operation: unsupported_operation(other),
491            }),
492        }
493    }
494
495    /// Read an already-built dependency.
496    fn cached<'c>(&self, cache: &'c Cache, id: NodeId, budget: Budget) -> GeomResult<&'c Built> {
497        cache.get(&EvalKey::new(id, budget)).ok_or_else(|| {
498            GeomError::InvalidInput(format!(
499                "dependency {id:?} was not evaluated first at {budget:?}"
500            ))
501        })
502    }
503
504    /// A boolean, with the operands' channel fates composed onto the
505    /// provider's (#115): a channel lost upstream stays reported lost.
506    fn build_boolean(
507        &self,
508        graph: &GeometryGraph,
509        left: NodeId,
510        right: NodeId,
511        operator: axiolid_core::BooleanOperator,
512        options: &ExecutionOptions,
513        cache: &Cache,
514    ) -> GeomResult<Built> {
515        let subject = self.cached(cache, left, Budget::of(options))?;
516        refuse_surface_operand(subject, "subject")?;
517        let bounded_tool = match self.node(graph, right)? {
518            GeometryNode::HalfSpace(hs) => Some(axiolid_construct::half_space::for_subject(
519                &subject.mesh,
520                *hs,
521                options.tolerance(),
522            )?),
523            _ => None,
524        };
525        // A bounded half-space is built here from the subject, so it has no
526        // upstream fates of its own.
527        let (tool, tool_fates) = match bounded_tool.as_ref() {
528            Some(tool) => (tool, None),
529            None => {
530                let built = self.cached(cache, right, Budget::of(options))?;
531                refuse_surface_operand(built, "tool")?;
532                (&built.mesh, Some(&built.fates))
533            }
534        };
535        let outcome = self
536            .boolean
537            .boolean(&subject.mesh, tool, operator, options)?;
538        if let Some(pinch) = crate::pinch::find(&outcome.mesh) {
539            return Err(GeomError::Degenerate(match pinch {
540                crate::pinch::Pinch::Edge { from, to } => format!(
541                    "{operator:?} result touches itself along the edge {from:?} to {to:?}: \
542                     the operands meet tangentially there (a void tangent to its host's face), \
543                     leaving no material between two faces"
544                ),
545                crate::pinch::Pinch::Vertex { at } => format!(
546                    "{operator:?} result touches itself at the point {at:?}: the operands \
547                     meet tangentially there, leaving no material between two faces"
548                ),
549            }));
550        }
551        Ok(channels::after_boolean(
552            outcome.mesh,
553            &subject.fates,
554            tool_fates,
555            outcome.evidence.attribute_fates,
556        ))
557    }
558
559    /// Every non-boolean solid family; unsupported ones are explicitly
560    /// refused. Booleans go through [`Self::build_boolean`], which needs the
561    /// operands' cached fates.
562    fn build_solid(
563        &self,
564        graph: &GeometryGraph,
565        operation: &SolidOperation,
566        options: &ExecutionOptions,
567    ) -> GeomResult<TriMesh> {
568        match operation {
569            SolidOperation::Extrusion {
570                profile,
571                direction,
572                depth,
573            } => {
574                let node = self.node(graph, *profile)?;
575                let GeometryNode::Profile(shape) = node else {
576                    return Err(GeomError::InvalidInput(format!(
577                        "extrusion profile {profile:?} is not a Profile node"
578                    )));
579                };
580                let rings = profile_rings(shape, chord_error(options), options.tolerance())?;
581                extrude_profile(&rings, *direction, *depth, options.tolerance())
582            }
583            SolidOperation::Revolution {
584                profile,
585                axis_origin,
586                axis_direction,
587                angle,
588            } => {
589                let node = self.node(graph, *profile)?;
590                let GeometryNode::Profile(shape) = node else {
591                    return Err(GeomError::InvalidInput(format!(
592                        "revolution profile {profile:?} is not a Profile node"
593                    )));
594                };
595                let rings = profile_rings(shape, chord_error(options), options.tolerance())?;
596                axiolid_construct::revolve::revolve(
597                    &rings,
598                    *axis_origin,
599                    *axis_direction,
600                    *angle,
601                    options.tolerance(),
602                )
603            }
604            SolidOperation::TaperedExtrusion {
605                start_profile,
606                end_profile,
607                direction,
608                depth,
609            } => {
610                let a = self.rings_of(graph, *start_profile, options, "taper start profile")?;
611                let b = self.rings_of(graph, *end_profile, options, "taper end profile")?;
612                axiolid_construct::sweep::tapered_extrude(&a, &b, *direction, *depth)
613            }
614            SolidOperation::TaperedRevolution {
615                start_profile,
616                end_profile,
617                axis_origin,
618                axis_direction,
619                angle,
620            } => {
621                let a = self.rings_of(graph, *start_profile, options, "taper start profile")?;
622                let b = self.rings_of(graph, *end_profile, options, "taper end profile")?;
623                axiolid_construct::sweep::tapered_revolve(
624                    &a,
625                    &b,
626                    *axis_origin,
627                    *axis_direction,
628                    *angle,
629                    options.tolerance(),
630                )
631            }
632            SolidOperation::SweptDisk {
633                directrix,
634                radius,
635                inner_radius,
636                parameter_range,
637                fillet_radius,
638            } => {
639                let path = self.directrix_points(graph, *directrix, *parameter_range, options)?;
640                axiolid_construct::sweep::swept_disk(
641                    &path,
642                    *radius,
643                    *inner_radius,
644                    *fillet_radius,
645                    options.tolerance(),
646                )
647            }
648            SolidOperation::FixedReferenceSweep {
649                profile,
650                directrix,
651                reference_direction,
652                parameter_range,
653            } => {
654                let rings = self.rings_of(graph, *profile, options, "sweep profile")?;
655                let path = self.directrix_points(graph, *directrix, *parameter_range, options)?;
656                axiolid_construct::sweep::fixed_reference_sweep(&rings, &path, *reference_direction)
657            }
658            SolidOperation::SurfaceCurveSweep {
659                profile,
660                directrix,
661                reference_surface,
662                parameter_range,
663            } => {
664                let rings =
665                    self.rings_of(graph, *profile, options, "surface curve sweep profile")?;
666                let path = self.directrix_points(graph, *directrix, *parameter_range, options)?;
667                let normals = self.surface_normals(graph, *reference_surface, &path, options)?;
668                axiolid_construct::sweep::surface_curve_sweep(&rings, &path, &normals)
669            }
670            SolidOperation::SectionedSpine { spine, sections } => {
671                let path = self.directrix_points(graph, *spine, None, options)?;
672                if sections.len() != path.len() {
673                    return Err(GeomError::InvalidInput(format!(
674                        "a sectioned spine needs one section per spine point: {} sections, {} points",
675                        sections.len(),
676                        path.len()
677                    )));
678                }
679                let mut placed = Vec::with_capacity(sections.len());
680                for (section, origin) in sections.iter().zip(&path) {
681                    let rings =
682                        self.rings_of(graph, section.profile, options, "spine section profile")?;
683                    // The section's own placement positions its profile;
684                    // the spine point supplies the station origin.
685                    let pts = rings
686                        .outer
687                        .iter()
688                        .chain(rings.holes.iter().flatten())
689                        .map(|p| {
690                            section
691                                .placement
692                                .transform_point3(Point3::new(p.x, p.y, 0.0))
693                                + *origin
694                        })
695                        .collect();
696                    placed.push((rings, pts));
697                }
698                axiolid_construct::sweep::sectioned_spine(&placed)
699            }
700            SolidOperation::BoundedHalfSpace {
701                half_space,
702                boundary,
703                placement,
704            } => {
705                let node = self.node(graph, *half_space)?;
706                let GeometryNode::HalfSpace(hs) = node else {
707                    return Err(GeomError::InvalidInput(format!(
708                        "half-space {half_space:?} is not a HalfSpace node"
709                    )));
710                };
711                let rings = self.boundary_rings(graph, *boundary)?;
712                // The declared margin is the contract's own knob for how far
713                // an unbounded half-space extends before it can be meshed.
714                let margin = axiolid_primitive::ClipMargin::new(2.0)
715                    .expect("2.0 is a valid positive clip margin");
716                // `placement` is the boundary's OWN frame, independent of the
717                // clip plane: the profile is authored in it, so it has to
718                // orient the profile before the slab is built. Applying it
719                // only to the finished mesh cannot express an in-plane
720                // rotation, because by then the boundary has already been
721                // framed against the plane normal.
722                let frame = PlaneFrame::new(
723                    placement.translation,
724                    placement.matrix3.x_axis.normalize_or_zero(),
725                    placement.matrix3.y_axis.normalize_or_zero(),
726                    options.tolerance(),
727                )
728                .map_err(|error| {
729                    GeomError::InvalidInput(format!(
730                        "bounded half-space placement is not a usable boundary frame: {error}"
731                    ))
732                })?;
733                let mesh = axiolid_construct::half_space::bounded_half_space_in_frame(
734                    &rings,
735                    hs.boundary,
736                    frame,
737                    hs.agreement,
738                    margin,
739                    options.tolerance(),
740                )?;
741                Ok(mesh)
742            }
743            // Routed to `build_boolean` by `build`; unreachable here.
744            SolidOperation::Boolean { .. } => Err(GeomError::InvalidInput(
745                "boolean must be built through build_boolean".into(),
746            )),
747            // Naming the capability lets a caller register a provider for it
748            // rather than guess. `Unsupported` names only the operation, which
749            // collapses revolution, swept disk, fixed-reference sweep and the
750            // rest into one indistinguishable answer -- a caller learns that
751            // "a sweep" failed but not WHICH provider is missing, so it cannot
752            // act. `UnsupportedInput` carries the family, matching what the
753            // exact path already reports.
754            other => Err(GeomError::UnsupportedInput {
755                backend: self.descriptor().id,
756                operation: Operation::Sweep,
757                input: crate::exact::solid_operation_family(other),
758            }),
759        }
760    }
761}
762
763/// Convert a world-space tolerance into conservative instance-local space.
764/// Convert world-space budgets into conservative instance-local space.
765///
766/// The tolerance and the chord budget are both lengths, so both shrink by
767/// the transform's largest stretch: a chord within `c` of the curve in local
768/// space is within `stretch * c` of it in world space.
769fn instance_local_budget(transform: Transform3, budget: Budget) -> GeomResult<Budget> {
770    let (tolerance, stretch) = instance_local_tolerance(transform, budget.tolerance)?;
771    Ok(Budget {
772        tolerance,
773        chord: budget.chord / stretch,
774    })
775}
776
777fn instance_local_tolerance(
778    transform: Transform3,
779    tolerance: Tolerance,
780) -> GeomResult<(Tolerance, Scalar)> {
781    let m = transform.matrix3;
782    let sx = m.x_axis.length();
783    let sy = m.y_axis.length();
784    let sz = m.z_axis.length();
785    let max_scale = sx.max(sy).max(sz);
786    if !max_scale.is_finite() || max_scale == 0.0 {
787        return Err(GeomError::InvalidInput(
788            "instance transform has no finite scale".into(),
789        ));
790    }
791    let eps = 32.0 * f64::EPSILON;
792    let orthogonal = m.x_axis.dot(m.y_axis).abs() <= eps * sx * sy
793        && m.x_axis.dot(m.z_axis).abs() <= eps * sx * sz
794        && m.y_axis.dot(m.z_axis).abs() <= eps * sy * sz;
795    let stretch = if orthogonal {
796        max_scale * (1.0 + 3.0 * eps)
797    } else {
798        (sx * sx + sy * sy + sz * sz).sqrt()
799    };
800    if !stretch.is_finite() {
801        return Err(GeomError::InvalidInput(
802            "instance transform scale overflow".into(),
803        ));
804    }
805    Tolerance::new(tolerance.linear() / stretch, tolerance.angular())
806        .map(|local| (local, stretch))
807        .map_err(|error| GeomError::InvalidInput(error.to_string()))
808}
809
810/// How far a chord may sit from the curve it replaces (#165).
811///
812/// The caller's `ExecutionOptions::with_chord_error`, else the linear
813/// tolerance. Kept separate from the tolerance because the tolerance is a
814/// coincidence test: at `Tolerance::MILLIMETRE` it leaves a 5 mm arc four
815/// chords per half turn, percent-level area error on a slot or a gutter.
816pub(crate) fn chord_error(options: &ExecutionOptions) -> Scalar {
817    options
818        .chord_error()
819        .unwrap_or_else(|| options.tolerance().linear())
820}
821
822/// The tolerance with the chord budget as its linear part, for providers
823/// that take one number for both (the CSG primitive tessellator).
824fn chord_tolerance(options: &ExecutionOptions) -> GeomResult<Tolerance> {
825    Tolerance::new(chord_error(options), options.tolerance().angular())
826        .map_err(|error| GeomError::InvalidInput(error.to_string()))
827}
828
829fn is_subject_bounded_half_space_boolean(
830    graph: &GeometryGraph,
831    operation: &SolidOperation,
832) -> bool {
833    let SolidOperation::Boolean {
834        right, operator, ..
835    } = operation
836    else {
837        return false;
838    };
839    // These operators stay within the finite left-hand subject, so a prism
840    // covering its bounds is an exact finite stand-in for the half-space.
841    // Union and XOR are unbounded and must remain unsupported.
842    matches!(
843        operator,
844        axiolid_core::BooleanOperator::Difference | axiolid_core::BooleanOperator::Intersection
845    ) && matches!(graph.get(*right), Some(GeometryNode::HalfSpace(_)))
846}
847
848/// Which capability a node family would need.
849///
850/// Reporting the real missing capability lets a caller register a provider
851/// that supplies it, instead of guessing from a generic failure.
852fn unsupported_operation(node: &GeometryNode) -> Operation {
853    match node {
854        GeometryNode::Curve2(_) | GeometryNode::Curve3(_) | GeometryNode::OpenProfile(_) => {
855            Operation::CurveEvaluation
856        }
857        GeometryNode::Surface(_) => Operation::SurfaceEvaluation,
858        GeometryNode::Profile(_) => Operation::ProfileTriangulation,
859        GeometryNode::BRep(_) | GeometryNode::PolygonMesh(_) => Operation::Tessellation,
860        _ => Operation::GraphCompilation,
861    }
862}
863
864impl<B: MeshBoolean> MeshCompiler for ReferenceMeshCompiler<B> {
865    /// Bounded by the peak mesh size, which is data-dependent, so the honest
866    /// answer is unbounded rather than an invented constant.
867    fn scratch_requirement(&self) -> ScratchRequirement {
868        ScratchRequirement::Unbounded
869    }
870
871    fn compile_mesh(
872        &self,
873        graph: &GeometryGraph,
874        root: NodeId,
875        options: &ExecutionOptions,
876    ) -> GeomResult<TriMesh> {
877        self.admit_budget(options)?;
878        let mut cache = Cache::new();
879        self.evaluate(graph, root, options, &mut cache)
880            .map(|built| built.mesh)
881    }
882
883    /// Every channel on the result, and every channel an input had that the
884    /// result lost, is reported with its fate (#115).
885    fn compile_mesh_reported(
886        &self,
887        graph: &GeometryGraph,
888        root: NodeId,
889        options: &ExecutionOptions,
890    ) -> GeomResult<CompileOutcome> {
891        self.admit_budget(options)?;
892        let mut cache = Cache::new();
893        let built = self.evaluate(graph, root, options, &mut cache)?;
894        Ok(CompileOutcome::tracked(built.mesh, built.fates.into_vec()).with_closure(built.closure))
895    }
896
897    /// Overriding the `_into` seam gives both call shapes one shared cache,
898    /// so a subtree referenced by several roots is compiled once per batch.
899    fn compile_mesh_batch_into(
900        &self,
901        graph: &GeometryGraph,
902        roots: &[NodeId],
903        options: &ExecutionOptions,
904        destination: &mut Vec<TriMesh>,
905    ) -> GeomResult<()> {
906        self.admit_budget(options)?;
907        destination.reserve(roots.len());
908        let mut cache = Cache::new();
909        for &root in roots {
910            destination.push(self.evaluate(graph, root, options, &mut cache)?.mesh);
911        }
912        Ok(())
913    }
914}
915
916impl<B: MeshBoolean> ReferenceMeshCompiler<B> {
917    /// Refuse a compilation whose memory budget this compiler cannot honour.
918    ///
919    /// Graph compilation caches every intermediate mesh, and peak size is
920    /// data-dependent, so `scratch_requirement` honestly reports
921    /// [`ScratchRequirement::Unbounded`]. By contract an unbounded requirement
922    /// never fits a DECLARED budget -- admitting it anyway would make the
923    /// budget advisory, which is the failure the type exists to prevent.
924    ///
925    /// Before this check the field was simply ignored here: a caller could set
926    /// a budget and watch the compiler allocate past it without a word. A
927    /// caller that sets no budget is unaffected.
928    fn admit_budget(&self, options: &ExecutionOptions) -> GeomResult<()> {
929        if self.scratch_requirement().fits_budget(options, 0) {
930            return Ok(());
931        }
932        Err(GeomError::BudgetExceeded {
933            resource: "graph compilation intermediate meshes",
934        })
935    }
936
937    /// The boolean provider this compiler dispatches to.
938    ///
939    /// Exposed so an application can apply its own source-format set
940    /// operations with the same provider the compiler uses, rather than
941    /// constructing a second one that might differ.
942    pub const fn boolean_provider(&self) -> &B {
943        &self.boolean
944    }
945}
946
947/// A face's outer ring followed by its holes.
948fn face_rings(face: &axiolid_mesh::PolygonFace) -> impl Iterator<Item = &Vec<u32>> {
949    std::iter::once(&face.outer).chain(face.holes.iter())
950}
951
952/// A boolean needs two volumes; a surface model has none (#161).
953///
954/// Refused rather than handed to the provider, which would see a closed
955/// surface model as a valid solid and return a confident, meaningless
956/// result.
957fn refuse_surface_operand(built: &Built, role: &'static str) -> GeomResult<()> {
958    if built.closure == axiolid_mesh_compile_contract::MeshClosure::Solid {
959        return Ok(());
960    }
961    Err(GeomError::InvalidInput(format!(
962        "boolean {role} is a surface model: it encloses no volume to combine"
963    )))
964}
965
966/// An authored mesh, with its closure read from its structure (#161).
967///
968/// A mesh node carries no "this is a solid" declaration the way a B-rep
969/// does, so the geometry is the only evidence: a closed, consistently
970/// wound two-manifold bounds a solid, anything else (an open face set, a
971/// single sheet) is a surface.
972///
973/// Closure is a property of the authored connectivity, so it is read from
974/// the corner indices ([`axiolid_mesh::EdgeAdjacency`]), not from
975/// `audit_mesh`. The audit drops every triangle below an area threshold
976/// before it counts edges, and real exports carry legitimate small faces
977/// (a 1 mm reveal, the end of a thin lining); dropping them opened
978/// otherwise closed shells, so 522 authored-closed face sets on real
979/// models were reported as surfaces. Only a triangle that repeats a
980/// corner index is skipped: it covers no edge pair at all.
981fn authored_mesh(mesh: TriMesh, _options: &ExecutionOptions) -> Built {
982    let closure = if authored_mesh_is_closed(&mesh) {
983        axiolid_mesh_compile_contract::MeshClosure::Solid
984    } else {
985        axiolid_mesh_compile_contract::MeshClosure::Surface
986    };
987    Built::with_closure(mesh, closure)
988}
989
990/// Every edge of the index connectivity is shared by exactly two
991/// triangles traversing it in opposite directions, the mesh has at least
992/// one such triangle, and every position is finite.
993fn authored_mesh_is_closed(mesh: &TriMesh) -> bool {
994    if mesh.indices.is_empty() || mesh.positions.iter().any(|p| !p.is_finite()) {
995        return false;
996    }
997    let adjacency = axiolid_mesh::EdgeAdjacency::build(mesh);
998    adjacency.edge_count() > 0 && adjacency.is_closed_two_manifold()
999}