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