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};
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            // Keep the side of the contact line AWAY from the spine edge
210            // (mirrors the fillet builder): the strip between the contact
211            // line and the old edge is what the chamfer face replaces. The
212            // side is resolved inside the trimmer, whose Left/Right frame
213            // follows each face's wire traversal and cannot be predicted
214            // here — a surface-normal side test against the section centre
215            // reads the same for both traversals and picks the wrong chain
216            // on one of them (the concave notch on a canonically-wound
217            // prism kept the ridge strip and grew the solid).
218            let spine_pt = stripe.spine.evaluate(topo, 0.0)?;
219            let keep = TrimKeep::AwayFrom(spine_pt);
220
221            let current_face1 = face_replacements
222                .get(&stripe.face1)
223                .copied()
224                .unwrap_or(stripe.face1);
225            let trim1 = trimmer::trim_face(
226                topo,
227                current_face1,
228                &contact1_pts,
229                &[(0.0, 0.0), (1.0, 0.0)],
230                keep,
231            );
232
233            match trim1 {
234                Ok(tr) if tr.trimmed_face != current_face1 => {
235                    if let Some(slot) = stripe_contact_edges.last_mut() {
236                        slot.0 = tr.contact_edge;
237                    }
238                    face_replacements.insert(stripe.face1, tr.trimmed_face);
239                }
240                Ok(_) => {}
241                Err(e) => {
242                    log::warn!("chamfer trimming failed on face {:?}: {e}", stripe.face1);
243                }
244            }
245
246            let current_face2 = face_replacements
247                .get(&stripe.face2)
248                .copied()
249                .unwrap_or(stripe.face2);
250            let trim2 = trimmer::trim_face(
251                topo,
252                current_face2,
253                &contact2_pts,
254                &[(0.0, 0.0), (1.0, 0.0)],
255                keep,
256            );
257
258            match trim2 {
259                Ok(tr) if tr.trimmed_face != current_face2 => {
260                    if let Some(slot) = stripe_contact_edges.last_mut() {
261                        slot.1 = tr.contact_edge;
262                    }
263                    face_replacements.insert(stripe.face2, tr.trimmed_face);
264                }
265                Ok(_) => {}
266                Err(e) => {
267                    log::warn!("chamfer trimming failed on face {:?}: {e}", stripe.face2);
268                }
269            }
270        }
271
272        let mut blend_face_ids: Vec<FaceId> = Vec::new();
273
274        for (si, sr) in stripe_results.iter().enumerate() {
275            // Reuse the trimmed neighbours' contact edges (mirrors the fillet
276            // builder): a freshly minted duplicate leaves both copies use-1
277            // and opens the shell along the chamfer flanks.
278            let (c1, c2) = stripe_contact_edges
279                .get(si)
280                .copied()
281                .unwrap_or((None, None));
282            let blend_face_id =
283                crate::builder_utils::create_blend_face_with_contacts(topo, &sr.stripe, c1, c2)?
284                    .face;
285            blend_face_ids.push(blend_face_id);
286        }
287
288        let mut result_faces: Vec<FaceId> = Vec::new();
289
290        for &fid in &original_faces {
291            if !touched_faces.contains(&fid) {
292                result_faces.push(fid);
293            }
294        }
295
296        for &fid in &touched_faces {
297            let replacement = face_replacements.get(&fid).copied();
298            result_faces.push(replacement.unwrap_or(fid));
299        }
300
301        result_faces.extend(&blend_face_ids);
302
303        let new_shell = Shell::new(result_faces)?;
304        let new_shell_id = topo.add_shell(new_shell);
305        let new_solid = Solid::new(new_shell_id, Vec::new());
306        let new_solid_id = topo.add_solid(new_solid);
307
308        let is_partial = !failed.is_empty();
309        Ok(BlendResult {
310            solid: new_solid_id,
311            succeeded,
312            failed,
313            is_partial,
314        })
315    }
316}
317
318/// Compute a chamfer stripe for a single edge using the adjacency index.
319///
320/// # Errors
321///
322/// Returns [`BlendError`] if the edge is non-manifold, if topology lookups
323/// fail, or if the analytic path cannot produce a result.
324fn compute_chamfer_stripe(
325    topo: &Topology,
326    adjacency: &brepkit_topology::adjacency::AdjacencyIndex,
327    edge_id: EdgeId,
328    d1: f64,
329    d2: f64,
330) -> Result<StripeResult, BlendError> {
331    let adj_faces = adjacency.faces_for_edge(edge_id);
332    if adj_faces.len() != 2 {
333        log::warn!(
334            "edge {edge_id:?} has {} adjacent faces (expected 2) — cannot chamfer non-manifold or boundary edges",
335            adj_faces.len()
336        );
337        return Err(BlendError::StartSolutionFailure {
338            edge: edge_id,
339            t: 0.0,
340        });
341    }
342    let face1 = adj_faces[0];
343    let face2 = adj_faces[1];
344
345    let surf1 = topo.face(face1)?.surface().clone();
346    let surf2 = topo.face(face2)?.surface().clone();
347
348    let spine = Spine::from_single_edge(topo, edge_id)?;
349
350    if let Some(result) =
351        analytic::try_analytic_chamfer(&surf1, &surf2, &spine, topo, d1, d2, face1, face2)?
352    {
353        return Ok(result);
354    }
355
356    log::debug!(
357        target: "brepkit_approx",
358        "chamfer: analytic path unavailable for {}+{} — v1 has no walker fallback, returning UnsupportedSurface",
359        surf1.type_tag(),
360        surf2.type_tag()
361    );
362    // v1: no walker fallback for non-analytic surface pairs.
363    Err(BlendError::UnsupportedSurface {
364        face: face1,
365        surface_tag: format!(
366            "{}+{} (walker not yet integrated)",
367            surf1.type_tag(),
368            surf2.type_tag()
369        ),
370    })
371}
372
373#[cfg(test)]
374mod tests {
375    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
376
377    use super::*;
378    use brepkit_topology::adjacency::AdjacencyIndex;
379    use brepkit_topology::face::FaceSurface;
380    use brepkit_topology::test_utils::make_unit_cube_manifold;
381
382    /// Find the first manifold edge of the solid (shared by exactly 2 faces).
383    fn find_manifold_edge(topo: &Topology, solid: SolidId) -> EdgeId {
384        let adjacency = AdjacencyIndex::build(topo, solid).unwrap();
385        let shell_id = topo.solid(solid).unwrap().outer_shell();
386        let faces = topo.shell(shell_id).unwrap().faces().to_vec();
387
388        for &fid in &faces {
389            let face = topo.face(fid).unwrap();
390            let wire = topo.wire(face.outer_wire()).unwrap();
391            for oe in wire.edges() {
392                let adj = adjacency.faces_for_edge(oe.edge());
393                if adj.len() == 2 {
394                    return oe.edge();
395                }
396            }
397        }
398        panic!("cube should have manifold edges");
399    }
400
401    #[test]
402    fn chamfer_builder_symmetric() {
403        let mut topo = Topology::new();
404        let solid = make_unit_cube_manifold(&mut topo);
405        let target_edge = find_manifold_edge(&topo, solid);
406
407        let shell_id = topo.solid(solid).unwrap().outer_shell();
408        let original_face_count = topo.shell(shell_id).unwrap().faces().len();
409
410        let mut builder = ChamferBuilder::new(&mut topo, solid);
411        builder.add_edges_symmetric(&[target_edge], 0.1);
412        let result = builder.build().expect("chamfer build should succeed");
413
414        let result_solid = topo.solid(result.solid).unwrap();
415        let result_shell = topo.shell(result_solid.outer_shell()).unwrap();
416
417        assert!(
418            result_shell.faces().len() > original_face_count,
419            "expected more faces after chamfer: got {}, original {}",
420            result_shell.faces().len(),
421            original_face_count,
422        );
423
424        assert!(result.succeeded.contains(&target_edge));
425        assert!(result.failed.is_empty());
426        assert!(!result.is_partial);
427
428        let mut found_chamfer_plane = false;
429        for &fid in result_shell.faces() {
430            let face = topo.face(fid).unwrap();
431            if matches!(face.surface(), FaceSurface::Plane { .. }) {
432                found_chamfer_plane = true;
433            }
434        }
435        assert!(
436            found_chamfer_plane,
437            "chamfer should produce a planar blend surface"
438        );
439    }
440
441    #[test]
442    fn chamfer_builder_distance_angle() {
443        let mut topo = Topology::new();
444        let solid = make_unit_cube_manifold(&mut topo);
445        let target_edge = find_manifold_edge(&topo, solid);
446
447        let shell_id = topo.solid(solid).unwrap().outer_shell();
448        let original_face_count = topo.shell(shell_id).unwrap().faces().len();
449
450        // 45-degree angle means d2 = distance * tan(45deg) = distance.
451        let distance = 0.15;
452        let angle = std::f64::consts::FRAC_PI_4;
453
454        let mut builder = ChamferBuilder::new(&mut topo, solid);
455        builder.add_edges_distance_angle(&[target_edge], distance, angle);
456        let result = builder.build().expect("chamfer build should succeed");
457
458        let result_solid = topo.solid(result.solid).unwrap();
459        let result_shell = topo.shell(result_solid.outer_shell()).unwrap();
460
461        assert!(
462            result_shell.faces().len() > original_face_count,
463            "expected more faces after distance-angle chamfer"
464        );
465        assert!(result.succeeded.contains(&target_edge));
466        assert!(result.failed.is_empty());
467    }
468
469    #[test]
470    fn chamfer_builder_empty_edges_error() {
471        let mut topo = Topology::new();
472        let solid = make_unit_cube_manifold(&mut topo);
473
474        let builder = ChamferBuilder::new(&mut topo, solid);
475        let result = builder.build();
476        assert!(result.is_err(), "empty edge set should produce an error");
477    }
478}