Skip to main content

u_nesting_cutting/
gtsp.rs

1//! Generalized TSP (GTSP) formulation for cutting path optimization.
2//!
3//! Models the cutting sequence problem as a GTSP where:
4//! - Each contour is a **cluster** with multiple candidate pierce points
5//! - The goal is to select exactly one candidate per cluster and find the
6//!   shortest tour visiting all clusters
7//!
8//! # Algorithm
9//!
10//! 1. **Discretize**: Generate N equidistant pierce candidates per contour
11//! 2. **Distance matrix**: Compute asymmetric rapid-traverse distances
12//!    between all candidate pairs across different clusters
13//! 3. **Solve**: Use Noon-Bean transformation to ATSP, then apply
14//!    metaheuristic solver (Cycle 46)
15//!
16//! # References
17//!
18//! - Noon & Bean (1993), "An efficient transformation of the GTSP"
19//! - Dewil et al. (2015), "An improvement heuristic framework for the laser
20//!   cutting tool path problem"
21
22use u_nesting_core::timing::Timer;
23
24use crate::config::{CutDirectionPreference, CuttingConfig};
25use crate::contour::{ContourType, CutContour};
26use crate::cost::point_distance;
27use crate::result::CutDirection;
28
29/// A candidate pierce point on a contour.
30#[derive(Debug, Clone)]
31pub struct PierceCandidate {
32    /// Contour this candidate belongs to.
33    pub contour_id: usize,
34    /// Index within the cluster's candidate list.
35    pub candidate_index: usize,
36    /// The pierce point coordinates.
37    pub point: (f64, f64),
38    /// The nearest vertex index on the contour.
39    pub vertex_index: usize,
40    /// Cut direction for this contour.
41    pub direction: CutDirection,
42    /// End point after cutting the full contour (same as `point` for closed contours).
43    pub end_point: (f64, f64),
44}
45
46/// A GTSP cluster: one contour with its candidate pierce points.
47#[derive(Debug, Clone)]
48pub struct GtspCluster {
49    /// ID of the contour this cluster represents.
50    pub contour_id: usize,
51    /// Candidate pierce points for this contour.
52    pub candidates: Vec<PierceCandidate>,
53}
54
55/// A complete GTSP instance with distance matrix.
56#[derive(Debug, Clone)]
57pub struct GtspInstance {
58    /// Clusters (one per contour).
59    pub clusters: Vec<GtspCluster>,
60    /// Asymmetric distance matrix between all candidates.
61    /// `distances[i][j]` = rapid distance from end_point of global candidate `i`
62    /// to pierce point of global candidate `j`.
63    /// Intra-cluster distances are set to `f64::MAX` (invalid transitions).
64    pub distances: Vec<Vec<f64>>,
65    /// Distance from home position to each candidate's pierce point.
66    pub home_distances: Vec<f64>,
67    /// Cumulative offset for each cluster in the global index.
68    pub cluster_offsets: Vec<usize>,
69    /// Total number of candidates across all clusters.
70    pub total_candidates: usize,
71}
72
73impl GtspInstance {
74    /// Returns the cluster index and local candidate index for a global index.
75    pub fn global_to_local(&self, global_idx: usize) -> (usize, usize) {
76        for (c, offset) in self.cluster_offsets.iter().enumerate() {
77            let size = self.clusters[c].candidates.len();
78            if global_idx >= *offset && global_idx < offset + size {
79                return (c, global_idx - offset);
80            }
81        }
82        // Should never happen if global_idx is valid
83        (self.clusters.len() - 1, 0)
84    }
85
86    /// Returns the global index for a cluster and local candidate index.
87    pub fn local_to_global(&self, cluster_idx: usize, candidate_idx: usize) -> usize {
88        self.cluster_offsets[cluster_idx] + candidate_idx
89    }
90
91    /// Returns the candidate at a global index.
92    pub fn candidate(&self, global_idx: usize) -> &PierceCandidate {
93        let (c, l) = self.global_to_local(global_idx);
94        &self.clusters[c].candidates[l]
95    }
96}
97
98/// Discretizes contours into GTSP clusters with equidistant pierce candidates.
99///
100/// Each contour gets `config.pierce_candidates` equidistant points along its
101/// perimeter. If `pierce_candidates == 1`, uses the centroid-facing vertex.
102///
103/// # Arguments
104///
105/// * `contours` - Cut contours to discretize
106/// * `config` - Cutting configuration (pierce_candidates, direction preferences)
107pub fn discretize_contours(contours: &[CutContour], config: &CuttingConfig) -> Vec<GtspCluster> {
108    let n_candidates = config.pierce_candidates.max(1);
109
110    contours
111        .iter()
112        .map(|contour| {
113            let direction = determine_direction(contour.contour_type, config);
114            let candidates = if n_candidates == 1 {
115                // Single candidate: use first vertex
116                vec![PierceCandidate {
117                    contour_id: contour.id,
118                    candidate_index: 0,
119                    point: contour.vertices[0],
120                    vertex_index: 0,
121                    direction,
122                    end_point: contour.vertices[0],
123                }]
124            } else {
125                generate_equidistant_candidates(contour, n_candidates, direction)
126            };
127
128            GtspCluster {
129                contour_id: contour.id,
130                candidates,
131            }
132        })
133        .collect()
134}
135
136/// Builds a GTSP instance with asymmetric distance matrix.
137///
138/// The distance matrix is indexed by global candidate indices. Intra-cluster
139/// distances are set to `f64::MAX` since we must visit exactly one candidate
140/// per cluster.
141///
142/// # Arguments
143///
144/// * `clusters` - GTSP clusters from `discretize_contours`
145/// * `home` - Home/start position for the cutting head
146pub fn build_gtsp_instance(clusters: Vec<GtspCluster>, home: (f64, f64)) -> GtspInstance {
147    // Compute cluster offsets
148    let mut cluster_offsets = Vec::with_capacity(clusters.len());
149    let mut offset = 0;
150    for cluster in &clusters {
151        cluster_offsets.push(offset);
152        offset += cluster.candidates.len();
153    }
154    let total = offset;
155
156    // Collect all candidates for distance computation
157    let all_candidates: Vec<&PierceCandidate> =
158        clusters.iter().flat_map(|c| c.candidates.iter()).collect();
159
160    // Build distance matrix
161    let mut distances = vec![vec![f64::MAX; total]; total];
162    for (i, ci) in all_candidates.iter().enumerate() {
163        for (j, cj) in all_candidates.iter().enumerate() {
164            if ci.contour_id == cj.contour_id {
165                continue; // Intra-cluster: keep as MAX
166            }
167            distances[i][j] = point_distance(ci.end_point, cj.point);
168        }
169    }
170
171    // Home distances
172    let home_distances: Vec<f64> = all_candidates
173        .iter()
174        .map(|c| point_distance(home, c.point))
175        .collect();
176
177    GtspInstance {
178        clusters,
179        distances,
180        home_distances,
181        cluster_offsets,
182        total_candidates: total,
183    }
184}
185
186/// Evaluates the total rapid distance for a GTSP solution.
187///
188/// A solution is a list of global candidate indices, one per cluster, in visit order.
189pub fn evaluate_solution(instance: &GtspInstance, solution: &[usize]) -> f64 {
190    if solution.is_empty() {
191        return 0.0;
192    }
193
194    let mut total = instance.home_distances[solution[0]];
195
196    for i in 1..solution.len() {
197        total += instance.distances[solution[i - 1]][solution[i]];
198    }
199
200    total
201}
202
203/// Solves the GTSP using a greedy nearest-neighbor heuristic.
204///
205/// For each step, selects the nearest candidate from any unvisited cluster.
206/// Returns the global candidate indices in visit order.
207pub fn solve_nn(instance: &GtspInstance) -> Vec<usize> {
208    let n_clusters = instance.clusters.len();
209    if n_clusters == 0 {
210        return Vec::new();
211    }
212
213    let mut visited_clusters = vec![false; n_clusters];
214    let mut solution = Vec::with_capacity(n_clusters);
215
216    // First: find nearest candidate from home
217    let mut best_idx = 0;
218    let mut best_dist = f64::MAX;
219    for (g, dist) in instance.home_distances.iter().enumerate() {
220        if *dist < best_dist {
221            best_dist = *dist;
222            best_idx = g;
223        }
224    }
225
226    let (cluster, _) = instance.global_to_local(best_idx);
227    visited_clusters[cluster] = true;
228    solution.push(best_idx);
229
230    // Greedily add nearest unvisited cluster's candidate
231    for _ in 1..n_clusters {
232        let last = *solution.last().expect("solution not empty");
233        let mut next_best = 0;
234        let mut next_dist = f64::MAX;
235
236        for (g, &dist) in instance.distances[last].iter().enumerate() {
237            let (c, _) = instance.global_to_local(g);
238            if visited_clusters[c] {
239                continue;
240            }
241            if dist < next_dist {
242                next_dist = dist;
243                next_best = g;
244            }
245        }
246
247        let (c, _) = instance.global_to_local(next_best);
248        visited_clusters[c] = true;
249        solution.push(next_best);
250    }
251
252    solution
253}
254
255/// Solves the GTSP with precedence constraints using NN + constrained 2-opt.
256///
257/// This is the main solver that respects the precedence DAG. It:
258/// 1. Builds a precedence-aware NN solution (only visiting clusters whose
259///    predecessors have already been visited)
260/// 2. Improves with 2-opt swaps that maintain precedence validity
261///
262/// Returns the global candidate indices in visit order.
263pub fn solve_constrained(
264    instance: &GtspInstance,
265    dag: &crate::hierarchy::CuttingDag,
266    config: &CuttingConfig,
267) -> Vec<usize> {
268    let n_clusters = instance.clusters.len();
269    if n_clusters == 0 {
270        return Vec::new();
271    }
272
273    // Step 1: Precedence-aware NN
274    let mut solution = nn_constrained(instance, dag);
275
276    // Step 2: Constrained 2-opt (wall-clock bounded via config.time_limit_ms)
277    if config.max_2opt_iterations > 0 && solution.len() >= 3 {
278        improve_2opt_constrained(&mut solution, instance, dag, config);
279    }
280
281    solution
282}
283
284/// Nearest-neighbor construction that respects precedence constraints.
285fn nn_constrained(instance: &GtspInstance, dag: &crate::hierarchy::CuttingDag) -> Vec<usize> {
286    let n_clusters = instance.clusters.len();
287    let mut visited_clusters = vec![false; n_clusters];
288    let mut solution = Vec::with_capacity(n_clusters);
289    let mut visited_contours: std::collections::HashSet<usize> =
290        std::collections::HashSet::with_capacity(n_clusters);
291
292    for _ in 0..n_clusters {
293        let mut best_idx = None;
294        let mut best_dist = f64::MAX;
295
296        for (ci, cluster) in instance.clusters.iter().enumerate() {
297            if visited_clusters[ci] {
298                continue;
299            }
300
301            // Check precedence: all predecessors' clusters must be visited
302            let predecessors = dag.predecessors(cluster.contour_id);
303            let ready = predecessors
304                .iter()
305                .all(|pred_id| visited_contours.contains(pred_id));
306            if !ready {
307                continue;
308            }
309
310            // Find the best candidate in this cluster
311            for cand in &cluster.candidates {
312                let global = instance.local_to_global(ci, cand.candidate_index);
313                let dist = if solution.is_empty() {
314                    instance.home_distances[global]
315                } else {
316                    let last: usize = *solution.last().expect("solution not empty");
317                    let row: &Vec<f64> = &instance.distances[last];
318                    row[global]
319                };
320
321                if dist < best_dist {
322                    best_dist = dist;
323                    best_idx = Some((ci, global));
324                }
325            }
326        }
327
328        if let Some((ci, global)) = best_idx {
329            visited_clusters[ci] = true;
330            visited_contours.insert(instance.clusters[ci].contour_id);
331            solution.push(global);
332        }
333    }
334
335    solution
336}
337
338/// How many candidate moves to evaluate between wall-clock checks (see the
339/// equivalent constant in `sequence.rs` for rationale).
340const TIME_CHECK_INTERVAL: u32 = 512;
341
342/// Constrained 2-opt improvement for GTSP solutions.
343///
344/// Tries swapping candidates within clusters and reversing sub-sequences,
345/// accepting only moves that reduce cost and respect precedence.
346///
347/// Bounded by `config.time_limit_ms` (0 = unlimited). Both move neighborhoods
348/// are checked from inside their innermost loop, at a point where `solution`
349/// holds an accepted (best-so-far) state, so timeout returns a valid result.
350fn improve_2opt_constrained(
351    solution: &mut [usize],
352    instance: &GtspInstance,
353    dag: &crate::hierarchy::CuttingDag,
354    config: &CuttingConfig,
355) {
356    let n = solution.len();
357    let timer = Timer::now();
358    let time_limited = config.time_limit_ms > 0;
359    let mut since_check: u32 = 0;
360    let mut improved = true;
361    let mut iterations = 0;
362    let mut current_cost = evaluate_solution(instance, solution);
363
364    while improved && iterations < config.max_2opt_iterations {
365        improved = false;
366        iterations += 1;
367
368        // Move 1: Try swapping each position to a better candidate in the same cluster
369        for pos in 0..n {
370            let current_global = solution[pos];
371            let (ci, _) = instance.global_to_local(current_global);
372            let cluster = &instance.clusters[ci];
373
374            for cand in &cluster.candidates {
375                let alt_global = instance.local_to_global(ci, cand.candidate_index);
376                if alt_global == current_global {
377                    continue;
378                }
379
380                // Wall-clock guard: `solution` is accepted state before the
381                // tentative swap below.
382                since_check += 1;
383                if time_limited && since_check >= TIME_CHECK_INTERVAL {
384                    since_check = 0;
385                    if timer.elapsed_ms() >= config.time_limit_ms {
386                        return;
387                    }
388                }
389
390                solution[pos] = alt_global;
391                let new_cost = evaluate_solution(instance, solution);
392
393                if new_cost < current_cost - 1e-10 {
394                    current_cost = new_cost;
395                    improved = true;
396                } else {
397                    solution[pos] = current_global; // Undo
398                }
399            }
400        }
401
402        // Move 2: Try reversing sub-sequences (cluster-order level)
403        for i in 0..n.saturating_sub(1) {
404            for j in (i + 2)..n {
405                // Wall-clock guard: `solution` is accepted state before the
406                // tentative reverse below.
407                since_check += 1;
408                if time_limited && since_check >= TIME_CHECK_INTERVAL {
409                    since_check = 0;
410                    if timer.elapsed_ms() >= config.time_limit_ms {
411                        return;
412                    }
413                }
414
415                solution[i + 1..=j].reverse();
416
417                // Check precedence validity
418                let cluster_order: Vec<usize> = solution
419                    .iter()
420                    .map(|&g| instance.clusters[instance.global_to_local(g).0].contour_id)
421                    .collect();
422
423                if dag.is_valid_sequence(&cluster_order) {
424                    let new_cost = evaluate_solution(instance, solution);
425                    if new_cost < current_cost - 1e-10 {
426                        current_cost = new_cost;
427                        improved = true;
428                    } else {
429                        solution[i + 1..=j].reverse(); // Undo
430                    }
431                } else {
432                    solution[i + 1..=j].reverse(); // Undo — violates precedence
433                }
434            }
435        }
436    }
437}
438
439/// Determines the cutting direction for a contour type.
440fn determine_direction(contour_type: ContourType, config: &CuttingConfig) -> CutDirection {
441    let pref = match contour_type {
442        ContourType::Exterior => config.exterior_direction,
443        ContourType::Interior => config.interior_direction,
444    };
445
446    match pref {
447        CutDirectionPreference::Ccw => CutDirection::Ccw,
448        CutDirectionPreference::Cw => CutDirection::Cw,
449        CutDirectionPreference::Auto => match contour_type {
450            ContourType::Exterior => CutDirection::Ccw,
451            ContourType::Interior => CutDirection::Cw,
452        },
453    }
454}
455
456/// Generates equidistant pierce candidates along a contour's perimeter.
457fn generate_equidistant_candidates(
458    contour: &CutContour,
459    n: usize,
460    direction: CutDirection,
461) -> Vec<PierceCandidate> {
462    let vertices = &contour.vertices;
463    let nv = vertices.len();
464    if nv == 0 {
465        return Vec::new();
466    }
467
468    // Compute cumulative edge lengths
469    let mut edge_lengths = Vec::with_capacity(nv);
470    let mut cumulative = Vec::with_capacity(nv + 1);
471    cumulative.push(0.0);
472
473    for i in 0..nv {
474        let j = (i + 1) % nv;
475        let len = point_distance(vertices[i], vertices[j]);
476        edge_lengths.push(len);
477        cumulative.push(cumulative[i] + len);
478    }
479
480    let perimeter = *cumulative.last().expect("at least one vertex");
481    if perimeter < 1e-12 {
482        return vec![PierceCandidate {
483            contour_id: contour.id,
484            candidate_index: 0,
485            point: vertices[0],
486            vertex_index: 0,
487            direction,
488            end_point: vertices[0],
489        }];
490    }
491
492    let spacing = perimeter / n as f64;
493    let mut candidates = Vec::with_capacity(n);
494
495    for k in 0..n {
496        let target_dist = k as f64 * spacing;
497
498        // Find which edge this distance falls on
499        let (point, vertex_idx) =
500            point_at_distance(vertices, &cumulative, &edge_lengths, target_dist);
501
502        candidates.push(PierceCandidate {
503            contour_id: contour.id,
504            candidate_index: k,
505            point,
506            vertex_index: vertex_idx,
507            direction,
508            end_point: point, // Closed contour: returns to pierce point
509        });
510    }
511
512    candidates
513}
514
515/// Finds the point at a given distance along the contour perimeter.
516fn point_at_distance(
517    vertices: &[(f64, f64)],
518    cumulative: &[f64],
519    edge_lengths: &[f64],
520    distance: f64,
521) -> ((f64, f64), usize) {
522    let nv = vertices.len();
523    let perimeter = cumulative[nv];
524    let dist = distance % perimeter;
525
526    for i in 0..nv {
527        if dist >= cumulative[i] && dist <= cumulative[i + 1] + 1e-12 {
528            let edge_len = edge_lengths[i];
529            if edge_len < 1e-12 {
530                return (vertices[i], i);
531            }
532            let t = (dist - cumulative[i]) / edge_len;
533            let j = (i + 1) % nv;
534            let px = vertices[i].0 + t * (vertices[j].0 - vertices[i].0);
535            let py = vertices[i].1 + t * (vertices[j].1 - vertices[i].1);
536            return ((px, py), i);
537        }
538    }
539
540    // Fallback: last vertex
541    (vertices[nv - 1], nv - 1)
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn make_rect(id: usize, x: f64, y: f64, w: f64, h: f64) -> CutContour {
549        CutContour {
550            id,
551            geometry_id: format!("part{}", id),
552            instance: 0,
553            contour_type: ContourType::Exterior,
554            vertices: vec![(x, y), (x + w, y), (x + w, y + h), (x, y + h)],
555            perimeter: 2.0 * (w + h),
556            centroid: (x + w / 2.0, y + h / 2.0),
557        }
558    }
559
560    #[test]
561    fn test_discretize_single_candidate() {
562        let contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
563        let config = CuttingConfig::new().with_pierce_candidates(1);
564        let clusters = discretize_contours(&contours, &config);
565
566        assert_eq!(clusters.len(), 1);
567        assert_eq!(clusters[0].candidates.len(), 1);
568        assert_eq!(clusters[0].candidates[0].point, (0.0, 0.0));
569    }
570
571    #[test]
572    fn test_discretize_four_candidates_on_square() {
573        let contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
574        let config = CuttingConfig::new().with_pierce_candidates(4);
575        let clusters = discretize_contours(&contours, &config);
576
577        assert_eq!(clusters.len(), 1);
578        let cands = &clusters[0].candidates;
579        assert_eq!(cands.len(), 4);
580
581        // Perimeter = 40, spacing = 10 → points at distances 0, 10, 20, 30
582        // Vertices: (0,0), (10,0), (10,10), (0,10)
583        // dist 0 → (0,0), dist 10 → (10,0), dist 20 → (10,10), dist 30 → (0,10)
584        assert!((cands[0].point.0 - 0.0).abs() < 1e-10);
585        assert!((cands[0].point.1 - 0.0).abs() < 1e-10);
586        assert!((cands[1].point.0 - 10.0).abs() < 1e-10);
587        assert!((cands[1].point.1 - 0.0).abs() < 1e-10);
588        assert!((cands[2].point.0 - 10.0).abs() < 1e-10);
589        assert!((cands[2].point.1 - 10.0).abs() < 1e-10);
590        assert!((cands[3].point.0 - 0.0).abs() < 1e-10);
591        assert!((cands[3].point.1 - 10.0).abs() < 1e-10);
592    }
593
594    #[test]
595    fn test_discretize_eight_candidates_midpoints() {
596        let contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
597        let config = CuttingConfig::new().with_pierce_candidates(8);
598        let clusters = discretize_contours(&contours, &config);
599
600        let cands = &clusters[0].candidates;
601        assert_eq!(cands.len(), 8);
602
603        // Perimeter = 40, spacing = 5 → candidates at 0, 5, 10, 15, 20, 25, 30, 35
604        // dist 0 → (0,0), dist 5 → (5,0), dist 10 → (10,0), dist 15 → (10,5)
605        assert!((cands[1].point.0 - 5.0).abs() < 1e-10);
606        assert!((cands[1].point.1 - 0.0).abs() < 1e-10);
607        assert!((cands[3].point.0 - 10.0).abs() < 1e-10);
608        assert!((cands[3].point.1 - 5.0).abs() < 1e-10);
609    }
610
611    #[test]
612    fn test_build_gtsp_instance_distances() {
613        let contours = vec![
614            make_rect(0, 0.0, 0.0, 10.0, 10.0),
615            make_rect(1, 20.0, 0.0, 10.0, 10.0),
616        ];
617        let config = CuttingConfig::new().with_pierce_candidates(1);
618        let clusters = discretize_contours(&contours, &config);
619        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
620
621        assert_eq!(instance.total_candidates, 2);
622        assert_eq!(instance.clusters.len(), 2);
623
624        // Intra-cluster distances should be MAX
625        assert_eq!(instance.distances[0][0], f64::MAX);
626        assert_eq!(instance.distances[1][1], f64::MAX);
627
628        // Inter-cluster distance: (0,0) → (20,0) = 20.0
629        assert!((instance.distances[0][1] - 20.0).abs() < 1e-10);
630        // Inter-cluster distance: (20,0) → (0,0) = 20.0
631        assert!((instance.distances[1][0] - 20.0).abs() < 1e-10);
632
633        // Home distances
634        assert!((instance.home_distances[0] - 0.0).abs() < 1e-10);
635        assert!((instance.home_distances[1] - 20.0).abs() < 1e-10);
636    }
637
638    #[test]
639    fn test_global_to_local() {
640        let contours = vec![
641            make_rect(0, 0.0, 0.0, 10.0, 10.0),
642            make_rect(1, 20.0, 0.0, 10.0, 10.0),
643        ];
644        let config = CuttingConfig::new().with_pierce_candidates(4);
645        let clusters = discretize_contours(&contours, &config);
646        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
647
648        assert_eq!(instance.total_candidates, 8);
649        assert_eq!(instance.global_to_local(0), (0, 0));
650        assert_eq!(instance.global_to_local(3), (0, 3));
651        assert_eq!(instance.global_to_local(4), (1, 0));
652        assert_eq!(instance.global_to_local(7), (1, 3));
653    }
654
655    #[test]
656    fn test_evaluate_solution() {
657        let contours = vec![
658            make_rect(0, 0.0, 0.0, 10.0, 10.0),
659            make_rect(1, 30.0, 0.0, 10.0, 10.0),
660        ];
661        let config = CuttingConfig::new().with_pierce_candidates(1);
662        let clusters = discretize_contours(&contours, &config);
663        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
664
665        // Solution: visit cluster 0 first (candidate 0), then cluster 1 (candidate 1)
666        let cost = evaluate_solution(&instance, &[0, 1]);
667        // Home (0,0) → candidate 0 at (0,0) = 0.0
668        // candidate 0 end (0,0) → candidate 1 at (30,0) = 30.0
669        assert!((cost - 30.0).abs() < 1e-10);
670    }
671
672    #[test]
673    fn test_solve_nn() {
674        let contours = vec![
675            make_rect(0, 50.0, 0.0, 10.0, 10.0),
676            make_rect(1, 10.0, 0.0, 10.0, 10.0),
677            make_rect(2, 30.0, 0.0, 10.0, 10.0),
678        ];
679        let config = CuttingConfig::new().with_pierce_candidates(1);
680        let clusters = discretize_contours(&contours, &config);
681        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
682
683        let solution = solve_nn(&instance);
684        assert_eq!(solution.len(), 3);
685
686        // NN from (0,0): nearest is contour 1 at (10,0), then 2 at (30,0), then 0 at (50,0)
687        let cluster_order: Vec<usize> = solution
688            .iter()
689            .map(|&g| instance.global_to_local(g).0)
690            .collect();
691        assert_eq!(cluster_order, vec![1, 2, 0]);
692    }
693
694    #[test]
695    fn test_solve_nn_empty() {
696        let instance = build_gtsp_instance(Vec::new(), (0.0, 0.0));
697        let solution = solve_nn(&instance);
698        assert!(solution.is_empty());
699    }
700
701    #[test]
702    fn test_nn_picks_best_candidate() {
703        // Two contours, each with 2 candidates
704        // Contour 0 at (0,0)-(10,10): candidates at (0,0) and (10,0)
705        // Contour 1 at (12,0)-(22,10): candidates at (12,0) and (22,0)
706        // From home (0,0), NN should pick candidate (0,0) of contour 0
707        // Then from (0,0), should pick candidate (12,0) of contour 1
708        let contours = vec![
709            make_rect(0, 0.0, 0.0, 10.0, 10.0),
710            make_rect(1, 12.0, 0.0, 10.0, 10.0),
711        ];
712        let config = CuttingConfig::new().with_pierce_candidates(4);
713        let clusters = discretize_contours(&contours, &config);
714        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
715
716        let solution = solve_nn(&instance);
717        assert_eq!(solution.len(), 2);
718
719        // First should be from cluster 0 (nearest to home)
720        let (c0, _) = instance.global_to_local(solution[0]);
721        assert_eq!(c0, 0);
722
723        // Second should be from cluster 1 — pick the nearest candidate
724        let (c1, l1) = instance.global_to_local(solution[1]);
725        assert_eq!(c1, 1);
726        // Candidate at (12,0) should be picked (nearest to end of cluster 0)
727        let picked = &instance.clusters[1].candidates[l1];
728        assert!((picked.point.0 - 12.0).abs() < 1e-10);
729    }
730
731    #[test]
732    fn test_direction_assignment() {
733        let mut contours = vec![make_rect(0, 0.0, 0.0, 10.0, 10.0)];
734        contours[0].contour_type = ContourType::Interior;
735
736        let config = CuttingConfig::default();
737        let clusters = discretize_contours(&contours, &config);
738
739        // Interior with Auto should be CW
740        assert_eq!(clusters[0].candidates[0].direction, CutDirection::Cw);
741    }
742
743    #[test]
744    fn test_multi_candidate_improves_over_single() {
745        // Three rectangles laid out with offset — multi-candidate should find
746        // better pierce points than single-candidate
747        let contours = vec![
748            make_rect(0, 0.0, 0.0, 10.0, 10.0),
749            make_rect(1, 15.0, 5.0, 10.0, 10.0),
750            make_rect(2, 30.0, 0.0, 10.0, 10.0),
751        ];
752
753        // Single candidate
754        let config1 = CuttingConfig::new().with_pierce_candidates(1);
755        let clusters1 = discretize_contours(&contours, &config1);
756        let inst1 = build_gtsp_instance(clusters1, (0.0, 0.0));
757        let sol1 = solve_nn(&inst1);
758        let cost1 = evaluate_solution(&inst1, &sol1);
759
760        // 8 candidates per contour
761        let config8 = CuttingConfig::new().with_pierce_candidates(8);
762        let clusters8 = discretize_contours(&contours, &config8);
763        let inst8 = build_gtsp_instance(clusters8, (0.0, 0.0));
764        let sol8 = solve_nn(&inst8);
765        let cost8 = evaluate_solution(&inst8, &sol8);
766
767        // More candidates should give same or better solution
768        assert!(
769            cost8 <= cost1 + 1e-6,
770            "Multi-candidate cost {} should be <= single-candidate cost {}",
771            cost8,
772            cost1
773        );
774    }
775
776    #[test]
777    fn test_solve_constrained_respects_precedence() {
778        use crate::hierarchy::CuttingDag;
779
780        // Part with interior hole — hole must be cut first
781        let contours = vec![
782            CutContour {
783                id: 0,
784                geometry_id: "part1".to_string(),
785                instance: 0,
786                contour_type: ContourType::Exterior,
787                vertices: vec![(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)],
788                perimeter: 80.0,
789                centroid: (10.0, 10.0),
790            },
791            CutContour {
792                id: 1,
793                geometry_id: "part1".to_string(),
794                instance: 0,
795                contour_type: ContourType::Interior,
796                vertices: vec![(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)],
797                perimeter: 40.0,
798                centroid: (10.0, 10.0),
799            },
800        ];
801
802        let dag = CuttingDag::build(&contours);
803        let config = CuttingConfig::new().with_pierce_candidates(4);
804        let clusters = discretize_contours(&contours, &config);
805        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
806
807        let solution = solve_constrained(&instance, &dag, &config);
808        assert_eq!(solution.len(), 2);
809
810        // Interior (cluster for contour 1) must come before Exterior (cluster for contour 0)
811        let cluster_order: Vec<usize> = solution
812            .iter()
813            .map(|&g| instance.clusters[instance.global_to_local(g).0].contour_id)
814            .collect();
815
816        let pos_interior = cluster_order.iter().position(|&id| id == 1).unwrap();
817        let pos_exterior = cluster_order.iter().position(|&id| id == 0).unwrap();
818        assert!(pos_interior < pos_exterior);
819    }
820
821    #[test]
822    fn test_solve_constrained_with_multiple_parts() {
823        use crate::hierarchy::CuttingDag;
824
825        let contours = vec![
826            make_rect(0, 0.0, 0.0, 10.0, 10.0),
827            make_rect(1, 15.0, 0.0, 10.0, 10.0),
828            make_rect(2, 30.0, 0.0, 10.0, 10.0),
829        ];
830
831        let dag = CuttingDag::build(&contours);
832        let config = CuttingConfig::new().with_pierce_candidates(4);
833        let clusters = discretize_contours(&contours, &config);
834        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
835
836        let solution = solve_constrained(&instance, &dag, &config);
837        assert_eq!(solution.len(), 3);
838
839        // NN should visit in order: 0 (nearest), 1, 2
840        let cluster_order: Vec<usize> = solution
841            .iter()
842            .map(|&g| instance.global_to_local(g).0)
843            .collect();
844        assert_eq!(cluster_order, vec![0, 1, 2]);
845    }
846
847    #[test]
848    fn test_2opt_improves_solution() {
849        use crate::hierarchy::CuttingDag;
850
851        // Place contours in a way that NN gives a suboptimal order
852        // Contours arranged: home(0,0) → C1(5,20) → C0(5,0) → C2(5,40)
853        // NN from home: picks C0 (nearest), then C1, then C2
854        // Optimal: C0, C2, C1 might be worse... Let's use a zigzag
855        let contours = vec![
856            make_rect(0, 0.0, 0.0, 10.0, 10.0),
857            make_rect(1, 20.0, 0.0, 10.0, 10.0),
858            make_rect(2, 40.0, 0.0, 10.0, 10.0),
859        ];
860
861        let dag = CuttingDag::build(&contours);
862        let config = CuttingConfig::new().with_pierce_candidates(4);
863        let clusters = discretize_contours(&contours, &config);
864        let instance = build_gtsp_instance(clusters, (0.0, 0.0));
865
866        let solution = solve_constrained(&instance, &dag, &config);
867        let cost = evaluate_solution(&instance, &solution);
868
869        // Cost should be reasonable (not worse than worst case)
870        // Worst case: home→C2(40,0)→C0(0,0)→C1(20,0) = 40+40+20 = 100
871        assert!(
872            cost < 100.0,
873            "Solution cost {} should be < worst case 100",
874            cost
875        );
876    }
877
878    #[test]
879    fn test_constrained_empty() {
880        use crate::hierarchy::CuttingDag;
881
882        let contours: Vec<CutContour> = Vec::new();
883        let dag = CuttingDag::build(&contours);
884        let instance = build_gtsp_instance(Vec::new(), (0.0, 0.0));
885        let config = CuttingConfig::default();
886
887        let solution = solve_constrained(&instance, &dag, &config);
888        assert!(solution.is_empty());
889    }
890}