Skip to main content

cadforge_core/
element.rs

1//! Elements and their placement.
2
3use crate::id::GlobalId;
4use crate::property::PropertySets;
5use crate::representation::Representation;
6use glam::{DMat4, DVec3};
7use serde::{Deserialize, Serialize};
8
9/// The IFC class of an element.
10///
11/// Only classes CADForge authors natively are named; everything else round-trips through
12/// [`IfcClass::Other`] so that importing an unfamiliar model never loses its typing.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14pub enum IfcClass {
15    // Spatial structure
16    Project,
17    Site,
18    Building,
19    BuildingStorey,
20    Space,
21    // Physical elements CADForge authors natively (ADR-0004: all profile sweeps)
22    Wall,
23    Slab,
24    Roof,
25    Column,
26    Beam,
27    Door,
28    Window,
29    Stair,
30    Covering,
31    Furniture,
32    OpeningElement,
33    BuildingElementProxy,
34    /// Anything imported that CADForge does not author natively. Preserved verbatim.
35    Other(String),
36}
37
38impl IfcClass {
39    /// The IFC entity name, for export and for display.
40    pub fn ifc_name(&self) -> &str {
41        match self {
42            Self::Project => "IfcProject",
43            Self::Site => "IfcSite",
44            Self::Building => "IfcBuilding",
45            Self::BuildingStorey => "IfcBuildingStorey",
46            Self::Space => "IfcSpace",
47            Self::Wall => "IfcWall",
48            Self::Slab => "IfcSlab",
49            Self::Roof => "IfcRoof",
50            Self::Column => "IfcColumn",
51            Self::Beam => "IfcBeam",
52            Self::Door => "IfcDoor",
53            Self::Window => "IfcWindow",
54            Self::Stair => "IfcStair",
55            Self::Covering => "IfcCovering",
56            Self::Furniture => "IfcFurniture",
57            Self::OpeningElement => "IfcOpeningElement",
58            Self::BuildingElementProxy => "IfcBuildingElementProxy",
59            Self::Other(name) => name,
60        }
61    }
62
63    /// True for the spatial structure hierarchy, which contains elements rather than being
64    /// contained by them.
65    ///
66    /// `Other` participates: IFC4X3 introduced a whole spatial hierarchy for infrastructure —
67    /// `IfcRoadPart`, `IfcBridgePart`, `IfcFacilityPart` — and CADForge does not author any of
68    /// it, so those arrive as `Other`. Treating them as non-spatial strands every element
69    /// contained in one. Found by importing the corpus: a single road model produced 26 of
70    /// them and 38 dangling-reference warnings.
71    pub fn is_spatial(&self) -> bool {
72        match self {
73            Self::Project | Self::Site | Self::Building | Self::BuildingStorey | Self::Space => {
74                true
75            }
76            Self::Other(name) => is_spatial_entity(name),
77            _ => false,
78        }
79    }
80
81    /// True for the four classes that form the containment spine and are written by the
82    /// exporter as structure rather than as products.
83    ///
84    /// Distinct from [`IfcClass::is_spatial`]: a space and a road part are spatial — things
85    /// can be contained in them — but they are written as products, because the exporter
86    /// composes exactly one project/site/building/storey chain.
87    pub fn is_structure_spine(&self) -> bool {
88        matches!(
89            self,
90            Self::Project | Self::Site | Self::Building | Self::BuildingStorey
91        )
92    }
93
94    /// True if an element of this class may host openings (`IfcRelVoidsElement`).
95    pub fn can_host_openings(&self) -> bool {
96        matches!(
97            self,
98            Self::Wall | Self::Slab | Self::Roof | Self::Column | Self::Beam
99        )
100    }
101}
102
103/// Spatial entities CADForge does not model natively but must still accept as containers.
104///
105/// Mostly the IFC4X3 infrastructure hierarchy. Not exhaustive, and deliberately a list rather
106/// than a prefix rule — `IfcSpatialZone` is spatial and `IfcSpaceHeater` is not.
107fn is_spatial_entity(name: &str) -> bool {
108    matches!(
109        name.to_ascii_uppercase().as_str(),
110        "IFCSPATIALELEMENT"
111            | "IFCSPATIALSTRUCTUREELEMENT"
112            | "IFCSPATIALZONE"
113            | "IFCEXTERNALSPATIALELEMENT"
114            | "IFCEXTERNALSPATIALSTRUCTUREELEMENT"
115            | "IFCFACILITY"
116            | "IFCFACILITYPART"
117            | "IFCFACILITYPARTCOMMON"
118            | "IFCBRIDGE"
119            | "IFCBRIDGEPART"
120            | "IFCROAD"
121            | "IFCROADPART"
122            | "IFCRAILWAY"
123            | "IFCRAILWAYPART"
124            | "IFCMARINEFACILITY"
125            | "IFCMARINEPART"
126            | "IFCTUNNEL"
127            | "IFCTUNNELPART"
128    )
129}
130
131/// An object placement, modelled as IFC models it.
132///
133/// Stored as `IfcAxis2Placement3D` does — a location plus a Z axis and a reference X axis —
134/// rather than as a raw 4×4. Keeping the IFC shape avoids a lossy decomposition on every
135/// export, and it makes a non-orthogonal or mirrored transform impossible to represent by
136/// accident.
137#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
138pub struct Placement {
139    pub location: DVec3,
140    /// Local Z. Normalised on construction.
141    pub axis: DVec3,
142    /// Local X. Orthogonalised against `axis` on construction.
143    pub ref_direction: DVec3,
144}
145
146impl Placement {
147    /// Identity placement at the origin.
148    pub fn identity() -> Self {
149        Self {
150            location: DVec3::ZERO,
151            axis: DVec3::Z,
152            ref_direction: DVec3::X,
153        }
154    }
155
156    pub fn at(location: DVec3) -> Self {
157        Self {
158            location,
159            ..Self::identity()
160        }
161    }
162
163    /// Build a placement, normalising `axis` and orthogonalising `ref_direction` against it.
164    ///
165    /// Degenerate input (zero-length or parallel axes) falls back to the identity basis rather
166    /// than producing a silently broken transform.
167    pub fn new(location: DVec3, axis: DVec3, ref_direction: DVec3) -> Self {
168        let z = axis.try_normalize().unwrap_or(DVec3::Z);
169        let x = (ref_direction - z * z.dot(ref_direction))
170            .try_normalize()
171            .unwrap_or_else(|| {
172                // Any direction orthogonal to z will do; pick the more stable of two.
173                let candidate = if z.x.abs() < 0.9 { DVec3::X } else { DVec3::Y };
174                (candidate - z * z.dot(candidate))
175                    .try_normalize()
176                    .unwrap_or(DVec3::X)
177            });
178        Self {
179            location,
180            axis: z,
181            ref_direction: x,
182        }
183    }
184
185    /// The local-to-parent transform.
186    pub fn to_matrix(self) -> DMat4 {
187        let z = self.axis;
188        let x = self.ref_direction;
189        let y = z.cross(x);
190        DMat4::from_cols(
191            x.extend(0.0),
192            y.extend(0.0),
193            z.extend(0.0),
194            self.location.extend(1.0),
195        )
196    }
197
198    /// Recover a placement from a rigid transform.
199    ///
200    /// Import needs this: IFC nests placements, so an element's transform is the composition
201    /// of its whole `IfcLocalPlacement` chain, and getting back to a storey-relative placement
202    /// means composing, inverting, and decomposing.
203    ///
204    /// Only rotation and translation survive. Scale and shear are discarded by
205    /// re-orthogonalising, because `Placement` cannot represent them and silently keeping a
206    /// scaled basis would corrupt every downstream length.
207    pub fn from_matrix(matrix: DMat4) -> Self {
208        Self::new(
209            matrix.w_axis.truncate(),
210            matrix.z_axis.truncate(),
211            matrix.x_axis.truncate(),
212        )
213    }
214
215    /// Translate without touching orientation.
216    pub fn translated(self, delta: DVec3) -> Self {
217        Self {
218            location: self.location + delta,
219            ..self
220        }
221    }
222}
223
224impl Default for Placement {
225    fn default() -> Self {
226        Self::identity()
227    }
228}
229
230/// An axis-aligned bounding box in world space.
231#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
232pub struct BoundingBox {
233    pub min: DVec3,
234    pub max: DVec3,
235}
236
237impl BoundingBox {
238    pub fn new(min: DVec3, max: DVec3) -> Self {
239        Self {
240            min: min.min(max),
241            max: min.max(max),
242        }
243    }
244
245    /// The box containing no points. Union with anything returns that thing.
246    pub fn empty() -> Self {
247        Self {
248            min: DVec3::splat(f64::INFINITY),
249            max: DVec3::splat(f64::NEG_INFINITY),
250        }
251    }
252
253    pub fn from_points(points: impl IntoIterator<Item = DVec3>) -> Self {
254        points.into_iter().fold(Self::empty(), |b, p| b.extended(p))
255    }
256
257    pub fn is_empty(&self) -> bool {
258        self.min.x > self.max.x || self.min.y > self.max.y || self.min.z > self.max.z
259    }
260
261    pub fn extended(self, p: DVec3) -> Self {
262        Self {
263            min: self.min.min(p),
264            max: self.max.max(p),
265        }
266    }
267
268    pub fn union(self, other: Self) -> Self {
269        if self.is_empty() {
270            return other;
271        }
272        if other.is_empty() {
273            return self;
274        }
275        Self {
276            min: self.min.min(other.min),
277            max: self.max.max(other.max),
278        }
279    }
280
281    pub fn intersects(&self, other: &Self) -> bool {
282        !self.is_empty()
283            && !other.is_empty()
284            && self.min.x <= other.max.x
285            && self.max.x >= other.min.x
286            && self.min.y <= other.max.y
287            && self.max.y >= other.min.y
288            && self.min.z <= other.max.z
289            && self.max.z >= other.min.z
290    }
291
292    pub fn center(&self) -> DVec3 {
293        (self.min + self.max) * 0.5
294    }
295
296    pub fn size(&self) -> DVec3 {
297        (self.max - self.min).max(DVec3::ZERO)
298    }
299}
300
301/// One element in the model.
302///
303/// The two revision counters are what make incremental work possible: a rename bumps only
304/// `semantic_revision`, so the renderer never rebuilds a mesh for it
305/// (`docs/ifc-semantics.md` §5, "incremental invalidation").
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct ElementRecord {
308    pub global_id: GlobalId,
309    pub class: IfcClass,
310    pub name: Option<String>,
311    pub object_type: Option<String>,
312    pub placement: Placement,
313    /// `IfcRelContainedInSpatialStructure` — the storey or space holding this element.
314    pub container: Option<GlobalId>,
315    /// `IfcRelDefinesByType` — the family type this instance is defined by.
316    pub type_ref: Option<GlobalId>,
317    pub properties: PropertySets,
318    /// Geometry in local space, evaluated from the family recipe. `None` for spatial
319    /// structure elements and for anything not yet flexed.
320    pub representation: Option<Representation>,
321    /// World-space bounds, once geometry has been evaluated.
322    pub bounds: Option<BoundingBox>,
323    /// Bumped when geometry must be rebuilt.
324    pub representation_revision: u64,
325    /// Bumped when metadata changes but geometry does not.
326    pub semantic_revision: u64,
327}
328
329impl ElementRecord {
330    pub fn new(global_id: GlobalId, class: IfcClass) -> Self {
331        Self {
332            global_id,
333            class,
334            name: None,
335            object_type: None,
336            placement: Placement::identity(),
337            container: None,
338            type_ref: None,
339            properties: PropertySets::default(),
340            representation: None,
341            bounds: None,
342            representation_revision: 0,
343            semantic_revision: 0,
344        }
345    }
346
347    pub fn with_name(mut self, name: impl Into<String>) -> Self {
348        self.name = Some(name.into());
349        self
350    }
351
352    pub fn with_placement(mut self, placement: Placement) -> Self {
353        self.placement = placement;
354        self
355    }
356
357    pub fn with_container(mut self, container: GlobalId) -> Self {
358        self.container = Some(container);
359        self
360    }
361
362    pub fn with_representation(mut self, representation: Representation) -> Self {
363        self.representation = Some(representation);
364        self
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn placement_orthogonalises_a_skewed_basis() {
374        let p = Placement::new(DVec3::ZERO, DVec3::Z * 3.0, DVec3::new(1.0, 0.0, 0.7));
375        assert!((p.axis.length() - 1.0).abs() < 1e-12);
376        assert!((p.ref_direction.length() - 1.0).abs() < 1e-12);
377        assert!(
378            p.axis.dot(p.ref_direction).abs() < 1e-12,
379            "axes must be orthogonal"
380        );
381    }
382
383    #[test]
384    fn placement_survives_degenerate_input() {
385        // Zero axis and a ref_direction parallel to it: both degenerate.
386        let p = Placement::new(DVec3::ONE, DVec3::ZERO, DVec3::ZERO);
387        assert!(p.axis.is_finite() && p.ref_direction.is_finite());
388        assert!(p.axis.dot(p.ref_direction).abs() < 1e-12);
389    }
390
391    #[test]
392    fn placement_matrix_is_right_handed() {
393        let p = Placement::new(DVec3::new(1.0, 2.0, 3.0), DVec3::Z, DVec3::X);
394        let m = p.to_matrix();
395        assert!((m.determinant() - 1.0).abs() < 1e-12);
396        assert_eq!(m.transform_point3(DVec3::ZERO), DVec3::new(1.0, 2.0, 3.0));
397    }
398
399    #[test]
400    fn a_placement_survives_a_matrix_round_trip() {
401        let original = Placement::new(
402            DVec3::new(3.0, -7.5, 2.25),
403            DVec3::new(0.0, 0.3, 1.0),
404            DVec3::new(1.0, 0.4, 0.0),
405        );
406        let recovered = Placement::from_matrix(original.to_matrix());
407
408        assert!((recovered.location - original.location).length() < 1e-12);
409        assert!((recovered.axis - original.axis).length() < 1e-12);
410        assert!((recovered.ref_direction - original.ref_direction).length() < 1e-12);
411    }
412
413    #[test]
414    fn composing_and_decomposing_recovers_a_relative_placement() {
415        // Exactly what import does: an element's world transform is its whole IfcLocalPlacement
416        // chain composed, and the storey-relative placement has to be recovered from it.
417        let storey = Placement::new(DVec3::new(0.0, 0.0, 3.5), DVec3::Z, DVec3::X);
418        let relative = Placement::new(DVec3::new(2.0, 1.0, 0.0), DVec3::Z, DVec3::Y);
419
420        let world = storey.to_matrix() * relative.to_matrix();
421        let recovered = Placement::from_matrix(storey.to_matrix().inverse() * world);
422
423        assert!((recovered.location - relative.location).length() < 1e-12);
424        assert!((recovered.ref_direction - relative.ref_direction).length() < 1e-12);
425    }
426
427    #[test]
428    fn scale_is_discarded_rather_than_silently_kept() {
429        // A scaled basis would corrupt every length downstream. Dropping it is wrong too, but
430        // it is wrong loudly and consistently rather than subtly.
431        let scaled = DMat4::from_scale(DVec3::new(3.0, 3.0, 3.0));
432        let recovered = Placement::from_matrix(scaled);
433        assert!((recovered.axis.length() - 1.0).abs() < 1e-12);
434        assert!((recovered.ref_direction.length() - 1.0).abs() < 1e-12);
435    }
436
437    #[test]
438    fn empty_box_is_the_union_identity() {
439        let b = BoundingBox::new(DVec3::ZERO, DVec3::ONE);
440        assert!(BoundingBox::empty().is_empty());
441        assert_eq!(BoundingBox::empty().union(b), b);
442        assert_eq!(b.union(BoundingBox::empty()), b);
443    }
444
445    #[test]
446    fn boxes_touching_at_a_face_intersect() {
447        let a = BoundingBox::new(DVec3::ZERO, DVec3::ONE);
448        let b = BoundingBox::new(DVec3::X, DVec3::new(2.0, 1.0, 1.0));
449        let far = BoundingBox::new(DVec3::splat(5.0), DVec3::splat(6.0));
450        assert!(a.intersects(&b));
451        assert!(!a.intersects(&far));
452        assert!(!a.intersects(&BoundingBox::empty()));
453    }
454
455    #[test]
456    fn new_normalises_inverted_corners() {
457        let b = BoundingBox::new(DVec3::ONE, DVec3::ZERO);
458        assert_eq!(b.min, DVec3::ZERO);
459        assert_eq!(b.size(), DVec3::ONE);
460    }
461
462    #[test]
463    fn infrastructure_spatial_parts_count_as_containers() {
464        // IFC4X3 spatial structure arrives as Other, because CADForge does not author roads.
465        // Refusing it as a container strands every element inside one.
466        assert!(IfcClass::Other("IfcRoadPart".into()).is_spatial());
467        assert!(IfcClass::Other("IFCBRIDGEPART".into()).is_spatial());
468        assert!(IfcClass::Other("IfcFacilityPart".into()).is_spatial());
469        assert!(IfcClass::Space.is_spatial());
470
471        // A prefix rule would get this wrong: a space heater is equipment, not a space.
472        assert!(!IfcClass::Other("IfcSpaceHeater".into()).is_spatial());
473        assert!(!IfcClass::Other("IfcPipeSegment".into()).is_spatial());
474        assert!(!IfcClass::Wall.is_spatial());
475    }
476
477    #[test]
478    fn the_structure_spine_is_narrower_than_spatial() {
479        // A space can contain things but is written as a product; only these four form the
480        // chain the exporter composes.
481        for class in [
482            IfcClass::Project,
483            IfcClass::Site,
484            IfcClass::Building,
485            IfcClass::BuildingStorey,
486        ] {
487            assert!(class.is_structure_spine() && class.is_spatial());
488        }
489        assert!(IfcClass::Space.is_spatial());
490        assert!(!IfcClass::Space.is_structure_spine());
491        assert!(!IfcClass::Other("IfcRoadPart".into()).is_structure_spine());
492    }
493
494    #[test]
495    fn unknown_classes_keep_their_name() {
496        let c = IfcClass::Other("IfcDistributionElement".into());
497        assert_eq!(c.ifc_name(), "IfcDistributionElement");
498        assert!(!c.can_host_openings());
499        assert!(IfcClass::Wall.can_host_openings());
500        assert!(IfcClass::BuildingStorey.is_spatial());
501    }
502}