Skip to main content

brepkit_blend/
chamfer_builder.rs

1//! Chamfer builder: orchestrates the full chamfer pipeline.
2//!
3//! Supports symmetric, asymmetric, and distance-angle chamfer modes on
4//! planar face pairs (v1). Reuses the analytic fast path and face trimming
5//! infrastructure from the fillet pipeline.
6
7use std::collections::HashSet;
8
9use brepkit_topology::Topology;
10use brepkit_topology::edge::EdgeId;
11use brepkit_topology::face::FaceId;
12use brepkit_topology::shell::Shell;
13use brepkit_topology::solid::{Solid, SolidId};
14
15use crate::analytic;
16use crate::builder_utils::sample_nurbs_endpoints;
17use crate::spine::Spine;
18use crate::stripe::StripeResult;
19use crate::trimmer::{self, TrimKeep, TrimSide};
20use crate::{BlendError, BlendResult};
21
22/// Internal representation of a chamfer edge set with its distance parameters.
23enum ChamferEdgeSet {
24    /// Two explicit distances (d1 on face 1, d2 on face 2).
25    TwoDistance {
26        /// Edges to chamfer.
27        edges: Vec<EdgeId>,
28        /// Distance on face 1.
29        d1: f64,
30        /// Distance on face 2.
31        d2: f64,
32    },
33    /// Distance on face 1 plus angle from face 1 toward face 2.
34    DistanceAngle {
35        /// Edges to chamfer.
36        edges: Vec<EdgeId>,
37        /// Distance on face 1.
38        distance: f64,
39        /// Angle from face 1 (radians).
40        angle: f64,
41    },
42}
43
44/// Builder for chamfer (bevel) operations on solid edges.
45///
46/// Collects edge sets with their distance parameters, then computes and
47/// assembles the chamfered solid in a single `build()` call.
48pub struct ChamferBuilder<'a> {
49    topo: &'a mut Topology,
50    solid: SolidId,
51    edge_sets: Vec<ChamferEdgeSet>,
52}
53
54impl<'a> ChamferBuilder<'a> {
55    /// Create a new chamfer builder for the given solid.
56    #[must_use]
57    pub fn new(topo: &'a mut Topology, solid: SolidId) -> Self {
58        Self {
59            topo,
60            solid,
61            edge_sets: Vec::new(),
62        }
63    }
64
65    /// Add edges with symmetric chamfer distance (d1 = d2 = d).
66    ///
67    /// Returns `&mut Self` for method chaining.
68    pub fn add_edges_symmetric(&mut self, edges: &[EdgeId], d: f64) -> &mut Self {
69        self.edge_sets.push(ChamferEdgeSet::TwoDistance {
70            edges: edges.to_vec(),
71            d1: d,
72            d2: d,
73        });
74        self
75    }
76
77    /// Add edges with asymmetric chamfer distances.
78    ///
79    /// `d1` is the distance on face 1, `d2` on face 2.
80    ///
81    /// Returns `&mut Self` for method chaining.
82    pub fn add_edges_asymmetric(&mut self, edges: &[EdgeId], d1: f64, d2: f64) -> &mut Self {
83        self.edge_sets.push(ChamferEdgeSet::TwoDistance {
84            edges: edges.to_vec(),
85            d1,
86            d2,
87        });
88        self
89    }
90
91    /// Add edges with distance-angle chamfer.
92    ///
93    /// `distance` is measured on face 1; `angle` (radians) determines
94    /// the depth on face 2 as `distance * tan(angle)`.
95    ///
96    /// Returns `&mut Self` for method chaining.
97    pub fn add_edges_distance_angle(
98        &mut self,
99        edges: &[EdgeId],
100        distance: f64,
101        angle: f64,
102    ) -> &mut Self {
103        self.edge_sets.push(ChamferEdgeSet::DistanceAngle {
104            edges: edges.to_vec(),
105            distance,
106            angle,
107        });
108        self
109    }
110
111    /// Compute and build the chamfered solid.
112    ///
113    /// # Algorithm
114    ///
115    /// 1. Build adjacency index for the solid.
116    /// 2. For each target edge, find the two adjacent faces.
117    /// 3. Build single-edge spines (no chain propagation in v1).
118    /// 4. Compute stripes via analytic fast path or record failure.
119    /// 5. Trim adjacent faces along contact curves.
120    /// 6. Assemble new solid from trimmed faces, blend faces, and untouched
121    ///    original faces.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`BlendError`] if no edges were specified, or if topology
126    /// lookups fail. Individual edge failures are recorded in
127    /// [`BlendResult::failed`] rather than aborting the whole operation.
128    #[allow(clippy::too_many_lines)]
129    pub fn build(self) -> Result<BlendResult, BlendError> {
130        let all_edges: Vec<(EdgeId, f64, f64)> = self
131            .edge_sets
132            .into_iter()
133            .flat_map(|set| {
134                let (edges, d1, d2) = match set {
135                    ChamferEdgeSet::TwoDistance { edges, d1, d2 } => (edges, d1, d2),
136                    ChamferEdgeSet::DistanceAngle {
137                        edges,
138                        distance,
139                        angle,
140                    } => {
141                        let d2 = distance * angle.tan();
142                        (edges, distance, d2)
143                    }
144                };
145                edges.into_iter().map(move |eid| (eid, d1, d2))
146            })
147            .collect();
148
149        if all_edges.is_empty() {
150            return Err(BlendError::Topology(
151                brepkit_topology::TopologyError::Empty {
152                    entity: "chamfer edge set",
153                },
154            ));
155        }
156
157        let topo = self.topo;
158
159        let adjacency = topo.build_adjacency(self.solid)?;
160
161        let shell_id = topo.solid(self.solid)?.outer_shell();
162        let original_faces: Vec<FaceId> = topo.shell(shell_id)?.faces().to_vec();
163
164        let mut touched_faces: HashSet<FaceId> = HashSet::new();
165
166        let mut succeeded: Vec<EdgeId> = Vec::new();
167        let mut failed: Vec<(EdgeId, BlendError)> = Vec::new();
168        let mut stripe_results: Vec<StripeResult> = Vec::new();
169
170        for (edge_id, d1, d2) in &all_edges {
171            let result = compute_chamfer_stripe(topo, &adjacency, *edge_id, *d1, *d2);
172            match result {
173                Ok(sr) => {
174                    touched_faces.insert(sr.stripe.face1);
175                    touched_faces.insert(sr.stripe.face2);
176                    stripe_results.push(sr);
177                    succeeded.push(*edge_id);
178                }
179                Err(e) => {
180                    failed.push((*edge_id, e));
181                }
182            }
183        }
184
185        // If no stripes succeeded, return the original solid with all failures.
186        if stripe_results.is_empty() {
187            return Ok(BlendResult {
188                solid: self.solid,
189                succeeded: Vec::new(),
190                failed,
191                is_partial: false,
192            });
193        }
194
195        let mut face_replacements: std::collections::HashMap<FaceId, FaceId> =
196            std::collections::HashMap::new();
197
198        let mut stripe_contact_edges: Vec<(
199            Option<brepkit_topology::edge::EdgeId>,
200            Option<brepkit_topology::edge::EdgeId>,
201        )> = Vec::new();
202        for sr in &stripe_results {
203            let stripe = &sr.stripe;
204            stripe_contact_edges.push((None, None));
205
206            let contact1_pts = sample_nurbs_endpoints(&stripe.contact1);
207            let contact2_pts = sample_nurbs_endpoints(&stripe.contact2);
208
209            let keep_side1 =
210                if let (Some(sec), Ok(face)) = (stripe.sections.first(), topo.face(stripe.face1)) {
211                    let n = face.surface().normal(0.0, 0.0);
212                    if n.dot(sec.center - sec.p1) > 0.0 {
213                        TrimSide::Right
214                    } else {
215                        TrimSide::Left
216                    }
217                } else {
218                    TrimSide::Right
219                };
220            let keep_side2 =
221                if let (Some(sec), Ok(face)) = (stripe.sections.first(), topo.face(stripe.face2)) {
222                    let n = face.surface().normal(0.0, 0.0);
223                    if n.dot(sec.center - sec.p2) > 0.0 {
224                        TrimSide::Right
225                    } else {
226                        TrimSide::Left
227                    }
228                } else {
229                    TrimSide::Right
230                };
231
232            let current_face1 = face_replacements
233                .get(&stripe.face1)
234                .copied()
235                .unwrap_or(stripe.face1);
236            let trim1 = trimmer::trim_face(
237                topo,
238                current_face1,
239                &contact1_pts,
240                &[(0.0, 0.0), (1.0, 0.0)],
241                TrimKeep::Side(keep_side1),
242            );
243
244            match trim1 {
245                Ok(tr) if tr.trimmed_face != current_face1 => {
246                    if let Some(slot) = stripe_contact_edges.last_mut() {
247                        slot.0 = tr.contact_edge;
248                    }
249                    face_replacements.insert(stripe.face1, tr.trimmed_face);
250                }
251                Ok(_) => {}
252                Err(e) => {
253                    log::warn!("chamfer trimming failed on face {:?}: {e}", stripe.face1);
254                }
255            }
256
257            let current_face2 = face_replacements
258                .get(&stripe.face2)
259                .copied()
260                .unwrap_or(stripe.face2);
261            let trim2 = trimmer::trim_face(
262                topo,
263                current_face2,
264                &contact2_pts,
265                &[(0.0, 0.0), (1.0, 0.0)],
266                TrimKeep::Side(keep_side2),
267            );
268
269            match trim2 {
270                Ok(tr) if tr.trimmed_face != current_face2 => {
271                    if let Some(slot) = stripe_contact_edges.last_mut() {
272                        slot.1 = tr.contact_edge;
273                    }
274                    face_replacements.insert(stripe.face2, tr.trimmed_face);
275                }
276                Ok(_) => {}
277                Err(e) => {
278                    log::warn!("chamfer trimming failed on face {:?}: {e}", stripe.face2);
279                }
280            }
281        }
282
283        let mut blend_face_ids: Vec<FaceId> = Vec::new();
284
285        for (si, sr) in stripe_results.iter().enumerate() {
286            // Reuse the trimmed neighbours' contact edges (mirrors the fillet
287            // builder): a freshly minted duplicate leaves both copies use-1
288            // and opens the shell along the chamfer flanks.
289            let (c1, c2) = stripe_contact_edges
290                .get(si)
291                .copied()
292                .unwrap_or((None, None));
293            let blend_face_id =
294                crate::builder_utils::create_blend_face_with_contacts(topo, &sr.stripe, c1, c2)?
295                    .face;
296            blend_face_ids.push(blend_face_id);
297        }
298
299        let mut result_faces: Vec<FaceId> = Vec::new();
300
301        for &fid in &original_faces {
302            if !touched_faces.contains(&fid) {
303                result_faces.push(fid);
304            }
305        }
306
307        for &fid in &touched_faces {
308            let replacement = face_replacements.get(&fid).copied();
309            result_faces.push(replacement.unwrap_or(fid));
310        }
311
312        result_faces.extend(&blend_face_ids);
313
314        let new_shell = Shell::new(result_faces)?;
315        let new_shell_id = topo.add_shell(new_shell);
316        let new_solid = Solid::new(new_shell_id, Vec::new());
317        let new_solid_id = topo.add_solid(new_solid);
318
319        let is_partial = !failed.is_empty();
320        Ok(BlendResult {
321            solid: new_solid_id,
322            succeeded,
323            failed,
324            is_partial,
325        })
326    }
327}
328
329/// Compute a chamfer stripe for a single edge using the adjacency index.
330///
331/// # Errors
332///
333/// Returns [`BlendError`] if the edge is non-manifold, if topology lookups
334/// fail, or if the analytic path cannot produce a result.
335fn compute_chamfer_stripe(
336    topo: &Topology,
337    adjacency: &brepkit_topology::adjacency::AdjacencyIndex,
338    edge_id: EdgeId,
339    d1: f64,
340    d2: f64,
341) -> Result<StripeResult, BlendError> {
342    let adj_faces = adjacency.faces_for_edge(edge_id);
343    if adj_faces.len() != 2 {
344        log::warn!(
345            "edge {edge_id:?} has {} adjacent faces (expected 2) — cannot chamfer non-manifold or boundary edges",
346            adj_faces.len()
347        );
348        return Err(BlendError::StartSolutionFailure {
349            edge: edge_id,
350            t: 0.0,
351        });
352    }
353    let face1 = adj_faces[0];
354    let face2 = adj_faces[1];
355
356    let surf1 = topo.face(face1)?.surface().clone();
357    let surf2 = topo.face(face2)?.surface().clone();
358
359    let spine = Spine::from_single_edge(topo, edge_id)?;
360
361    if let Some(result) =
362        analytic::try_analytic_chamfer(&surf1, &surf2, &spine, topo, d1, d2, face1, face2)?
363    {
364        return Ok(result);
365    }
366
367    log::debug!(
368        target: "brepkit_approx",
369        "chamfer: analytic path unavailable for {}+{} — v1 has no walker fallback, returning UnsupportedSurface",
370        surf1.type_tag(),
371        surf2.type_tag()
372    );
373    // v1: no walker fallback for non-analytic surface pairs.
374    Err(BlendError::UnsupportedSurface {
375        face: face1,
376        surface_tag: format!(
377            "{}+{} (walker not yet integrated)",
378            surf1.type_tag(),
379            surf2.type_tag()
380        ),
381    })
382}
383
384#[cfg(test)]
385mod tests {
386    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
387
388    use super::*;
389    use brepkit_topology::adjacency::AdjacencyIndex;
390    use brepkit_topology::face::FaceSurface;
391    use brepkit_topology::test_utils::make_unit_cube_manifold;
392
393    /// Find the first manifold edge of the solid (shared by exactly 2 faces).
394    fn find_manifold_edge(topo: &Topology, solid: SolidId) -> EdgeId {
395        let adjacency = AdjacencyIndex::build(topo, solid).unwrap();
396        let shell_id = topo.solid(solid).unwrap().outer_shell();
397        let faces = topo.shell(shell_id).unwrap().faces().to_vec();
398
399        for &fid in &faces {
400            let face = topo.face(fid).unwrap();
401            let wire = topo.wire(face.outer_wire()).unwrap();
402            for oe in wire.edges() {
403                let adj = adjacency.faces_for_edge(oe.edge());
404                if adj.len() == 2 {
405                    return oe.edge();
406                }
407            }
408        }
409        panic!("cube should have manifold edges");
410    }
411
412    #[test]
413    fn chamfer_builder_symmetric() {
414        let mut topo = Topology::new();
415        let solid = make_unit_cube_manifold(&mut topo);
416        let target_edge = find_manifold_edge(&topo, solid);
417
418        let shell_id = topo.solid(solid).unwrap().outer_shell();
419        let original_face_count = topo.shell(shell_id).unwrap().faces().len();
420
421        let mut builder = ChamferBuilder::new(&mut topo, solid);
422        builder.add_edges_symmetric(&[target_edge], 0.1);
423        let result = builder.build().expect("chamfer build should succeed");
424
425        let result_solid = topo.solid(result.solid).unwrap();
426        let result_shell = topo.shell(result_solid.outer_shell()).unwrap();
427
428        assert!(
429            result_shell.faces().len() > original_face_count,
430            "expected more faces after chamfer: got {}, original {}",
431            result_shell.faces().len(),
432            original_face_count,
433        );
434
435        assert!(result.succeeded.contains(&target_edge));
436        assert!(result.failed.is_empty());
437        assert!(!result.is_partial);
438
439        let mut found_chamfer_plane = false;
440        for &fid in result_shell.faces() {
441            let face = topo.face(fid).unwrap();
442            if matches!(face.surface(), FaceSurface::Plane { .. }) {
443                found_chamfer_plane = true;
444            }
445        }
446        assert!(
447            found_chamfer_plane,
448            "chamfer should produce a planar blend surface"
449        );
450    }
451
452    #[test]
453    fn chamfer_builder_distance_angle() {
454        let mut topo = Topology::new();
455        let solid = make_unit_cube_manifold(&mut topo);
456        let target_edge = find_manifold_edge(&topo, solid);
457
458        let shell_id = topo.solid(solid).unwrap().outer_shell();
459        let original_face_count = topo.shell(shell_id).unwrap().faces().len();
460
461        // 45-degree angle means d2 = distance * tan(45deg) = distance.
462        let distance = 0.15;
463        let angle = std::f64::consts::FRAC_PI_4;
464
465        let mut builder = ChamferBuilder::new(&mut topo, solid);
466        builder.add_edges_distance_angle(&[target_edge], distance, angle);
467        let result = builder.build().expect("chamfer build should succeed");
468
469        let result_solid = topo.solid(result.solid).unwrap();
470        let result_shell = topo.shell(result_solid.outer_shell()).unwrap();
471
472        assert!(
473            result_shell.faces().len() > original_face_count,
474            "expected more faces after distance-angle chamfer"
475        );
476        assert!(result.succeeded.contains(&target_edge));
477        assert!(result.failed.is_empty());
478    }
479
480    #[test]
481    fn chamfer_builder_empty_edges_error() {
482        let mut topo = Topology::new();
483        let solid = make_unit_cube_manifold(&mut topo);
484
485        let builder = ChamferBuilder::new(&mut topo, solid);
486        let result = builder.build();
487        assert!(result.is_err(), "empty edge set should produce an error");
488    }
489}