Skip to main content

ifc_geometry/resource/
point.rs

1//! `IfcPoint` subtypes and the IFC4 coordinate lists.
2//!
3//! # The dimension trap
4//!
5//! `IfcCartesianPoint.Coordinates` is `LIST [1:3]`, so a point in a real file
6//! is 2D *or* 3D and nothing in the record says which except its length. A 2D
7//! point silently read as `[x, y, 0.0]` is indistinguishable from a 3D point
8//! that happens to sit on the z=0 plane, and the difference matters: a 2D
9//! profile curve read as 3D geometry will be swept in the wrong space.
10//!
11//! So [`CartesianPoint::dimension`] is public and padding to 3D is an explicit
12//! call ([`CartesianPoint::coordinates_3d`]), never something that happens
13//! behind the caller's back.
14//!
15//! # Point lists
16//!
17//! IFC4 added `IfcCartesianPointList2D`/`3D` so tessellated geometry does not
18//! need one entity per vertex; a 200k-triangle mesh would otherwise be 100k+
19//! `IfcCartesianPoint` records. Everything that indexes into these lists
20//! (`IfcIndexedPolyCurve`, `IfcTriangulatedFaceSet`) uses **1-based** indices,
21//! which is why [`CartesianPointList2D::point`] takes one.
22
23use crate::error::{GeometryError, GeometryResult};
24use crate::slots::Slots;
25use ifc_model::{Entity, EntityId, Model, Value};
26
27/// Attribute slots, absolute STEP positions including inherited attributes.
28pub(crate) mod slot {
29    /// `IfcCartesianPoint` (no inherited explicit attributes).
30    pub mod cartesian_point {
31        /// `Coordinates : LIST [1:3] OF IfcLengthMeasure`.
32        pub const COORDINATES: usize = 0;
33    }
34
35    /// `IfcPointOnCurve` (supertype `IfcPoint` declares nothing explicit).
36    pub mod point_on_curve {
37        /// `BasisCurve : IfcCurve`.
38        pub const BASIS_CURVE: usize = 0;
39        /// `PointParameter : IfcParameterValue`.
40        pub const POINT_PARAMETER: usize = 1;
41    }
42
43    /// `IfcPointOnSurface`.
44    pub mod point_on_surface {
45        /// `BasisSurface : IfcSurface`.
46        pub const BASIS_SURFACE: usize = 0;
47        /// `PointParameterU : IfcParameterValue`.
48        pub const POINT_PARAMETER_U: usize = 1;
49        /// `PointParameterV : IfcParameterValue`.
50        pub const POINT_PARAMETER_V: usize = 2;
51    }
52
53    /// `IfcCartesianPointList2D` / `3D`. The supertype `IfcCartesianPointList`
54    /// declares only a DERIVE attribute, so `CoordList` is slot 0 in both.
55    pub mod point_list {
56        /// `CoordList : LIST [1:?] OF LIST [n:n] OF IfcLengthMeasure`.
57        pub const COORD_LIST: usize = 0;
58    }
59}
60
61/// A borrowed view of an `IfcCartesianPoint`.
62#[derive(Debug, Clone, Copy)]
63pub struct CartesianPoint<'m> {
64    slots: Slots<'m>,
65}
66
67impl<'m> CartesianPoint<'m> {
68    /// Wrap an entity assumed to be an `IfcCartesianPoint`.
69    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
70        Self {
71            slots: Slots::new(id, entity),
72        }
73    }
74
75    /// The entity id.
76    pub fn id(&self) -> EntityId {
77        self.slots.id()
78    }
79
80    /// The raw `Coordinates` list, exactly as long as the file wrote it.
81    pub fn coordinates(&self) -> GeometryResult<Vec<f64>> {
82        self.slots
83            .req_f64_list(slot::cartesian_point::COORDINATES, "Coordinates")
84    }
85
86    /// How many coordinates the point actually carries: 2 or 3.
87    ///
88    /// A length outside that range is [`crate::GeometryError::Degenerate`]: the
89    /// schema's `CP2Dor3D` rule requires at least 2, and a 1- or 4-element
90    /// list has no defined meaning.
91    pub fn dimension(&self) -> GeometryResult<usize> {
92        let n = self.coordinates()?.len();
93        match n {
94            2 | 3 => Ok(n),
95            other => Err(self
96                .slots
97                .degenerate(format!("Coordinates has {other} entries, expected 2 or 3"))),
98        }
99    }
100
101    /// The coordinates promoted to 3D, padding a 2D point with `z = 0`.
102    ///
103    /// Deliberately a separate call from [`Self::coordinates`]: padding is a
104    /// decision about the point's meaning, so the caller makes it. Check
105    /// [`Self::dimension`] first when the distinction matters.
106    pub fn coordinates_3d(&self) -> GeometryResult<[f64; 3]> {
107        let c = self.coordinates()?;
108        match c.len() {
109            2 => Ok([c[0], c[1], 0.0]),
110            3 => Ok([c[0], c[1], c[2]]),
111            other => Err(self
112                .slots
113                .degenerate(format!("Coordinates has {other} entries, expected 2 or 3"))),
114        }
115    }
116}
117
118/// A borrowed view of an `IfcPointOnCurve`.
119///
120/// The point is a parameter on a curve, not stored coordinates, so evaluating
121/// it means evaluating the basis curve. This view resolves the reference and
122/// the parameter; the evaluation belongs to the curve module.
123#[derive(Debug, Clone, Copy)]
124pub struct PointOnCurve<'m> {
125    slots: Slots<'m>,
126}
127
128impl<'m> PointOnCurve<'m> {
129    /// Wrap an entity assumed to be an `IfcPointOnCurve`.
130    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
131        Self {
132            slots: Slots::new(id, entity),
133        }
134    }
135
136    /// The entity id.
137    pub fn id(&self) -> EntityId {
138        self.slots.id()
139    }
140
141    /// The `IfcCurve` this point is parameterized on.
142    pub fn basis_curve(&self) -> GeometryResult<EntityId> {
143        self.slots
144            .req_ref(slot::point_on_curve::BASIS_CURVE, "BasisCurve")
145    }
146
147    /// The curve parameter.
148    ///
149    /// In the curve's own parameter space, which for a trimmed or reparameter-
150    /// ized curve is not arc length and not normalized to `0..1`.
151    pub fn point_parameter(&self) -> GeometryResult<f64> {
152        self.slots
153            .req_f64(slot::point_on_curve::POINT_PARAMETER, "PointParameter")
154    }
155}
156
157/// A borrowed view of an `IfcPointOnSurface`.
158#[derive(Debug, Clone, Copy)]
159pub struct PointOnSurface<'m> {
160    slots: Slots<'m>,
161}
162
163impl<'m> PointOnSurface<'m> {
164    /// Wrap an entity assumed to be an `IfcPointOnSurface`.
165    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
166        Self {
167            slots: Slots::new(id, entity),
168        }
169    }
170
171    /// The entity id.
172    pub fn id(&self) -> EntityId {
173        self.slots.id()
174    }
175
176    /// The `IfcSurface` this point lies on.
177    pub fn basis_surface(&self) -> GeometryResult<EntityId> {
178        self.slots
179            .req_ref(slot::point_on_surface::BASIS_SURFACE, "BasisSurface")
180    }
181
182    /// The `(u, v)` parameters, in the surface's own parameter space.
183    pub fn parameters(&self) -> GeometryResult<(f64, f64)> {
184        let u = self
185            .slots
186            .req_f64(slot::point_on_surface::POINT_PARAMETER_U, "PointParameterU")?;
187        let v = self
188            .slots
189            .req_f64(slot::point_on_surface::POINT_PARAMETER_V, "PointParameterV")?;
190        Ok((u, v))
191    }
192}
193
194/// A borrowed view of an `IfcCartesianPointList2D`.
195#[derive(Debug, Clone, Copy)]
196pub struct CartesianPointList2D<'m> {
197    slots: Slots<'m>,
198}
199
200impl<'m> CartesianPointList2D<'m> {
201    /// Wrap an entity assumed to be an `IfcCartesianPointList2D`.
202    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
203        Self {
204            slots: Slots::new(id, entity),
205        }
206    }
207
208    /// The entity id.
209    pub fn id(&self) -> EntityId {
210        self.slots.id()
211    }
212
213    /// Every coordinate pair, in file order.
214    ///
215    /// A row of the wrong width fails rather than being padded or truncated:
216    /// the width is what distinguishes this entity from its 3D sibling.
217    pub fn coordinates(&self) -> GeometryResult<Vec<[f64; 2]>> {
218        rows::<2>(&self.slots, slot::point_list::COORD_LIST, "CoordList")
219    }
220
221    /// The point at a **1-based** index, as IFC index attributes write them.
222    ///
223    /// Returns `None` for 0 or for an index past the end, which is what a
224    /// malformed `IfcIndexedPolyCurve` produces and must not panic.
225    pub fn point(&self, one_based: usize) -> GeometryResult<Option<[f64; 2]>> {
226        let coords = self.coordinates()?;
227        Ok(one_based
228            .checked_sub(1)
229            .and_then(|i| coords.get(i).copied()))
230    }
231}
232
233/// A borrowed view of an `IfcCartesianPointList3D`.
234#[derive(Debug, Clone, Copy)]
235pub struct CartesianPointList3D<'m> {
236    slots: Slots<'m>,
237}
238
239impl<'m> CartesianPointList3D<'m> {
240    /// Wrap an entity assumed to be an `IfcCartesianPointList3D`.
241    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
242        Self {
243            slots: Slots::new(id, entity),
244        }
245    }
246
247    /// The entity id.
248    pub fn id(&self) -> EntityId {
249        self.slots.id()
250    }
251
252    /// Every coordinate triple, in file order.
253    pub fn coordinates(&self) -> GeometryResult<Vec<[f64; 3]>> {
254        rows::<3>(&self.slots, slot::point_list::COORD_LIST, "CoordList")
255    }
256
257    /// The point at a **1-based** index, as IFC index attributes write them.
258    pub fn point(&self, one_based: usize) -> GeometryResult<Option<[f64; 3]>> {
259        let coords = self.coordinates()?;
260        Ok(one_based
261            .checked_sub(1)
262            .and_then(|i| coords.get(i).copied()))
263    }
264}
265
266/// An `IfcCartesianPointList` of either concrete dimension.
267///
268/// A slot typed as the abstract list (`IfcIndexedPolyCurve.Points`) may hold
269/// either subtype, and the subtype -- not the first row -- fixes the width.
270/// Produced by [`crate::resource::resolve::cartesian_point_list`].
271#[derive(Debug, Clone, Copy)]
272pub enum CartesianPointList<'m> {
273    /// An `IfcCartesianPointList2D`.
274    TwoD(CartesianPointList2D<'m>),
275    /// An `IfcCartesianPointList3D`.
276    ThreeD(CartesianPointList3D<'m>),
277}
278
279impl CartesianPointList<'_> {
280    /// The entity id.
281    pub fn id(&self) -> EntityId {
282        match self {
283            Self::TwoD(list) => list.id(),
284            Self::ThreeD(list) => list.id(),
285        }
286    }
287
288    /// The row width the subtype declares: 2 or 3.
289    pub fn dimension(&self) -> usize {
290        match self {
291            Self::TwoD(_) => 2,
292            Self::ThreeD(_) => 3,
293        }
294    }
295}
296
297/// Resolve a reference that must be an `IfcCartesianPoint`, promoted to 3D.
298///
299/// Placements, operators and curves all need exactly this, and each of them
300/// getting the dangling-reference and wrong-type errors right independently is
301/// how those errors end up inconsistent.
302pub fn cartesian_point_3d(
303    model: &Model,
304    referrer: EntityId,
305    id: EntityId,
306) -> GeometryResult<[f64; 3]> {
307    crate::resource::resolve::cartesian_point(model, referrer, id)?.coordinates_3d()
308}
309
310/// Read a `LIST OF LIST OF REAL` where every row has exactly `N` entries.
311fn rows<const N: usize>(
312    slots: &Slots<'_>,
313    index: usize,
314    name: &'static str,
315) -> GeometryResult<Vec<[f64; N]>> {
316    let value = slots.req(index, name)?;
317    let outer = value
318        .as_list()
319        .ok_or_else(|| wrong_kind(slots, name, "a list of coordinate rows", value))?;
320
321    let mut out = Vec::with_capacity(outer.len());
322    for row in outer {
323        let items = row
324            .as_list()
325            .ok_or_else(|| wrong_kind(slots, name, "a list of coordinate rows", row))?;
326        if items.len() != N {
327            return Err(slots.degenerate(format!(
328                "{name} row has {} entries, expected {N}",
329                items.len()
330            )));
331        }
332        let mut coords = [0.0; N];
333        for (dst, src) in coords.iter_mut().zip(items) {
334            *dst = src
335                .unwrap_typed()
336                .as_f64()
337                .ok_or_else(|| wrong_kind(slots, name, "numeric coordinates", src))?;
338        }
339        out.push(coords);
340    }
341    Ok(out)
342}
343
344/// Build a `WrongValueKind` error for a nested aggregate.
345///
346/// `Slots` keeps its own equivalent private, and duplicating the message shape
347/// here would let the two drift; this stays a one-liner over the public enum.
348fn wrong_kind(
349    slots: &Slots<'_>,
350    attribute: &'static str,
351    expected: &'static str,
352    found: &Value,
353) -> GeometryError {
354    GeometryError::WrongValueKind {
355        entity: slots.id(),
356        type_name: slots.type_name().to_string(),
357        attribute,
358        expected,
359        found: format!("{found:?}"),
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn reals(values: &[f64]) -> Value {
368        Value::List(values.iter().copied().map(Value::Real).collect())
369    }
370
371    fn point_entity(values: &[f64]) -> Entity {
372        Entity::new("IFCCARTESIANPOINT", vec![reals(values)])
373    }
374
375    #[test]
376    fn two_dimensional_points_are_not_silently_promoted_to_3d() {
377        let e = point_entity(&[1.0, 2.0]);
378        let p = CartesianPoint::new(EntityId(1), &e);
379        assert_eq!(p.dimension().unwrap(), 2, "the file wrote two coordinates");
380        assert_eq!(p.coordinates().unwrap(), vec![1.0, 2.0]);
381        // Padding happens only when the caller asks for it.
382        assert_eq!(p.coordinates_3d().unwrap(), [1.0, 2.0, 0.0]);
383    }
384
385    #[test]
386    fn three_dimensional_points_keep_their_z() {
387        let e = point_entity(&[1.0, 2.0, 3.0]);
388        let p = CartesianPoint::new(EntityId(1), &e);
389        assert_eq!(p.dimension().unwrap(), 3);
390        assert_eq!(p.coordinates_3d().unwrap(), [1.0, 2.0, 3.0]);
391    }
392
393    /// `LIST [1:3]` permits one entry syntactically; the `CP2Dor3D` rule does
394    /// not, and a one-coordinate point has no geometric meaning.
395    #[test]
396    fn a_single_coordinate_is_degenerate_rather_than_zero_padded() {
397        let e = point_entity(&[1.0]);
398        let p = CartesianPoint::new(EntityId(9), &e);
399        assert!(p.dimension().is_err());
400        assert!(p.coordinates_3d().is_err());
401    }
402
403    #[test]
404    fn a_missing_coordinate_list_names_the_entity() {
405        let e = Entity::new("IFCCARTESIANPOINT", vec![]);
406        let err = CartesianPoint::new(EntityId(7), &e)
407            .coordinates()
408            .unwrap_err();
409        assert!(err.to_string().contains("#7"), "got: {err}");
410        assert!(err.to_string().contains("Coordinates"), "got: {err}");
411    }
412
413    #[test]
414    fn typed_length_measures_do_not_hide_the_number() {
415        let e = Entity::new(
416            "IFCCARTESIANPOINT",
417            vec![Value::List(vec![
418                Value::Typed {
419                    type_name: "IFCLENGTHMEASURE".into(),
420                    value: Box::new(Value::Real(4.0)),
421                },
422                Value::Integer(0),
423            ])],
424        );
425        let p = CartesianPoint::new(EntityId(1), &e);
426        assert_eq!(p.coordinates_3d().unwrap(), [4.0, 0.0, 0.0]);
427    }
428
429    #[test]
430    fn point_on_curve_exposes_its_basis_and_parameter() {
431        let e = Entity::new(
432            "IFCPOINTONCURVE",
433            vec![
434                Value::Ref(EntityId(5)),
435                Value::Typed {
436                    type_name: "IFCPARAMETERVALUE".into(),
437                    value: Box::new(Value::Real(0.25)),
438                },
439            ],
440        );
441        let p = PointOnCurve::new(EntityId(1), &e);
442        assert_eq!(p.basis_curve().unwrap(), EntityId(5));
443        assert_eq!(p.point_parameter().unwrap(), 0.25);
444    }
445
446    #[test]
447    fn point_on_surface_exposes_both_parameters_in_order() {
448        let e = Entity::new(
449            "IFCPOINTONSURFACE",
450            vec![
451                Value::Ref(EntityId(5)),
452                Value::Real(0.25),
453                Value::Real(0.75),
454            ],
455        );
456        let p = PointOnSurface::new(EntityId(1), &e);
457        assert_eq!(p.basis_surface().unwrap(), EntityId(5));
458        assert_eq!(p.parameters().unwrap(), (0.25, 0.75));
459    }
460
461    #[test]
462    fn point_lists_read_every_row_in_file_order() {
463        let e = Entity::new(
464            "IFCCARTESIANPOINTLIST3D",
465            vec![Value::List(vec![
466                reals(&[0.0, 0.0, 0.0]),
467                reals(&[1.0, 0.0, 0.0]),
468                reals(&[1.0, 1.0, 0.0]),
469            ])],
470        );
471        let list = CartesianPointList3D::new(EntityId(1), &e);
472        let coords = list.coordinates().unwrap();
473        assert_eq!(coords.len(), 3);
474        assert_eq!(coords[2], [1.0, 1.0, 0.0]);
475    }
476
477    /// IFC index attributes are 1-based; treating them as 0-based shifts every
478    /// triangle by one vertex, which renders as plausible-looking garbage.
479    #[test]
480    fn point_list_indices_are_one_based_and_zero_is_out_of_range() {
481        let e = Entity::new(
482            "IFCCARTESIANPOINTLIST2D",
483            vec![Value::List(vec![reals(&[7.0, 8.0]), reals(&[9.0, 10.0])])],
484        );
485        let list = CartesianPointList2D::new(EntityId(1), &e);
486        assert_eq!(list.point(1).unwrap(), Some([7.0, 8.0]));
487        assert_eq!(list.point(2).unwrap(), Some([9.0, 10.0]));
488        assert_eq!(list.point(0).unwrap(), None, "there is no index 0 in IFC");
489        assert_eq!(list.point(3).unwrap(), None);
490    }
491
492    /// A 3D row inside a 2D list is a real exporter bug; truncating it would
493    /// silently drop the z of every vertex.
494    #[test]
495    fn a_row_of_the_wrong_width_fails_instead_of_being_truncated() {
496        let e = Entity::new(
497            "IFCCARTESIANPOINTLIST2D",
498            vec![Value::List(vec![reals(&[1.0, 2.0, 3.0])])],
499        );
500        let err = CartesianPointList2D::new(EntityId(4), &e)
501            .coordinates()
502            .unwrap_err();
503        assert!(err.to_string().contains("#4"), "got: {err}");
504    }
505
506    #[test]
507    fn resolving_a_point_reference_rejects_the_wrong_entity_type() {
508        let mut model = Model::new();
509        model.insert(EntityId(1), Entity::new("IFCDIRECTION", vec![]));
510        let err = cartesian_point_3d(&model, EntityId(2), EntityId(1)).unwrap_err();
511        assert!(matches!(
512            err,
513            GeometryError::WrongEntityType {
514                expected: "IfcCartesianPoint",
515                ..
516            }
517        ));
518    }
519
520    #[test]
521    fn resolving_a_dangling_point_reference_names_the_referrer() {
522        let model = Model::new();
523        let err = cartesian_point_3d(&model, EntityId(2), EntityId(99)).unwrap_err();
524        assert_eq!(err.entity(), Some(EntityId(2)));
525    }
526
527    #[test]
528    fn resolving_a_point_reference_yields_its_coordinates() {
529        let mut model = Model::new();
530        model.insert(EntityId(1), point_entity(&[1.0, 2.0, 3.0]));
531        assert_eq!(
532            cartesian_point_3d(&model, EntityId(2), EntityId(1)).unwrap(),
533            [1.0, 2.0, 3.0]
534        );
535    }
536}