Skip to main content

ifc_lite_geometry/processors/
surface.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
5//! SurfaceOfLinearExtrusion processor - surface sweep geometry.
6
7use crate::{Error, Mesh, Point2, Point3, Result, TessellationQuality, Vector3};
8use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
9use nalgebra::Matrix4;
10
11use super::helpers::{get_axis2_placement_transform_by_id, get_direction_by_id};
12use crate::router::GeometryProcessor;
13
14/// SurfaceOfLinearExtrusion processor
15/// Handles IfcSurfaceOfLinearExtrusion - surface created by sweeping a curve along a direction
16pub struct SurfaceOfLinearExtrusionProcessor;
17
18#[path = "curve_walk.rs"]
19mod curve_walk;
20use curve_walk::{CurveWalk, MAX_CURVE_NODES, SEAM_EPS};
21
22impl SurfaceOfLinearExtrusionProcessor {
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28impl GeometryProcessor for SurfaceOfLinearExtrusionProcessor {
29    fn process(
30        &self,
31        entity: &DecodedEntity,
32        decoder: &mut EntityDecoder,
33        _schema: &IfcSchema,
34        _quality: TessellationQuality,
35    ) -> Result<Mesh> {
36        // IfcSurfaceOfLinearExtrusion attributes:
37        // 0: SweptCurve (IfcProfileDef - usually IfcArbitraryOpenProfileDef)
38        // 1: Position (IfcAxis2Placement3D)
39        // 2: ExtrudedDirection (IfcDirection)
40        // 3: Depth (length)
41
42        // Get the swept curve (profile)
43        let curve_attr = entity.get(0).ok_or_else(|| {
44            Error::geometry("SurfaceOfLinearExtrusion missing SweptCurve".to_string())
45        })?;
46
47        let curve_id = curve_attr.as_entity_ref().ok_or_else(|| {
48            Error::geometry("Expected entity reference for SweptCurve".to_string())
49        })?;
50
51        // Get position
52        let position_attr = entity.get(1);
53        let position_transform = if let Some(attr) = position_attr {
54            if let Some(pos_id) = attr.as_entity_ref() {
55                get_axis2_placement_transform_by_id(pos_id, decoder)?
56            } else {
57                Matrix4::identity()
58            }
59        } else {
60            Matrix4::identity()
61        };
62
63        // Get extrusion direction
64        let direction_attr = entity.get(2).ok_or_else(|| {
65            Error::geometry("SurfaceOfLinearExtrusion missing ExtrudedDirection".to_string())
66        })?;
67
68        let direction = if let Some(dir_id) = direction_attr.as_entity_ref() {
69            get_direction_by_id(dir_id, decoder)
70                .ok_or_else(|| Error::geometry("Failed to get direction".to_string()))?
71        } else {
72            Vector3::new(0.0, 0.0, 1.0) // Default to Z-up
73        };
74
75        // Get depth
76        let depth = entity
77            .get(3)
78            .and_then(|v| v.as_float())
79            .ok_or_else(|| Error::geometry("SurfaceOfLinearExtrusion missing Depth".to_string()))?;
80
81        // Get curve points from the profile
82        let curve_points = Self::get_profile_curve_points(curve_id, decoder)?;
83
84        if curve_points.len() < 2 {
85            return Ok(Mesh::new());
86        }
87
88        // Extrude the curve to create a surface (quad strip)
89        let extrusion = direction.normalize() * depth;
90
91        let mut positions = Vec::with_capacity(curve_points.len() * 2 * 3);
92        let mut indices = Vec::with_capacity((curve_points.len() - 1) * 6);
93
94        // Create vertices: bottom row, then top row
95        for point in &curve_points {
96            // Transform 2D point to 3D using position
97            let p3d = position_transform.transform_point(&Point3::new(point.x, point.y, 0.0));
98            positions.push(p3d.x as f32);
99            positions.push(p3d.y as f32);
100            positions.push(p3d.z as f32);
101        }
102
103        for point in &curve_points {
104            // Extruded point
105            let p3d = position_transform.transform_point(&Point3::new(point.x, point.y, 0.0));
106            let p_extruded = p3d + extrusion;
107            positions.push(p_extruded.x as f32);
108            positions.push(p_extruded.y as f32);
109            positions.push(p_extruded.z as f32);
110        }
111
112        // Create quad strip triangles
113        let n = curve_points.len() as u32;
114        for i in 0..n - 1 {
115            // Two triangles per quad
116            // Triangle 1: bottom-left, bottom-right, top-left
117            indices.push(i);
118            indices.push(i + 1);
119            indices.push(i + n);
120
121            // Triangle 2: bottom-right, top-right, top-left
122            indices.push(i + 1);
123            indices.push(i + n + 1);
124            indices.push(i + n);
125        }
126
127        Ok(Mesh {
128            positions,
129            normals: Vec::new(),
130            indices,
131            rtc_applied: false, 
132            origin: [0.0; 3],        instance_meta: None, local_bounds: None, local_to_world: None })
133    }
134
135    fn supported_types(&self) -> Vec<IfcType> {
136        vec![IfcType::IfcSurfaceOfLinearExtrusion]
137    }
138}
139
140impl SurfaceOfLinearExtrusionProcessor {
141    /// Extract curve points from a profile definition
142    /// Longest nested-curve chain the profile sampler will follow. See
143    /// `curve_points_guarded` for why this sits alongside the visited set.
144    const MAX_CURVE_NESTING_DEPTH: u32 = 32;
145
146    fn get_profile_curve_points(
147        profile_id: u32,
148        decoder: &mut EntityDecoder,
149    ) -> Result<Vec<Point2<f64>>> {
150        let mut walk = CurveWalk::new();
151        Self::profile_curve_points_guarded(profile_id, decoder, &mut walk)
152    }
153
154    fn profile_curve_points_guarded(
155        profile_id: u32,
156        decoder: &mut EntityDecoder,
157        walk: &mut CurveWalk,
158    ) -> Result<Vec<Point2<f64>>> {
159        let profile = decoder.decode_by_id(profile_id)?;
160
161        // IfcArbitraryOpenProfileDef: 0=ProfileType, 1=ProfileName, 2=Curve
162        // IfcArbitraryClosedProfileDef: 0=ProfileType, 1=ProfileName, 2=OuterCurve
163        let curve_attr = profile
164            .get(2)
165            .ok_or_else(|| Error::geometry("Profile missing curve".to_string()))?;
166
167        let curve_id = curve_attr
168            .as_entity_ref()
169            .ok_or_else(|| Error::geometry("Expected entity reference for curve".to_string()))?;
170
171        Self::curve_points_guarded(curve_id, decoder, 0, walk)
172    }
173
174    /// Sample a CURVE (not a profile) into 2D points.
175    ///
176    /// Split out of `get_profile_curve_points` because
177    /// `extract_composite_curve_points` was calling that function with a
178    /// segment's `ParentCurve` id — a curve where a profile was expected. It
179    /// read attribute 2 of the curve as "the profile's curve", and an
180    /// `IfcPolyline` has no attribute 2, so every composite-curve profile
181    /// errored on each segment, had the error swallowed by the caller's
182    /// `if let Ok(..)`, and returned `Ok(vec![])`. Silently: no points, no
183    /// error, indistinguishable from a legitimately empty profile.
184    ///
185    /// Guarded by BOTH a visited set and a depth cap, because they bound
186    /// different things. The set stops cycles and fan-out --
187    /// `extract_composite_curve_points` loops over segments, so `k` segments
188    /// each leading back cost `O(k^depth)` and a cap alone would trade the
189    /// abort for a hang. The cap stops a long ACYCLIC chain, where every
190    /// insert succeeds, the set never fires, and the recursion aborts on stack
191    /// depth alone (Codex, #2871/#2872 review). Neither substitutes for the
192    /// other (#2866).
193    fn curve_points_guarded(
194        curve_id: u32,
195        decoder: &mut EntityDecoder,
196        depth: u32,
197        walk: &mut CurveWalk,
198    ) -> Result<Vec<Point2<f64>>> {
199        if depth >= Self::MAX_CURVE_NESTING_DEPTH || !walk.seen.insert(curve_id) {
200            return Ok(Vec::new());
201        }
202        walk.spend()?;
203        let out = Self::curve_points_inner(curve_id, decoder, depth, walk);
204        // PATH-scoped: removed on the way out. A global set would be a memo
205        // that returns the WRONG value -- it hands back an empty vec rather
206        // than the points it computed the first time -- and the caller
207        // ACCUMULATES, so a ParentCurve legitimately reused by two segments
208        // would contribute once and silently shorten the profile.
209        walk.seen.remove(&curve_id);
210        out
211    }
212
213    fn curve_points_inner(
214        curve_id: u32,
215        decoder: &mut EntityDecoder,
216        depth: u32,
217        walk: &mut CurveWalk,
218    ) -> Result<Vec<Point2<f64>>> {
219
220        // Get curve entity to determine type
221        let curve = decoder.decode_by_id(curve_id)?;
222
223        match curve.ifc_type {
224            IfcType::IfcPolyline => {
225                // IfcPolyline: attribute 0 is Points (list of IfcCartesianPoint)
226                let point_ids = decoder
227                    .get_polyloop_point_ids_fast(curve_id)
228                    .ok_or_else(|| Error::geometry("Failed to get polyline points".to_string()))?;
229
230                let mut points = Vec::with_capacity(point_ids.len());
231                for point_id in point_ids {
232                    if let Some((x, y, _z)) = decoder.get_cartesian_point_fast(point_id) {
233                        points.push(Point2::new(x, y));
234                    }
235                }
236                Ok(points)
237            }
238            IfcType::IfcCompositeCurve => {
239                // Handle composite curves by extracting segments
240                Self::extract_composite_curve_points(curve_id, decoder, depth, walk)
241            }
242            _ => {
243                // Fallback: try to get points directly
244                if let Some(point_ids) = decoder.get_polyloop_point_ids_fast(curve_id) {
245                    let mut points = Vec::with_capacity(point_ids.len());
246                    for point_id in point_ids {
247                        if let Some((x, y, _z)) = decoder.get_cartesian_point_fast(point_id) {
248                            points.push(Point2::new(x, y));
249                        }
250                    }
251                    Ok(points)
252                } else {
253                    Ok(Vec::new())
254                }
255            }
256        }
257    }
258
259    /// Extract points from a composite curve
260    fn extract_composite_curve_points(
261        curve_id: u32,
262        decoder: &mut EntityDecoder,
263        depth: u32,
264        walk: &mut CurveWalk,
265    ) -> Result<Vec<Point2<f64>>> {
266        let curve = decoder.decode_by_id(curve_id)?;
267
268        // IfcCompositeCurve: attribute 0 is Segments (list of IfcCompositeCurveSegment)
269        let segments_attr = curve
270            .get(0)
271            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
272
273        let segment_refs = segments_attr
274            .as_list()
275            .ok_or_else(|| Error::geometry("Expected segment list".to_string()))?;
276
277        let mut all_points: Vec<Point2<f64>> = Vec::new();
278
279        for seg_ref in segment_refs {
280            let seg_id = seg_ref.as_entity_ref().ok_or_else(|| {
281                Error::geometry("Expected entity reference for segment".to_string())
282            })?;
283
284            let segment = decoder.decode_by_id(seg_id)?;
285
286            // IfcCompositeCurveSegment: 0=Transition, 1=SameSense, 2=ParentCurve
287            let parent_curve_attr = segment
288                .get(2)
289                .ok_or_else(|| Error::geometry("Segment missing ParentCurve".to_string()))?;
290
291            let parent_curve_id = parent_curve_attr.as_entity_ref().ok_or_else(|| {
292                Error::geometry("Expected entity reference for parent curve".to_string())
293            })?;
294
295            // IfcCompositeCurveSegment.SameSense (attribute 1): when false the
296            // segment traverses its ParentCurve BACKWARDS. Nothing applied it
297            // before because no segment ever produced points to orient -- the
298            // dispatch bug above meant every one came back empty, so a
299            // reversed segment and a forward one were indistinguishable.
300            let same_sense = segment
301                .get(1)
302                .map(|v| match v {
303                    ifc_lite_core::AttributeValue::Enum(e) => e != "F" && e != ".F.",
304                    _ => true,
305                })
306                .unwrap_or(true);
307
308            // The ParentCurve is a CURVE, not a profile. Routing it through the
309            // profile entry point read its attribute 2 as "the curve" and
310            // dropped every segment (#2866).
311            if let Ok(mut segment_points) =
312                Self::curve_points_guarded(parent_curve_id, decoder, depth + 1, walk)
313            {
314                if !same_sense {
315                    segment_points.reverse();
316                }
317                // Drop the seam point only when it ACTUALLY duplicates the
318                // previous segment's end. A `.DISCONTINUOUS.` transition, or a
319                // gap from a malformed file, leaves a real point that an
320                // unconditional skip would eat.
321                let drop_seam = match (all_points.last(), segment_points.first()) {
322                    (Some(prev), Some(next)) => {
323                        (prev.x - next.x).abs() < SEAM_EPS && (prev.y - next.y).abs() < SEAM_EPS
324                    }
325                    _ => false,
326                };
327                let start_idx = usize::from(drop_seam);
328                all_points.extend(segment_points.into_iter().skip(start_idx));
329            }
330
331            // The `if let Ok(..)` above deliberately tolerates ONE malformed
332            // segment rather than losing the whole profile -- but it must not
333            // swallow budget exhaustion, or the loop keeps going and returns a
334            // truncated profile as if it were complete. That is the silent
335            // wrong answer this guard exists to avoid, so exhaustion is
336            // re-raised here where the tolerance cannot hide it.
337            if walk.exhausted {
338                return Err(Error::geometry(format!(
339                    "Curve traversal exceeded {MAX_CURVE_NODES} nested curves"
340                )));
341            }
342        }
343
344        Ok(all_points)
345    }
346}
347
348impl Default for SurfaceOfLinearExtrusionProcessor {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354#[cfg(test)]
355#[path = "surface_cycle_tests.rs"]
356mod surface_cycle_tests;