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 /// What to do with a collapsed poly-loop face (#46).
172 face_policy: DegenerateFacePolicy,
173}
174
175/// The structural kind of a node, as net lowering needs it.
176#[derive(Debug, Clone, PartialEq)]
177pub(crate) enum NodeShape {
178 /// Merges its members; not itself a boolean operand.
179 Collection(Vec<NodeId>),
180 /// Reuses `source` under `transform`.
181 Instance {
182 /// The reused node.
183 source: NodeId,
184 /// Local-to-parent transform.
185 transform: axiolid_core::Transform3,
186 },
187 /// A family the graph validator admits as a boolean operand.
188 Solid,
189 /// Anything else: curves, surfaces, profiles, points.
190 Other,
191}
192
193impl NodeShape {
194 fn of(node: &GeometryNode) -> Self {
195 match node {
196 GeometryNode::Collection(members) => Self::Collection(members.clone()),
197 GeometryNode::Instance(instance) => Self::Instance {
198 source: instance.source,
199 transform: instance.transform,
200 },
201 // Mirrors axiolid-model's `ExpectedReference::Solid`.
202 GeometryNode::Primitive(_)
203 | GeometryNode::HalfSpace(_)
204 | GeometryNode::SolidOperation(_)
205 | GeometryNode::BRep(_)
206 | GeometryNode::PolygonMesh(_)
207 | GeometryNode::TriMesh(_) => Self::Solid,
208 _ => Self::Other,
209 }
210 }
211}
212
213impl<'a> LoweringSession<'a> {
214 /// Open a session with the default recursion budget.
215 pub fn new(model: &'a Model, units: &'a UnitScale) -> Self {
216 Self::with_limits(model, units, SessionLimits::default())
217 }
218
219 /// Open a session with an explicit recursion budget.
220 pub fn with_limits(model: &'a Model, units: &'a UnitScale, limits: SessionLimits) -> Self {
221 Self {
222 model,
223 units,
224 limits,
225 builder: GeometryGraphBuilder::new(),
226 nodes: 0,
227 memo: BTreeMap::new(),
228 active: Vec::new(),
229 provenance: ProvenanceMap::default(),
230 texture_maps: None,
231 shapes: BTreeMap::new(),
232 face_policy: DegenerateFacePolicy::default(),
233 }
234 }
235
236 /// Set what lowering does with a collapsed poly-loop face (#46).
237 ///
238 /// Builder-style so the default constructors stay unchanged:
239 /// `LoweringSession::new(model, units).with_face_policy(policy)`.
240 #[must_use]
241 pub fn with_face_policy(mut self, policy: DegenerateFacePolicy) -> Self {
242 self.face_policy = policy;
243 self
244 }
245
246 /// The degenerate-face policy in force.
247 pub fn face_policy(&self) -> DegenerateFacePolicy {
248 self.face_policy
249 }
250
251 /// Record a face left out under [`DegenerateFacePolicy::DropAndReport`].
252 pub(crate) fn report_dropped_face(&mut self, face: EntityId) {
253 self.provenance.record_dropped_face(face);
254 }
255
256 /// The `IfcIndexedTriangleTextureMap`s whose `MappedTo` is `face_set`, in
257 /// file order. Empty for an untextured face set.
258 pub(crate) fn triangle_texture_maps(&mut self, face_set: EntityId) -> &[EntityId] {
259 let model = self.model;
260 let index = self.texture_maps.get_or_insert_with(|| {
261 let mut index: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
262 // `ids_of_type` yields file order, so each list stays in it.
263 for &map in model.ids_of_type("IFCINDEXEDTRIANGLETEXTUREMAP") {
264 // `MappedTo` is slot 1 in IFC4 and IFC4X3 alike.
265 let target = model
266 .get(map)
267 .and_then(|entity| entity.attributes.get(1))
268 .and_then(ifc_model::Value::as_ref_id);
269 if let Some(target) = target {
270 index.entry(target).or_default().push(map);
271 }
272 }
273 index
274 });
275 index.get(&face_set).map_or(&[], Vec::as_slice)
276 }
277
278 /// The model being lowered.
279 pub fn model(&self) -> &'a Model {
280 self.model
281 }
282
283 /// The resolved unit scale for this model.
284 pub fn units(&self) -> &'a UnitScale {
285 self.units
286 }
287
288 /// Number of nodes appended so far.
289 ///
290 /// Exposed so tests can assert that a memoized hit appends nothing.
291 pub fn node_count(&self) -> usize {
292 self.nodes
293 }
294
295 /// Append one node, attributing any graph fault to the current entity.
296 pub fn node(&mut self, node: GeometryNode) -> GeometryResult<NodeId> {
297 let source = self.active.last().copied();
298 let shape = NodeShape::of(&node);
299 let id = self
300 .builder
301 .push(node)
302 .map_err(|error| graph_error(source.unwrap_or(EntityId(0)), error))?;
303 self.nodes += 1;
304 self.shapes.insert(id, shape);
305 if let Some(source) = source {
306 self.provenance.record(id, source);
307 }
308 Ok(id)
309 }
310
311 /// Append one node, attributing any graph fault to `entity`.
312 pub fn node_for(&mut self, entity: EntityId, node: GeometryNode) -> GeometryResult<NodeId> {
313 let shape = NodeShape::of(&node);
314 let id = self
315 .builder
316 .push(node)
317 .map_err(|error| graph_error(entity, error))?;
318 self.nodes += 1;
319 self.shapes.insert(id, shape);
320 self.provenance.record(id, entity);
321 Ok(id)
322 }
323
324 /// The structural kind of an appended node; `None` for a foreign id.
325 pub(crate) fn shape(&self, node: NodeId) -> Option<&NodeShape> {
326 self.shapes.get(&node)
327 }
328
329 /// Source attribution accumulated so far.
330 pub fn provenance(&self) -> &ProvenanceMap {
331 &self.provenance
332 }
333
334 /// Resolve an entity or report the dangling reference against `referrer`.
335 pub fn entity(
336 &self,
337 referrer: EntityId,
338 id: EntityId,
339 ) -> GeometryResult<&'a ifc_model::Entity> {
340 self.model.get(id).ok_or(GeometryError::MissingEntity {
341 referrer,
342 missing: id,
343 })
344 }
345
346 /// Look up a previously lowered result for `entity` under `frame`.
347 pub fn memoized(
348 &self,
349 entity: EntityId,
350 family: &'static str,
351 frame: Transform,
352 ) -> Option<NodeId> {
353 self.memo.get(&MemoKey::new(entity, family, frame)).copied()
354 }
355
356 /// Record the lowered result for `entity` under `frame`.
357 pub fn memoize(
358 &mut self,
359 entity: EntityId,
360 family: &'static str,
361 frame: Transform,
362 node: NodeId,
363 ) {
364 self.memo.insert(MemoKey::new(entity, family, frame), node);
365 }
366
367 /// Mark `entity` as active in the current chain.
368 ///
369 /// Returns [`GeometryError::CyclicChain`] if the entity is already active
370 /// and [`GeometryError::ChainTooDeep`] once the depth budget is exhausted.
371 /// Every successful call must be paired with [`LoweringSession::exit`].
372 pub fn enter(&mut self, entity: EntityId, kind: &'static str) -> GeometryResult<()> {
373 if self.active.contains(&entity) {
374 return Err(GeometryError::CyclicChain { entity, kind });
375 }
376 if self.active.len() >= self.limits.max_depth {
377 return Err(GeometryError::ChainTooDeep {
378 entity,
379 kind,
380 limit: self.limits.max_depth,
381 });
382 }
383 self.active.push(entity);
384 Ok(())
385 }
386
387 /// Release `entity` from the active chain.
388 ///
389 /// Sharing is not recursion: once a subtree is complete the entity must be
390 /// reachable again from a sibling branch.
391 pub fn exit(&mut self, entity: EntityId) {
392 if self.active.last() == Some(&entity) {
393 self.active.pop();
394 return;
395 }
396 debug_assert!(false, "lowering scopes must exit in LIFO order");
397 if let Some(index) = self.active.iter().rposition(|&active| active == entity) {
398 self.active.remove(index);
399 }
400 }
401
402 /// Borrowed attribute view for `entity`.
403 pub fn slots(&self, entity: EntityId) -> GeometryResult<Slots<'a>> {
404 let resolved = self.entity(entity, entity)?;
405 Ok(Slots::new(entity, resolved))
406 }
407
408 /// Upper-cased IFC type name for `entity`.
409 ///
410 /// Dispatch compares against canonical upper-case names because STEP files
411 /// are case-insensitive in practice and exporters disagree.
412 pub fn type_name(&self, entity: EntityId) -> GeometryResult<String> {
413 Ok(self.entity(entity, entity)?.type_name.to_ascii_uppercase())
414 }
415
416 /// Build a typed `Unsupported` error naming the offending entity.
417 pub fn unsupported(
418 &self,
419 entity: EntityId,
420 type_name: &str,
421 detail: &'static str,
422 ) -> GeometryError {
423 GeometryError::Unsupported {
424 entity,
425 type_name: type_name.to_string(),
426 detail,
427 }
428 }
429
430 /// Build a typed `Degenerate` error naming the offending entity.
431 ///
432 /// Structurally impossible geometry is distinct from an unimplemented
433 /// family: the file is understood and the shape does not exist.
434 pub fn degenerate(
435 &self,
436 entity: EntityId,
437 type_name: &str,
438 detail: impl Into<String>,
439 ) -> GeometryError {
440 GeometryError::Degenerate {
441 entity,
442 type_name: type_name.to_string(),
443 detail: detail.into(),
444 }
445 }
446
447 /// Check a file-declared aggregate size against the element budget.
448 ///
449 /// Takes `u128` so an overflowing product (`triangles * 3`) can be
450 /// computed in a wider type and reported honestly instead of wrapping to a
451 /// small number that passes the check. Returns the size as `usize` only
452 /// once it is known to fit.
453 ///
454 /// Call this *before* reserving, not after: the point is to refuse the
455 /// declaration, not to survive the allocation.
456 pub fn check_aggregate(
457 &self,
458 entity: EntityId,
459 type_name: &str,
460 what: &'static str,
461 requested: u128,
462 ) -> GeometryResult<usize> {
463 let limit = self.limits.max_aggregate_elements;
464 if requested > limit as u128 {
465 return Err(GeometryError::AggregateTooLarge {
466 entity,
467 type_name: type_name.to_string(),
468 what,
469 requested,
470 limit,
471 });
472 }
473 Ok(requested as usize)
474 }
475
476 /// Lower a nested operand through the total dispatcher.
477 ///
478 /// Kept on the session so recursive families do not each re-import the
479 /// dispatcher and risk diverging on cycle/limit handling.
480 pub fn lower_operand(&mut self, entity: EntityId, frame: Transform) -> GeometryResult<NodeId> {
481 crate::lower::dispatch::lower_representation_item(self, entity, frame)
482 }
483
484 /// Freeze the graph with `root` as its single output root.
485 pub fn finish(self, root: NodeId) -> GeometryResult<LoweredGeometry> {
486 let entity = self.current_entity();
487 let graph = self
488 .builder
489 .finish(vec![root])
490 .map_err(|error| graph_error(entity, error))?;
491 Ok(LoweredGeometry {
492 graph,
493 root,
494 provenance: self.provenance,
495 })
496 }
497
498 /// Best-effort attribution target for graph faults raised outside a family.
499 fn current_entity(&self) -> EntityId {
500 self.active.last().copied().unwrap_or(EntityId(0))
501 }
502}
503
504/// Translate a graph construction fault into a located IFC error.
505///
506/// A bare [`GraphError`] names a `NodeId`, which is meaningless when debugging
507/// a 500k-entity file; the IFC entity is the addressable unit.
508pub(crate) fn graph_error(entity: EntityId, error: GraphError) -> GeometryError {
509 GeometryError::Degenerate {
510 entity,
511 type_name: "geometry graph".to_string(),
512 detail: error.to_string(),
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn the_memo_key_separates_entity_family_and_frame() {
522 let frame = Transform::identity();
523 let mut moved = Transform::identity();
524 moved.origin = [1.0, 0.0, 0.0];
525
526 let base = MemoKey::new(EntityId(1), "solid", frame);
527 assert_eq!(base, MemoKey::new(EntityId(1), "solid", frame));
528 assert_ne!(base, MemoKey::new(EntityId(2), "solid", frame));
529 assert_ne!(base, MemoKey::new(EntityId(1), "profile", frame));
530 assert_ne!(base, MemoKey::new(EntityId(1), "solid", moved));
531 }
532
533 #[test]
534 fn signed_zero_does_not_alias_positive_zero_in_the_key() {
535 // -0.0 == 0.0 numerically but has a distinct bit pattern. Keying on
536 // bits keeps memoization an exact-identity optimization.
537 let mut negative = Transform::identity();
538 negative.origin = [-0.0, 0.0, 0.0];
539 assert_ne!(
540 MemoKey::new(EntityId(1), "solid", Transform::identity()),
541 MemoKey::new(EntityId(1), "solid", negative)
542 );
543 }
544
545 #[test]
546 fn the_default_aggregate_budget_is_documented() {
547 assert_eq!(
548 SessionLimits::default().max_aggregate_elements,
549 SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS
550 );
551 assert_eq!(SessionLimits::DEFAULT_MAX_AGGREGATE_ELEMENTS, 16_777_216);
552 }
553
554 #[test]
555 fn the_default_depth_budget_is_documented() {
556 assert_eq!(
557 SessionLimits::default().max_depth,
558 SessionLimits::DEFAULT_MAX_DEPTH
559 );
560 }
561}