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}