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            welded_in_object_frame: false,
133            plane_tags: None,
134            origin: [0.0; 3],        instance_meta: None, local_bounds: None, local_to_world: None })
135    }
136
137    fn supported_types(&self) -> Vec<IfcType> {
138        vec![IfcType::IfcSurfaceOfLinearExtrusion]
139    }
140}
141
142impl SurfaceOfLinearExtrusionProcessor {
143    /// Extract curve points from a profile definition
144    /// Longest nested-curve chain the profile sampler will follow. See
145    /// `curve_points_guarded` for why this sits alongside the visited set.
146    const MAX_CURVE_NESTING_DEPTH: u32 = 32;
147
148    fn get_profile_curve_points(
149        profile_id: u32,
150        decoder: &mut EntityDecoder,
151    ) -> Result<Vec<Point2<f64>>> {
152        let mut walk = CurveWalk::new();
153        Self::profile_curve_points_guarded(profile_id, decoder, &mut walk)
154    }
155
156    fn profile_curve_points_guarded(
157        profile_id: u32,
158        decoder: &mut EntityDecoder,
159        walk: &mut CurveWalk,
160    ) -> Result<Vec<Point2<f64>>> {
161        let profile = decoder.decode_by_id(profile_id)?;
162
163        // IfcArbitraryOpenProfileDef: 0=ProfileType, 1=ProfileName, 2=Curve
164        // IfcArbitraryClosedProfileDef: 0=ProfileType, 1=ProfileName, 2=OuterCurve
165        let curve_attr = profile
166            .get(2)
167            .ok_or_else(|| Error::geometry("Profile missing curve".to_string()))?;
168
169        let curve_id = curve_attr
170            .as_entity_ref()
171            .ok_or_else(|| Error::geometry("Expected entity reference for curve".to_string()))?;
172
173        Self::curve_points_guarded(curve_id, decoder, 0, walk)
174    }
175
176    /// Sample a CURVE (not a profile) into 2D points.
177    ///
178    /// Split out of `get_profile_curve_points` because
179    /// `extract_composite_curve_points` was calling that function with a
180    /// segment's `ParentCurve` id — a curve where a profile was expected. It
181    /// read attribute 2 of the curve as "the profile's curve", and an
182    /// `IfcPolyline` has no attribute 2, so every composite-curve profile
183    /// errored on each segment, had the error swallowed by the caller's
184    /// `if let Ok(..)`, and returned `Ok(vec![])`. Silently: no points, no
185    /// error, indistinguishable from a legitimately empty profile.
186    ///
187    /// Guarded by BOTH a visited set and a depth cap, because they bound
188    /// different things. The set stops cycles and fan-out --
189    /// `extract_composite_curve_points` loops over segments, so `k` segments
190    /// each leading back cost `O(k^depth)` and a cap alone would trade the
191    /// abort for a hang. The cap stops a long ACYCLIC chain, where every
192    /// insert succeeds, the set never fires, and the recursion aborts on stack
193    /// depth alone (Codex, #2871/#2872 review). Neither substitutes for the
194    /// other (#2866).
195    fn curve_points_guarded(
196        curve_id: u32,
197        decoder: &mut EntityDecoder,
198        depth: u32,
199        walk: &mut CurveWalk,
200    ) -> Result<Vec<Point2<f64>>> {
201        if depth >= Self::MAX_CURVE_NESTING_DEPTH || !walk.seen.insert(curve_id) {
202            return Ok(Vec::new());
203        }
204        walk.spend()?;
205        let out = Self::curve_points_inner(curve_id, decoder, depth, walk);
206        // PATH-scoped: removed on the way out. A global set would be a memo
207        // that returns the WRONG value -- it hands back an empty vec rather
208        // than the points it computed the first time -- and the caller
209        // ACCUMULATES, so a ParentCurve legitimately reused by two segments
210        // would contribute once and silently shorten the profile.
211        walk.seen.remove(&curve_id);
212        out
213    }
214
215    fn curve_points_inner(
216        curve_id: u32,
217        decoder: &mut EntityDecoder,
218        depth: u32,
219        walk: &mut CurveWalk,
220    ) -> Result<Vec<Point2<f64>>> {
221
222        // Get curve entity to determine type
223        let curve = decoder.decode_by_id(curve_id)?;
224
225        match curve.ifc_type {
226            IfcType::IfcPolyline => {
227                // IfcPolyline: attribute 0 is Points (list of IfcCartesianPoint)
228                let point_ids = decoder
229                    .get_polyloop_point_ids_fast(curve_id)
230                    .ok_or_else(|| Error::geometry("Failed to get polyline points".to_string()))?;
231
232                // A point that does not resolve refuses the whole curve, never
233                // the one corner: a polyline one vertex short is a different
234                // outline, meshed as if it were the authored one. Same rule as
235                // `helpers::extract_loop_points_by_id`.
236                let mut points = Vec::with_capacity(point_ids.len());
237                for point_id in point_ids {
238                    let (x, y, _z) = decoder.get_cartesian_point_fast(point_id).ok_or_else(|| {
239                        Error::geometry(format!("Polyline point #{point_id} did not resolve"))
240                    })?;
241                    points.push(Point2::new(x, y));
242                }
243                Ok(points)
244            }
245            IfcType::IfcCompositeCurve => {
246                // Handle composite curves by extracting segments
247                Self::extract_composite_curve_points(curve_id, decoder, depth, walk)
248            }
249            _ => {
250                // Fallback: try to get points directly
251                if let Some(point_ids) = decoder.get_polyloop_point_ids_fast(curve_id) {
252                    let mut points = Vec::with_capacity(point_ids.len());
253                    for point_id in point_ids {
254                        let (x, y, _z) = decoder.get_cartesian_point_fast(point_id).ok_or_else(|| {
255                            Error::geometry(format!("Curve point #{point_id} did not resolve"))
256                        })?;
257                        points.push(Point2::new(x, y));
258                    }
259                    Ok(points)
260                } else {
261                    Ok(Vec::new())
262                }
263            }
264        }
265    }
266
267    /// Extract points from a composite curve
268    fn extract_composite_curve_points(
269        curve_id: u32,
270        decoder: &mut EntityDecoder,
271        depth: u32,
272        walk: &mut CurveWalk,
273    ) -> Result<Vec<Point2<f64>>> {
274        let curve = decoder.decode_by_id(curve_id)?;
275
276        // IfcCompositeCurve: attribute 0 is Segments (list of IfcCompositeCurveSegment)
277        let segments_attr = curve
278            .get(0)
279            .ok_or_else(|| Error::geometry("CompositeCurve missing Segments".to_string()))?;
280
281        let segment_refs = segments_attr
282            .as_list()
283            .ok_or_else(|| Error::geometry("Expected segment list".to_string()))?;
284
285        let mut all_points: Vec<Point2<f64>> = Vec::new();
286
287        for seg_ref in segment_refs {
288            let seg_id = seg_ref.as_entity_ref().ok_or_else(|| {
289                Error::geometry("Expected entity reference for segment".to_string())
290            })?;
291
292            let segment = decoder.decode_by_id(seg_id)?;
293
294            // IfcCompositeCurveSegment: 0=Transition, 1=SameSense, 2=ParentCurve
295            let parent_curve_attr = segment
296                .get(2)
297                .ok_or_else(|| Error::geometry("Segment missing ParentCurve".to_string()))?;
298
299            let parent_curve_id = parent_curve_attr.as_entity_ref().ok_or_else(|| {
300                Error::geometry("Expected entity reference for parent curve".to_string())
301            })?;
302
303            // IfcCompositeCurveSegment.SameSense (attribute 1): when false the
304            // segment traverses its ParentCurve BACKWARDS. Nothing applied it
305            // before because no segment ever produced points to orient -- the
306            // dispatch bug above meant every one came back empty, so a
307            // reversed segment and a forward one were indistinguishable.
308            let same_sense = segment
309                .get(1)
310                .map(|v| match v {
311                    ifc_lite_core::AttributeValue::Enum(e) => e != "F" && e != ".F.",
312                    _ => true,
313                })
314                .unwrap_or(true);
315
316            // The ParentCurve is a CURVE, not a profile. Routing it through the
317            // profile entry point read its attribute 2 as "the curve" and
318            // dropped every segment (#2866).
319            if let Ok(mut segment_points) =
320                Self::curve_points_guarded(parent_curve_id, decoder, depth + 1, walk)
321            {
322                if !same_sense {
323                    segment_points.reverse();
324                }
325                // Drop the seam point only when it ACTUALLY duplicates the
326                // previous segment's end. A `.DISCONTINUOUS.` transition, or a
327                // gap from a malformed file, leaves a real point that an
328                // unconditional skip would eat.
329                let drop_seam = match (all_points.last(), segment_points.first()) {
330                    (Some(prev), Some(next)) => {
331                        (prev.x - next.x).abs() < SEAM_EPS && (prev.y - next.y).abs() < SEAM_EPS
332                    }
333                    _ => false,
334                };
335                let start_idx = usize::from(drop_seam);
336                all_points.extend(segment_points.into_iter().skip(start_idx));
337            }
338
339            // The `if let Ok(..)` above deliberately tolerates ONE malformed
340            // segment rather than losing the whole profile -- but it must not
341            // swallow budget exhaustion, or the loop keeps going and returns a
342            // truncated profile as if it were complete. That is the silent
343            // wrong answer this guard exists to avoid, so exhaustion is
344            // re-raised here where the tolerance cannot hide it.
345            if walk.exhausted {
346                return Err(Error::geometry(format!(
347                    "Curve traversal exceeded {MAX_CURVE_NODES} nested curves"
348                )));
349            }
350        }
351
352        Ok(all_points)
353    }
354}
355
356impl Default for SurfaceOfLinearExtrusionProcessor {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362#[cfg(test)]
363#[path = "surface_cycle_tests.rs"]
364mod surface_cycle_tests;