1use 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
22enum ChamferEdgeSet {
24 TwoDistance {
26 edges: Vec<EdgeId>,
28 d1: f64,
30 d2: f64,
32 },
33 DistanceAngle {
35 edges: Vec<EdgeId>,
37 distance: f64,
39 angle: f64,
41 },
42}
43
44pub struct ChamferBuilder<'a> {
49 topo: &'a mut Topology,
50 solid: SolidId,
51 edge_sets: Vec<ChamferEdgeSet>,
52}
53
54impl<'a> ChamferBuilder<'a> {
55 #[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 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 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 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 #[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 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 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
329fn 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 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 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 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}