Skip to main content

brepkit_operations/
evolution.rs

1//! Evolution tracking for modeling operations.
2//!
3//! Records how faces evolve through booleans, fillets, and other operations,
4//! enabling downstream consumers to track face provenance (e.g., for applying
5//! persistent attributes like color or constraints).
6
7use std::collections::{HashMap, HashSet};
8
9use brepkit_math::vec::{Point3, Vec3};
10
11/// Tracks how faces evolve through a modeling operation.
12///
13/// After a boolean, fillet, or other operation, this map records:
14/// - **modified**: input face -> output faces that replace it
15/// - **generated**: input face -> new faces created adjacent to it
16/// - **deleted**: input faces that were completely removed
17#[derive(Debug, Clone, Default)]
18pub struct EvolutionMap {
19    /// Input face -> output faces that are modified versions of it.
20    pub modified: HashMap<usize, Vec<usize>>,
21    /// Input face -> new faces generated from it (e.g., blend faces from fillet).
22    pub generated: HashMap<usize, Vec<usize>>,
23    /// Input faces that were completely removed.
24    pub deleted: HashSet<usize>,
25}
26
27impl EvolutionMap {
28    /// Create an empty evolution map.
29    #[must_use]
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Record that `input` was modified into `output`.
35    pub fn add_modified(&mut self, input: usize, output: usize) {
36        self.modified.entry(input).or_default().push(output);
37    }
38
39    /// Record that `output` was generated from `input`.
40    pub fn add_generated(&mut self, input: usize, output: usize) {
41        self.generated.entry(input).or_default().push(output);
42    }
43
44    /// Record that `input` was deleted.
45    pub fn add_deleted(&mut self, input: usize) {
46        self.deleted.insert(input);
47    }
48
49    /// Serialize to JSON without serde.
50    ///
51    /// Produces a JSON object with `modified`, `generated`, and `deleted` fields.
52    #[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/// Build an [`EvolutionMap`] by matching output faces to input faces purely
84/// from geometry (face normal + centroid signatures `(index, normal, centroid)`).
85///
86/// This is operation-agnostic — any op that can snapshot face signatures before
87/// and after (booleans, fillets, …) reuses it:
88/// - An output face whose normal+centroid is close to an input face is a
89///   **modified** version of it (every near-tied input is recorded, so a
90///   same-domain merge of two inputs into one output keeps both origins).
91/// - An output face matching no input is **generated**, attributed to the
92///   nearest input (e.g. a fillet blend face or a boolean intersection face).
93/// - An input face matched by no output is **deleted**.
94#[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    // Normal dot threshold cos(45°) — relaxed because faces split by an
104    // operation may shift slightly. Centroid distance² cap is generous.
105    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        // Accept any near-tied match: two inputs legitimately contributing to
137        // one output (e.g. the two halves of a same-domain-merged face).
138        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    // Unmatched outputs are generated — attribute each to the nearest input.
148    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    // Any input matched by nothing was deleted.
168    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            // Same normal+position as input 0 → modified.
196            (100usize, pz, Point3::new(0.0, 0.0, 0.0)),
197            // Orthogonal normal, matches nothing → generated, nearest input is 0.
198            (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        // The fillet adds a blend face (6 → 7) yet removes no box face.
222        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        // Every output face — including the new blend — is attributed to an
231        // input, so a downstream face reference always resolves.
232        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}