Skip to main content

u_nesting_cutting/
common_edge.rs

1//! Common-edge detection for nested parts.
2//!
3//! Detects pairs of edges from different parts that are close enough to be
4//! cut in a single pass (common-line cutting). This reduces material waste,
5//! pierce count, and total cutting time.
6//!
7//! # Algorithm
8//!
9//! For each pair of exterior contours from different parts:
10//! 1. Compare all edge pairs for proximity and collinearity
11//! 2. Edges within `kerf_width + tolerance` that are anti-parallel (opposite
12//!    direction) and have overlapping projections are common-edge candidates
13//!
14//! # References
15//!
16//! - Hu, Lin & Fu (2023), "Optimizing Cutting Sequences for Common-Edge Nested Parts"
17
18use crate::contour::{ContourId, ContourType, CutContour};
19
20/// A detected common edge between two contours.
21#[derive(Debug, Clone)]
22pub struct CommonEdge {
23    /// ID of the first contour.
24    pub contour_a: ContourId,
25    /// Edge index in contour A.
26    pub edge_a: usize,
27    /// ID of the second contour.
28    pub contour_b: ContourId,
29    /// Edge index in contour B.
30    pub edge_b: usize,
31    /// Length of the overlapping segment.
32    pub overlap_length: f64,
33    /// Midpoint of the shared edge.
34    pub midpoint: (f64, f64),
35}
36
37/// Result of common-edge detection.
38#[derive(Debug, Clone)]
39pub struct CommonEdgeResult {
40    /// Detected common edges.
41    pub common_edges: Vec<CommonEdge>,
42    /// Total length of all common edges.
43    pub total_common_length: f64,
44}
45
46/// Detects common edges between exterior contours.
47///
48/// Two edges from different contours are considered "common" when:
49/// - They are approximately anti-parallel (opposite direction)
50/// - Their perpendicular distance is within `max_distance`
51/// - They have overlapping projections along the edge direction
52///
53/// # Arguments
54///
55/// * `contours` - All cut contours
56/// * `max_distance` - Maximum distance for common-edge eligibility
57///   (typically `kerf_width + tolerance`)
58/// * `angle_tolerance` - Maximum angle deviation in radians (default ~0.05 ≈ 3°)
59pub fn detect_common_edges(
60    contours: &[CutContour],
61    max_distance: f64,
62    angle_tolerance: f64,
63) -> CommonEdgeResult {
64    let mut common_edges = Vec::new();
65
66    // Only exterior contours participate in common-edge detection
67    let exteriors: Vec<&CutContour> = contours
68        .iter()
69        .filter(|c| c.contour_type == ContourType::Exterior)
70        .collect();
71
72    // Compare all pairs of exterior contours from different parts
73    for i in 0..exteriors.len() {
74        for j in (i + 1)..exteriors.len() {
75            let ca = exteriors[i];
76            let cb = exteriors[j];
77
78            // Quick AABB overlap check to skip distant contours
79            if !aabb_proximity(ca, cb, max_distance) {
80                continue;
81            }
82
83            // Compare all edge pairs
84            detect_edges_between(ca, cb, max_distance, angle_tolerance, &mut common_edges);
85        }
86    }
87
88    let total_common_length: f64 = common_edges.iter().map(|e| e.overlap_length).sum();
89
90    CommonEdgeResult {
91        common_edges,
92        total_common_length,
93    }
94}
95
96/// Checks if two contours' AABBs are close enough to potentially share edges.
97fn aabb_proximity(a: &CutContour, b: &CutContour, max_dist: f64) -> bool {
98    let (a_min, a_max) = contour_aabb(a);
99    let (b_min, b_max) = contour_aabb(b);
100
101    // Check if expanded AABBs overlap
102    a_min.0 - max_dist <= b_max.0
103        && a_max.0 + max_dist >= b_min.0
104        && a_min.1 - max_dist <= b_max.1
105        && a_max.1 + max_dist >= b_min.1
106}
107
108/// Computes the AABB of a contour.
109fn contour_aabb(c: &CutContour) -> ((f64, f64), (f64, f64)) {
110    let mut min_x = f64::MAX;
111    let mut min_y = f64::MAX;
112    let mut max_x = f64::MIN;
113    let mut max_y = f64::MIN;
114
115    for &(x, y) in &c.vertices {
116        min_x = min_x.min(x);
117        min_y = min_y.min(y);
118        max_x = max_x.max(x);
119        max_y = max_y.max(y);
120    }
121
122    ((min_x, min_y), (max_x, max_y))
123}
124
125/// Detects common edges between two specific contours.
126fn detect_edges_between(
127    ca: &CutContour,
128    cb: &CutContour,
129    max_distance: f64,
130    angle_tolerance: f64,
131    results: &mut Vec<CommonEdge>,
132) {
133    let na = ca.vertices.len();
134    let nb = cb.vertices.len();
135
136    for i in 0..na {
137        let a0 = ca.vertices[i];
138        let a1 = ca.vertices[(i + 1) % na];
139        let (da_x, da_y) = (a1.0 - a0.0, a1.1 - a0.1);
140        let len_a = (da_x * da_x + da_y * da_y).sqrt();
141
142        if len_a < 1e-12 {
143            continue; // Skip degenerate edges
144        }
145
146        let dir_a = (da_x / len_a, da_y / len_a);
147
148        for j in 0..nb {
149            let b0 = cb.vertices[j];
150            let b1 = cb.vertices[(j + 1) % nb];
151            let (db_x, db_y) = (b1.0 - b0.0, b1.1 - b0.1);
152            let len_b = (db_x * db_x + db_y * db_y).sqrt();
153
154            if len_b < 1e-12 {
155                continue;
156            }
157
158            let dir_b = (db_x / len_b, db_y / len_b);
159
160            // Check anti-parallelism: dot product should be close to -1
161            let dot = dir_a.0 * dir_b.0 + dir_a.1 * dir_b.1;
162            if dot > -1.0 + angle_tolerance {
163                continue; // Not anti-parallel
164            }
165
166            // Compute perpendicular distance between the two edge lines
167            let perp_dist = perpendicular_distance(a0, dir_a, b0);
168            if perp_dist > max_distance {
169                continue;
170            }
171
172            // Compute overlap along the edge direction
173            let overlap = compute_overlap(a0, a1, b0, b1, dir_a);
174            if overlap <= 1e-6 {
175                continue; // No meaningful overlap
176            }
177
178            // Compute midpoint of the overlapping segment
179            let t_a0 = project_onto_line(a0, dir_a, b0);
180            let t_a1 = project_onto_line(a0, dir_a, b1);
181            let t_b_min = t_a0.min(t_a1).max(0.0);
182            let t_b_max = t_a0.max(t_a1).min(len_a);
183            let t_mid = (t_b_min + t_b_max) / 2.0;
184            let midpoint = (a0.0 + dir_a.0 * t_mid, a0.1 + dir_a.1 * t_mid);
185
186            results.push(CommonEdge {
187                contour_a: ca.id,
188                edge_a: i,
189                contour_b: cb.id,
190                edge_b: j,
191                overlap_length: overlap,
192                midpoint,
193            });
194        }
195    }
196}
197
198/// Computes perpendicular distance from a point to a line defined by point + direction.
199fn perpendicular_distance(line_point: (f64, f64), line_dir: (f64, f64), point: (f64, f64)) -> f64 {
200    let dx = point.0 - line_point.0;
201    let dy = point.1 - line_point.1;
202    // Cross product magnitude gives perpendicular distance
203    (dx * line_dir.1 - dy * line_dir.0).abs()
204}
205
206/// Projects a point onto a line and returns the parameter t.
207fn project_onto_line(line_origin: (f64, f64), line_dir: (f64, f64), point: (f64, f64)) -> f64 {
208    let dx = point.0 - line_origin.0;
209    let dy = point.1 - line_origin.1;
210    dx * line_dir.0 + dy * line_dir.1
211}
212
213/// Computes the overlap length between two anti-parallel edge segments.
214fn compute_overlap(
215    a0: (f64, f64),
216    a1: (f64, f64),
217    b0: (f64, f64),
218    b1: (f64, f64),
219    dir_a: (f64, f64),
220) -> f64 {
221    let len_a = ((a1.0 - a0.0).powi(2) + (a1.1 - a0.1).powi(2)).sqrt();
222
223    // Project B endpoints onto A's direction
224    let t_b0 = project_onto_line(a0, dir_a, b0);
225    let t_b1 = project_onto_line(a0, dir_a, b1);
226
227    let b_min = t_b0.min(t_b1);
228    let b_max = t_b0.max(t_b1);
229
230    // Overlap with A's range [0, len_a]
231    let overlap_start = b_min.max(0.0);
232    let overlap_end = b_max.min(len_a);
233
234    (overlap_end - overlap_start).max(0.0)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn make_rect(id: usize, x: f64, y: f64, w: f64, h: f64) -> CutContour {
242        CutContour {
243            id,
244            geometry_id: format!("part{}", id),
245            instance: 0,
246            contour_type: ContourType::Exterior,
247            vertices: vec![(x, y), (x + w, y), (x + w, y + h), (x, y + h)],
248            perimeter: 2.0 * (w + h),
249            centroid: (x + w / 2.0, y + h / 2.0),
250        }
251    }
252
253    #[test]
254    fn test_adjacent_rectangles_share_edge() {
255        // Two 10x10 rectangles side by side with 0.2mm gap (kerf)
256        let contours = vec![
257            make_rect(0, 0.0, 0.0, 10.0, 10.0),
258            make_rect(1, 10.2, 0.0, 10.0, 10.0), // 0.2 gap
259        ];
260
261        let result = detect_common_edges(&contours, 0.5, 0.1);
262
263        // Right edge of rect 0 (x=10) and left edge of rect 1 (x=10.2) should match
264        assert!(
265            !result.common_edges.is_empty(),
266            "Should detect common edge between adjacent rectangles"
267        );
268        assert!(result.total_common_length > 9.0); // Close to 10.0
269    }
270
271    #[test]
272    fn test_distant_rectangles_no_common() {
273        let contours = vec![
274            make_rect(0, 0.0, 0.0, 10.0, 10.0),
275            make_rect(1, 50.0, 0.0, 10.0, 10.0), // Far apart
276        ];
277
278        let result = detect_common_edges(&contours, 0.5, 0.1);
279        assert!(result.common_edges.is_empty());
280    }
281
282    #[test]
283    fn test_perpendicular_edges_no_common() {
284        // Two rectangles with perpendicular adjacent edges
285        let contours = vec![
286            make_rect(0, 0.0, 0.0, 10.0, 10.0),
287            make_rect(1, 10.0, 10.0, 10.0, 10.0), // Corner-touching
288        ];
289
290        let result = detect_common_edges(&contours, 0.5, 0.1);
291        // Only anti-parallel edges match, perpendicular ones don't
292        // The bottom of rect1 (y=10) and top of rect0 (y=10) may match
293        // but the side edges at the corner shouldn't
294        for ce in &result.common_edges {
295            assert!(ce.overlap_length > 0.0);
296        }
297    }
298
299    #[test]
300    fn test_interior_contours_ignored() {
301        let mut contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
302        contours.push(CutContour {
303            id: 1,
304            geometry_id: "part1".to_string(),
305            instance: 0,
306            contour_type: ContourType::Interior, // Interior
307            vertices: vec![(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)],
308            perimeter: 24.0,
309            centroid: (5.0, 5.0),
310        });
311
312        let result = detect_common_edges(&contours, 0.5, 0.1);
313        assert!(
314            result.common_edges.is_empty(),
315            "Interior contours should not participate"
316        );
317    }
318
319    #[test]
320    fn test_stacked_rectangles_horizontal() {
321        // Two 10x5 rectangles stacked vertically with 0.3mm gap
322        let contours = vec![
323            make_rect(0, 0.0, 0.0, 10.0, 5.0),
324            make_rect(1, 0.0, 5.3, 10.0, 5.0),
325        ];
326
327        let result = detect_common_edges(&contours, 0.5, 0.1);
328        assert!(!result.common_edges.is_empty());
329        // Top edge of rect 0 (y=5) and bottom edge of rect 1 (y=5.3) should match
330        assert!(result.total_common_length > 9.0);
331    }
332
333    #[test]
334    fn test_common_edge_result_fields() {
335        let contours = vec![
336            make_rect(0, 0.0, 0.0, 10.0, 10.0),
337            make_rect(1, 10.1, 0.0, 10.0, 10.0),
338        ];
339
340        let result = detect_common_edges(&contours, 0.5, 0.1);
341        assert!(!result.common_edges.is_empty());
342
343        let ce = &result.common_edges[0];
344        assert!(ce.contour_a == 0 || ce.contour_a == 1);
345        assert!(ce.contour_b == 0 || ce.contour_b == 1);
346        assert_ne!(ce.contour_a, ce.contour_b);
347        assert!(ce.overlap_length > 0.0);
348    }
349
350    #[test]
351    fn test_empty_contours() {
352        let result = detect_common_edges(&[], 0.5, 0.1);
353        assert!(result.common_edges.is_empty());
354        assert_eq!(result.total_common_length, 0.0);
355    }
356}