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/// Resolve a reference that must be an `IfcCartesianPoint`, promoted to 3D.
267///
268/// Placements, operators and curves all need exactly this, and each of them
269/// getting the dangling-reference and wrong-type errors right independently is
270/// how those errors end up inconsistent.
271pub fn cartesian_point_3d(
272    model: &Model,
273    referrer: EntityId,
274    id: EntityId,
275) -> GeometryResult<[f64; 3]> {
276    let entity = model.get(id).ok_or(GeometryError::MissingEntity {
277        referrer,
278        missing: id,
279    })?;
280    if !entity.is_type("IFCCARTESIANPOINT") {
281        return Err(GeometryError::WrongEntityType {
282            entity: id,
283            actual: entity.type_name.to_string(),
284            expected: "IfcCartesianPoint",
285        });
286    }
287    CartesianPoint::new(id, entity).coordinates_3d()
288}
289
290/// Read a `LIST OF LIST OF REAL` where every row has exactly `N` entries.
291fn rows<const N: usize>(
292    slots: &Slots<'_>,
293    index: usize,
294    name: &'static str,
295) -> GeometryResult<Vec<[f64; N]>> {
296    let value = slots.req(index, name)?;
297    let outer = value
298        .as_list()
299        .ok_or_else(|| wrong_kind(slots, name, "a list of coordinate rows", value))?;
300
301    let mut out = Vec::with_capacity(outer.len());
302    for row in outer {
303        let items = row
304            .as_list()
305            .ok_or_else(|| wrong_kind(slots, name, "a list of coordinate rows", row))?;
306        if items.len() != N {
307            return Err(slots.degenerate(format!(
308                "{name} row has {} entries, expected {N}",
309                items.len()
310            )));
311        }
312        let mut coords = [0.0; N];
313        for (dst, src) in coords.iter_mut().zip(items) {
314            *dst = src
315                .unwrap_typed()
316                .as_f64()
317                .ok_or_else(|| wrong_kind(slots, name, "numeric coordinates", src))?;
318        }
319        out.push(coords);
320    }
321    Ok(out)
322}
323
324/// Build a `WrongValueKind` error for a nested aggregate.
325///
326/// `Slots` keeps its own equivalent private, and duplicating the message shape
327/// here would let the two drift; this stays a one-liner over the public enum.
328fn wrong_kind(
329    slots: &Slots<'_>,
330    attribute: &'static str,
331    expected: &'static str,
332    found: &Value,
333) -> GeometryError {
334    GeometryError::WrongValueKind {
335        entity: slots.id(),
336        type_name: slots.type_name().to_string(),
337        attribute,
338        expected,
339        found: format!("{found:?}"),
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn reals(values: &[f64]) -> Value {
348        Value::List(values.iter().copied().map(Value::Real).collect())
349    }
350
351    fn point_entity(values: &[f64]) -> Entity {
352        Entity::new("IFCCARTESIANPOINT", vec![reals(values)])
353    }
354
355    #[test]
356    fn two_dimensional_points_are_not_silently_promoted_to_3d() {
357        let e = point_entity(&[1.0, 2.0]);
358        let p = CartesianPoint::new(EntityId(1), &e);
359        assert_eq!(p.dimension().unwrap(), 2, "the file wrote two coordinates");
360        assert_eq!(p.coordinates().unwrap(), vec![1.0, 2.0]);
361        // Padding happens only when the caller asks for it.
362        assert_eq!(p.coordinates_3d().unwrap(), [1.0, 2.0, 0.0]);
363    }
364
365    #[test]
366    fn three_dimensional_points_keep_their_z() {
367        let e = point_entity(&[1.0, 2.0, 3.0]);
368        let p = CartesianPoint::new(EntityId(1), &e);
369        assert_eq!(p.dimension().unwrap(), 3);
370        assert_eq!(p.coordinates_3d().unwrap(), [1.0, 2.0, 3.0]);
371    }
372
373    /// `LIST [1:3]` permits one entry syntactically; the `CP2Dor3D` rule does
374    /// not, and a one-coordinate point has no geometric meaning.
375    #[test]
376    fn a_single_coordinate_is_degenerate_rather_than_zero_padded() {
377        let e = point_entity(&[1.0]);
378        let p = CartesianPoint::new(EntityId(9), &e);
379        assert!(p.dimension().is_err());
380        assert!(p.coordinates_3d().is_err());
381    }
382
383    #[test]
384    fn a_missing_coordinate_list_names_the_entity() {
385        let e = Entity::new("IFCCARTESIANPOINT", vec![]);
386        let err = CartesianPoint::new(EntityId(7), &e)
387            .coordinates()
388            .unwrap_err();
389        assert!(err.to_string().contains("#7"), "got: {err}");
390        assert!(err.to_string().contains("Coordinates"), "got: {err}");
391    }
392
393    #[test]
394    fn typed_length_measures_do_not_hide_the_number() {
395        let e = Entity::new(
396            "IFCCARTESIANPOINT",
397            vec![Value::List(vec![
398                Value::Typed {
399                    type_name: "IFCLENGTHMEASURE".into(),
400                    value: Box::new(Value::Real(4.0)),
401                },
402                Value::Integer(0),
403            ])],
404        );
405        let p = CartesianPoint::new(EntityId(1), &e);
406        assert_eq!(p.coordinates_3d().unwrap(), [4.0, 0.0, 0.0]);
407    }
408
409    #[test]
410    fn point_on_curve_exposes_its_basis_and_parameter() {
411        let e = Entity::new(
412            "IFCPOINTONCURVE",
413            vec![
414                Value::Ref(EntityId(5)),
415                Value::Typed {
416                    type_name: "IFCPARAMETERVALUE".into(),
417                    value: Box::new(Value::Real(0.25)),
418                },
419            ],
420        );
421        let p = PointOnCurve::new(EntityId(1), &e);
422        assert_eq!(p.basis_curve().unwrap(), EntityId(5));
423        assert_eq!(p.point_parameter().unwrap(), 0.25);
424    }
425
426    #[test]
427    fn point_on_surface_exposes_both_parameters_in_order() {
428        let e = Entity::new(
429            "IFCPOINTONSURFACE",
430            vec![
431                Value::Ref(EntityId(5)),
432                Value::Real(0.25),
433                Value::Real(0.75),
434            ],
435        );
436        let p = PointOnSurface::new(EntityId(1), &e);
437        assert_eq!(p.basis_surface().unwrap(), EntityId(5));
438        assert_eq!(p.parameters().unwrap(), (0.25, 0.75));
439    }
440
441    #[test]
442    fn point_lists_read_every_row_in_file_order() {
443        let e = Entity::new(
444            "IFCCARTESIANPOINTLIST3D",
445            vec![Value::List(vec![
446                reals(&[0.0, 0.0, 0.0]),
447                reals(&[1.0, 0.0, 0.0]),
448                reals(&[1.0, 1.0, 0.0]),
449            ])],
450        );
451        let list = CartesianPointList3D::new(EntityId(1), &e);
452        let coords = list.coordinates().unwrap();
453        assert_eq!(coords.len(), 3);
454        assert_eq!(coords[2], [1.0, 1.0, 0.0]);
455    }
456
457    /// IFC index attributes are 1-based; treating them as 0-based shifts every
458    /// triangle by one vertex, which renders as plausible-looking garbage.
459    #[test]
460    fn point_list_indices_are_one_based_and_zero_is_out_of_range() {
461        let e = Entity::new(
462            "IFCCARTESIANPOINTLIST2D",
463            vec![Value::List(vec![reals(&[7.0, 8.0]), reals(&[9.0, 10.0])])],
464        );
465        let list = CartesianPointList2D::new(EntityId(1), &e);
466        assert_eq!(list.point(1).unwrap(), Some([7.0, 8.0]));
467        assert_eq!(list.point(2).unwrap(), Some([9.0, 10.0]));
468        assert_eq!(list.point(0).unwrap(), None, "there is no index 0 in IFC");
469        assert_eq!(list.point(3).unwrap(), None);
470    }
471
472    /// A 3D row inside a 2D list is a real exporter bug; truncating it would
473    /// silently drop the z of every vertex.
474    #[test]
475    fn a_row_of_the_wrong_width_fails_instead_of_being_truncated() {
476        let e = Entity::new(
477            "IFCCARTESIANPOINTLIST2D",
478            vec![Value::List(vec![reals(&[1.0, 2.0, 3.0])])],
479        );
480        let err = CartesianPointList2D::new(EntityId(4), &e)
481            .coordinates()
482            .unwrap_err();
483        assert!(err.to_string().contains("#4"), "got: {err}");
484    }
485
486    #[test]
487    fn resolving_a_point_reference_rejects_the_wrong_entity_type() {
488        let mut model = Model::new();
489        model.insert(EntityId(1), Entity::new("IFCDIRECTION", vec![]));
490        let err = cartesian_point_3d(&model, EntityId(2), EntityId(1)).unwrap_err();
491        assert!(matches!(
492            err,
493            GeometryError::WrongEntityType {
494                expected: "IfcCartesianPoint",
495                ..
496            }
497        ));
498    }
499
500    #[test]
501    fn resolving_a_dangling_point_reference_names_the_referrer() {
502        let model = Model::new();
503        let err = cartesian_point_3d(&model, EntityId(2), EntityId(99)).unwrap_err();
504        assert_eq!(err.entity(), Some(EntityId(2)));
505    }
506
507    #[test]
508    fn resolving_a_point_reference_yields_its_coordinates() {
509        let mut model = Model::new();
510        model.insert(EntityId(1), point_entity(&[1.0, 2.0, 3.0]));
511        assert_eq!(
512            cartesian_point_3d(&model, EntityId(2), EntityId(1)).unwrap(),
513            [1.0, 2.0, 3.0]
514        );
515    }
516}