Skip to main content

ifc_lite_geometry/
profile.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//! 2D Profile definitions and triangulation
6
7use crate::error::{Error, Result};
8use crate::tessellation::TessellationQuality;
9use nalgebra::Point2;
10
11/// 2D Profile with optional holes
12#[derive(Debug, Clone)]
13pub struct Profile2D {
14    /// Outer boundary (counter-clockwise)
15    pub outer: Vec<Point2<f64>>,
16    /// Holes (clockwise)
17    pub holes: Vec<Vec<Point2<f64>>>,
18}
19
20impl Profile2D {
21    /// Create a new profile
22    pub fn new(outer: Vec<Point2<f64>>) -> Self {
23        Self {
24            outer,
25            holes: Vec::new(),
26        }
27    }
28
29    /// Add a hole to the profile
30    pub fn add_hole(&mut self, hole: Vec<Point2<f64>>) {
31        self.holes.push(hole);
32    }
33
34    /// Translate the profile so the centre of its outer bounding box sits at the
35    /// origin.
36    ///
37    /// IFC parameterised profiles (I/U/L/T/C/Z/…) are defined centred on their
38    /// bounding box, and the swept-area `Position` placement is applied relative
39    /// to that centred origin. Some per-shape builders are easier to read when
40    /// written from a corner; centring them here in one place keeps every
41    /// parametric profile consistent with the spec. Holes are shifted by the same
42    /// offset so they stay aligned with the outer boundary. No-op for profiles
43    /// that are already centred.
44    pub fn center_on_bbox(&mut self) {
45        if self.outer.is_empty() {
46            return;
47        }
48        let mut min_x = f64::INFINITY;
49        let mut min_y = f64::INFINITY;
50        let mut max_x = f64::NEG_INFINITY;
51        let mut max_y = f64::NEG_INFINITY;
52        for p in &self.outer {
53            min_x = min_x.min(p.x);
54            min_y = min_y.min(p.y);
55            max_x = max_x.max(p.x);
56            max_y = max_y.max(p.y);
57        }
58        let cx = (min_x + max_x) / 2.0;
59        let cy = (min_y + max_y) / 2.0;
60        if cx == 0.0 && cy == 0.0 {
61            return;
62        }
63        for p in &mut self.outer {
64            p.x -= cx;
65            p.y -= cy;
66        }
67        for hole in &mut self.holes {
68            for p in hole {
69                p.x -= cx;
70                p.y -= cy;
71            }
72        }
73    }
74
75    /// Triangulate the profile using earcutr
76    /// Returns triangle indices into the flattened vertex array
77    pub fn triangulate(&self) -> Result<Triangulation> {
78        if self.outer.len() < 3 {
79            return Err(Error::InvalidProfile(
80                "Profile must have at least 3 vertices".to_string(),
81            ));
82        }
83
84        // Flatten vertices for earcutr
85        let mut vertices = Vec::with_capacity(
86            (self.outer.len() + self.holes.iter().map(|h| h.len()).sum::<usize>()) * 2,
87        );
88
89        // Add outer boundary
90        for p in &self.outer {
91            vertices.push(p.x);
92            vertices.push(p.y);
93        }
94
95        // Add holes
96        let mut hole_indices = Vec::with_capacity(self.holes.len());
97        for hole in &self.holes {
98            hole_indices.push(vertices.len() / 2);
99            for p in hole {
100                vertices.push(p.x);
101                vertices.push(p.y);
102            }
103        }
104
105        // Triangulate (guarded — see `triangulation::safe_earcut`)
106        let indices = if hole_indices.is_empty() {
107            crate::triangulation::safe_earcut(&vertices, &[], 2)
108                .map_err(Error::TriangulationError)?
109        } else {
110            crate::triangulation::safe_earcut(&vertices, &hole_indices, 2)
111                .map_err(Error::TriangulationError)?
112        };
113
114        // Convert to Point2 array
115        let mut points = Vec::with_capacity(vertices.len() / 2);
116        for i in (0..vertices.len()).step_by(2) {
117            if i + 1 >= vertices.len() {
118                break;
119            }
120            points.push(Point2::new(vertices[i], vertices[i + 1]));
121        }
122
123        Ok(Triangulation { points, indices })
124    }
125}
126
127/// Triangulated profile result
128#[derive(Debug, Clone)]
129pub struct Triangulation {
130    /// All vertices (outer + holes)
131    pub points: Vec<Point2<f64>>,
132    /// Triangle indices
133    pub indices: Vec<usize>,
134}
135
136/// Void metadata for depth-aware extrusion
137///
138/// Tracks information about a void that has been projected to the 2D profile plane,
139/// including its depth range for generating internal caps when the void doesn't
140/// extend through the full extrusion depth.
141#[derive(Debug, Clone)]
142pub struct VoidInfo {
143    /// Hole contour in 2D profile space (clockwise winding for holes)
144    pub contour: Vec<Point2<f64>>,
145    /// Start depth in extrusion space (0.0 = bottom cap)
146    pub depth_start: f64,
147    /// End depth in extrusion space (extrusion_depth = top cap)
148    pub depth_end: f64,
149    /// Whether void extends full depth (no internal caps needed)
150    pub is_through: bool,
151}
152
153impl VoidInfo {
154    /// Create a new void info
155    pub fn new(
156        contour: Vec<Point2<f64>>,
157        depth_start: f64,
158        depth_end: f64,
159        is_through: bool,
160    ) -> Self {
161        Self {
162            contour,
163            depth_start,
164            depth_end,
165            is_through,
166        }
167    }
168
169    /// Create a through void (extends full depth)
170    pub fn through(contour: Vec<Point2<f64>>, depth: f64) -> Self {
171        Self {
172            contour,
173            depth_start: 0.0,
174            depth_end: depth,
175            is_through: true,
176        }
177    }
178}
179
180/// Profile with void tracking for depth-aware extrusion
181///
182/// Extends Profile2D with metadata about voids that have been classified as
183/// coplanar and can be handled at the profile level. This allows for:
184/// - Through voids: Added as holes before single extrusion
185/// - Partial-depth voids: Generate internal caps at depth boundaries
186#[derive(Debug, Clone)]
187pub struct Profile2DWithVoids {
188    /// Base profile (outer boundary + any existing holes)
189    pub profile: Profile2D,
190    /// Void metadata for depth-aware extrusion
191    pub voids: Vec<VoidInfo>,
192}
193
194impl Profile2DWithVoids {
195    /// Create a new profile with voids
196    pub fn new(profile: Profile2D, voids: Vec<VoidInfo>) -> Self {
197        Self { profile, voids }
198    }
199
200    /// Create from a base profile with no voids
201    pub fn from_profile(profile: Profile2D) -> Self {
202        Self {
203            profile,
204            voids: Vec::new(),
205        }
206    }
207
208    /// Add a void to the profile
209    pub fn add_void(&mut self, void: VoidInfo) {
210        self.voids.push(void);
211    }
212
213    /// Get all through voids (can be added as simple holes)
214    pub fn through_voids(&self) -> impl Iterator<Item = &VoidInfo> {
215        self.voids.iter().filter(|v| v.is_through)
216    }
217
218    /// Get all partial-depth voids (need internal caps)
219    pub fn partial_voids(&self) -> impl Iterator<Item = &VoidInfo> {
220        self.voids.iter().filter(|v| !v.is_through)
221    }
222
223    /// Check if there are any voids
224    pub fn has_voids(&self) -> bool {
225        !self.voids.is_empty()
226    }
227
228    /// Get number of voids
229    pub fn void_count(&self) -> usize {
230        self.voids.len()
231    }
232
233    /// Create a profile with through-voids merged as holes
234    ///
235    /// Returns a Profile2D where all through-voids have been added as holes,
236    /// suitable for single-pass extrusion.
237    pub fn profile_with_through_holes(&self) -> Profile2D {
238        let mut profile = self.profile.clone();
239
240        for void in self.through_voids() {
241            profile.add_hole(void.contour.clone());
242        }
243
244        profile
245    }
246}
247
248/// Common profile types
249#[derive(Debug, Clone)]
250pub enum ProfileType {
251    Rectangle {
252        width: f64,
253        height: f64,
254    },
255    Circle {
256        radius: f64,
257    },
258    HollowCircle {
259        outer_radius: f64,
260        inner_radius: f64,
261    },
262    Polygon {
263        points: Vec<Point2<f64>>,
264    },
265}
266
267impl ProfileType {
268    /// Convert to Profile2D at the historical default tessellation density.
269    pub fn to_profile(&self) -> Profile2D {
270        self.to_profile_with_quality(TessellationQuality::Medium)
271    }
272
273    /// Convert to Profile2D, tessellating circular profiles at the given
274    /// `quality` (rectangle and polygon profiles are unaffected).
275    pub fn to_profile_with_quality(&self, quality: TessellationQuality) -> Profile2D {
276        match self {
277            Self::Rectangle { width, height } => create_rectangle(*width, *height),
278            Self::Circle { radius } => create_circle(*radius, None, quality),
279            Self::HollowCircle {
280                outer_radius,
281                inner_radius,
282            } => create_circle(*outer_radius, Some(*inner_radius), quality),
283            Self::Polygon { points } => Profile2D::new(points.clone()),
284        }
285    }
286}
287
288/// Create a rectangular profile
289#[inline]
290pub fn create_rectangle(width: f64, height: f64) -> Profile2D {
291    let half_w = width / 2.0;
292    let half_h = height / 2.0;
293
294    Profile2D::new(vec![
295        Point2::new(-half_w, -half_h),
296        Point2::new(half_w, -half_h),
297        Point2::new(half_w, half_h),
298        Point2::new(-half_w, half_h),
299    ])
300}
301
302/// Create a circular profile (with optional hole)
303///
304/// Segment count is derived from `radius` and the requested tessellation
305/// `quality`; [`TessellationQuality::Medium`] reproduces the historical
306/// radius-only segment count exactly.
307pub fn create_circle(radius: f64, hole_radius: Option<f64>, quality: TessellationQuality) -> Profile2D {
308    let segments = calculate_circle_segments(radius, quality);
309
310    let mut outer = Vec::with_capacity(segments);
311
312    for i in 0..segments {
313        let angle = 2.0 * std::f64::consts::PI * (i as f64) / (segments as f64);
314        outer.push(Point2::new(radius * angle.cos(), radius * angle.sin()));
315    }
316
317    let mut profile = Profile2D::new(outer);
318
319    // Add hole if specified
320    if let Some(hole_r) = hole_radius {
321        let hole_segments = calculate_circle_segments(hole_r, quality);
322        let mut hole = Vec::with_capacity(hole_segments);
323
324        for i in 0..hole_segments {
325            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (hole_segments as f64);
326            // Reverse winding for hole (clockwise)
327            hole.push(Point2::new(hole_r * angle.cos(), hole_r * angle.sin()));
328        }
329        hole.reverse(); // Make clockwise
330
331        profile.add_hole(hole);
332    }
333
334    profile
335}
336
337/// Calculate adaptive number of segments for a circular opening / profile.
338///
339/// The radius-based rule (`ceil(sqrt(radius) * 8)`, clamped to `[8, 32]`) is the
340/// historical baseline returned at [`TessellationQuality::Medium`] and above —
341/// opening circles never get *finer* (denser caps only add earcut bridge
342/// slivers). Below Medium they coarsen via
343/// [`TessellationQuality::circle_profile_segments`].
344#[inline]
345pub fn calculate_circle_segments(radius: f64, quality: TessellationQuality) -> usize {
346    // Adaptive segment calculation - optimized for performance
347    // Smaller circles need fewer segments
348    let base = ((radius.sqrt() * 8.0).ceil() as usize).clamp(8, 32);
349
350    quality.circle_profile_segments(base)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_rectangle_profile() {
359        let profile = create_rectangle(10.0, 5.0);
360        assert_eq!(profile.outer.len(), 4);
361        assert_eq!(profile.holes.len(), 0);
362
363        // Check bounds
364        assert_eq!(profile.outer[0], Point2::new(-5.0, -2.5));
365        assert_eq!(profile.outer[1], Point2::new(5.0, -2.5));
366        assert_eq!(profile.outer[2], Point2::new(5.0, 2.5));
367        assert_eq!(profile.outer[3], Point2::new(-5.0, 2.5));
368    }
369
370    #[test]
371    fn test_circle_profile() {
372        let profile = create_circle(5.0, None, TessellationQuality::Medium);
373        assert!(profile.outer.len() >= 8);
374        assert_eq!(profile.holes.len(), 0);
375
376        // Check first point is on circle
377        let first = profile.outer[0];
378        let dist = (first.x * first.x + first.y * first.y).sqrt();
379        assert!((dist - 5.0).abs() < 0.001);
380    }
381
382    #[test]
383    fn test_hollow_circle() {
384        let profile = create_circle(10.0, Some(5.0), TessellationQuality::Medium);
385        assert!(profile.outer.len() >= 8);
386        assert_eq!(profile.holes.len(), 1);
387
388        // Check hole
389        let hole = &profile.holes[0];
390        assert!(hole.len() >= 8);
391    }
392
393    #[test]
394    fn test_triangulate_rectangle() {
395        let profile = create_rectangle(10.0, 5.0);
396        let tri = profile.triangulate().unwrap();
397
398        assert_eq!(tri.points.len(), 4);
399        assert_eq!(tri.indices.len(), 6); // 2 triangles = 6 indices
400    }
401
402    #[test]
403    fn test_triangulate_circle() {
404        let profile = create_circle(5.0, None, TessellationQuality::Medium);
405        let tri = profile.triangulate().unwrap();
406
407        assert!(tri.points.len() >= 8);
408        // Triangle count should be points - 2
409        assert_eq!(tri.indices.len(), (tri.points.len() - 2) * 3);
410    }
411
412    #[test]
413    fn test_triangulate_hollow_circle() {
414        let profile = create_circle(10.0, Some(5.0), TessellationQuality::Medium);
415        let tri = profile.triangulate().unwrap();
416
417        // Should have vertices from both outer and inner circles
418        let outer_count = calculate_circle_segments(10.0, TessellationQuality::Medium);
419        let inner_count = calculate_circle_segments(5.0, TessellationQuality::Medium);
420        assert_eq!(tri.points.len(), outer_count + inner_count);
421    }
422
423    #[test]
424    fn test_circle_segments() {
425        use TessellationQuality::Medium;
426        assert_eq!(calculate_circle_segments(1.0, Medium), 8); // sqrt(1)*8=8, clamped to min 8
427        assert_eq!(calculate_circle_segments(4.0, Medium), 16); // sqrt(4)*8=16
428        assert!(calculate_circle_segments(100.0, Medium) <= 32); // Max clamp at 32
429        assert!(calculate_circle_segments(0.1, Medium) >= 8); // Min clamp
430    }
431}