Skip to main content

ifc_geometry/
error.rs

1//! Why interpreting a geometry entity failed.
2//!
3//! Every failure names the entity that caused it. A geometry bug in a
4//! 500k-entity file is unfindable otherwise, and "returned None" tells you
5//! nothing about which of 3,000 walls was malformed.
6
7use ifc_model::EntityId;
8use thiserror::Error;
9
10/// The result of interpreting IFC geometry.
11pub type GeometryResult<T> = Result<T, GeometryError>;
12
13/// Failures when reading or lowering IFC geometry.
14#[derive(Debug, Clone, PartialEq, Error)]
15#[non_exhaustive]
16pub enum GeometryError {
17    /// An entity referenced by an attribute is not in the model.
18    #[error("{referrer} references missing entity {missing}")]
19    MissingEntity {
20        /// The entity holding the dangling reference.
21        referrer: EntityId,
22        /// The id that does not resolve.
23        missing: EntityId,
24    },
25
26    /// An attribute slot was empty but the geometry needs it.
27    #[error("{entity} ({type_name}) has no {attribute}")]
28    MissingAttribute {
29        /// The offending entity.
30        entity: EntityId,
31        /// Its IFC type.
32        type_name: String,
33        /// Which attribute was required.
34        attribute: &'static str,
35    },
36
37    /// An attribute held a value of the wrong shape.
38    #[error("{entity} ({type_name}).{attribute}: expected {expected}, found {found}")]
39    WrongValueKind {
40        /// The offending entity.
41        entity: EntityId,
42        /// Its IFC type.
43        type_name: String,
44        /// Which attribute.
45        attribute: &'static str,
46        /// What the schema requires.
47        expected: &'static str,
48        /// What the file actually contains.
49        found: String,
50    },
51
52    /// The entity is not the type the caller assumed.
53    #[error("{entity} is {actual}, not a {expected}")]
54    WrongEntityType {
55        /// The offending entity.
56        entity: EntityId,
57        /// The type it actually has.
58        actual: String,
59        /// The type family that was required.
60        expected: &'static str,
61    },
62
63    /// A recognized IFC entity whose interpretation is not implemented.
64    ///
65    /// Distinct from [`Self::WrongEntityType`]: the file is valid and we simply
66    /// do not handle it yet. Never silently substituted with a wrong shape.
67    #[error("{type_name} ({entity}) is valid IFC but not yet interpreted: {detail}")]
68    Unsupported {
69        /// The entity in question.
70        entity: EntityId,
71        /// Its IFC type.
72        type_name: String,
73        /// What specifically is missing.
74        detail: &'static str,
75    },
76
77    /// A placement or mapped-item chain refers back to itself.
78    ///
79    /// The IFC spec pushes cycle prevention to the application layer, so real
80    /// files do contain them. Detecting beats overflowing the stack.
81    #[error("cyclic {kind} chain through {entity}")]
82    CyclicChain {
83        /// Where the cycle was detected.
84        entity: EntityId,
85        /// What kind of chain: `placement`, `mapped item`, ...
86        kind: &'static str,
87    },
88
89    /// A chain exceeded its depth limit without closing.
90    #[error("{kind} chain through {entity} exceeded depth {limit}")]
91    ChainTooDeep {
92        /// Where the walk gave up.
93        entity: EntityId,
94        /// What kind of chain.
95        kind: &'static str,
96        /// The limit that was hit.
97        limit: usize,
98    },
99
100    /// An aggregate declared more elements than the caller's budget allows.
101    ///
102    /// IFC aggregate counts are file-controlled, so a small hostile file can
103    /// declare an enormous element count and drive a large allocation before
104    /// any geometric validation runs. This is that budget refusing, and it is
105    /// always a refusal: nothing is truncated to fit.
106    #[error("{entity} ({type_name}) declares {requested} {what}, over the limit of {limit}")]
107    AggregateTooLarge {
108        /// The offending entity.
109        entity: EntityId,
110        /// Its IFC type.
111        type_name: String,
112        /// What was being counted: `knot multiplicities`, `triangle indices`.
113        what: &'static str,
114        /// How many elements the file asked for.
115        requested: u128,
116        /// The budget that refused it.
117        limit: usize,
118    },
119
120    /// A lowered product reached the mesh compiler, which refused it.
121    ///
122    /// Distinct from [`Self::Unsupported`]: lowering succeeded and the neutral
123    /// DAG is valid IFC meaning. What failed is *execution* -- a provider that
124    /// cannot evaluate this operation, or a budget that will not fund it. The
125    /// compiler's own reason is preserved verbatim, because it names the
126    /// missing capability precisely enough for a caller to register a provider
127    /// for it. Only reachable with the `compile` feature.
128    #[cfg(feature = "compile")]
129    #[error("{entity} could not be compiled to a mesh: {reason}")]
130    CompilationRefused {
131        /// The product whose body representation was being compiled.
132        entity: EntityId,
133        /// The compiler's refusal, as reported by the provider.
134        reason: String,
135    },
136
137    /// A volume was asked of a product whose compiled body is not a solid.
138    ///
139    /// Raised by `CompiledMesh::solid_mesh` for a surface model
140    /// (`IfcShellBasedSurfaceModel`, `IfcFaceBasedSurfaceModel`) and for a
141    /// backend that does not report closure. A surface has area, not
142    /// volume; reporting the divergence sum of a closed shell the file never
143    /// declared a solid would be a plausible, wrong number.
144    #[cfg(feature = "compile")]
145    #[error("{entity} is not a solid (closure {closure:?}); it has no volume")]
146    NotASolid {
147        /// The product whose body was compiled.
148        entity: EntityId,
149        /// The closure the backend reported: `Surface` or `Unknown`.
150        closure: axiolid_mesh_compile_contract::MeshClosure,
151    },
152
153    /// An opening that voids a host could not be subtracted from it (#44).
154    ///
155    /// Raised only when a caller asked for NET geometry. Returning the gross
156    /// body instead would make every net quantity downstream silently wrong,
157    /// so the host is refused and the opening named. `cause` says why: the
158    /// opening's body did not lower, it has no body, or the kernel refused the
159    /// subtraction.
160    #[error("opening {opening} could not be subtracted from {host}: {cause}")]
161    OpeningNotSubtracted {
162        /// The host whose net geometry was requested.
163        host: EntityId,
164        /// The voiding element that could not be removed.
165        opening: EntityId,
166        /// Why it could not be removed.
167        #[source]
168        cause: Box<GeometryError>,
169    },
170
171    /// An `IfcLinearPlacement`'s cached `CartesianPosition` disagrees with
172    /// the position its `RelativePlacement` derives (#354).
173    ///
174    /// Raised only when the caller supplied a curve evaluator and kept the
175    /// default `CachedPositionPolicy::Verify`. IFC4.3 makes the cache "an
176    /// optional fallback" for the linear expression, so a cache farther than
177    /// the model's tolerance from it is stale or wrong, and placing the
178    /// product by either would be a guess. Positions are world coordinates in
179    /// metres.
180    #[cfg(feature = "compile")]
181    #[error(
182        "{placement} (IFCLINEARPLACEMENT): the cached CartesianPosition {cached:?} is {distance} m \
183         from the position {derived:?} its RelativePlacement derives, beyond the model's \
184         tolerance of {tolerance} m"
185    )]
186    CachedPlacementMismatch {
187        /// The `IfcLinearPlacement`.
188        placement: EntityId,
189        /// The cached location, in metres.
190        cached: [f64; 3],
191        /// The derived location, in metres.
192        derived: [f64; 3],
193        /// Their distance, in metres.
194        distance: f64,
195        /// The tolerance it exceeds, in metres.
196        tolerance: f64,
197    },
198
199    /// The geometry is structurally impossible.
200    ///
201    /// A degenerate direction, a zero-radius circle, a self-referencing
202    /// boolean. The file parses; the geometry does not exist.
203    #[error("{entity} ({type_name}) is geometrically invalid: {detail}")]
204    Degenerate {
205        /// The offending entity.
206        entity: EntityId,
207        /// Its IFC type.
208        type_name: String,
209        /// Why it cannot be built.
210        detail: String,
211    },
212
213    /// Units could not be resolved, so coordinates have no defined scale.
214    #[error("unit resolution failed: {0}")]
215    Units(String),
216
217    /// An authored value was rejected before anything was staged (ADR 0011).
218    ///
219    /// Distinct from [`Self::Degenerate`], which describes an entity that
220    /// already exists in a file. Here there is no entity yet and therefore no
221    /// id to report: the caller passed a value that could not be written.
222    #[error("cannot author {type_name}.{attribute}: {detail}")]
223    InvalidAuthoredValue {
224        /// The IFC type being authored.
225        type_name: &'static str,
226        /// The attribute whose value was rejected.
227        attribute: &'static str,
228        /// Why it cannot be written.
229        detail: String,
230    },
231
232    /// A release-aware writer could not bind one known IFC release from the
233    /// model's `FILE_SCHEMA`: it names an unrecognised release, or several.
234    ///
235    /// A model with no `FILE_SCHEMA` binds IFC4, like the other authoring
236    /// crates. Nothing is staged.
237    #[error("cannot author {type_name}: {detail}")]
238    AuthoringSchemaUnbound {
239        /// The IFC type being authored.
240        type_name: &'static str,
241        /// What the header declares instead of one known release.
242        detail: String,
243    },
244
245    /// The model's declared release does not declare the entity a
246    /// release-aware writer was asked to author. Nothing is staged.
247    #[error("{type_name} is not declared by {schema:?}")]
248    AuthoringEntityNotInSchema {
249        /// The IFC type being authored.
250        type_name: &'static str,
251        /// The bound release.
252        schema: ifc_schema::SchemaVersion,
253    },
254}
255
256impl GeometryError {
257    /// The entity this error is about, when there is one.
258    ///
259    /// Lets a caller collect failures per element rather than aborting a whole
260    /// file for one bad wall.
261    pub fn entity(&self) -> Option<EntityId> {
262        match self {
263            Self::MissingEntity { referrer, .. } => Some(*referrer),
264            Self::MissingAttribute { entity, .. }
265            | Self::WrongValueKind { entity, .. }
266            | Self::WrongEntityType { entity, .. }
267            | Self::Unsupported { entity, .. }
268            | Self::CyclicChain { entity, .. }
269            | Self::ChainTooDeep { entity, .. }
270            | Self::AggregateTooLarge { entity, .. }
271            | Self::Degenerate { entity, .. } => Some(*entity),
272            #[cfg(feature = "compile")]
273            Self::CompilationRefused { entity, .. } => Some(*entity),
274            #[cfg(feature = "compile")]
275            Self::NotASolid { entity, .. } => Some(*entity),
276            #[cfg(feature = "compile")]
277            Self::CachedPlacementMismatch { placement, .. } => Some(*placement),
278            // The opening is what failed; `host` stays readable on the variant.
279            Self::OpeningNotSubtracted { opening, .. } => Some(*opening),
280            Self::Units(_)
281            | Self::InvalidAuthoredValue { .. }
282            | Self::AuthoringSchemaUnbound { .. }
283            | Self::AuthoringEntityNotInSchema { .. } => None,
284        }
285    }
286
287    /// Is this "valid IFC we do not handle yet" rather than a broken file?
288    ///
289    /// Callers building a viewer usually want to skip and count these, while
290    /// treating genuine corruption differently.
291    pub fn is_unsupported(&self) -> bool {
292        match self {
293            Self::Unsupported { .. } => true,
294            // A net refusal is as supported as the reason behind it.
295            Self::OpeningNotSubtracted { cause, .. } => cause.is_unsupported(),
296            _ => false,
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn errors_name_the_entity_so_failures_are_locatable() {
307        let e = GeometryError::MissingAttribute {
308            entity: EntityId(42),
309            type_name: "IFCEXTRUDEDAREASOLID".into(),
310            attribute: "SweptArea",
311        };
312        assert_eq!(e.entity(), Some(EntityId(42)));
313        assert!(e.to_string().contains("#42"));
314        assert!(e.to_string().contains("SweptArea"));
315    }
316
317    #[test]
318    fn unsupported_is_distinguishable_from_corruption() {
319        let unsupported = GeometryError::Unsupported {
320            entity: EntityId(1),
321            type_name: "IFCSECTIONEDSPINE".into(),
322            detail: "spine interpolation",
323        };
324        let broken = GeometryError::Degenerate {
325            entity: EntityId(1),
326            type_name: "IFCCIRCLE".into(),
327            detail: "zero radius".into(),
328        };
329        assert!(unsupported.is_unsupported());
330        assert!(!broken.is_unsupported());
331    }
332}