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 -> ExactBRep` memo table; a discrete value cannot enter that cache.
5//!
6//! It never falls back to mesh compilation. Extrusions, revolutions and
7//! booleans of sharp rectangle prisms along +z are compiled exactly; every
8//! other family is refused with `GeomError::UnsupportedInput` naming it.
9
10use std::collections::{HashMap, HashSet};
11
12use axiolid_brep::ExactBRep;
13use axiolid_construct::boolean_exact::{boolean_prisms_exact, Prism};
14use axiolid_construct::extrude::extrude_profile_exact;
15use axiolid_construct::revolve_exact::revolve_profile_exact;
16use axiolid_contracts::{
17    Backend, BackendDescriptor, BackendId, ExecutionOptions, ExecutionTarget, GeomError,
18    GeomResult, Operation,
19};
20use axiolid_exact_compile_contract::ExactCompiler;
21use axiolid_model::{GeometryGraph, GeometryNode, NodeId, SolidOperation};
22
23/// Scalar reference implementation of the exact-compilation capability.
24#[derive(Debug, Clone, Copy, Default)]
25pub struct ReferenceExactCompiler;
26
27impl ReferenceExactCompiler {
28    /// Stable identity of this backend.
29    pub const ID: BackendId = BackendId::new("scalar-exact-compile");
30
31    /// Construct the reference exact compiler.
32    pub const fn new() -> Self {
33        Self
34    }
35}
36
37impl Backend for ReferenceExactCompiler {
38    fn descriptor(&self) -> BackendDescriptor {
39        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
40    }
41}
42
43impl ExactCompiler for ReferenceExactCompiler {
44    fn compile_exact(
45        &self,
46        graph: &GeometryGraph,
47        root: NodeId,
48        options: &ExecutionOptions,
49    ) -> GeomResult<ExactBRep> {
50        ExactCompilation::new(graph, options).compile(root)
51    }
52
53    fn compile_exact_batch_into(
54        &self,
55        graph: &GeometryGraph,
56        roots: &[NodeId],
57        options: &ExecutionOptions,
58        destination: &mut Vec<ExactBRep>,
59    ) -> GeomResult<()> {
60        destination.reserve(roots.len());
61        let mut compilation = ExactCompilation::new(graph, options);
62        for &root in roots {
63            destination.push(compilation.compile(root)?);
64        }
65        Ok(())
66    }
67}
68
69struct ExactCompilation<'a> {
70    graph: &'a GeometryGraph,
71    options: &'a ExecutionOptions,
72    cache: HashMap<NodeId, ExactBRep>,
73    active: HashSet<NodeId>,
74    cache_hits: usize,
75    evaluated_nodes: usize,
76}
77
78impl<'a> ExactCompilation<'a> {
79    fn new(graph: &'a GeometryGraph, options: &'a ExecutionOptions) -> Self {
80        Self {
81            graph,
82            options,
83            cache: HashMap::new(),
84            active: HashSet::new(),
85            cache_hits: 0,
86            evaluated_nodes: 0,
87        }
88    }
89
90    fn compile(&mut self, root: NodeId) -> GeomResult<ExactBRep> {
91        if let Some(cached) = self.cache.get(&root) {
92            self.cache_hits += 1;
93            return Ok(cached.clone());
94        }
95        if !self.active.insert(root) {
96            return Err(GeomError::InvalidInput(format!(
97                "exact compilation cycle reached at node {root:?}"
98            )));
99        }
100
101        self.evaluated_nodes += 1;
102        let result = self.compile_uncached(root);
103        self.active.remove(&root);
104        let exact = result?;
105        self.cache.insert(root, exact.clone());
106        Ok(exact)
107    }
108
109    fn compile_uncached(&self, root: NodeId) -> GeomResult<ExactBRep> {
110        let node = self.graph.get(root).ok_or_else(|| {
111            GeomError::InvalidInput(format!("node {root:?} does not belong to this graph"))
112        })?;
113
114        match node {
115            GeometryNode::SolidOperation(SolidOperation::Extrusion {
116                profile,
117                direction,
118                depth,
119            }) => {
120                let profile_node = self.graph.get(*profile).ok_or_else(|| {
121                    GeomError::InvalidInput(format!(
122                        "extrusion profile {profile:?} does not belong to this graph"
123                    ))
124                })?;
125                let GeometryNode::Profile(profile) = profile_node else {
126                    return Err(unsupported("extrusion profile reference"));
127                };
128                extrude_profile_exact(profile, *direction, *depth, self.options.tolerance())
129                    .map_err(remap_construction_error)
130            }
131            GeometryNode::SolidOperation(SolidOperation::Revolution {
132                profile,
133                axis_origin,
134                axis_direction,
135                angle,
136            }) => {
137                let profile_node = self.graph.get(*profile).ok_or_else(|| {
138                    GeomError::InvalidInput(format!(
139                        "revolution profile {profile:?} does not belong to this graph"
140                    ))
141                })?;
142                let GeometryNode::Profile(profile) = profile_node else {
143                    return Err(unsupported("revolution profile reference"));
144                };
145                revolve_profile_exact(
146                    profile,
147                    *axis_origin,
148                    *axis_direction,
149                    *angle,
150                    self.options.tolerance(),
151                )
152                .map_err(remap_construction_error)
153            }
154            GeometryNode::SolidOperation(SolidOperation::Boolean {
155                left,
156                right,
157                operator,
158            }) => {
159                let subject = self.prism_operand(*left, "boolean subject")?;
160                let tool = self.prism_operand(*right, "boolean tool")?;
161                boolean_prisms_exact(&subject, &tool, *operator, self.options.tolerance())
162                    .map_err(remap_construction_error)
163            }
164            _ => Err(unsupported(exact_input_family(node))),
165        }
166    }
167}
168
169impl ExactCompilation<'_> {
170    /// Recover a prism from a boolean operand.
171    ///
172    /// Only a sharp filled rectangle extruded along +z is a prism this path
173    /// can name. Recovering one from an arbitrary exact B-rep is its own
174    /// inference problem, and guessing wrong would mis-identify the operand,
175    /// so anything else is refused by name.
176    fn prism_operand(&self, id: NodeId, role: &'static str) -> GeomResult<Prism> {
177        let _ = role;
178        let node = self.graph.get(id).ok_or_else(|| {
179            GeomError::InvalidInput(format!("operand {id:?} does not belong to this graph"))
180        })?;
181        let GeometryNode::SolidOperation(SolidOperation::Extrusion {
182            profile,
183            direction,
184            depth,
185        }) = *node
186        else {
187            return Err(unsupported(
188                "exact boolean operand that is not an extrusion",
189            ));
190        };
191        if direction.normalize_or_zero().dot(axiolid_core::Vec3::Z)
192            < 1.0 - self.options.tolerance().linear()
193        {
194            return Err(unsupported(
195                "exact boolean operand with an oblique extrusion",
196            ));
197        }
198        let profile_node = self.graph.get(profile).ok_or_else(|| {
199            GeomError::InvalidInput(format!("profile {profile:?} does not belong to this graph"))
200        })?;
201        let GeometryNode::Profile(axiolid_profile::Profile::Rectangle(rectangle)) = profile_node
202        else {
203            return Err(unsupported(
204                "exact boolean operand with a non-rectangle profile",
205            ));
206        };
207        if rectangle.thickness.is_some()
208            || rectangle.outer_radius.is_some()
209            || rectangle.inner_radius.is_some()
210        {
211            return Err(unsupported(
212                "exact boolean operand with a non-sharp profile",
213            ));
214        }
215        let (hx, hy) = (rectangle.x / 2.0, rectangle.y / 2.0);
216        Ok(Prism {
217            rings: vec![vec![
218                axiolid_core::Point2::new(-hx, -hy),
219                axiolid_core::Point2::new(hx, -hy),
220                axiolid_core::Point2::new(hx, hy),
221                axiolid_core::Point2::new(-hx, hy),
222            ]],
223            bottom: 0.0,
224            top: depth,
225        })
226    }
227}
228
229fn remap_construction_error(error: GeomError) -> GeomError {
230    match error {
231        GeomError::UnsupportedInput { input, .. } => unsupported(input),
232        error => error,
233    }
234}
235
236fn unsupported(input: &'static str) -> GeomError {
237    GeomError::UnsupportedInput {
238        backend: ReferenceExactCompiler::ID,
239        operation: Operation::GraphCompilation,
240        input,
241    }
242}
243
244fn exact_input_family(node: &GeometryNode) -> &'static str {
245    match node {
246        GeometryNode::Point2(_) => "2D point",
247        GeometryNode::Point3(_) => "3D point",
248        GeometryNode::Vector2(_) => "2D vector",
249        GeometryNode::Vector3(_) => "3D vector",
250        GeometryNode::Frame2(_) => "2D frame",
251        GeometryNode::Frame3(_) => "3D frame",
252        GeometryNode::Transform(_) => "transform",
253        GeometryNode::PointList2(_) => "2D point list",
254        GeometryNode::PointList3(_) => "3D point list",
255        GeometryNode::Primitive(_) => "primitive",
256        GeometryNode::Curve2(_) => "2D curve",
257        GeometryNode::Curve3(_) => "3D curve",
258        GeometryNode::CurveRelation(_) => "curve relation",
259        GeometryNode::PointOnCurve(_) => "point on curve",
260        GeometryNode::Surface(_) => "surface",
261        GeometryNode::SurfaceRelation(_) => "surface relation",
262        GeometryNode::PointOnSurface(_) => "point on surface",
263        GeometryNode::Profile(_) => "profile",
264        GeometryNode::OpenProfile(_) => "open profile",
265        GeometryNode::HalfSpace(_) => "half-space",
266        GeometryNode::BRep(_) => "B-rep",
267        GeometryNode::PolygonMesh(_) => "polygon mesh",
268        GeometryNode::TriMesh(_) => "triangle mesh",
269        GeometryNode::BoundingBox(_) => "bounding box",
270        GeometryNode::SolidOperation(operation) => solid_operation_family(operation),
271        GeometryNode::Instance(_) => "instance",
272        GeometryNode::Collection(_) => "collection",
273        _ => "unknown geometry node",
274    }
275}
276
277pub(crate) fn solid_operation_family(operation: &SolidOperation) -> &'static str {
278    match operation {
279        SolidOperation::Extrusion { .. } => "extrusion",
280        SolidOperation::TaperedExtrusion { .. } => "tapered extrusion",
281        SolidOperation::Revolution { .. } => "revolution",
282        SolidOperation::TaperedRevolution { .. } => "tapered revolution",
283        SolidOperation::SweptDisk { .. } => "swept disk",
284        SolidOperation::FixedReferenceSweep { .. } => "fixed-reference sweep",
285        SolidOperation::SurfaceCurveSweep { .. } => "surface-curve sweep",
286        SolidOperation::SectionedSpine { .. } => "sectioned spine",
287        SolidOperation::Boolean { .. } => "boolean",
288        SolidOperation::BoundedHalfSpace { .. } => "bounded half-space",
289        _ => "unknown solid operation",
290    }
291}
292
293#[cfg(test)]
294mod family_name_tests {
295    use super::{solid_operation_family, SOLID_FAMILY_NAMES};
296    use axiolid_core::Vec3;
297    use axiolid_model::{GeometryGraphBuilder, GeometryNode, SolidOperation};
298    use axiolid_profile::{Profile, RectangleProfile};
299
300    /// The exported table matches what the naming function actually returns.
301    ///
302    /// The table is hand-written, so it can drift from the match arm it
303    /// mirrors. Walking the real function for a constructed variant turns
304    /// drift into a failing test rather than a stale diagnostic, and the
305    /// count assertion catches a family added without a name.
306    #[test]
307    fn the_exported_table_matches_the_naming_function() {
308        assert_eq!(
309            SOLID_FAMILY_NAMES.len(),
310            10,
311            "the declared family count changed; update the table"
312        );
313        for name in SOLID_FAMILY_NAMES {
314            assert!(!name.is_empty());
315            assert_ne!(*name, "unknown solid operation");
316        }
317
318        // NodeId has no public constructor, so build a real one.
319        let mut builder = GeometryGraphBuilder::new();
320        let profile = builder
321            .push(GeometryNode::Profile(Profile::Rectangle(
322                RectangleProfile {
323                    x: 1.0,
324                    y: 1.0,
325                    thickness: None,
326                    outer_radius: None,
327                    inner_radius: None,
328                },
329            )))
330            .expect("a rectangle profile is a valid node");
331
332        let extrusion = SolidOperation::Extrusion {
333            profile,
334            direction: Vec3::Z,
335            depth: 1.0,
336        };
337        assert!(
338            SOLID_FAMILY_NAMES.contains(&solid_operation_family(&extrusion)),
339            "the function returned a name the table does not carry"
340        );
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use axiolid_core::{Tolerance, Vec3};
347    use axiolid_model::GeometryGraphBuilder;
348    use axiolid_profile::{Profile, RectangleProfile};
349
350    use super::*;
351
352    #[test]
353    fn duplicate_roots_hit_the_exact_result_cache() {
354        let mut builder = GeometryGraphBuilder::new();
355        let profile = builder
356            .push(GeometryNode::Profile(Profile::Rectangle(
357                RectangleProfile {
358                    x: 2.0,
359                    y: 1.0,
360                    thickness: None,
361                    outer_radius: None,
362                    inner_radius: None,
363                },
364            )))
365            .unwrap();
366        let root = builder
367            .push(GeometryNode::SolidOperation(SolidOperation::Extrusion {
368                profile,
369                direction: Vec3::Z,
370                depth: 1.0,
371            }))
372            .unwrap();
373        let graph = builder.finish(vec![root]).unwrap();
374        let options = ExecutionOptions::new(Tolerance::METRE);
375        let mut compilation = ExactCompilation::new(&graph, &options);
376
377        let first = compilation.compile(root).unwrap();
378        let second = compilation.compile(root).unwrap();
379
380        assert_eq!(first, second);
381        assert_eq!(compilation.evaluated_nodes, 1);
382        assert_eq!(compilation.cache_hits, 1);
383        assert_eq!(compilation.cache.len(), 1);
384    }
385}
386
387/// Every diagnostic name `solid_operation_family` can return.
388///
389/// Kept beside that function so the two cannot drift: a family added there
390/// without a name here fails the contract test. Exposed for that test rather
391/// than for callers, who receive the name inside a refusal.
392pub const SOLID_FAMILY_NAMES: &[&str] = &[
393    "extrusion",
394    "tapered extrusion",
395    "revolution",
396    "tapered revolution",
397    "swept disk",
398    "fixed-reference sweep",
399    "surface-curve sweep",
400    "sectioned spine",
401    "boolean",
402    "bounded half-space",
403];