Skip to main content

ifc_geometry/input/
material_usage.rs

1//! Geometry-only projections of MaterialResource usage entities.
2//!
3//! These views intentionally overlap a few authored slots with `ifc-material`.
4//! Sibling domain crates may not depend on one another: `ifc-material` owns material
5//! identity/quantities; this module owns only shape inputs. Values stay in project
6//! units until lowering. No material semantics enter Axiolid.
7
8use ifc_model::{Entity, EntityId, Value};
9
10use crate::error::{GeometryError, GeometryResult};
11use crate::slots::Slots;
12
13/// Axis along which an IFC material layer set affects product geometry.
14#[non_exhaustive]
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LayerSetDirection {
17    /// Layers stack along the product's local x axis.
18    Axis1,
19    /// Layers stack along the product's local y axis.
20    Axis2,
21    /// Layers stack along the product's local z axis.
22    Axis3,
23}
24
25/// Whether layer thickness accumulates along the axis or against it.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DirectionSense {
28    /// Thickness accumulates in the axis direction.
29    Positive,
30    /// Thickness accumulates opposite the axis direction.
31    Negative,
32}
33
34/// Positive IFC cardinal reference. Standard values are 1 through 19.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct CardinalPoint(u64);
37impl CardinalPoint {
38    /// The raw reference, including values outside the standard range.
39    pub fn get(self) -> u64 {
40        self.0
41    }
42    /// The reference when it names one of the 19 standard cardinal points.
43    ///
44    /// IFC permits any positive integer, so a file may carry a value with no
45    /// standard meaning; that case returns `None` rather than guessing.
46    pub fn standard(self) -> Option<u8> {
47        u8::try_from(self.0)
48            .ok()
49            .filter(|value| (1..=19).contains(value))
50    }
51}
52
53fn checked<'m>(
54    id: EntityId,
55    entity: &'m Entity,
56    expected: &'static str,
57) -> GeometryResult<Slots<'m>> {
58    if !entity.is_type(expected) {
59        return Err(GeometryError::WrongEntityType {
60            entity: id,
61            actual: entity.type_name.to_string(),
62            expected,
63        });
64    }
65    Ok(Slots::new(id, entity))
66}
67
68fn optional_i64(
69    slots: &Slots<'_>,
70    index: usize,
71    name: &'static str,
72) -> GeometryResult<Option<i64>> {
73    match slots.opt(index) {
74        None => Ok(None),
75        Some(_) => slots.req_i64(index, name).map(Some),
76    }
77}
78
79fn optional_f64(
80    slots: &Slots<'_>,
81    index: usize,
82    name: &'static str,
83) -> GeometryResult<Option<f64>> {
84    match slots.opt(index) {
85        None => Ok(None),
86        Some(_) => slots.req_f64(index, name).map(Some),
87    }
88}
89
90fn cardinal(
91    slots: &Slots<'_>,
92    index: usize,
93    name: &'static str,
94) -> GeometryResult<Option<CardinalPoint>> {
95    let Some(value) = optional_i64(slots, index, name)? else {
96        return Ok(None);
97    };
98    let value = u64::try_from(value)
99        .ok()
100        .filter(|value| *value > 0)
101        .ok_or_else(|| slots.degenerate(format!("{name} must be positive")))?;
102    Ok(Some(CardinalPoint(value)))
103}
104
105fn positive_optional(
106    slots: &Slots<'_>,
107    index: usize,
108    name: &'static str,
109) -> GeometryResult<Option<f64>> {
110    let value = optional_f64(slots, index, name)?;
111    if value.is_some_and(|value| !value.is_finite() || value <= 0.0) {
112        return Err(slots.degenerate(format!("{name} must be finite and positive")));
113    }
114    Ok(value)
115}
116
117fn required_enum<'a>(
118    slots: &'a Slots<'_>,
119    index: usize,
120    name: &'static str,
121) -> GeometryResult<&'a str> {
122    let value = slots.req(index, name)?;
123    match value {
124        Value::Enum(token) => Ok(token),
125        _ => Err(GeometryError::WrongValueKind {
126            entity: slots.id(),
127            type_name: slots.type_name().to_string(),
128            attribute: name,
129            expected: "an enumeration",
130            found: format!("{value:?}"),
131        }),
132    }
133}
134
135#[derive(Debug, Clone, Copy)]
136/// Geometry-only projection of `IfcMaterialProfileSetUsage`, including its
137/// subtype `IfcMaterialProfileSetUsageTapering` (#136).
138pub struct MaterialProfileSetUsageGeometry<'m> {
139    slots: Slots<'m>,
140}
141impl<'m> MaterialProfileSetUsageGeometry<'m> {
142    /// Borrows `entity`, checking it is an `IfcMaterialProfileSetUsage` or
143    /// its subtype `IfcMaterialProfileSetUsageTapering`.
144    ///
145    /// The subtype keeps `ForProfileSet`, `CardinalPoint` and
146    /// `ReferenceExtent` at slots 0..2 in IFC4 and IFC4X3, so every accessor
147    /// here reads it unchanged; [`Self::tapering`] reaches its end profile
148    /// set and end cardinal point.
149    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
150        if entity.is_type("IFCMATERIALPROFILESETUSAGETAPERING") {
151            return Ok(Self {
152                slots: Slots::new(id, entity),
153            });
154        }
155        Ok(Self {
156            slots: checked(id, entity, "IFCMATERIALPROFILESETUSAGE")?,
157        })
158    }
159    /// The tapering projection when the record is an
160    /// `IfcMaterialProfileSetUsageTapering`; `None` for a plain usage.
161    pub fn tapering(self) -> Option<MaterialProfileSetUsageTaperingGeometry<'m>> {
162        self.slots
163            .type_name()
164            .eq_ignore_ascii_case("IFCMATERIALPROFILESETUSAGETAPERING")
165            .then_some(MaterialProfileSetUsageTaperingGeometry { slots: self.slots })
166    }
167    /// The `ForProfileSet` reference.
168    pub fn profile_set_id(self) -> GeometryResult<EntityId> {
169        self.slots.req_ref(0, "ForProfileSet")
170    }
171    /// The `CardinalPoint`, when authored.
172    pub fn cardinal_point(self) -> GeometryResult<Option<CardinalPoint>> {
173        cardinal(&self.slots, 1, "CardinalPoint")
174    }
175    /// The `ReferenceExtent`, when authored. Must be positive.
176    pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
177        positive_optional(&self.slots, 2, "ReferenceExtent")
178    }
179}
180
181#[derive(Debug, Clone, Copy)]
182/// Geometry-only projection of `IfcMaterialProfileSetUsageTapering`.
183pub struct MaterialProfileSetUsageTaperingGeometry<'m> {
184    slots: Slots<'m>,
185}
186impl<'m> MaterialProfileSetUsageTaperingGeometry<'m> {
187    /// Borrows `entity`, checking it declares the expected IFC type.
188    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
189        Ok(Self {
190            slots: checked(id, entity, "IFCMATERIALPROFILESETUSAGETAPERING")?,
191        })
192    }
193    /// The `ForProfileSet` reference.
194    pub fn profile_set_id(self) -> GeometryResult<EntityId> {
195        self.slots.req_ref(0, "ForProfileSet")
196    }
197    /// The `CardinalPoint`, when authored.
198    pub fn cardinal_point(self) -> GeometryResult<Option<CardinalPoint>> {
199        cardinal(&self.slots, 1, "CardinalPoint")
200    }
201    /// The `ReferenceExtent`, when authored. Must be positive.
202    pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
203        positive_optional(&self.slots, 2, "ReferenceExtent")
204    }
205    /// The `ForProfileEndSet` reference.
206    pub fn end_profile_set_id(self) -> GeometryResult<EntityId> {
207        self.slots.req_ref(3, "ForProfileEndSet")
208    }
209    /// The `CardinalEndPoint`, when authored.
210    pub fn cardinal_end_point(self) -> GeometryResult<Option<CardinalPoint>> {
211        cardinal(&self.slots, 4, "CardinalEndPoint")
212    }
213}
214
215#[derive(Debug, Clone, Copy)]
216/// Geometry-only projection of `IfcMaterialProfile`.
217pub struct MaterialProfileGeometry<'m> {
218    slots: Slots<'m>,
219}
220impl<'m> MaterialProfileGeometry<'m> {
221    /// Borrows `entity`, checking it declares the expected IFC type.
222    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
223        if !entity.is_type("IFCMATERIALPROFILE") && !entity.is_type("IFCMATERIALPROFILEWITHOFFSETS")
224        {
225            return Err(GeometryError::WrongEntityType {
226                entity: id,
227                actual: entity.type_name.to_string(),
228                expected: "IfcMaterialProfile",
229            });
230        }
231        Ok(Self {
232            slots: Slots::new(id, entity),
233        })
234    }
235    /// The `Profile` reference.
236    pub fn profile_id(self) -> GeometryResult<EntityId> {
237        self.slots.req_ref(3, "Profile")
238    }
239    /// The authored offset pair.
240    pub fn offset_values(self) -> GeometryResult<Option<[f64; 2]>> {
241        if !self
242            .slots
243            .type_name()
244            .eq_ignore_ascii_case("IFCMATERIALPROFILEWITHOFFSETS")
245        {
246            return Ok(None);
247        }
248        let values = self.slots.req_f64_list(6, "OffsetValues")?;
249        let values: [f64; 2] = values.try_into().map_err(|_| {
250            self.slots
251                .degenerate("OffsetValues must contain exactly two lengths")
252        })?;
253        if !values.iter().all(|value| value.is_finite()) {
254            return Err(self.slots.degenerate("OffsetValues must be finite"));
255        }
256        Ok(Some(values))
257    }
258}
259
260#[derive(Debug, Clone, Copy)]
261/// Geometry-only projection of `IfcMaterialLayerSetUsage`.
262pub struct MaterialLayerSetUsageGeometry<'m> {
263    slots: Slots<'m>,
264}
265impl<'m> MaterialLayerSetUsageGeometry<'m> {
266    /// Borrows `entity`, checking it declares the expected IFC type.
267    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
268        Ok(Self {
269            slots: checked(id, entity, "IFCMATERIALLAYERSETUSAGE")?,
270        })
271    }
272    /// The `ForLayerSet` reference.
273    pub fn layer_set_id(self) -> GeometryResult<EntityId> {
274        self.slots.req_ref(0, "ForLayerSet")
275    }
276    /// The `LayerSetDirection`.
277    pub fn layer_set_direction(self) -> GeometryResult<LayerSetDirection> {
278        match required_enum(&self.slots, 1, "LayerSetDirection")? {
279            token if token.eq_ignore_ascii_case("AXIS1") => Ok(LayerSetDirection::Axis1),
280            token if token.eq_ignore_ascii_case("AXIS2") => Ok(LayerSetDirection::Axis2),
281            token if token.eq_ignore_ascii_case("AXIS3") => Ok(LayerSetDirection::Axis3),
282            _ => Err(self
283                .slots
284                .degenerate("LayerSetDirection must be AXIS1, AXIS2, or AXIS3")),
285        }
286    }
287    /// The `DirectionSense`.
288    pub fn direction_sense(self) -> GeometryResult<DirectionSense> {
289        match required_enum(&self.slots, 2, "DirectionSense")? {
290            token if token.eq_ignore_ascii_case("POSITIVE") => Ok(DirectionSense::Positive),
291            token if token.eq_ignore_ascii_case("NEGATIVE") => Ok(DirectionSense::Negative),
292            _ => Err(self
293                .slots
294                .degenerate("DirectionSense must be POSITIVE or NEGATIVE")),
295        }
296    }
297    /// The `OffsetFromReferenceLine` distance.
298    pub fn offset_from_reference_line(self) -> GeometryResult<f64> {
299        let value = self.slots.req_f64(3, "OffsetFromReferenceLine")?;
300        if !value.is_finite() {
301            return Err(self
302                .slots
303                .degenerate("OffsetFromReferenceLine must be finite"));
304        }
305        Ok(value)
306    }
307    /// The `ReferenceExtent`, when authored. Must be positive.
308    pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
309        positive_optional(&self.slots, 4, "ReferenceExtent")
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    fn r(id: u64) -> Value {
317        Value::Ref(EntityId(id))
318    }
319
320    #[test]
321    fn profile_usage_reads_absolute_cardinal_and_taper_slots() {
322        let entity = Entity::new(
323            "IFCMATERIALPROFILESETUSAGETAPERING",
324            vec![
325                r(10),
326                Value::Integer(9),
327                Value::Real(4.0),
328                r(11),
329                Value::Integer(5),
330            ],
331        );
332        let view = MaterialProfileSetUsageTaperingGeometry::new(EntityId(1), &entity).unwrap();
333        assert_eq!(view.profile_set_id().unwrap(), EntityId(10));
334        assert_eq!(view.cardinal_point().unwrap().unwrap().standard(), Some(9));
335        assert_eq!(view.reference_extent().unwrap(), Some(4.0));
336        assert_eq!(view.end_profile_set_id().unwrap(), EntityId(11));
337        assert_eq!(view.cardinal_end_point().unwrap().unwrap().get(), 5);
338    }
339
340    /// #136: the subtype is a profile-set usage, and its taper stays
341    /// reachable; a plain usage has no taper, and other types are refused.
342    #[test]
343    fn profile_set_usage_accepts_its_tapering_subtype() {
344        let tapering = Entity::new(
345            "IFCMATERIALPROFILESETUSAGETAPERING",
346            vec![
347                r(10),
348                Value::Integer(9),
349                Value::Real(4.0),
350                r(11),
351                Value::Integer(5),
352            ],
353        );
354        let usage = MaterialProfileSetUsageGeometry::new(EntityId(1), &tapering).unwrap();
355        assert_eq!(usage.profile_set_id().unwrap(), EntityId(10));
356        assert_eq!(usage.cardinal_point().unwrap().unwrap().get(), 9);
357        assert_eq!(usage.reference_extent().unwrap(), Some(4.0));
358        let taper = usage
359            .tapering()
360            .expect("a tapering usage exposes its taper");
361        assert_eq!(taper.end_profile_set_id().unwrap(), EntityId(11));
362        assert_eq!(taper.cardinal_end_point().unwrap().unwrap().get(), 5);
363
364        let plain = Entity::new(
365            "IFCMATERIALPROFILESETUSAGE",
366            vec![r(10), Value::Null, Value::Null],
367        );
368        let usage = MaterialProfileSetUsageGeometry::new(EntityId(2), &plain).unwrap();
369        assert!(usage.tapering().is_none());
370
371        let other = Entity::new("IFCMATERIALLAYERSETUSAGE", vec![r(10)]);
372        assert!(matches!(
373            MaterialProfileSetUsageGeometry::new(EntityId(3), &other),
374            Err(GeometryError::WrongEntityType { .. })
375        ));
376    }
377
378    #[test]
379    fn layer_usage_preserves_axis_sense_signed_offset_and_extent() {
380        let entity = Entity::new(
381            "IFCMATERIALLAYERSETUSAGE",
382            vec![
383                r(20),
384                Value::Enum("AXIS2".into()),
385                Value::Enum("NEGATIVE".into()),
386                Value::Real(-0.25),
387                Value::Real(8.0),
388            ],
389        );
390        let view = MaterialLayerSetUsageGeometry::new(EntityId(2), &entity).unwrap();
391        assert_eq!(view.layer_set_id().unwrap(), EntityId(20));
392        assert_eq!(
393            view.layer_set_direction().unwrap(),
394            LayerSetDirection::Axis2
395        );
396        assert_eq!(view.direction_sense().unwrap(), DirectionSense::Negative);
397        assert_eq!(view.offset_from_reference_line().unwrap(), -0.25);
398        assert_eq!(view.reference_extent().unwrap(), Some(8.0));
399    }
400
401    #[test]
402    fn profile_offsets_are_kept_as_two_signed_project_lengths() {
403        let entity = Entity::new(
404            "IFCMATERIALPROFILEWITHOFFSETS",
405            vec![
406                Value::Null,
407                Value::Null,
408                Value::Null,
409                r(30),
410                Value::Null,
411                Value::Null,
412                Value::List(vec![Value::Real(-1.0), Value::Real(2.0)]),
413            ],
414        );
415        let view = MaterialProfileGeometry::new(EntityId(3), &entity).unwrap();
416        assert_eq!(view.profile_id().unwrap(), EntityId(30));
417        assert_eq!(view.offset_values().unwrap(), Some([-1.0, 2.0]));
418    }
419
420    #[test]
421    fn invalid_geometry_inputs_report_the_source_entity() {
422        let entity = Entity::new(
423            "IFCMATERIALPROFILESETUSAGE",
424            vec![r(1), Value::Integer(0), Value::Real(-2.0)],
425        );
426        let view = MaterialProfileSetUsageGeometry::new(EntityId(77), &entity).unwrap();
427        for error in [
428            view.cardinal_point().unwrap_err(),
429            view.reference_extent().unwrap_err(),
430        ] {
431            assert_eq!(error.entity(), Some(EntityId(77)));
432        }
433    }
434}