ifc_geometry/input/context.rs
1//! Representation contexts and the sub-contexts drawings are authored into.
2//!
3//! # Why this matters beyond geometry
4//!
5//! Every `IfcRepresentation` names a context. A 3D viewer can ignore it -- all
6//! the geometry it wants sits in the one `Model` context. Drawing production
7//! cannot: a floor plan is *defined* as the geometry authored into a
8//! sub-context whose `TargetView` is `PLAN_VIEW`. Without reading contexts
9//! there is no way to tell a plan curve from a centreline.
10//!
11//! # The `*` trap
12//!
13//! `IfcGeometricRepresentationSubContext` redeclares six inherited attributes
14//! as DERIVED. Real files write them as `*`:
15//!
16//! ```text
17//! IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$)
18//! ```
19//!
20//! `*` is not `$`. It means "this value lives on my parent", so a sub-context's
21//! precision, coordinate dimension and world coordinate system must be resolved
22//! by walking to `ParentContext`. Reading the slot directly yields the marker,
23//! and a consumer that treats it as absent silently loses the project's
24//! precision and placement.
25
26use ifc_model::{Entity, EntityId, Model, Value};
27
28use crate::constraint::local::PlacementResolver;
29use crate::constraint::placement::{resolve_with, LinearResolution};
30use crate::error::{GeometryError, GeometryResult};
31use crate::resource::placement::axis_placement_transform;
32use crate::slots::Slots;
33use crate::transform::Transform;
34use crate::units::UnitScale;
35
36use super::representation::{
37 representation_slot, select_product_representation, RepresentationPurpose,
38};
39
40/// Absolute slots on `IfcGeometricRepresentationContext`.
41///
42/// Identical in IFC2x3, IFC4 and IFC4x3; asserted in `tests/context_slots.rs`.
43pub mod context_slot {
44 /// OPTIONAL identifier, e.g. `Plan`, `Model`.
45 pub const CONTEXT_IDENTIFIER: usize = 0;
46 /// OPTIONAL type, e.g. `Model`, `Plan`, `NotDefined`.
47 pub const CONTEXT_TYPE: usize = 1;
48 /// 2 or 3; DERIVED on a sub-context.
49 pub const COORDINATE_SPACE_DIMENSION: usize = 2;
50 /// OPTIONAL model precision; DERIVED on a sub-context.
51 pub const PRECISION: usize = 3;
52 /// Placement of the context origin; DERIVED on a sub-context.
53 pub const WORLD_COORDINATE_SYSTEM: usize = 4;
54 /// OPTIONAL true-north direction; DERIVED on a sub-context.
55 pub const TRUE_NORTH: usize = 5;
56}
57
58/// Slots added by `IfcGeometricRepresentationSubContext`.
59///
60/// The subtype inherits all six slots above, so its own attributes start at 6.
61/// Getting this wrong reads `TargetScale` as the target view.
62pub mod sub_context_slot {
63 /// The parent `IfcGeometricRepresentationContext`. Required.
64 pub const PARENT_CONTEXT: usize = 6;
65 /// OPTIONAL scale, e.g. 0.01 for 1:100.
66 pub const TARGET_SCALE: usize = 7;
67 /// OPTIONAL `.PLAN_VIEW.`, `.MODEL_VIEW.`, `.ELEVATION_VIEW.`, ...
68 pub const TARGET_VIEW: usize = 8;
69 /// OPTIONAL free-text view when `TargetView` is `.USERDEFINED.`
70 pub const USER_DEFINED_TARGET_VIEW: usize = 9;
71}
72
73/// The intended presentation of a sub-context.
74///
75/// Drawing production selects on this: a floor plan is the geometry authored
76/// into a `PlanView` sub-context.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum TargetView {
79 /// `.PLAN_VIEW.` — the horizontal section a floor plan is drawn from.
80 PlanView,
81 /// `.MODEL_VIEW.` — the 3D shape a viewer draws.
82 ModelView,
83 /// `.ELEVATION_VIEW.`
84 ElevationView,
85 /// `.SECTION_VIEW.`
86 SectionView,
87 /// `.GRAPH_VIEW.` — schematic lines such as an analytical model.
88 GraphView,
89 /// `.SKETCH_VIEW.`
90 SketchView,
91 /// `.REFLECTED_PLAN_VIEW.` — a ceiling plan.
92 ReflectedPlanView,
93 /// `.USERDEFINED.`, carrying `UserDefinedTargetView` when the author set it.
94 UserDefined(Option<String>),
95 /// `.NOTDEFINED.`
96 NotDefined,
97 /// An enumeration value this crate does not know.
98 ///
99 /// Preserved rather than collapsed into `NotDefined`: a later schema may
100 /// add views, and reporting the literal keeps the file readable.
101 Other(String),
102}
103
104impl TargetView {
105 /// Parse a STEP enumeration constant.
106 fn parse(literal: &str, user_defined: Option<String>) -> Self {
107 match literal.trim_matches('.').to_ascii_uppercase().as_str() {
108 "PLAN_VIEW" => Self::PlanView,
109 "MODEL_VIEW" => Self::ModelView,
110 "ELEVATION_VIEW" => Self::ElevationView,
111 "SECTION_VIEW" => Self::SectionView,
112 "GRAPH_VIEW" => Self::GraphView,
113 "SKETCH_VIEW" => Self::SketchView,
114 "REFLECTED_PLAN_VIEW" => Self::ReflectedPlanView,
115 "USERDEFINED" => Self::UserDefined(user_defined),
116 "NOTDEFINED" => Self::NotDefined,
117 other => Self::Other(other.to_string()),
118 }
119 }
120
121 /// Whether this view is drawn as a 2D plan.
122 ///
123 /// Reflected plans included: a ceiling plan is still a plan, and a caller
124 /// selecting plan geometry wants it.
125 #[must_use]
126 pub fn is_plan(&self) -> bool {
127 matches!(self, Self::PlanView | Self::ReflectedPlanView)
128 }
129}
130
131/// One `IfcGeometricRepresentationContext` or its sub-context.
132#[derive(Debug, Clone, Copy)]
133pub struct RepresentationContext<'m> {
134 slots: Slots<'m>,
135}
136
137impl<'m> RepresentationContext<'m> {
138 /// Wrap an entity assumed to be a representation context.
139 pub fn new(id: EntityId, entity: &'m Entity) -> Self {
140 Self {
141 slots: Slots::new(id, entity),
142 }
143 }
144
145 /// This context's entity id.
146 pub fn id(&self) -> EntityId {
147 self.slots.id()
148 }
149
150 /// Whether this is an `IfcGeometricRepresentationSubContext`.
151 pub fn is_sub_context(&self) -> bool {
152 self.slots
153 .type_name()
154 .eq_ignore_ascii_case("IFCGEOMETRICREPRESENTATIONSUBCONTEXT")
155 }
156
157 /// `ContextIdentifier`, e.g. `Body`, `Axis`, `Plan`.
158 pub fn identifier(&self) -> Option<String> {
159 self.slots.opt_text(context_slot::CONTEXT_IDENTIFIER)
160 }
161
162 /// `ContextType`, e.g. `Model`, `Plan`, `Design`.
163 pub fn context_type(&self) -> Option<String> {
164 self.slots.opt_text(context_slot::CONTEXT_TYPE)
165 }
166
167 /// The parent context of a sub-context, if it declares one.
168 pub fn parent(&self) -> Option<EntityId> {
169 match self.slots.opt(sub_context_slot::PARENT_CONTEXT)? {
170 Value::Ref(id) => Some(*id),
171 _ => None,
172 }
173 }
174
175 /// `TargetScale`, e.g. `0.01` for 1:100. Sub-contexts only.
176 pub fn target_scale(&self) -> Option<f64> {
177 match self
178 .slots
179 .opt(sub_context_slot::TARGET_SCALE)?
180 .unwrap_typed()
181 {
182 Value::Real(value) => Some(*value),
183 Value::Integer(value) => Some(*value as f64),
184 _ => None,
185 }
186 }
187
188 /// `TargetView`. Sub-contexts only; `None` on a root context.
189 pub fn target_view(&self) -> Option<TargetView> {
190 let literal = match self
191 .slots
192 .opt(sub_context_slot::TARGET_VIEW)?
193 .unwrap_typed()
194 {
195 Value::Enum(text) => text.to_string(),
196 _ => return None,
197 };
198 let user_defined = self
199 .slots
200 .opt_text(sub_context_slot::USER_DEFINED_TARGET_VIEW);
201 Some(TargetView::parse(&literal, user_defined))
202 }
203
204 /// Whether this context is authored for plan drawing.
205 pub fn is_plan_view(&self) -> bool {
206 self.target_view().is_some_and(|view| view.is_plan())
207 }
208}
209
210/// Maximum parent links followed when resolving a DERIVED attribute.
211///
212/// The schema permits one level of sub-context, so 8 is far past any
213/// well-formed file. It is a *termination* bound, not a validation rule: a
214/// malformed file that chains or cycles contexts stops here and reports the
215/// value as unresolved rather than looping.
216///
217/// This is deliberately the only termination mechanism. An earlier draft also
218/// carried a visited-set; with the bound in place it could never change an
219/// outcome, and a second guard that cannot fail is a claim the tests cannot
220/// check. One bound, tested at its edge.
221const MAX_PARENT_DEPTH: usize = 8;
222
223/// Resolve an inherited slot, following `ParentContext` past every `*`.
224///
225/// A sub-context redeclares six attributes as DERIVED and writes them as `*`,
226/// meaning "read this from my parent". Returns `None` when the chain ends
227/// without a concrete value, or when it is still unresolved after
228/// [`MAX_PARENT_DEPTH`] links — which is what a cycle looks like from here.
229fn resolve_inherited(model: &Model, start: EntityId, slot: usize) -> Option<&Value> {
230 let mut current = start;
231
232 for _ in 0..MAX_PARENT_DEPTH {
233 let entity = model.get(current)?;
234 let view = RepresentationContext::new(current, entity);
235 match entity.attribute(slot) {
236 // A concrete value stops the walk.
237 Some(value) if !matches!(value, Value::Derived | Value::Null) => {
238 return Some(value);
239 }
240 // `*` means the value lives further up.
241 Some(Value::Derived) => {
242 current = view.parent()?;
243 }
244 // `$` or an absent slot is genuinely unset.
245 _ => return None,
246 }
247 }
248 None
249}
250
251impl RepresentationContext<'_> {
252 /// `Precision`, resolved through `ParentContext` when written as `*`.
253 ///
254 /// The value a tolerance-sensitive consumer needs; reading the slot
255 /// directly on a sub-context yields the DERIVED marker instead.
256 pub fn precision(&self, model: &Model) -> Option<f64> {
257 match resolve_inherited(model, self.id(), context_slot::PRECISION)?.unwrap_typed() {
258 Value::Real(value) => Some(*value),
259 Value::Integer(value) => Some(*value as f64),
260 _ => None,
261 }
262 }
263
264 /// `CoordinateSpaceDimension`, resolved through `ParentContext`.
265 pub fn coordinate_space_dimension(&self, model: &Model) -> Option<i64> {
266 match resolve_inherited(model, self.id(), context_slot::COORDINATE_SPACE_DIMENSION)?
267 .unwrap_typed()
268 {
269 Value::Integer(value) => Some(*value),
270 _ => None,
271 }
272 }
273
274 /// `WorldCoordinateSystem`, resolved through `ParentContext`.
275 ///
276 /// Without this a sub-context appears to have no placement and geometry
277 /// authored into it lands at the wrong origin.
278 pub fn world_coordinate_system(&self, model: &Model) -> Option<EntityId> {
279 match resolve_inherited(model, self.id(), context_slot::WORLD_COORDINATE_SYSTEM)? {
280 Value::Ref(id) => Some(*id),
281 _ => None,
282 }
283 }
284
285 /// `TrueNorth`, resolved through `ParentContext`.
286 pub fn true_north(&self, model: &Model) -> Option<EntityId> {
287 match resolve_inherited(model, self.id(), context_slot::TRUE_NORTH)? {
288 Value::Ref(id) => Some(*id),
289 _ => None,
290 }
291 }
292}
293
294/// Every representation context in the model, in file order.
295///
296/// Includes both root contexts and sub-contexts; use
297/// [`RepresentationContext::is_sub_context`] to tell them apart.
298pub fn all_contexts(model: &Model) -> Vec<RepresentationContext<'_>> {
299 let mut out = Vec::new();
300 for type_name in [
301 "IFCGEOMETRICREPRESENTATIONCONTEXT",
302 "IFCGEOMETRICREPRESENTATIONSUBCONTEXT",
303 ] {
304 for &id in model.ids_of_type(type_name) {
305 if let Some(entity) = model.get(id) {
306 out.push(RepresentationContext::new(id, entity));
307 }
308 }
309 }
310 out.sort_by_key(RepresentationContext::id);
311 out
312}
313
314/// Sub-contexts whose `TargetView` is a plan view.
315///
316/// The entry point for drawing production: geometry authored into one of these
317/// is what a floor plan is made of.
318pub fn plan_contexts(model: &Model) -> Vec<RepresentationContext<'_>> {
319 all_contexts(model)
320 .into_iter()
321 .filter(RepresentationContext::is_plan_view)
322 .collect()
323}
324
325/// The context a representation is authored into.
326pub fn context_of(model: &Model, representation: EntityId) -> Option<RepresentationContext<'_>> {
327 let entity = model.get(representation)?;
328 let context_id =
329 match Slots::new(representation, entity).opt(representation_slot::CONTEXT_OF_ITEMS)? {
330 Value::Ref(id) => *id,
331 _ => return None,
332 };
333 let context_entity = model.get(context_id)?;
334 Some(RepresentationContext::new(context_id, context_entity))
335}
336
337/// The frame a representation's items are authored in, in metres.
338///
339/// `IfcGeometricRepresentationContext.WorldCoordinateSystem` is mandatory and
340/// defines model space for every representation that names the context. Most
341/// files write the identity, so ignoring it looks correct on a corpus; a file
342/// that surveys its site into a real coordinate system does not agree.
343///
344/// A sub-context inherits the value through `ParentContext`, which
345/// [`RepresentationContext::world_coordinate_system`] already resolves.
346/// Coordinates are raw file units, so the frame converts once here, matching
347/// how the placement chain is handled. Kernel-free, so lowering and
348/// [`crate::body_description`] compose the same frame.
349pub(crate) fn representation_frame(
350 model: &Model,
351 units: &UnitScale,
352 representation: EntityId,
353) -> GeometryResult<Transform> {
354 let Some(context) = context_of(model, representation) else {
355 return Ok(Transform::identity());
356 };
357 let Some(placement_id) = context.world_coordinate_system(model) else {
358 return Ok(Transform::identity());
359 };
360 let placement = model
361 .get(placement_id)
362 .ok_or(GeometryError::MissingEntity {
363 referrer: context.id(),
364 missing: placement_id,
365 })?;
366 Ok(axis_placement_transform(model, placement_id, placement)?.to_metres(units))
367}
368
369/// The frame a product's representation of `purpose` is placed in, in
370/// metres: the representation context's `WorldCoordinateSystem` composed
371/// above the product's placement chain, exactly as lowering and
372/// [`crate::body_description`] place its items.
373///
374/// Geometry that belongs to the product but is not one of its
375/// representation items -- a space boundary's connection surface, which IFC
376/// authors in the relating space's coordinate system -- is placed with this
377/// frame to land where the product's body does. `Ok(None)` when the product
378/// has no representation for `purpose`. `TrueNorth` is not applied: it
379/// states where north lies in model space and moves no coordinate.
380pub fn product_representation_frame(
381 model: &Model,
382 units: &UnitScale,
383 product: EntityId,
384 purpose: RepresentationPurpose,
385) -> GeometryResult<Option<Transform>> {
386 representation_frame_resolved(
387 model,
388 units,
389 product,
390 purpose,
391 LinearResolution::cache_only(),
392 )
393}
394
395/// [`product_representation_frame`], deriving an `IfcLinearPlacement`
396/// through a caller-supplied evaluator (#353), as
397/// [`crate::constraint::placement::product_world_transform_with_evaluator`]
398/// does.
399///
400/// # Errors
401///
402/// As [`product_representation_frame`] and
403/// [`crate::constraint::placement::product_world_transform_with_evaluator`].
404#[cfg(feature = "compile")]
405pub fn product_representation_frame_with_evaluator(
406 model: &Model,
407 units: &UnitScale,
408 product: EntityId,
409 purpose: RepresentationPurpose,
410 evaluator: &dyn axiolid_curve_evaluate_contract::CurveEvaluator,
411 cached: crate::constraint::placement::CachedPositionPolicy,
412) -> GeometryResult<Option<Transform>> {
413 representation_frame_resolved(
414 model,
415 units,
416 product,
417 purpose,
418 LinearResolution::derive(evaluator, cached),
419 )
420}
421
422/// Shared body of the representation-frame entry points.
423pub(crate) fn representation_frame_resolved(
424 model: &Model,
425 units: &UnitScale,
426 product: EntityId,
427 purpose: RepresentationPurpose,
428 linear: LinearResolution<'_>,
429) -> GeometryResult<Option<Transform>> {
430 let Some(representation) = select_product_representation(model, product, purpose)? else {
431 return Ok(None);
432 };
433 let placement = resolve_with(&mut PlacementResolver::new(), model, units, product, linear)?;
434 // Model space is the context's frame; the product's chain is expressed
435 // inside it, so the context frame composes above the placement.
436 Ok(Some(
437 representation_frame(model, units, representation)?.compose(&placement),
438 ))
439}