Skip to main content

ifc_geometry/authoring/
tessellation.rs

1//! Tessellated geometry: meshes carried as shared points plus indices.
2//!
3//! # The 1-based index invariant
4//!
5//! `CoordIndex`, `PnIndex` and `InnerCoordIndices` all count from 1
6//! ([`crate::solid`]). Rust counts from 0. Every writer here takes
7//! **0-based** indices, the only choice that composes with a caller's
8//! own vertex buffers, and converts on the way in. A caller who
9//! already holds 1-based data would otherwise have to decrement before
10//! calling and this module would have to trust that they did.
11//!
12//! The conversion is where a mesh silently deforms: an off-by-one
13//! shifts every triangle onto its neighbour's vertices, which still
14//! parses, still renders, and is wrong. So the bound check happens
15//! against the point list the indices refer to, not against the index
16//! values alone.
17//!
18//! Nothing here evaluates: a face set is a description of a mesh, and
19//! writing it needs no kernel (ADR 0011).
20
21use ifc_model::{Entity, EntityId, Transaction, Value};
22
23use crate::error::GeometryError;
24
25use super::{invalid, require_finite};
26
27/// `IfcCartesianPointList2D`/`3D`: coordinates shared by index.
28const LIST_2D: &str = "IFCCARTESIANPOINTLIST2D";
29const LIST_3D: &str = "IFCCARTESIANPOINTLIST3D";
30
31/// Slots for the tessellation entities, from IFC4 ADD2.
32mod slot {
33    /// `IfcCartesianPointList*.CoordList`.
34    pub const COORD_LIST: usize = 0;
35    /// `IfcCartesianPointList*.TagList`.
36    pub const TAG_LIST: usize = 1;
37    /// `IfcTessellatedFaceSet.Coordinates`, inherited by both face sets.
38    pub const COORDINATES: usize = 0;
39    /// `IfcTriangulatedFaceSet.Normals`.
40    pub const TRI_NORMALS: usize = 1;
41    /// `IfcTriangulatedFaceSet.Closed`.
42    pub const TRI_CLOSED: usize = 2;
43    /// `IfcTriangulatedFaceSet.CoordIndex`.
44    pub const TRI_COORD_INDEX: usize = 3;
45    /// `IfcTriangulatedFaceSet.PnIndex`.
46    pub const TRI_PN_INDEX: usize = 4;
47    /// `IfcPolygonalFaceSet.Closed`.
48    pub const POLY_CLOSED: usize = 1;
49    /// `IfcPolygonalFaceSet.Faces`.
50    pub const POLY_FACES: usize = 2;
51    /// `IfcPolygonalFaceSet.PnIndex`.
52    pub const POLY_PN_INDEX: usize = 3;
53    /// `IfcIndexedPolygonalFace.CoordIndex`.
54    pub const FACE_COORD_INDEX: usize = 0;
55    /// `IfcIndexedPolygonalFaceWithVoids.InnerCoordIndices`.
56    pub const FACE_INNER: usize = 1;
57}
58
59/// Convert one 0-based index, refusing anything outside the point list.
60///
61/// The upper bound is the reason this is not a bare `+ 1`: an index
62/// past the end produces a record that parses and denotes a vertex
63/// that does not exist.
64fn index_1based(
65    type_name: &'static str,
66    attribute: &'static str,
67    value: usize,
68    point_count: usize,
69) -> Result<Value, GeometryError> {
70    if value >= point_count {
71        return Err(invalid(
72            type_name,
73            attribute,
74            format!("index {value} is outside a point list of {point_count}"),
75        ));
76    }
77    // IfcPositiveInteger: the schema counts from 1.
78    Ok(Value::Integer(value as i64 + 1))
79}
80
81/// Stage an `IfcCartesianPointList3D`.
82///
83/// Tags are optional but, when given, must match the point count: the
84/// schema pairs them positionally, so a short list silently retags the
85/// wrong vertices.
86///
87/// # Errors
88///
89/// Refuses an empty list, a non-finite coordinate, or a tag list whose
90/// length disagrees with the points.
91pub fn cartesian_point_list_3d(
92    tx: &mut Transaction,
93    points: &[[f64; 3]],
94    tags: Option<&[&str]>,
95) -> Result<EntityId, GeometryError> {
96    point_list(
97        tx,
98        LIST_3D,
99        points.iter().map(|p| p.as_slice()),
100        points.len(),
101        tags,
102    )
103}
104
105/// Stage an `IfcCartesianPointList2D`.
106///
107/// # Errors
108///
109/// As [`cartesian_point_list_3d`].
110pub fn cartesian_point_list_2d(
111    tx: &mut Transaction,
112    points: &[[f64; 2]],
113    tags: Option<&[&str]>,
114) -> Result<EntityId, GeometryError> {
115    point_list(
116        tx,
117        LIST_2D,
118        points.iter().map(|p| p.as_slice()),
119        points.len(),
120        tags,
121    )
122}
123
124/// Shared body: the two lists differ only in the width of a row.
125fn point_list<'p>(
126    tx: &mut Transaction,
127    type_name: &'static str,
128    rows: impl Iterator<Item = &'p [f64]>,
129    count: usize,
130    tags: Option<&[&str]>,
131) -> Result<EntityId, GeometryError> {
132    if count == 0 {
133        return Err(invalid(
134            type_name,
135            "CoordList",
136            "expected at least one point",
137        ));
138    }
139    let mut coords = Vec::with_capacity(count);
140    for row in rows {
141        require_finite(type_name, "CoordList", row)?;
142        coords.push(Value::List(row.iter().copied().map(Value::Real).collect()));
143    }
144    // `TagList` is an IFC4X3 addition: IFC4 declares CoordList alone.
145    // Writing a trailing `$` would make the record two attributes wide
146    // and invalid against IFC4, so the slot only appears when the
147    // caller actually supplies tags -- and a caller who does is
148    // asking for IFC4X3 by construction.
149    let mut attrs = vec![Value::Null; slot::COORD_LIST + 1];
150    attrs[slot::COORD_LIST] = Value::List(coords);
151    if let Some(tags) = tags {
152        if tags.len() != count {
153            return Err(invalid(
154                type_name,
155                "TagList",
156                format!("{} tags for {count} points", tags.len()),
157            ));
158        }
159        attrs.resize(slot::TAG_LIST + 1, Value::Null);
160        attrs[slot::TAG_LIST] =
161            Value::List(tags.iter().map(|t| Value::Text((*t).into())).collect());
162    }
163    Ok(tx.create(Entity::new(type_name, attrs)))
164}
165
166/// The optional parts of an `IfcTriangulatedFaceSet`.
167#[derive(Debug, Default, Clone, Copy)]
168pub struct TriangulatedExtras<'a> {
169    /// `Closed`: whether the triangles bound a solid.
170    ///
171    /// Left unset when unknown. `false` is a claim that the mesh is
172    /// open, which is not the same as declining to say.
173    pub closed: Option<bool>,
174    /// `Normals`, one per vertex or per face as the schema allows.
175    pub normals: Option<&'a [[f64; 3]]>,
176    /// `PnIndex`, 0-based here; converted on the way in.
177    pub pn_index: Option<&'a [usize]>,
178}
179
180/// Stage an `IfcTriangulatedFaceSet` over an existing point list.
181///
182/// `triangles` are **0-based** indices into `points`, which must be the
183/// list `coordinates` refers to: the count is what bounds the indices.
184///
185/// # Errors
186///
187/// Refuses an empty triangle list, an index outside the point list, or
188/// a non-finite normal.
189pub fn triangulated_face_set(
190    tx: &mut Transaction,
191    coordinates: EntityId,
192    point_count: usize,
193    triangles: &[[usize; 3]],
194    extras: TriangulatedExtras<'_>,
195) -> Result<EntityId, GeometryError> {
196    let attrs = triangulated_attrs(
197        "IFCTRIANGULATEDFACESET",
198        5,
199        coordinates,
200        point_count,
201        triangles,
202        extras,
203    )?;
204    Ok(tx.create(Entity::new("IFCTRIANGULATEDFACESET", attrs)))
205}
206
207/// The shared body of the triangulated face sets.
208///
209/// `IfcTriangulatedIrregularNetwork` adds a sixth slot but is otherwise
210/// identical, so the index checking lives here rather than twice.
211fn triangulated_attrs(
212    type_name: &'static str,
213    arity: usize,
214    coordinates: EntityId,
215    point_count: usize,
216    triangles: &[[usize; 3]],
217    extras: TriangulatedExtras<'_>,
218) -> Result<Vec<Value>, GeometryError> {
219    if triangles.is_empty() {
220        return Err(invalid(
221            type_name,
222            "CoordIndex",
223            "expected at least one triangle",
224        ));
225    }
226    let mut indexed = Vec::with_capacity(triangles.len());
227    for triangle in triangles {
228        let mut row = Vec::with_capacity(3);
229        for value in triangle {
230            row.push(index_1based(type_name, "CoordIndex", *value, point_count)?);
231        }
232        indexed.push(Value::List(row));
233    }
234
235    let mut attrs = vec![Value::Null; arity];
236    attrs[slot::COORDINATES] = Value::Ref(coordinates);
237    attrs[slot::TRI_COORD_INDEX] = Value::List(indexed);
238    if let Some(closed) = extras.closed {
239        attrs[slot::TRI_CLOSED] = Value::Bool(closed);
240    }
241    if let Some(normals) = extras.normals {
242        let mut rows = Vec::with_capacity(normals.len());
243        for normal in normals {
244            require_finite(type_name, "Normals", normal)?;
245            rows.push(Value::List(
246                normal.iter().copied().map(Value::Real).collect(),
247            ));
248        }
249        attrs[slot::TRI_NORMALS] = Value::List(rows);
250    }
251    if let Some(pn) = extras.pn_index {
252        let mut rows = Vec::with_capacity(pn.len());
253        for value in pn {
254            rows.push(index_1based(type_name, "PnIndex", *value, point_count)?);
255        }
256        attrs[slot::TRI_PN_INDEX] = Value::List(rows);
257    }
258    Ok(attrs)
259}
260
261/// Stage an `IfcIndexedPolygonalFace`.
262///
263/// `outer` is 0-based. The schema requires at least three vertices: a
264/// face through two points bounds no area.
265///
266/// # Errors
267///
268/// Refuses fewer than three vertices or an index outside the list.
269pub fn indexed_polygonal_face(
270    tx: &mut Transaction,
271    outer: &[usize],
272    point_count: usize,
273) -> Result<EntityId, GeometryError> {
274    const T: &str = "IFCINDEXEDPOLYGONALFACE";
275    let indices = face_loop(T, "CoordIndex", outer, point_count)?;
276    let mut attrs = vec![Value::Null];
277    attrs[slot::FACE_COORD_INDEX] = indices;
278    Ok(tx.create(Entity::new(T, attrs)))
279}
280
281/// Stage an `IfcIndexedPolygonalFaceWithVoids`.
282///
283/// Both the outer loop and every inner loop are 0-based here. The
284/// schema does not say the voids lie inside the outer loop -- checking
285/// that needs geometry, which this module does not do -- so a caller
286/// who passes a disjoint loop gets a face that parses and is wrong in
287/// a way only an evaluator can see.
288///
289/// # Errors
290///
291/// Refuses an empty void list (that is a plain
292/// [`indexed_polygonal_face`]), any loop under three vertices, or an
293/// index outside the list.
294pub fn indexed_polygonal_face_with_voids(
295    tx: &mut Transaction,
296    outer: &[usize],
297    voids: &[&[usize]],
298    point_count: usize,
299) -> Result<EntityId, GeometryError> {
300    const T: &str = "IFCINDEXEDPOLYGONALFACEWITHVOIDS";
301    if voids.is_empty() {
302        return Err(invalid(
303            T,
304            "InnerCoordIndices",
305            "expected at least one void; a face without voids is an IfcIndexedPolygonalFace",
306        ));
307    }
308    let outer_indices = face_loop(T, "CoordIndex", outer, point_count)?;
309    let mut inner = Vec::with_capacity(voids.len());
310    for loop_indices in voids {
311        inner.push(face_loop(
312            T,
313            "InnerCoordIndices",
314            loop_indices,
315            point_count,
316        )?);
317    }
318    let mut attrs = vec![Value::Null; 2];
319    attrs[slot::FACE_COORD_INDEX] = outer_indices;
320    attrs[slot::FACE_INNER] = Value::List(inner);
321    Ok(tx.create(Entity::new(T, attrs)))
322}
323
324/// One closed loop of at least three 0-based indices.
325fn face_loop(
326    type_name: &'static str,
327    attribute: &'static str,
328    indices: &[usize],
329    point_count: usize,
330) -> Result<Value, GeometryError> {
331    if indices.len() < 3 {
332        return Err(invalid(
333            type_name,
334            attribute,
335            format!("expected at least 3 vertices, got {}", indices.len()),
336        ));
337    }
338    let mut row = Vec::with_capacity(indices.len());
339    for value in indices {
340        row.push(index_1based(type_name, attribute, *value, point_count)?);
341    }
342    Ok(Value::List(row))
343}
344
345/// Stage an `IfcPolygonalFaceSet` over existing faces.
346///
347/// The faces must already be staged: they are `IfcIndexedPolygonalFace`
348/// records in their own right, and the schema requires the list be
349/// UNIQUE, so a face reused twice is refused here rather than left for
350/// a validator.
351///
352/// # Errors
353///
354/// Refuses an empty or duplicated face list, or a `PnIndex` entry
355/// outside the point list.
356pub fn polygonal_face_set(
357    tx: &mut Transaction,
358    coordinates: EntityId,
359    point_count: usize,
360    faces: &[EntityId],
361    closed: Option<bool>,
362    pn_index: Option<&[usize]>,
363) -> Result<EntityId, GeometryError> {
364    const T: &str = "IFCPOLYGONALFACESET";
365    if faces.is_empty() {
366        return Err(invalid(T, "Faces", "expected at least one face"));
367    }
368    let mut seen = faces.to_vec();
369    seen.sort_unstable();
370    seen.dedup();
371    if seen.len() != faces.len() {
372        return Err(invalid(
373            T,
374            "Faces",
375            "expected a UNIQUE face list, got a repeated face",
376        ));
377    }
378
379    let mut attrs = vec![Value::Null; 4];
380    attrs[slot::COORDINATES] = Value::Ref(coordinates);
381    attrs[slot::POLY_FACES] = Value::List(faces.iter().copied().map(Value::Ref).collect());
382    if let Some(closed) = closed {
383        attrs[slot::POLY_CLOSED] = Value::Bool(closed);
384    }
385    if let Some(pn) = pn_index {
386        let mut rows = Vec::with_capacity(pn.len());
387        for value in pn {
388            rows.push(index_1based(T, "PnIndex", *value, point_count)?);
389        }
390        attrs[slot::POLY_PN_INDEX] = Value::List(rows);
391    }
392    Ok(tx.create(Entity::new(T, attrs)))
393}
394
395/// Stage an `IfcTriangulatedIrregularNetwork`.
396///
397/// A terrain surface: the same triangulated face set plus `Flags`, one
398/// per triangle, marking break lines and holes.
399///
400/// `Closed` is forced to FALSE by `NotClosed` -- a TIN is a height
401/// field, not a solid, so it cannot enclose a volume. The caller does
402/// not choose it.
403///
404/// # Errors
405///
406/// Refuses an empty triangle list, an index outside the point list, a
407/// non-finite normal, and a `flags` length that does not match the
408/// triangle count.
409pub fn triangulated_irregular_network(
410    tx: &mut Transaction,
411    coordinates: EntityId,
412    point_count: usize,
413    triangles: &[[usize; 3]],
414    extras: TriangulatedExtras<'_>,
415    flags: &[i64],
416) -> Result<EntityId, GeometryError> {
417    const T: &str = "IFCTRIANGULATEDIRREGULARNETWORK";
418    const FLAGS: usize = 5;
419    if flags.len() != triangles.len() {
420        return Err(invalid(
421            T,
422            "Flags",
423            format!(
424                "expected one flag per triangle: {} triangles, {} flags",
425                triangles.len(),
426                flags.len()
427            ),
428        ));
429    }
430    let mut attrs = triangulated_attrs(T, 6, coordinates, point_count, triangles, extras)?;
431    // NotClosed: a terrain surface is a height field, never a solid.
432    attrs[slot::TRI_CLOSED] = Value::Bool(false);
433    attrs[FLAGS] = Value::List(flags.iter().copied().map(Value::Integer).collect());
434    Ok(tx.create(Entity::new(T, attrs)))
435}