ifc_lite_geometry/processors/tessellated/triangulated.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use crate::{Error, Mesh, Result, TessellationQuality};
6use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
7
8use crate::router::GeometryProcessor;
9
10use super::polygonal::PolygonalFaceSetProcessor;
11
12/// TriangulatedFaceSet processor (P0)
13/// Handles IfcTriangulatedFaceSet - explicit triangle meshes
14pub struct TriangulatedFaceSetProcessor;
15
16impl TriangulatedFaceSetProcessor {
17 pub fn new() -> Self {
18 Self
19 }
20
21 /// Parse an `IfcTriangulatedFaceSet`'s positions + triangle indices and
22 /// apply the closed-shell outward orientation. Returns
23 /// `(positions, indices, flipped)` where `flipped` is whether the whole
24 /// shell was winding-flipped — the texture path needs it to keep the
25 /// parallel `TexCoordIndex` in lockstep (#961). Shared by `process` and
26 /// [`Self::process_with_texture`] so there is one parse/orient code path.
27 fn parse_positions_and_orient(
28 entity: &DecodedEntity,
29 decoder: &mut EntityDecoder,
30 ) -> Result<(Vec<f32>, Vec<u32>, bool)> {
31 // IfcTriangulatedFaceSet attributes:
32 // 0: Coordinates (IfcCartesianPointList3D)
33 // 1: Normals (optional)
34 // 2: Closed (optional)
35 // 3: CoordIndex (list of list of IfcPositiveInteger)
36
37 // Get coordinate entity reference
38 let coords_attr = entity.get(0).ok_or_else(|| {
39 Error::geometry("TriangulatedFaceSet missing Coordinates".to_string())
40 })?;
41
42 let coord_entity_id = coords_attr.as_entity_ref().ok_or_else(|| {
43 Error::geometry("Expected entity reference for Coordinates".to_string())
44 })?;
45
46 // FAST PATH: Try direct parsing of raw bytes (3-5x faster)
47 // This bypasses Token/AttributeValue allocations entirely
48 use ifc_lite_core::{extract_coordinate_list_from_entity, parse_indices_direct};
49
50 let positions = if let Some(raw_bytes) = decoder.get_raw_bytes(coord_entity_id) {
51 // Fast path: parse coordinates directly from raw bytes
52 // Use extract_coordinate_list_from_entity to skip entity header (#N=IFCTYPE...)
53 extract_coordinate_list_from_entity(raw_bytes).unwrap_or_default()
54 } else {
55 // Fallback path: use standard decoding
56 let coords_entity = decoder.decode_by_id(coord_entity_id)?;
57
58 let coord_list_attr = coords_entity.get(0).ok_or_else(|| {
59 Error::geometry("CartesianPointList3D missing CoordList".to_string())
60 })?;
61
62 let coord_list = coord_list_attr
63 .as_list()
64 .ok_or_else(|| Error::geometry("Expected coordinate list".to_string()))?;
65
66 use ifc_lite_core::AttributeValue;
67 AttributeValue::parse_coordinate_list_3d(coord_list)
68 };
69
70 // Get face indices - try fast path first
71 let indices_attr = entity
72 .get(3)
73 .ok_or_else(|| Error::geometry("TriangulatedFaceSet missing CoordIndex".to_string()))?;
74
75 // For indices, we need to extract from the main entity's raw bytes
76 // Fast path: parse directly if we can get the raw CoordIndex section
77 let indices = if let Some(raw_entity_bytes) = decoder.get_raw_bytes(entity.id) {
78 // Find the CoordIndex attribute (4th attribute, index 3)
79 // and parse directly
80 if let Some(coord_index_bytes) = super::super::extract_coord_index_bytes(raw_entity_bytes) {
81 parse_indices_direct(coord_index_bytes)
82 } else {
83 // Fallback to standard parsing
84 let face_list = indices_attr
85 .as_list()
86 .ok_or_else(|| Error::geometry("Expected face index list".to_string()))?;
87 use ifc_lite_core::AttributeValue;
88 AttributeValue::parse_index_list(face_list)
89 }
90 } else {
91 let face_list = indices_attr
92 .as_list()
93 .ok_or_else(|| Error::geometry("Expected face index list".to_string()))?;
94 use ifc_lite_core::AttributeValue;
95 AttributeValue::parse_index_list(face_list)
96 };
97
98 // Read Closed (attribute 2): .T. means definitely closed, .F. means
99 // definitely open, $ / UNKNOWN means "not specified". Revit-exported
100 // light fixtures and similar families in IFC4 often omit Closed
101 // ($) but still author closed shells — sometimes with inward-facing
102 // winding (issue #819, IFC4TessellationComplex.ifc). Mirror the
103 // PolygonalFaceSet orientation pass but be less strict: also apply
104 // it when Closed is unknown, never when explicitly .F.
105 let closed_attr = entity.get(2);
106 let is_open = closed_attr
107 .and_then(|a| a.as_enum())
108 .map(|v| v == "F")
109 .unwrap_or(false);
110
111 let mut indices = indices;
112 let flipped = if !is_open {
113 PolygonalFaceSetProcessor::orient_closed_shell_outward(&positions, &mut indices)
114 } else {
115 false
116 };
117
118 Ok((positions, indices, flipped))
119 }
120
121 /// Tessellate a textured `IfcTriangulatedFaceSet` (#961): builds the same
122 /// flat-shaded mesh as [`process`] plus a per-vertex UV array aligned 1:1
123 /// with the emitted vertices. `map.tex_coord_index` is parallel to the
124 /// original `CoordIndex`; the same whole-shell winding flip is applied to it
125 /// so corners stay aligned after orientation.
126 pub fn process_with_texture(
127 &self,
128 entity: &DecodedEntity,
129 decoder: &mut EntityDecoder,
130 map: &crate::processors::texture::ResolvedTextureMap,
131 ) -> Result<(Mesh, Vec<f32>)> {
132 let (positions, indices, flipped) = Self::parse_positions_and_orient(entity, decoder)?;
133 let tex_coord_index = match &map.tex_coord_index {
134 Some(authored) => {
135 let mut idx = authored.clone();
136 if flipped {
137 for tri in idx.iter_mut() {
138 tri.swap(1, 2);
139 }
140 }
141 idx
142 }
143 // TexCoordIndex omitted (`$`): texture vertices pair 1:1 with the
144 // face set's Coordinates, so the CoordIndex IS the UV index (#1781).
145 // Derived from the POST-orientation `indices` (0-based → 1-based),
146 // so the whole-shell winding flip is already reflected — no swap.
147 None => indices
148 .chunks_exact(3)
149 .map(|t| [t[0] + 1, t[1] + 1, t[2] + 1])
150 .collect(),
151 };
152 let (mut mesh, uvs) = PolygonalFaceSetProcessor::build_flat_shaded_mesh_with_uvs(
153 &positions,
154 &indices,
155 &map.tex_coords,
156 &tex_coord_index,
157 );
158 mesh.validate_indices();
159 Ok((mesh, uvs))
160 }
161}
162
163impl GeometryProcessor for TriangulatedFaceSetProcessor {
164 #[inline]
165 fn process(
166 &self,
167 entity: &DecodedEntity,
168 decoder: &mut EntityDecoder,
169 _schema: &IfcSchema,
170 _quality: TessellationQuality,
171 ) -> Result<Mesh> {
172 let (positions, indices, _flipped) = Self::parse_positions_and_orient(entity, decoder)?;
173
174 // Flat-shade by duplicating vertices per-triangle. Without this, the
175 // downstream per-vertex normal accumulator (`csg::calculate_normals`)
176 // averages adjacent face normals at every shared vertex, which
177 // softens crisp facet edges into a muddy gradient on faceted
178 // geometry — visible in issue #819 on `IFC4TessellationComplex.ifc`
179 // where the user contrasted ifc-lite's smoothed dome with the
180 // facet-sharp BIMVision render. `PolygonalFaceSetProcessor` already
181 // does this; bringing `IfcTriangulatedFaceSet` to parity matches
182 // IfcOpenShell / web-ifc behaviour for `Normals = $`.
183 //
184 // 3× vertex bloat. Acceptable for Revit lighting/family export
185 // sizes; if it ever becomes a bottleneck on giant tessellated
186 // models, gate this on per-edge crease angle.
187 let mut mesh = PolygonalFaceSetProcessor::build_flat_shaded_mesh(&positions, &indices);
188 mesh.validate_indices();
189 Ok(mesh)
190 }
191
192 fn supported_types(&self) -> Vec<IfcType> {
193 // IfcTriangulatedIrregularNetwork is a subtype of IfcTriangulatedFaceSet
194 // that adds an optional `ClosedOrOpen` list at the end and is used for
195 // terrain (TIN) surfaces. We don't read the extra attribute and the
196 // inherited Coordinates / Closed / CoordIndex layout is identical, so
197 // routing TIN through the same processor is correct.
198 vec![
199 IfcType::IfcTriangulatedFaceSet,
200 IfcType::IfcTriangulatedIrregularNetwork,
201 ]
202 }
203}
204
205impl Default for TriangulatedFaceSetProcessor {
206 fn default() -> Self {
207 Self::new()
208 }
209}