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    /// Reverse `outer` when it winds clockwise, so the counter-clockwise
78    /// contract on [`outer`](Self::outer) holds however a builder listed
79    /// its points. Holes are untouched (their contract is clockwise and the
80    /// hollow builders emit them that way). The extrusion and earcut paths
81    /// each re-derive orientation and were right either way; the 2D drawing
82    /// path emits `outer` verbatim, and that is where a clockwise T or Z
83    /// showed.
84    pub fn make_outer_ccw(&mut self) {
85        if crate::bool2d::compute_signed_area(&self.outer) < 0.0 {
86            self.outer.reverse();
87        }
88    }
89
90    /// Triangulate the profile using earcutr
91    /// Returns triangle indices into the flattened vertex array
92    pub fn triangulate(&self) -> Result<Triangulation> {
93        triangulate_rings(&self.outer, &self.holes)
94    }
95}
96
97
98/// Void metadata for depth-aware extrusion
99///
100/// Tracks information about a void that has been projected to the 2D profile plane,
101/// including its depth range for generating internal caps when the void doesn't
102/// extend through the full extrusion depth.
103#[derive(Debug, Clone)]
104pub struct VoidInfo {
105    /// Hole contour in 2D profile space (clockwise winding for holes)
106    pub contour: Vec<Point2<f64>>,
107    /// Start depth in extrusion space (0.0 = bottom cap)
108    pub depth_start: f64,
109    /// End depth in extrusion space (extrusion_depth = top cap)
110    pub depth_end: f64,
111    /// Whether void extends full depth (no internal caps needed)
112    pub is_through: bool,
113}
114
115impl VoidInfo {
116    /// Create a new void info
117    pub fn new(
118        contour: Vec<Point2<f64>>,
119        depth_start: f64,
120        depth_end: f64,
121        is_through: bool,
122    ) -> Self {
123        Self {
124            contour,
125            depth_start,
126            depth_end,
127            is_through,
128        }
129    }
130
131    /// Create a through void (extends full depth)
132    pub fn through(contour: Vec<Point2<f64>>, depth: f64) -> Self {
133        Self {
134            contour,
135            depth_start: 0.0,
136            depth_end: depth,
137            is_through: true,
138        }
139    }
140}
141
142/// Profile with void tracking for depth-aware extrusion
143///
144/// Extends Profile2D with metadata about voids that have been classified as
145/// coplanar and can be handled at the profile level. This allows for:
146/// - Through voids: Added as holes before single extrusion
147/// - Partial-depth voids: Generate internal caps at depth boundaries
148#[derive(Debug, Clone)]
149pub struct Profile2DWithVoids {
150    /// Base profile (outer boundary + any existing holes)
151    pub profile: Profile2D,
152    /// Void metadata for depth-aware extrusion
153    pub voids: Vec<VoidInfo>,
154}
155
156impl Profile2DWithVoids {
157    /// Create a new profile with voids
158    pub fn new(profile: Profile2D, voids: Vec<VoidInfo>) -> Self {
159        Self { profile, voids }
160    }
161
162    /// Create from a base profile with no voids
163    pub fn from_profile(profile: Profile2D) -> Self {
164        Self {
165            profile,
166            voids: Vec::new(),
167        }
168    }
169
170    /// Add a void to the profile
171    pub fn add_void(&mut self, void: VoidInfo) {
172        self.voids.push(void);
173    }
174
175    /// Get all through voids (can be added as simple holes)
176    pub fn through_voids(&self) -> impl Iterator<Item = &VoidInfo> {
177        self.voids.iter().filter(|v| v.is_through)
178    }
179
180    /// Get all partial-depth voids (need internal caps)
181    pub fn partial_voids(&self) -> impl Iterator<Item = &VoidInfo> {
182        self.voids.iter().filter(|v| !v.is_through)
183    }
184
185    /// Check if there are any voids
186    pub fn has_voids(&self) -> bool {
187        !self.voids.is_empty()
188    }
189
190    /// Get number of voids
191    pub fn void_count(&self) -> usize {
192        self.voids.len()
193    }
194
195    /// Create a profile with through-voids merged as holes
196    ///
197    /// Returns a Profile2D where all through-voids have been added as holes,
198    /// suitable for single-pass extrusion.
199    pub fn profile_with_through_holes(&self) -> Profile2D {
200        let mut profile = self.profile.clone();
201
202        for void in self.through_voids() {
203            profile.add_hole(void.contour.clone());
204        }
205
206        profile
207    }
208}
209
210/// Common profile types
211#[derive(Debug, Clone)]
212pub enum ProfileType {
213    Rectangle {
214        width: f64,
215        height: f64,
216    },
217    Circle {
218        radius: f64,
219    },
220    HollowCircle {
221        outer_radius: f64,
222        inner_radius: f64,
223    },
224    Polygon {
225        points: Vec<Point2<f64>>,
226    },
227}
228
229impl ProfileType {
230    /// Convert to Profile2D at the historical default tessellation density.
231    pub fn to_profile(&self) -> Profile2D {
232        self.to_profile_with_quality(TessellationQuality::Medium)
233    }
234
235    /// Convert to Profile2D, tessellating circular profiles at the given
236    /// `quality` (rectangle and polygon profiles are unaffected).
237    pub fn to_profile_with_quality(&self, quality: TessellationQuality) -> Profile2D {
238        match self {
239            Self::Rectangle { width, height } => create_rectangle(*width, *height),
240            Self::Circle { radius } => create_circle(*radius, None, quality),
241            Self::HollowCircle {
242                outer_radius,
243                inner_radius,
244            } => create_circle(*outer_radius, Some(*inner_radius), quality),
245            Self::Polygon { points } => Profile2D::new(points.clone()),
246        }
247    }
248}
249
250/// Create a rectangular profile
251#[inline]
252pub fn create_rectangle(width: f64, height: f64) -> Profile2D {
253    Profile2D::new(rectangle_ring(width, height))
254}
255
256/// Create a circular profile (with optional hole)
257///
258/// Segment count is derived from `radius` and the requested tessellation
259/// `quality`; [`TessellationQuality::Medium`] reproduces the historical
260/// radius-only segment count exactly.
261pub fn create_circle(radius: f64, hole_radius: Option<f64>, quality: TessellationQuality) -> Profile2D {
262    let segments = calculate_circle_segments(radius, quality);
263
264    let mut outer = Vec::with_capacity(segments);
265
266    for i in 0..segments {
267        let angle = 2.0 * std::f64::consts::PI * (i as f64) / (segments as f64);
268        outer.push(Point2::new(radius * angle.cos(), radius * angle.sin()));
269    }
270
271    let mut profile = Profile2D::new(outer);
272
273    // Add hole if specified
274    if let Some(hole_r) = hole_radius {
275        let hole_segments = calculate_circle_segments(hole_r, quality);
276        let mut hole = Vec::with_capacity(hole_segments);
277
278        for i in 0..hole_segments {
279            let angle = 2.0 * std::f64::consts::PI * (i as f64) / (hole_segments as f64);
280            // Reverse winding for hole (clockwise)
281            hole.push(Point2::new(hole_r * angle.cos(), hole_r * angle.sin()));
282        }
283        hole.reverse(); // Make clockwise
284
285        profile.add_hole(hole);
286    }
287
288    profile
289}
290
291/// Calculate adaptive number of segments for a circular opening / profile.
292///
293/// The radius-based rule (`ceil(sqrt(radius) * 8)`, clamped to `[8, 32]`) is the
294/// historical baseline returned at [`TessellationQuality::Medium`] and above —
295/// opening circles never get *finer* (denser caps only add earcut bridge
296/// slivers). Below Medium they coarsen via
297/// [`TessellationQuality::circle_profile_segments`].
298#[inline]
299pub fn calculate_circle_segments(radius: f64, quality: TessellationQuality) -> usize {
300    // Adaptive segment calculation - optimized for performance
301    // Smaller circles need fewer segments
302    let base = ((radius.sqrt() * 8.0).ceil() as usize).clamp(8, 32);
303
304    quality.circle_profile_segments(base)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_rectangle_profile() {
313        let profile = create_rectangle(10.0, 5.0);
314        assert_eq!(profile.outer.len(), 4);
315        assert_eq!(profile.holes.len(), 0);
316
317        // Check bounds
318        assert_eq!(profile.outer[0], Point2::new(-5.0, -2.5));
319        assert_eq!(profile.outer[1], Point2::new(5.0, -2.5));
320        assert_eq!(profile.outer[2], Point2::new(5.0, 2.5));
321        assert_eq!(profile.outer[3], Point2::new(-5.0, 2.5));
322    }
323
324    #[test]
325    fn test_circle_profile() {
326        let profile = create_circle(5.0, None, TessellationQuality::Medium);
327        assert!(profile.outer.len() >= 8);
328        assert_eq!(profile.holes.len(), 0);
329
330        // Check first point is on circle
331        let first = profile.outer[0];
332        let dist = (first.x * first.x + first.y * first.y).sqrt();
333        assert!((dist - 5.0).abs() < 0.001);
334    }
335
336    #[test]
337    fn test_hollow_circle() {
338        let profile = create_circle(10.0, Some(5.0), TessellationQuality::Medium);
339        assert!(profile.outer.len() >= 8);
340        assert_eq!(profile.holes.len(), 1);
341
342        // Check hole
343        let hole = &profile.holes[0];
344        assert!(hole.len() >= 8);
345    }
346
347    #[test]
348    fn test_triangulate_rectangle() {
349        let profile = create_rectangle(10.0, 5.0);
350        let tri = profile.triangulate().unwrap();
351
352        assert_eq!(tri.points.len(), 4);
353        assert_eq!(tri.indices.len(), 6); // 2 triangles = 6 indices
354    }
355
356    #[test]
357    fn test_triangulate_circle() {
358        let profile = create_circle(5.0, None, TessellationQuality::Medium);
359        let tri = profile.triangulate().unwrap();
360
361        assert!(tri.points.len() >= 8);
362        // Triangle count should be points - 2
363        assert_eq!(tri.indices.len(), (tri.points.len() - 2) * 3);
364    }
365
366    #[test]
367    fn test_triangulate_hollow_circle() {
368        let profile = create_circle(10.0, Some(5.0), TessellationQuality::Medium);
369        let tri = profile.triangulate().unwrap();
370
371        // Should have vertices from both outer and inner circles
372        let outer_count = calculate_circle_segments(10.0, TessellationQuality::Medium);
373        let inner_count = calculate_circle_segments(5.0, TessellationQuality::Medium);
374        assert_eq!(tri.points.len(), outer_count + inner_count);
375    }
376
377    #[test]
378    fn test_circle_segments() {
379        use TessellationQuality::Medium;
380        assert_eq!(calculate_circle_segments(1.0, Medium), 8); // sqrt(1)*8=8, clamped to min 8
381        assert_eq!(calculate_circle_segments(4.0, Medium), 16); // sqrt(4)*8=16
382        assert!(calculate_circle_segments(100.0, Medium) <= 32); // Max clamp at 32
383        assert!(calculate_circle_segments(0.1, Medium) >= 8); // Min clamp
384    }
385}