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