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