1use std::collections::{HashMap, HashSet};
8
9use brepkit_math::vec::{Point3, Vec3};
10
11#[derive(Debug, Clone, Default)]
18pub struct EvolutionMap {
19 pub modified: HashMap<usize, Vec<usize>>,
21 pub generated: HashMap<usize, Vec<usize>>,
23 pub deleted: HashSet<usize>,
25}
26
27impl EvolutionMap {
28 #[must_use]
30 pub fn new() -> Self {
31 Self::default()
32 }
33
34 pub fn add_modified(&mut self, input: usize, output: usize) {
36 self.modified.entry(input).or_default().push(output);
37 }
38
39 pub fn add_generated(&mut self, input: usize, output: usize) {
41 self.generated.entry(input).or_default().push(output);
42 }
43
44 pub fn add_deleted(&mut self, input: usize) {
46 self.deleted.insert(input);
47 }
48
49 #[must_use]
53 pub fn to_json(&self) -> String {
54 let modified_entries: Vec<String> = self
55 .modified
56 .iter()
57 .map(|(k, vs)| {
58 let vals: Vec<String> = vs.iter().map(ToString::to_string).collect();
59 format!("\"{k}\":[{}]", vals.join(","))
60 })
61 .collect();
62
63 let generated_entries: Vec<String> = self
64 .generated
65 .iter()
66 .map(|(k, vs)| {
67 let vals: Vec<String> = vs.iter().map(ToString::to_string).collect();
68 format!("\"{k}\":[{}]", vals.join(","))
69 })
70 .collect();
71
72 let deleted_vals: Vec<String> = self.deleted.iter().map(ToString::to_string).collect();
73
74 format!(
75 "{{\"modified\":{{{}}},\"generated\":{{{}}},\"deleted\":[{}]}}",
76 modified_entries.join(","),
77 generated_entries.join(","),
78 deleted_vals.join(",")
79 )
80 }
81}
82
83#[must_use]
95pub fn build_evolution_by_geometry(
96 input_faces: &[(usize, Vec3, Point3)],
97 output_faces: &[(usize, Vec3, Point3)],
98) -> EvolutionMap {
99 let mut evo = EvolutionMap::new();
100 let mut matched_inputs: HashSet<usize> = HashSet::new();
101 let mut unmatched_outputs: Vec<(usize, Vec3, Point3)> = Vec::new();
102
103 let normal_threshold = 0.707;
106 let centroid_dist_sq_max = 100.0;
107
108 for &(out_idx, out_normal, out_centroid) in output_faces {
109 let mut best_score = f64::NEG_INFINITY;
110 let mut matches: Vec<(usize, f64)> = Vec::new();
111
112 for &(in_idx, in_normal, in_centroid) in input_faces {
113 let dot = out_normal.dot(in_normal);
114 if dot < normal_threshold {
115 continue;
116 }
117 let dx = out_centroid.x() - in_centroid.x();
118 let dy = out_centroid.y() - in_centroid.y();
119 let dz = out_centroid.z() - in_centroid.z();
120 let dist_sq = dx.mul_add(dx, dy.mul_add(dy, dz * dz));
121 if dist_sq > centroid_dist_sq_max {
122 continue;
123 }
124 let score = dot - dist_sq / centroid_dist_sq_max;
125 if score > best_score {
126 best_score = score;
127 }
128 matches.push((in_idx, score));
129 }
130
131 if matches.is_empty() {
132 unmatched_outputs.push((out_idx, out_normal, out_centroid));
133 continue;
134 }
135
136 let score_tol = 0.05;
139 for &(in_idx, score) in &matches {
140 if score >= best_score - score_tol {
141 evo.add_modified(in_idx, out_idx);
142 matched_inputs.insert(in_idx);
143 }
144 }
145 }
146
147 for &(out_idx, _out_normal, out_centroid) in &unmatched_outputs {
149 let mut best_dist_sq = f64::MAX;
150 let mut best_input: Option<usize> = None;
151 for &(in_idx, _, in_centroid) in input_faces {
152 let dx = out_centroid.x() - in_centroid.x();
153 let dy = out_centroid.y() - in_centroid.y();
154 let dz = out_centroid.z() - in_centroid.z();
155 let dist_sq = dx.mul_add(dx, dy.mul_add(dy, dz * dz));
156 if dist_sq < best_dist_sq {
157 best_dist_sq = dist_sq;
158 best_input = Some(in_idx);
159 }
160 }
161 if let Some(in_idx) = best_input {
162 evo.add_generated(in_idx, out_idx);
163 matched_inputs.insert(in_idx);
164 }
165 }
166
167 for &(in_idx, _, _) in input_faces {
169 if !matched_inputs.contains(&in_idx) {
170 evo.add_deleted(in_idx);
171 }
172 }
173
174 evo
175}
176
177#[cfg(test)]
178mod tests {
179 #![allow(clippy::unwrap_used, clippy::expect_used)]
180
181 use brepkit_math::vec::{Point3, Vec3};
182
183 use super::*;
184
185 #[test]
186 fn matcher_classifies_modified_generated_deleted() {
187 let pz = Vec3::new(0.0, 0.0, 1.0);
188 let nz = Vec3::new(0.0, 0.0, -1.0);
189 let px = Vec3::new(1.0, 0.0, 0.0);
190 let inputs = [
191 (0usize, pz, Point3::new(0.0, 0.0, 0.0)),
192 (1usize, nz, Point3::new(0.0, 0.0, -10.0)),
193 ];
194 let outputs = [
195 (100usize, pz, Point3::new(0.0, 0.0, 0.0)),
197 (200usize, px, Point3::new(1.0, 0.0, 0.0)),
199 ];
200 let evo = build_evolution_by_geometry(&inputs, &outputs);
201 assert_eq!(evo.modified.get(&0), Some(&vec![100]));
202 assert_eq!(evo.generated.get(&0), Some(&vec![200]));
203 assert!(evo.deleted.contains(&1), "input 1 had no output → deleted");
204 }
205
206 #[test]
207 fn fillet_evolution_tracks_all_faces() {
208 use brepkit_topology::explorer::solid_edges;
209
210 let mut topo = brepkit_topology::Topology::new();
211 let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
212 let inputs = crate::boolean::collect_face_signatures(&topo, cube).unwrap();
213 let edges = solid_edges(&topo, cube).unwrap();
214 let filleted = crate::blend_ops::fillet_v2(&mut topo, cube, &[edges[0]], 1.0)
215 .unwrap()
216 .solid;
217 let outputs = crate::boolean::collect_face_signatures(&topo, filleted).unwrap();
218
219 let evo = build_evolution_by_geometry(&inputs, &outputs);
220
221 assert_eq!(inputs.len(), 6);
223 assert_eq!(outputs.len(), 7);
224 assert!(
225 evo.deleted.is_empty(),
226 "no box face is deleted by the fillet"
227 );
228 assert_eq!(evo.modified.len(), 6, "all six box faces are tracked");
229
230 let tracked: HashSet<usize> = evo
233 .modified
234 .values()
235 .chain(evo.generated.values())
236 .flatten()
237 .copied()
238 .collect();
239 let output_indices: HashSet<usize> = outputs.iter().map(|&(i, _, _)| i).collect();
240 assert_eq!(tracked, output_indices, "every output face is attributed");
241 }
242}