1use ifc_model::{Entity, EntityId, Value};
9
10use crate::error::{GeometryError, GeometryResult};
11use crate::slots::Slots;
12
13#[non_exhaustive]
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LayerSetDirection {
17 Axis1,
19 Axis2,
21 Axis3,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DirectionSense {
28 Positive,
30 Negative,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct CardinalPoint(u64);
37impl CardinalPoint {
38 pub fn get(self) -> u64 {
40 self.0
41 }
42 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)]
136pub struct MaterialProfileSetUsageGeometry<'m> {
139 slots: Slots<'m>,
140}
141impl<'m> MaterialProfileSetUsageGeometry<'m> {
142 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 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 pub fn profile_set_id(self) -> GeometryResult<EntityId> {
169 self.slots.req_ref(0, "ForProfileSet")
170 }
171 pub fn cardinal_point(self) -> GeometryResult<Option<CardinalPoint>> {
173 cardinal(&self.slots, 1, "CardinalPoint")
174 }
175 pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
177 positive_optional(&self.slots, 2, "ReferenceExtent")
178 }
179}
180
181#[derive(Debug, Clone, Copy)]
182pub struct MaterialProfileSetUsageTaperingGeometry<'m> {
184 slots: Slots<'m>,
185}
186impl<'m> MaterialProfileSetUsageTaperingGeometry<'m> {
187 pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
189 Ok(Self {
190 slots: checked(id, entity, "IFCMATERIALPROFILESETUSAGETAPERING")?,
191 })
192 }
193 pub fn profile_set_id(self) -> GeometryResult<EntityId> {
195 self.slots.req_ref(0, "ForProfileSet")
196 }
197 pub fn cardinal_point(self) -> GeometryResult<Option<CardinalPoint>> {
199 cardinal(&self.slots, 1, "CardinalPoint")
200 }
201 pub fn reference_extent(self) -> GeometryResult<Option<f64>> {
203 positive_optional(&self.slots, 2, "ReferenceExtent")
204 }
205 pub fn end_profile_set_id(self) -> GeometryResult<EntityId> {
207 self.slots.req_ref(3, "ForProfileEndSet")
208 }
209 pub fn cardinal_end_point(self) -> GeometryResult<Option<CardinalPoint>> {
211 cardinal(&self.slots, 4, "CardinalEndPoint")
212 }
213}
214
215#[derive(Debug, Clone, Copy)]
216pub struct MaterialProfileGeometry<'m> {
218 slots: Slots<'m>,
219}
220impl<'m> MaterialProfileGeometry<'m> {
221 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 pub fn profile_id(self) -> GeometryResult<EntityId> {
237 self.slots.req_ref(3, "Profile")
238 }
239 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)]
261pub struct MaterialLayerSetUsageGeometry<'m> {
263 slots: Slots<'m>,
264}
265impl<'m> MaterialLayerSetUsageGeometry<'m> {
266 pub fn new(id: EntityId, entity: &'m Entity) -> GeometryResult<Self> {
268 Ok(Self {
269 slots: checked(id, entity, "IFCMATERIALLAYERSETUSAGE")?,
270 })
271 }
272 pub fn layer_set_id(self) -> GeometryResult<EntityId> {
274 self.slots.req_ref(0, "ForLayerSet")
275 }
276 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 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 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 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 #[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}