Skip to main content

ifc_geometry/lower/
session.rs

1//! One recursive lowering session over one shared graph builder.
2//!
3//! # Why a session exists
4//!
5//! Recursive lowering appends to a single session-owned builder, and family
6//! lowerers return [`NodeId`] instead of freezing isolated child graphs
7//! (`tests/lower_session.rs` pins this). That is not a style preference: a [`NodeId`]
8//! is owned by the graph that minted it, so handles from two independently
9//! finished graphs are mutually foreign. Every composite IFC family needs two
10//! children in one graph:
11//!
12//! - `IfcBooleanResult` references two operands,
13//! - `IfcMappedItem` reuses one source under many transforms,
14//! - a B-rep face set shares one surface across many faces,
15//! - `IfcCsgSolid` nests operations arbitrarily deep.
16//!
17//! The session also carries the model and resolved unit scale that every
18//! lowerer needs. Approximation policy is deliberately absent: lowering emits
19//! exact neutral geometry, while execution providers own tessellation tolerance.
20//!
21//! # What it guarantees
22//!
23//! - **One graph.** All nodes land in one builder, so any two lowered results
24//!   are composable.
25//! - **Memoization.** A shared IFC entity lowered under the same frame yields
26//!   the same node instead of a duplicate subtree.
27//! - **Bounded recursion.** Cyclic and over-deep chains produce typed errors
28//!   rather than a stack overflow.
29//! - **Located failures.** Graph construction faults are translated into
30//!   [`GeometryError`] values that name the offending IFC entity.
31
32use std::collections::BTreeMap;
33
34use axiolid_model::{GeometryGraphBuilder, GeometryNode, GraphError, NodeId};
35use ifc_model::{EntityId, Model};
36
37use crate::error::{GeometryError, GeometryResult};
38use crate::lower::{LoweredGeometry, ProvenanceMap};
39use crate::slots::Slots;
40use crate::transform::Transform;
41use crate::units::UnitScale;
42
43/// Recursion budget for chained IFC references.
44///
45/// IFC places no normative limit on placement or mapped-item nesting, so a
46/// budget is the only way to terminate on malformed input that is deep rather
47/// than strictly cyclic.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct SessionLimits {
50    /// Maximum simultaneously active entities in one chain.
51    pub max_depth: usize,
52    /// Maximum elements one file-declared aggregate may materialize.
53    pub max_aggregate_elements: usize,
54}
55
56impl SessionLimits {
57    /// Depth budget used when a caller states no preference.
58    ///
59    /// Real exporter output nests placements a few levels deep; 64 is far
60    /// above observed depth while still terminating quickly on bad input.
61    pub const DEFAULT_MAX_DEPTH: usize = 64;
62
63    /// Aggregate-element budget used when a caller states no preference.
64    ///
65    /// Sized against the largest thing a legitimate file plausibly declares:
66    /// a dense triangulated face set of ~5.5M triangles expands to 16M
67    /// indices, and the largest observed knot vectors are four orders of
68    /// magnitude smaller. 16M `f64` is ~128 MiB, which bounds a single
69    /// aggregate to something a workstation survives while still refusing the
70    /// billion-element declarations that motivate this budget.
71    ///
72    /// This is a refusal threshold, never a truncation point: an aggregate
73    /// over the limit produces [`crate::GeometryError::AggregateTooLarge`]
74    /// naming the entity, and no geometry is emitted for it.
75    pub const DEFAULT_MAX_AGGREGATE_ELEMENTS: usize = 16_777_216;
76}
77
78impl Default for SessionLimits {
79    fn default() -> Self {
80        Self {
81            max_depth: Self::DEFAULT_MAX_DEPTH,
82            max_aggregate_elements: Self::DEFAULT_MAX_AGGREGATE_ELEMENTS,
83        }
84    }
85}
86
87/// What lowering does with an `IfcPolyLoop` face bound that collapses (#46).
88///
89/// A poly loop "collapses" when fewer than three of its implied edges join
90/// two different points, e.g. `(A, A, B, B)`: it encloses no area, so it
91/// cannot bound a face. Authoring tools emit such sliver faces routinely.
92///
93/// The test is exactly the one the refusal applies, so
94/// [`DropAndReport`](Self::DropAndReport) drops precisely the faces that
95/// [`Refuse`](Self::Refuse) would have refused over, and nothing else.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum DegenerateFacePolicy {
99    /// Refuse the whole representation item with
100    /// [`GeometryError::Degenerate`] naming
101    /// the loop. The default: a file that says a face exists is not silently
102    /// contradicted.
103    #[default]
104    Refuse,
105    /// Leave the face out and record it in
106    /// [`ProvenanceMap::dropped_faces`](crate::lower::ProvenanceMap::dropped_faces).
107    ///
108    /// Only a face whose OUTER bound (or only bound) collapses is dropped:
109    /// that face covers no area, so omitting it removes no surface. A
110    /// collapsed inner bound of a face that still has a valid outer bound is
111    /// still refused, because dropping the face there would remove real area.
112    ///
113    /// Dropping a face can leave a shell open; the backend's own closure
114    /// check still decides whether the result is a solid.
115    DropAndReport,
116}
117
118/// Identity of one lowering result, used to deduplicate shared entities.
119///
120/// The frame is part of the key. Two `IfcMappedItem`s reusing one source under
121/// different transforms are different results, and collapsing them would place
122/// geometry at one location only. Floats are keyed by bit pattern so the key is
123/// totally ordered without imposing a tolerance policy: memoization must be an
124/// exact-identity optimization, never a geometric approximation.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
126struct MemoKey {
127    entity: u64,
128    family: &'static str,
129    basis: [[u64; 3]; 3],
130    origin: [u64; 3],
131}
132
133impl MemoKey {
134    fn new(entity: EntityId, family: &'static str, frame: Transform) -> Self {
135        Self {
136            entity: entity.0,
137            family,
138            basis: frame.basis.map(|axis| axis.map(f64::to_bits)),
139            origin: frame.origin.map(f64::to_bits),
140        }
141    }
142}
143
144/// A single recursive lowering pass over one shared graph builder.
145///
146/// Family lowerers take `&mut LoweringSession` and return [`NodeId`]. Only the
147/// public entry point calls [`LoweringSession::finish`].
148#[derive(Debug)]
149pub struct LoweringSession<'a> {
150    model: &'a Model,
151    units: &'a UnitScale,
152    limits: SessionLimits,
153    builder: GeometryGraphBuilder,
154    nodes: usize,
155    memo: BTreeMap<MemoKey, NodeId>,
156    active: Vec<EntityId>,
157    provenance: ProvenanceMap,
158    /// `MappedTo` face set -> its `IfcIndexedTriangleTextureMap`s, built on
159    /// first use. Maps point at face sets, not the other way round, so
160    /// finding a face set's map means a scan; doing it once per session
161    /// keeps lowering linear in the file.
162    texture_maps: Option<BTreeMap<EntityId, Vec<EntityId>>>,
163    /// What each appended node is, structurally, for net lowering (#44).
164    ///
165    /// A boolean operand must be a solid, and the graph validator rejects a
166    /// `Collection` (or an `Instance` of one) there. Net lowering therefore
167    /// has to split a multi-item body into its solid parts, which needs to
168    /// see node kinds before the graph is frozen. Recording them at push time
169    /// costs one small entry per node and avoids re-walking the builder.
170    shapes: BTreeMap<NodeId, NodeShape>,
171    /// Every atomic curve appended, kept for station lowering (#307).
172    ///
173    /// A station names a distance along its basis, and IFC gives a station
174    /// on a tangent discontinuity the PREVIOUS segment's tangent while the
175    /// neutral evaluators read the next one. Seeing where the basis's seams
176    /// are, and its stated length, needs the curve's stored data, which the
177    /// append-only builder does not hand back.
178    curves: BTreeMap<NodeId, AtomicCurve>,
179    /// What to do with a collapsed poly-loop face (#46).
180    face_policy: DegenerateFacePolicy,
181}
182
183/// The structural kind of a node, as net lowering needs it.
184#[derive(Debug, Clone, PartialEq)]
185pub(crate) enum NodeShape {
186    /// Merges its members; not itself a boolean operand.
187    Collection(Vec<NodeId>),
188    /// Reuses `source` under `transform`.
189    Instance {
190        /// The reused node.
191        source: NodeId,
192        /// Local-to-parent transform.
193        transform: axiolid_core::Transform3,
194    },
195    /// A family the graph validator admits as a boolean operand.
196    Solid,
197    /// Anything else: curves, surfaces, profiles, points.
198    Other,
199}
200
201/// An appended atomic curve, 2D or 3D, as stored.
202#[derive(Debug, Clone, PartialEq)]
203pub(crate) enum AtomicCurve {
204    /// A `GeometryNode::Curve2`.
205    Two(axiolid_curve::Curve2),
206    /// A `GeometryNode::Curve3`.
207    Three(axiolid_curve::Curve3),
208}
209
210impl AtomicCurve {
211    fn of(node: &GeometryNode) -> Option<Self> {
212        match node {
213            GeometryNode::Curve2(curve) => Some(Self::Two(curve.clone())),
214            GeometryNode::Curve3(curve) => Some(Self::Three(curve.clone())),
215            _ => None,
216        }
217    }
218}
219
220impl NodeShape {
221    fn of(node: &GeometryNode) -> Self {
222        match node {
223            GeometryNode::Collection(members) => Self::Collection(members.clone()),
224            GeometryNode::Instance(instance) => Self::Instance {
225                source: instance.source,
226                transform: instance.transform,
227            },
228            // Mirrors axiolid-model's `ExpectedReference::Solid`.
229            GeometryNode::Primitive(_)
230            | GeometryNode::HalfSpace(_)
231            | GeometryNode::SolidOperation(_)
232            | GeometryNode::BRep(_)
233            | GeometryNode::PolygonMesh(_)
234            | GeometryNode::TriMesh(_) => Self::Solid,
235            _ => Self::Other,
236        }
237    }
238}
239
240impl<'a> LoweringSession<'a> {
241    /// Open a session with the default recursion budget.
242    pub fn new(model: &'a Model, units: &'a UnitScale) -> Self {
243        Self::with_limits(model, units, SessionLimits::default())
244    }
245
246    /// Open a session with an explicit recursion budget.
247    pub fn with_limits(model: &'a Model, units: &'a UnitScale, limits: SessionLimits) -> Self {
248        Self {
249            model,
250            units,
251            limits,
252            builder: GeometryGraphBuilder::new(),
253            nodes: 0,
254            memo: BTreeMap::new(),
255            active: Vec::new(),
256            provenance: ProvenanceMap::default(),
257            texture_maps: None,
258            shapes: BTreeMap::new(),
259            curves: BTreeMap::new(),
260            face_policy: DegenerateFacePolicy::default(),
261        }
262    }
263
264    /// Set what lowering does with a collapsed poly-loop face (#46).
265    ///
266    /// Builder-style so the default constructors stay unchanged:
267    /// `LoweringSession::new(model, units).with_face_policy(policy)`.
268    #[must_use]
269    pub fn with_face_policy(mut self, policy: DegenerateFacePolicy) -> Self {
270        self.face_policy = policy;
271        self
272    }
273
274    /// The degenerate-face policy in force.
275    pub fn face_policy(&self) -> DegenerateFacePolicy {
276        self.face_policy
277    }
278
279    /// Record a face left out under [`DegenerateFacePolicy::DropAndReport`].
280    pub(crate) fn report_dropped_face(&mut self, face: EntityId) {
281        self.provenance.record_dropped_face(face);
282    }
283
284    /// The `IfcIndexedTriangleTextureMap`s whose `MappedTo` is `face_set`, in
285    /// file order. Empty for an untextured face set.
286    pub(crate) fn triangle_texture_maps(&mut self, face_set: EntityId) -> &[EntityId] {
287        let model = self.model;
288        let index = self.texture_maps.get_or_insert_with(|| {
289            let mut index: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
290            // `ids_of_type` yields file order, so each list stays in it.
291            for &map in model.ids_of_type("IFCINDEXEDTRIANGLETEXTUREMAP") {
292                // `MappedTo` is slot 1 in IFC4 and IFC4X3 alike.
293                let target = model
294                    .get(map)
295                    .and_then(|entity| entity.attributes.get(1))
296                    .and_then(ifc_model::Value::as_ref_id);
297                if let Some(target) = target {
298                    index.entry(target).or_default().push(map);
299                }
300            }
301            index
302        });
303        index.get(&face_set).map_or(&[], Vec::as_slice)
304    }
305
306    /// The model being lowered.
307    pub fn model(&self) -> &'a Model {
308        self.model
309    }
310
311    /// The resolved unit scale for this model.
312    pub fn units(&self) -> &'a UnitScale {
313        self.units
314    }
315
316    /// Number of nodes appended so far.
317    ///
318    /// Exposed so tests can assert that a memoized hit appends nothing.
319    pub fn node_count(&self) -> usize {
320        self.nodes
321    }
322
323    /// Append one node, attributing any graph fault to the current entity.
324    pub fn node(&mut self, node: GeometryNode) -> GeometryResult<NodeId> {
325        let source = self.active.last().copied();
326        let shape = NodeShape::of(&node);
327        let curve = AtomicCurve::of(&node);
328        let id = self
329            .builder
330            .push(node)
331            .map_err(|error| graph_error(source.unwrap_or(EntityId(0)), error))?;
332        self.nodes += 1;
333        self.shapes.insert(id, shape);
334        if let Some(curve) = curve {
335            self.curves.insert(id, curve);
336        }
337        if let Some(source) = source {
338            self.provenance.record(id, source);
339        }
340        Ok(id)
341    }
342
343    /// Append one node, attributing any graph fault to `entity`.
344    pub fn node_for(&mut self, entity: EntityId, node: GeometryNode) -> GeometryResult<NodeId> {
345        let shape = NodeShape::of(&node);
346        let curve = AtomicCurve::of(&node);
347        let id = self
348            .builder
349            .push(node)
350            .map_err(|error| graph_error(entity, error))?;
351        self.nodes += 1;
352        self.shapes.insert(id, shape);
353        if let Some(curve) = curve {
354            self.curves.insert(id, curve);
355        }
356        self.provenance.record(id, entity);
357        Ok(id)
358    }
359
360    /// The structural kind of an appended node; `None` for a foreign id.
361    pub(crate) fn shape(&self, node: NodeId) -> Option<&NodeShape> {
362        self.shapes.get(&node)
363    }
364
365    /// The stored data of an appended atomic curve; `None` for any other
366    /// node, a curve relation included.
367    pub(crate) fn atomic_curve(&self, node: NodeId) -> Option<&AtomicCurve> {
368        self.curves.get(&node)
369    }
370
371    /// Source attribution accumulated so far.
372    pub fn provenance(&self) -> &ProvenanceMap {
373        &self.provenance
374    }
375
376    /// Resolve an entity or report the dangling reference against `referrer`.
377    pub fn entity(
378        &self,
379        referrer: EntityId,
380        id: EntityId,
381    ) -> GeometryResult<&'a ifc_model::Entity> {
382        self.model.get(id).ok_or(GeometryError::MissingEntity {
383            referrer,
384            missing: id,
385        })
386    }
387
388    /// Look up a previously lowered result for `entity` under `frame`.
389    pub fn memoized(
390        &self,
391        entity: EntityId,
392        family: &'static str,
393        frame: Transform,
394    ) -> Option<NodeId> {
395        self.memo.get(&MemoKey::new(entity, family, frame)).copied()
396    }
397
398    /// Record the lowered result for `entity` under `frame`.
399    pub fn memoize(
400        &mut self,
401        entity: EntityId,
402        family: &'static str,
403        frame: Transform,
404        node: NodeId,
405    ) {
406        self.memo.insert(MemoKey::new(entity, family, frame), node);
407    }
408
409    /// Mark `entity` as active in the current chain.
410    ///
411    /// Returns [`GeometryError::CyclicChain`] if the entity is already active
412    /// and [`GeometryError::ChainTooDeep`] once the depth budget is exhausted.
413    /// Every successful call must be paired with [`LoweringSession::exit`].
414    pub fn enter(&mut self, entity: EntityId, kind: &'static str) -> GeometryResult<()> {
415        if self.active.contains(&entity) {
416            return Err(GeometryError::CyclicChain { entity, kind });
417        }
418        if self.active.len() >= self.limits.max_depth {
419            return Err(GeometryError::ChainTooDeep {
420                entity,
421                kind,
422                limit: self.limits.max_depth,
423            });
424        }
425        self.active.push(entity);
426        Ok(())
427    }
428
429    /// Release `entity` from the active chain.
430    ///
431    /// Sharing is not recursion: once a subtree is complete the entity must be
432    /// reachable again from a sibling branch.
433    pub fn exit(&mut self, entity: EntityId) {
434        if self.active.last() == Some(&entity) {
435            self.active.pop();
436            return;
437        }
438        debug_assert!(false, "lowering scopes must exit in LIFO order");
439        if let Some(index) = self.active.iter().rposition(|&active| active == entity) {
440            self.active.remove(index);
441        }
442    }
443
444    /// Borrowed attribute view for `entity`.
445    pub fn slots(&self, entity: EntityId) -> GeometryResult<Slots<'a>> {
446        let resolved = self.entity(entity, entity)?;
447        Ok(Slots::new(entity, resolved))
448    }
449
450    /// Upper-cased IFC type name for `entity`.
451    ///
452    /// Dispatch compares against canonical upper-case names because STEP files
453    /// are case-insensitive in practice and exporters disagree.
454    pub fn type_name(&self, entity: EntityId) -> GeometryResult<String> {
455        Ok(self.entity(entity, entity)?.type_name.to_ascii_uppercase())
456    }
457
458    /// Build a typed `Unsupported` error naming the offending entity.
459    pub fn unsupported(
460        &self,
461        entity: EntityId,
462        type_name: &str,
463        detail: &'static str,
464    ) -> GeometryError {
465        GeometryError::Unsupported {
466            entity,
467            type_name: type_name.to_string(),
468            detail,
469        }
470    }
471
472    /// Build a typed `Degenerate` error naming the offending entity.
473    ///
474    /// Structurally impossible geometry is distinct from an unimplemented
475    /// family: the file is understood and the shape does not exist.
476    pub fn degenerate(
477        &self,
478        entity: EntityId,
479        type_name: &str,
480        detail: impl Into<String>,
481    ) -> GeometryError {
482        GeometryError::Degenerate {
483            entity,
484            type_name: type_name.to_string(),
485            detail: detail.into(),
486        }
487    }
488
489    /// Check a file-declared aggregate size against the element budget.
490    ///
491    /// Takes `u128` so an overflowing product (`triangles * 3`) can be
492    /// computed in a wider type and reported honestly instead of wrapping to a
493    /// small number that passes the check. Returns the size as `usize` only
494    /// once it is known to fit.
495    ///
496    /// Call this *before* reserving, not after: the point is to refuse the
497    /// declaration, not to survive the allocation.
498    pub fn check_aggregate(
499        &self,
500        entity: EntityId,
501        type_name: &str,
502        what: &'static str,
503        requested: u128,
504    ) -> GeometryResult<usize> {
505        let limit = self.limits.max_aggregate_elements;
506        if requested > limit as u128 {
507            return Err(GeometryError::AggregateTooLarge {
508                entity,
509                type_name: type_name.to_string(),
510                what,
511                requested,
512                limit,
513            });
514        }
515        Ok(requested as usize)
516    }
517
518    /// Lower a nested operand through the total dispatcher.
519    ///
520    /// Kept on the session so recursive families do not each re-import the
521    /// dispatcher and risk diverging on cycle/limit handling.
522    pub fn lower_operand(&mut self, entity: EntityId, frame: Transform) -> GeometryResult<NodeId> {
523        crate::lower::dispatch::lower_representation_item(self, entity, frame)
524    }
525
526    /// Freeze the graph with `root` as its single output root.
527    pub fn finish(self, root: NodeId) -> GeometryResult<LoweredGeometry> {
528        let entity = self.current_entity();
529        let graph = self
530            .builder
531            .finish(vec![root])
532            .map_err(|error| graph_error(entity, error))?;
533        Ok(LoweredGeometry {
534            graph,
535            root,
536            provenance: self.provenance,
537        })
538    }
539
540    /// Best-effort attribution target for graph faults raised outside a family.
541    fn current_entity(&self) -> EntityId {
542        self.active.last().copied().unwrap_or(EntityId(0))
543    }
544}
545
546/// Translate a graph construction fault into a located IFC error.
547///
548/// A bare [`GraphError`] names a `NodeId`, which is meaningless when debugging
549/// a 500k-entity file; the IFC entity is the addressable unit.
550pub(crate) fn graph_error(entity: EntityId, error: GraphError) -> GeometryError {
551    GeometryError::Degenerate {
552        entity,
553        type_name: "geometry graph".to_string(),
554        detail: error.to_string(),
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn the_memo_key_separates_entity_family_and_frame() {
564        let frame = Transform::identity();
565        let mut moved = Transform::identity();
566        moved.origin = [1.0, 0.0, 0.0];
567
568        let base = MemoKey::new(EntityId(1), "solid", frame);
569        assert_eq!(base, MemoKey::new(EntityId(1), "solid", frame));
570        assert_ne!(base, MemoKey::new(EntityId(2), "solid", frame));
571        assert_ne!(base, MemoKey::new(EntityId(1), "profile", frame));
572        assert_ne!(base, MemoKey::new(EntityId(1), "solid", moved));
573    }
574
575    #[test]
576    fn signed_zero_does_not_alias_positive_zero_in_the_key() {
577        // -0.0 == 0.0 numerically but has a distinct bit pattern. Keying on
578        // bits keeps memoization an exact-identity optimization.
579        let mut negative = Transform::identity();
580        negative.origin = [-0.0, 0.0, 0.0];
581        assert_ne!(
582            MemoKey::new(EntityId(1), "solid", Transform::identity()),
583            MemoKey::new(EntityId(1), "solid", negative)
584        );
585    }
586
587    #[test]
588    fn the_default_aggregate_budget_is_documented() {
589        assert_eq!(
590            SessionLimits::default().max_aggregate_elements,
591            SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS
592        );
593        assert_eq!(SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS, 16_777_216);
594    }
595
596    #[test]
597    fn the_default_depth_budget_is_documented() {
598        assert_eq!(
599            SessionLimits::default().max_depth,
600            SessionLimits::DEFAULT_MAX_DEPTH
601        );
602    }
603}