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