Skip to main content

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