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}