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//! `AGENTS.md` requires that recursive lowering appends to a single
6//! session-owned builder and that family lowerers return [`NodeId`] instead of
7//! freezing isolated child graphs. 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/// Identity of one lowering result, used to deduplicate shared entities.
88///
89/// The frame is part of the key. Two `IfcMappedItem`s reusing one source under
90/// different transforms are different results, and collapsing them would place
91/// geometry at one location only. Floats are keyed by bit pattern so the key is
92/// totally ordered without imposing a tolerance policy: memoization must be an
93/// exact-identity optimization, never a geometric approximation.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
95struct MemoKey {
96    entity: u64,
97    family: &'static str,
98    basis: [[u64; 3]; 3],
99    origin: [u64; 3],
100}
101
102impl MemoKey {
103    fn new(entity: EntityId, family: &'static str, frame: Transform) -> Self {
104        Self {
105            entity: entity.0,
106            family,
107            basis: frame.basis.map(|axis| axis.map(f64::to_bits)),
108            origin: frame.origin.map(f64::to_bits),
109        }
110    }
111}
112
113/// A single recursive lowering pass over one shared graph builder.
114///
115/// Family lowerers take `&mut LoweringSession` and return [`NodeId`]. Only the
116/// public entry point calls [`LoweringSession::finish`].
117#[derive(Debug)]
118pub struct LoweringSession<'a> {
119    model: &'a Model,
120    units: &'a UnitScale,
121    limits: SessionLimits,
122    builder: GeometryGraphBuilder,
123    nodes: usize,
124    memo: BTreeMap<MemoKey, NodeId>,
125    active: Vec<EntityId>,
126    provenance: ProvenanceMap,
127}
128
129impl<'a> LoweringSession<'a> {
130    /// Open a session with the default recursion budget.
131    pub fn new(model: &'a Model, units: &'a UnitScale) -> Self {
132        Self::with_limits(model, units, SessionLimits::default())
133    }
134
135    /// Open a session with an explicit recursion budget.
136    pub fn with_limits(model: &'a Model, units: &'a UnitScale, limits: SessionLimits) -> Self {
137        Self {
138            model,
139            units,
140            limits,
141            builder: GeometryGraphBuilder::new(),
142            nodes: 0,
143            memo: BTreeMap::new(),
144            active: Vec::new(),
145            provenance: ProvenanceMap::default(),
146        }
147    }
148
149    /// The model being lowered.
150    pub fn model(&self) -> &'a Model {
151        self.model
152    }
153
154    /// The resolved unit scale for this model.
155    pub fn units(&self) -> &'a UnitScale {
156        self.units
157    }
158
159    /// Number of nodes appended so far.
160    ///
161    /// Exposed so tests can assert that a memoized hit appends nothing.
162    pub fn node_count(&self) -> usize {
163        self.nodes
164    }
165
166    /// Append one node, attributing any graph fault to the current entity.
167    pub fn node(&mut self, node: GeometryNode) -> GeometryResult<NodeId> {
168        let source = self.active.last().copied();
169        let id = self
170            .builder
171            .push(node)
172            .map_err(|error| graph_error(source.unwrap_or(EntityId(0)), error))?;
173        self.nodes += 1;
174        if let Some(source) = source {
175            self.provenance.record(id, source);
176        }
177        Ok(id)
178    }
179
180    /// Append one node, attributing any graph fault to `entity`.
181    pub fn node_for(&mut self, entity: EntityId, node: GeometryNode) -> GeometryResult<NodeId> {
182        let id = self
183            .builder
184            .push(node)
185            .map_err(|error| graph_error(entity, error))?;
186        self.nodes += 1;
187        self.provenance.record(id, entity);
188        Ok(id)
189    }
190
191    /// Source attribution accumulated so far.
192    pub fn provenance(&self) -> &ProvenanceMap {
193        &self.provenance
194    }
195
196    /// Resolve an entity or report the dangling reference against `referrer`.
197    pub fn entity(
198        &self,
199        referrer: EntityId,
200        id: EntityId,
201    ) -> GeometryResult<&'a ifc_model::Entity> {
202        self.model.get(id).ok_or(GeometryError::MissingEntity {
203            referrer,
204            missing: id,
205        })
206    }
207
208    /// Look up a previously lowered result for `entity` under `frame`.
209    pub fn memoized(
210        &self,
211        entity: EntityId,
212        family: &'static str,
213        frame: Transform,
214    ) -> Option<NodeId> {
215        self.memo.get(&MemoKey::new(entity, family, frame)).copied()
216    }
217
218    /// Record the lowered result for `entity` under `frame`.
219    pub fn memoize(
220        &mut self,
221        entity: EntityId,
222        family: &'static str,
223        frame: Transform,
224        node: NodeId,
225    ) {
226        self.memo.insert(MemoKey::new(entity, family, frame), node);
227    }
228
229    /// Mark `entity` as active in the current chain.
230    ///
231    /// Returns [`GeometryError::CyclicChain`] if the entity is already active
232    /// and [`GeometryError::ChainTooDeep`] once the depth budget is exhausted.
233    /// Every successful call must be paired with [`LoweringSession::exit`].
234    pub fn enter(&mut self, entity: EntityId, kind: &'static str) -> GeometryResult<()> {
235        if self.active.contains(&entity) {
236            return Err(GeometryError::CyclicChain { entity, kind });
237        }
238        if self.active.len() >= self.limits.max_depth {
239            return Err(GeometryError::ChainTooDeep {
240                entity,
241                kind,
242                limit: self.limits.max_depth,
243            });
244        }
245        self.active.push(entity);
246        Ok(())
247    }
248
249    /// Release `entity` from the active chain.
250    ///
251    /// Sharing is not recursion: once a subtree is complete the entity must be
252    /// reachable again from a sibling branch.
253    pub fn exit(&mut self, entity: EntityId) {
254        if self.active.last() == Some(&entity) {
255            self.active.pop();
256            return;
257        }
258        debug_assert!(false, "lowering scopes must exit in LIFO order");
259        if let Some(index) = self.active.iter().rposition(|&active| active == entity) {
260            self.active.remove(index);
261        }
262    }
263
264    /// Borrowed attribute view for `entity`.
265    pub fn slots(&self, entity: EntityId) -> GeometryResult<Slots<'a>> {
266        let resolved = self.entity(entity, entity)?;
267        Ok(Slots::new(entity, resolved))
268    }
269
270    /// Upper-cased IFC type name for `entity`.
271    ///
272    /// Dispatch compares against canonical upper-case names because STEP files
273    /// are case-insensitive in practice and exporters disagree.
274    pub fn type_name(&self, entity: EntityId) -> GeometryResult<String> {
275        Ok(self.entity(entity, entity)?.type_name.to_ascii_uppercase())
276    }
277
278    /// Build a typed `Unsupported` error naming the offending entity.
279    pub fn unsupported(
280        &self,
281        entity: EntityId,
282        type_name: &str,
283        detail: &'static str,
284    ) -> GeometryError {
285        GeometryError::Unsupported {
286            entity,
287            type_name: type_name.to_string(),
288            detail,
289        }
290    }
291
292    /// Build a typed `Degenerate` error naming the offending entity.
293    ///
294    /// Structurally impossible geometry is distinct from an unimplemented
295    /// family: the file is understood and the shape does not exist.
296    pub fn degenerate(
297        &self,
298        entity: EntityId,
299        type_name: &str,
300        detail: impl Into<String>,
301    ) -> GeometryError {
302        GeometryError::Degenerate {
303            entity,
304            type_name: type_name.to_string(),
305            detail: detail.into(),
306        }
307    }
308
309    /// Check a file-declared aggregate size against the element budget.
310    ///
311    /// Takes `u128` so an overflowing product (`triangles * 3`) can be
312    /// computed in a wider type and reported honestly instead of wrapping to a
313    /// small number that passes the check. Returns the size as `usize` only
314    /// once it is known to fit.
315    ///
316    /// Call this *before* reserving, not after: the point is to refuse the
317    /// declaration, not to survive the allocation.
318    pub fn check_aggregate(
319        &self,
320        entity: EntityId,
321        type_name: &str,
322        what: &'static str,
323        requested: u128,
324    ) -> GeometryResult<usize> {
325        let limit = self.limits.max_aggregate_elements;
326        if requested > limit as u128 {
327            return Err(GeometryError::AggregateTooLarge {
328                entity,
329                type_name: type_name.to_string(),
330                what,
331                requested,
332                limit,
333            });
334        }
335        Ok(requested as usize)
336    }
337
338    /// Lower a nested operand through the total dispatcher.
339    ///
340    /// Kept on the session so recursive families do not each re-import the
341    /// dispatcher and risk diverging on cycle/limit handling.
342    pub fn lower_operand(&mut self, entity: EntityId, frame: Transform) -> GeometryResult<NodeId> {
343        crate::lower::dispatch::lower_representation_item(self, entity, frame)
344    }
345
346    /// Freeze the graph with `root` as its single output root.
347    pub fn finish(self, root: NodeId) -> GeometryResult<LoweredGeometry> {
348        let entity = self.current_entity();
349        let graph = self
350            .builder
351            .finish(vec![root])
352            .map_err(|error| graph_error(entity, error))?;
353        Ok(LoweredGeometry {
354            graph,
355            root,
356            provenance: self.provenance,
357        })
358    }
359
360    /// Best-effort attribution target for graph faults raised outside a family.
361    fn current_entity(&self) -> EntityId {
362        self.active.last().copied().unwrap_or(EntityId(0))
363    }
364}
365
366/// Translate a graph construction fault into a located IFC error.
367///
368/// A bare [`GraphError`] names a `NodeId`, which is meaningless when debugging
369/// a 500k-entity file; the IFC entity is the addressable unit.
370pub(crate) fn graph_error(entity: EntityId, error: GraphError) -> GeometryError {
371    GeometryError::Degenerate {
372        entity,
373        type_name: "geometry graph".to_string(),
374        detail: error.to_string(),
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn the_memo_key_separates_entity_family_and_frame() {
384        let frame = Transform::identity();
385        let mut moved = Transform::identity();
386        moved.origin = [1.0, 0.0, 0.0];
387
388        let base = MemoKey::new(EntityId(1), "solid", frame);
389        assert_eq!(base, MemoKey::new(EntityId(1), "solid", frame));
390        assert_ne!(base, MemoKey::new(EntityId(2), "solid", frame));
391        assert_ne!(base, MemoKey::new(EntityId(1), "profile", frame));
392        assert_ne!(base, MemoKey::new(EntityId(1), "solid", moved));
393    }
394
395    #[test]
396    fn signed_zero_does_not_alias_positive_zero_in_the_key() {
397        // -0.0 == 0.0 numerically but has a distinct bit pattern. Keying on
398        // bits keeps memoization an exact-identity optimization.
399        let mut negative = Transform::identity();
400        negative.origin = [-0.0, 0.0, 0.0];
401        assert_ne!(
402            MemoKey::new(EntityId(1), "solid", Transform::identity()),
403            MemoKey::new(EntityId(1), "solid", negative)
404        );
405    }
406
407    #[test]
408    fn the_default_aggregate_budget_is_documented() {
409        assert_eq!(
410            SessionLimits::default().max_aggregate_elements,
411            SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS
412        );
413        assert_eq!(SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS, 16_777_216);
414    }
415
416    #[test]
417    fn the_default_depth_budget_is_documented() {
418        assert_eq!(
419            SessionLimits::default().max_depth,
420            SessionLimits::DEFAULT_MAX_DEPTH
421        );
422    }
423}