Skip to main content

ifc_georef/context/
source.rs

1//! The source side of a coordinate operation, and its inverse.
2//!
3//! `IfcCoordinateOperation.SourceCRS` is typed
4//! `IfcCoordinateReferenceSystemSelect = SELECT
5//! (IfcCoordinateReferenceSystem, IfcGeometricRepresentationContext)` in
6//! both IFC4 ADD2 TC1 and IFC4X3 ADD2. Both releases declare the matching
7//! inverse `HasCoordinateOperation : SET [0:1] OF IfcCoordinateOperation
8//! FOR SourceCRS` on `IfcCoordinateReferenceSystem` and on
9//! `IfcGeometricRepresentationContext`, and
10//! `IfcGeometricRepresentationSubContext` adds
11//! `WHERE NoCoordOperation : SIZEOF(...HasCoordinateOperation) = 0`.
12//!
13//! What differs is which CRS subtypes exist: IFC4 declares only
14//! `IfcProjectedCRS`; IFC4X3 adds `IfcGeographicCRS`. A schema-pinned read
15//! asks the pinned table; an unpinned read (no usable header) accepts the
16//! union and cannot tell the two apart.
17//!
18//! The inverse is not stored in a STEP file: it is recovered by scanning
19//! every `IfcCoordinateOperation` subtype for a `SourceCRS` naming the
20//! entity. More than one breaks the `SET [0:1]` and is refused rather than
21//! one operation being picked.
22
23use ifc_model::value::Value;
24use ifc_model::{EntityId, Model};
25
26use crate::error::{GeorefError, GeorefResult};
27use crate::slot::map_conversion as slot;
28use crate::view::GeorefView;
29
30/// The rule label reported when a source has more than one operation.
31pub(crate) const HAS_COORDINATE_OPERATION: &str =
32    "HasCoordinateOperation : SET [0:1] OF IfcCoordinateOperation";
33
34/// Coordinate-operation types an unpinned read recognizes: the IFC4X3 set,
35/// which contains IFC4's.
36const UNPINNED_OPERATIONS: &[&str] = &[
37    "IFCMAPCONVERSION",
38    "IFCMAPCONVERSIONSCALED",
39    "IFCRIGIDOPERATION",
40];
41
42/// CRS types an unpinned read recognizes as a source.
43const UNPINNED_CRS: &[&str] = &["IFCPROJECTEDCRS", "IFCGEOGRAPHICCRS"];
44
45/// The validated `SourceCRS` of a coordinate operation.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum OperationSource {
49    /// An `IfcGeometricRepresentationContext` (never a sub-context, which
50    /// `NoCoordOperation` forbids): the usual case, the project's own
51    /// engineering frame.
52    Context(EntityId),
53    /// An `IfcCoordinateReferenceSystem` subtype: an operation chained from
54    /// another CRS.
55    CoordinateReferenceSystem(EntityId),
56}
57
58impl OperationSource {
59    /// The source entity, whichever select member it is.
60    #[must_use]
61    pub const fn entity(&self) -> EntityId {
62        match self {
63            Self::Context(id) | Self::CoordinateReferenceSystem(id) => *id,
64        }
65    }
66}
67
68/// Read and validate the `SourceCRS` of the coordinate operation
69/// `operation` in a schema-pinned view.
70///
71/// # Errors
72///
73/// [`GeorefError::WrongType`] when `operation` is not an
74/// `IfcCoordinateOperation`, or its source is not a member of
75/// `IfcCoordinateReferenceSystemSelect`;
76/// [`GeorefError::UnsupportedOperation`] when either type is not declared
77/// in the pinned release (an `IfcGeographicCRS` under IFC4);
78/// [`GeorefError::MissingAttribute`] / [`GeorefError::InvalidAttribute`]
79/// for an unset or non-reference `SourceCRS`;
80/// [`GeorefError::MissingEntity`] for a dangling one;
81/// [`GeorefError::RuleViolation`] for a sub-context source
82/// (`NoCoordOperation`) or a source with more than one operation
83/// (`HasCoordinateOperation`).
84pub fn resolve_operation_source(
85    view: &GeorefView,
86    operation: EntityId,
87) -> GeorefResult<OperationSource> {
88    let actual = view.require_known_type(operation)?;
89    if !view.schema.is_a(actual, "IfcCoordinateOperation") {
90        return Err(GeorefError::WrongType {
91            entity: operation,
92            expected: "IFCCOORDINATEOPERATION",
93            actual: actual.to_owned(),
94        });
95    }
96    operation_source(view.model, Some(view), operation)
97}
98
99/// The one coordinate operation whose `SourceCRS` is `source`: the
100/// `HasCoordinateOperation` inverse, read in a schema-pinned view.
101///
102/// `Ok(None)` means `source` is a valid source with no operation.
103///
104/// # Errors
105///
106/// [`GeorefError::MissingEntity`] when `source` is not in the model;
107/// [`GeorefError::WrongType`] / [`GeorefError::UnsupportedOperation`] when
108/// it is not an `IfcCoordinateReferenceSystemSelect` member in the pinned
109/// release; [`GeorefError::RuleViolation`] when more than one operation
110/// names it, or a sub-context has any.
111pub fn coordinate_operation_for(
112    view: &GeorefView,
113    source: EntityId,
114) -> GeorefResult<Option<EntityId>> {
115    let model = view.model;
116    if model.get(source).is_none() {
117        return Err(GeorefError::MissingEntity {
118            referrer: source,
119            missing: source,
120        });
121    }
122    classify(model, Some(view), source)?;
123    let operations = operations_from(model, Some(view), source);
124    match operations.as_slice() {
125        [] => Ok(None),
126        [one] => {
127            forbid_sub_context(model, source)?;
128            Ok(Some(*one))
129        }
130        _ => Err(GeorefError::RuleViolation {
131            entity: source,
132            rule: HAS_COORDINATE_OPERATION,
133        }),
134    }
135}
136
137/// Read, type-check and inverse-check `operation`'s `SourceCRS`. `view`
138/// pins the release; `None` accepts the IFC4X3 union.
139pub(crate) fn operation_source(
140    model: &Model,
141    view: Option<&GeorefView>,
142    operation: EntityId,
143) -> GeorefResult<OperationSource> {
144    let entity = model.get(operation).ok_or(GeorefError::MissingEntity {
145        referrer: operation,
146        missing: operation,
147    })?;
148    let source = match entity.attribute(slot::SOURCE_CRS) {
149        None | Some(Value::Null) => {
150            return Err(GeorefError::MissingAttribute {
151                entity: operation,
152                index: slot::SOURCE_CRS,
153                name: "SourceCRS",
154            })
155        }
156        Some(value) => value.as_ref_id().ok_or(GeorefError::InvalidAttribute {
157            entity: operation,
158            index: slot::SOURCE_CRS,
159            name: "SourceCRS",
160        })?,
161    };
162    if model.get(source).is_none() {
163        return Err(GeorefError::MissingEntity {
164            referrer: operation,
165            missing: source,
166        });
167    }
168    let classified = classify(model, view, source)?;
169    forbid_sub_context(model, source)?;
170    if operations_from(model, view, source).len() > 1 {
171        return Err(GeorefError::RuleViolation {
172            entity: source,
173            rule: HAS_COORDINATE_OPERATION,
174        });
175    }
176    Ok(classified)
177}
178
179/// Which `IfcCoordinateReferenceSystemSelect` member `id` is.
180fn classify(
181    model: &Model,
182    view: Option<&GeorefView>,
183    id: EntityId,
184) -> GeorefResult<OperationSource> {
185    let entity = model.get(id).ok_or(GeorefError::MissingEntity {
186        referrer: id,
187        missing: id,
188    })?;
189    let type_name = entity.type_name.as_ref();
190    let (is_context, is_crs) = match view {
191        Some(view) => {
192            let declared = view.require_known_type(id)?;
193            (
194                view.schema
195                    .is_a(declared, "IfcGeometricRepresentationContext"),
196                view.schema.is_a(declared, "IfcCoordinateReferenceSystem"),
197            )
198        }
199        None => (
200            entity.is_type("IFCGEOMETRICREPRESENTATIONCONTEXT")
201                || entity.is_type("IFCGEOMETRICREPRESENTATIONSUBCONTEXT"),
202            UNPINNED_CRS.iter().any(|crs| entity.is_type(crs)),
203        ),
204    };
205    if is_context {
206        Ok(OperationSource::Context(id))
207    } else if is_crs {
208        Ok(OperationSource::CoordinateReferenceSystem(id))
209    } else {
210        Err(GeorefError::WrongType {
211            entity: id,
212            expected: "IFCCOORDINATEREFERENCESYSTEM or IFCGEOMETRICREPRESENTATIONCONTEXT",
213            actual: type_name.to_owned(),
214        })
215    }
216}
217
218/// `IfcGeometricRepresentationSubContext.NoCoordOperation`.
219fn forbid_sub_context(model: &Model, source: EntityId) -> GeorefResult<()> {
220    match model.get(source) {
221        Some(entity) if entity.is_type("IFCGEOMETRICREPRESENTATIONSUBCONTEXT") => {
222            Err(GeorefError::RuleViolation {
223                entity: source,
224                rule: "NoCoordOperation",
225            })
226        }
227        _ => Ok(()),
228    }
229}
230
231/// Every coordinate operation whose `SourceCRS` references `source`, in
232/// ascending id order.
233fn operations_from(model: &Model, view: Option<&GeorefView>, source: EntityId) -> Vec<EntityId> {
234    let types: Vec<&str> = match view {
235        Some(view) => view.schema.subtypes("IfcCoordinateOperation"),
236        None => UNPINNED_OPERATIONS.to_vec(),
237    };
238    let mut found: Vec<EntityId> = types
239        .into_iter()
240        .flat_map(|type_name| model.of_type(type_name))
241        .filter(|(_, operation)| operation.reference(slot::SOURCE_CRS) == Some(source))
242        .map(|(id, _)| id)
243        .collect();
244    found.sort_unstable();
245    found.dedup();
246    found
247}