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`.
137pub struct MaterialProfileSetUsageGeometry<'m> {
138    slots: Slots<'m>,
139}
140impl<'m> MaterialProfileSetUsageGeometry<'m> {
141    /// Borrows `entity`, checking it declares the expected IFC type.
142    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
143        Ok(Self {
144            slots: checked(id, entity, "IFCMATERIALPROFILESETUSAGE")?,
145        })
146    }
147    /// The `ForProfileSet` reference.
148    pub fn profile_set_id(self) -> GeometryResult<EntityId> {
149        self.slots.req_ref(0, "ForProfileSet")
150    }
151    /// The `CardinalPoint`, when authored.
152    pub fn cardinal_point(self) -> GeometryResult<Option<CardinalPoint>> {
153        cardinal(&self.slots, 1, "CardinalPoint")
154    }
155    /// The `ReferenceExtent`, when authored. Must be positive.
156    pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
157        positive_optional(&self.slots, 2, "ReferenceExtent")
158    }
159}
160
161#[derive(Debug, Clone, Copy)]
162/// Geometry-only projection of `IfcMaterialProfileSetUsageTapering`.
163pub struct MaterialProfileSetUsageTaperingGeometry<'m> {
164    slots: Slots<'m>,
165}
166impl<'m> MaterialProfileSetUsageTaperingGeometry<'m> {
167    /// Borrows `entity`, checking it declares the expected IFC type.
168    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
169        Ok(Self {
170            slots: checked(id, entity, "IFCMATERIALPROFILESETUSAGETAPERING")?,
171        })
172    }
173    /// The `ForProfileSet` reference.
174    pub fn profile_set_id(self) -> GeometryResult<EntityId> {
175        self.slots.req_ref(0, "ForProfileSet")
176    }
177    /// The `CardinalPoint`, when authored.
178    pub fn cardinal_point(self) -> GeometryResult<Option<CardinalPoint>> {
179        cardinal(&self.slots, 1, "CardinalPoint")
180    }
181    /// The `ReferenceExtent`, when authored. Must be positive.
182    pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
183        positive_optional(&self.slots, 2, "ReferenceExtent")
184    }
185    /// The `ForProfileEndSet` reference.
186    pub fn end_profile_set_id(self) -> GeometryResult<EntityId> {
187        self.slots.req_ref(3, "ForProfileEndSet")
188    }
189    /// The `CardinalEndPoint`, when authored.
190    pub fn cardinal_end_point(self) -> GeometryResult<Option<CardinalPoint>> {
191        cardinal(&self.slots, 4, "CardinalEndPoint")
192    }
193}
194
195#[derive(Debug, Clone, Copy)]
196/// Geometry-only projection of `IfcMaterialProfile`.
197pub struct MaterialProfileGeometry<'m> {
198    slots: Slots<'m>,
199}
200impl<'m> MaterialProfileGeometry<'m> {
201    /// Borrows `entity`, checking it declares the expected IFC type.
202    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
203        if !entity.is_type("IFCMATERIALPROFILE") && !entity.is_type("IFCMATERIALPROFILEWITHOFFSETS")
204        {
205            return Err(GeometryError::WrongEntityType {
206                entity: id,
207                actual: entity.type_name.to_string(),
208                expected: "IfcMaterialProfile",
209            });
210        }
211        Ok(Self {
212            slots: Slots::new(id, entity),
213        })
214    }
215    /// The `Profile` reference.
216    pub fn profile_id(self) -> GeometryResult<EntityId> {
217        self.slots.req_ref(3, "Profile")
218    }
219    /// The authored offset pair.
220    pub fn offset_values(self) -> GeometryResult<Option<[f64; 2]>> {
221        if !self
222            .slots
223            .type_name()
224            .eq_ignore_ascii_case("IFCMATERIALPROFILEWITHOFFSETS")
225        {
226            return Ok(None);
227        }
228        let values = self.slots.req_f64_list(6, "OffsetValues")?;
229        let values: [f64; 2] = values.try_into().map_err(|_| {
230            self.slots
231                .degenerate("OffsetValues must contain exactly two lengths")
232        })?;
233        if !values.iter().all(|value| value.is_finite()) {
234            return Err(self.slots.degenerate("OffsetValues must be finite"));
235        }
236        Ok(Some(values))
237    }
238}
239
240#[derive(Debug, Clone, Copy)]
241/// Geometry-only projection of `IfcMaterialLayerSetUsage`.
242pub struct MaterialLayerSetUsageGeometry<'m> {
243    slots: Slots<'m>,
244}
245impl<'m> MaterialLayerSetUsageGeometry<'m> {
246    /// Borrows `entity`, checking it declares the expected IFC type.
247    pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
248        Ok(Self {
249            slots: checked(id, entity, "IFCMATERIALLAYERSETUSAGE")?,
250        })
251    }
252    /// The `ForLayerSet` reference.
253    pub fn layer_set_id(self) -> GeometryResult<EntityId> {
254        self.slots.req_ref(0, "ForLayerSet")
255    }
256    /// The `LayerSetDirection`.
257    pub fn layer_set_direction(self) -> GeometryResult<LayerSetDirection> {
258        match required_enum(&self.slots, 1, "LayerSetDirection")? {
259            token if token.eq_ignore_ascii_case("AXIS1") => Ok(LayerSetDirection::Axis1),
260            token if token.eq_ignore_ascii_case("AXIS2") => Ok(LayerSetDirection::Axis2),
261            token if token.eq_ignore_ascii_case("AXIS3") => Ok(LayerSetDirection::Axis3),
262            _ => Err(self
263                .slots
264                .degenerate("LayerSetDirection must be AXIS1, AXIS2, or AXIS3")),
265        }
266    }
267    /// The `DirectionSense`.
268    pub fn direction_sense(self) -> GeometryResult<DirectionSense> {
269        match required_enum(&self.slots, 2, "DirectionSense")? {
270            token if token.eq_ignore_ascii_case("POSITIVE") => Ok(DirectionSense::Positive),
271            token if token.eq_ignore_ascii_case("NEGATIVE") => Ok(DirectionSense::Negative),
272            _ => Err(self
273                .slots
274                .degenerate("DirectionSense must be POSITIVE or NEGATIVE")),
275        }
276    }
277    /// The `OffsetFromReferenceLine` distance.
278    pub fn offset_from_reference_line(self) -> GeometryResult<f64> {
279        let value = self.slots.req_f64(3, "OffsetFromReferenceLine")?;
280        if !value.is_finite() {
281            return Err(self
282                .slots
283                .degenerate("OffsetFromReferenceLine must be finite"));
284        }
285        Ok(value)
286    }
287    /// The `ReferenceExtent`, when authored. Must be positive.
288    pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
289        positive_optional(&self.slots, 4, "ReferenceExtent")
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    fn r(id: u64) -> Value {
297        Value::Ref(EntityId(id))
298    }
299
300    #[test]
301    fn profile_usage_reads_absolute_cardinal_and_taper_slots() {
302        let entity = Entity::new(
303            "IFCMATERIALPROFILESETUSAGETAPERING",
304            vec![
305                r(10),
306                Value::Integer(9),
307                Value::Real(4.0),
308                r(11),
309                Value::Integer(5),
310            ],
311        );
312        let view = MaterialProfileSetUsageTaperingGeometry::new(EntityId(1), &entity).unwrap();
313        assert_eq!(view.profile_set_id().unwrap(), EntityId(10));
314        assert_eq!(view.cardinal_point().unwrap().unwrap().standard(), Some(9));
315        assert_eq!(view.reference_extent().unwrap(), Some(4.0));
316        assert_eq!(view.end_profile_set_id().unwrap(), EntityId(11));
317        assert_eq!(view.cardinal_end_point().unwrap().unwrap().get(), 5);
318    }
319
320    #[test]
321    fn layer_usage_preserves_axis_sense_signed_offset_and_extent() {
322        let entity = Entity::new(
323            "IFCMATERIALLAYERSETUSAGE",
324            vec![
325                r(20),
326                Value::Enum("AXIS2".into()),
327                Value::Enum("NEGATIVE".into()),
328                Value::Real(-0.25),
329                Value::Real(8.0),
330            ],
331        );
332        let view = MaterialLayerSetUsageGeometry::new(EntityId(2), &entity).unwrap();
333        assert_eq!(view.layer_set_id().unwrap(), EntityId(20));
334        assert_eq!(
335            view.layer_set_direction().unwrap(),
336            LayerSetDirection::Axis2
337        );
338        assert_eq!(view.direction_sense().unwrap(), DirectionSense::Negative);
339        assert_eq!(view.offset_from_reference_line().unwrap(), -0.25);
340        assert_eq!(view.reference_extent().unwrap(), Some(8.0));
341    }
342
343    #[test]
344    fn profile_offsets_are_kept_as_two_signed_project_lengths() {
345        let entity = Entity::new(
346            "IFCMATERIALPROFILEWITHOFFSETS",
347            vec![
348                Value::Null,
349                Value::Null,
350                Value::Null,
351                r(30),
352                Value::Null,
353                Value::Null,
354                Value::List(vec![Value::Real(-1.0), Value::Real(2.0)]),
355            ],
356        );
357        let view = MaterialProfileGeometry::new(EntityId(3), &entity).unwrap();
358        assert_eq!(view.profile_id().unwrap(), EntityId(30));
359        assert_eq!(view.offset_values().unwrap(), Some([-1.0, 2.0]));
360    }
361
362    #[test]
363    fn invalid_geometry_inputs_report_the_source_entity() {
364        let entity = Entity::new(
365            "IFCMATERIALPROFILESETUSAGE",
366            vec![r(1), Value::Integer(0), Value::Real(-2.0)],
367        );
368        let view = MaterialProfileSetUsageGeometry::new(EntityId(77), &entity).unwrap();
369        for error in [
370            view.cardinal_point().unwrap_err(),
371            view.reference_extent().unwrap_err(),
372        ] {
373            assert_eq!(error.entity(), Some(EntityId(77)));
374        }
375    }
376}