1use super::*;
2
3pub fn fillet_edge(
6 solid: &BrepSolid,
7 edge_id: u64,
8 radius: f64,
9 name: Option<&str>,
10) -> Result<BrepSolid, KernelRefusal> {
11 let _operation = crate::blend::BlendOperation::enter();
14 check_mixed_concavity(solid, edge_id, "fillet_edge")?;
15 check_support_extent(solid, edge_id, radius, "fillet_edge")?;
16 fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::GeneralFirst)
17}
18
19pub(crate) fn fillet_edge_cutter(
25 solid: &BrepSolid,
26 edge_id: u64,
27 radius: f64,
28 name: Option<&str>,
29) -> Result<BrepSolid, KernelRefusal> {
30 check_mixed_concavity(solid, edge_id, "fillet_edge")?;
31 check_support_extent(solid, edge_id, radius, "fillet_edge")?;
32 fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::CutterFirst)
33 .map(settle_cutter_scaffold)
34}
35
36pub fn chamfer_edge(
39 solid: &BrepSolid,
40 edge_id: u64,
41 distance: f64,
42 name: Option<&str>,
43) -> Result<BrepSolid, KernelRefusal> {
44 let _operation = crate::blend::BlendOperation::enter();
47 check_mixed_concavity(solid, edge_id, "chamfer_edge")?;
48 check_support_extent(solid, edge_id, distance, "chamfer_edge")?;
49 fillet_or_chamfer(solid, edge_id, distance, true, name, ToolEnds::default(), Lane::GeneralFirst)
50}
51
52fn apply_chamfer_offsets_profile(
56 solid: &BrepSolid,
57 cross: &EdgeCross,
58 profile: &[NurbsCurve],
59 name: Option<&str>,
60) -> Result<BrepSolid, KernelRefusal> {
61 let mut tool = match &cross.path {
62 EdgePath::Straight { direction, length } => {
63 extrude_profile_brep(profile, *direction, *length)?
64 }
65 EdgePath::Circular { .. } => {
66 return Err(KernelRefusal::unsupported(
67 KernelStage::Classify,
68 "asymmetric_curved_edge",
69 "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
70 in this slice (asymmetric chamfer on general/curved edges is out of scope)",
71 ));
72 }
73 };
74 let mut side_index = 0usize;
78 for shell in &mut tool.shells {
79 for face in &mut shell.faces {
80 if side_index == 1 {
81 if face.name.is_none() {
82 face.name = name.map(str::to_string);
83 }
84 } else {
85 face.name = Some(scaffold_tag(name));
86 }
87 side_index += 1;
88 }
89 }
90 apply_tool(solid, &tool, cross.convex).map(settle_cutter_scaffold)
91}
92
93pub fn chamfer_edge_asymmetric(
98 solid: &BrepSolid,
99 edge_id: u64,
100 d1: f64,
101 d2: f64,
102 name: Option<&str>,
103) -> Result<BrepSolid, KernelRefusal> {
104 let _operation = crate::blend::BlendOperation::enter();
107 if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
108 return Err(KernelRefusal::input(
109 KernelStage::Collect,
110 "setback_distances",
111 "chamfer_edge_asymmetric: both setback distances must be positive",
112 ));
113 }
114 let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
117 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
118 apply_chamfer_offsets_profile(solid, &cross, &profile, name)
119}
120
121pub fn chamfer_edge_angle(
127 solid: &BrepSolid,
128 edge_id: u64,
129 d1: f64,
130 angle_rad: f64,
131 name: Option<&str>,
132) -> Result<BrepSolid, KernelRefusal> {
133 let _operation = crate::blend::BlendOperation::enter();
136 if !(d1 > 0.0) || !d1.is_finite() {
137 return Err(KernelRefusal::input(
138 KernelStage::Collect,
139 "setback_d1",
140 "chamfer_edge_angle: setback distance d1 must be positive",
141 ));
142 }
143 let cross = analyze_edge(solid, edge_id, d1)?;
144 let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
145 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
146 apply_chamfer_offsets_profile(solid, &cross, &profile, name)
147}
148
149pub(super) fn heal_edge_vertex_gaps(
153 solid: &mut BrepSolid,
154 radius: f64,
155) -> Result<(), KernelRefusal> {
156 let search = (radius.abs() * 1e-3).max(1e-7);
157 crate::boolean::commit_nearby_edge_endpoints(solid, search)
158}
159
160fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
162 match crate::topology::nearest_edge(solid, point) {
163 Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
164 Some((_, distance)) => Err(KernelRefusal::input(
165 KernelStage::Collect,
166 "edge_point",
167 format!(
168 "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
169 ),
170 )),
171 None => Err(KernelRefusal::input(
172 KernelStage::Collect,
173 "no_edges",
174 "fillet_edges: solid has no edges",
175 )),
176 }
177}
178
179pub fn fillet_edges(
211 solid: &BrepSolid,
212 edge_points: &[Vec3],
213 edge_names: Option<&[String]>,
214 radius: f64,
215 chamfer: bool,
216 name: Option<&str>,
217) -> Result<BrepSolid, KernelRefusal> {
218 let _operation = crate::blend::BlendOperation::enter();
221 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
222 let (result, selection) =
223 fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name)?;
224 if selection.is_complete() {
225 return Ok(result);
226 }
227 let dropped: Vec<String> = selection
230 .rejected
231 .iter()
232 .map(|index| {
233 let point = edge_points[*index];
234 let named = edge_names
235 .and_then(|names| names.get(*index))
236 .map(|name| format!("`{name}` "))
237 .unwrap_or_default();
238 format!(
239 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
240 point.x, point.y, point.z
241 )
242 })
243 .collect();
244 Err(KernelRefusal::ill_posed(
248 KernelStage::Select,
249 "incomplete_selection",
250 format!(
251 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
252 rest of the selection and carry no wall in the result ({}). The group refusal was: \
253 {}. Blend them separately, or call the reported entry point to accept the partial \
254 selection deliberately.",
255 selection.applied.len(),
256 selection.requested,
257 selection.rejected.len(),
258 dropped.join(", "),
259 selection
260 .reason
261 .as_deref()
262 .unwrap_or("the whole group built but not every edge grew a wall"),
263 ),
264 ))
265}
266
267#[derive(Clone, Debug)]
274pub struct BlendSelection {
275 pub requested: usize,
277 pub applied: Vec<usize>,
279 pub rejected: Vec<usize>,
281 pub reason: Option<String>,
283}
284
285impl BlendSelection {
286 pub fn is_complete(&self) -> bool {
288 self.rejected.is_empty()
289 }
290}
291
292pub fn fillet_edges_reported_at_offset(
304 solid: &BrepSolid,
305 edge_points: &[Vec3],
306 edge_names: Option<&[String]>,
307 radius: f64,
308 chamfer: bool,
309 name: Option<&str>,
310 offset: Vec3,
311) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
312 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
313}
314
315#[allow(clippy::too_many_arguments)]
317fn fillet_translated(
318 solid: &BrepSolid,
319 edge_points: &[Vec3],
320 edge_names: Option<&[String]>,
321 radius: f64,
322 chamfer: bool,
323 name: Option<&str>,
324 offset: Vec3,
325 kind: crate::recentre::EntryKind,
326) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
327 let _entry = crate::recentre::TranslatedEntry::open(kind);
328 let translate = |solid: &BrepSolid, offset: Vec3| {
329 crate::recentre::translate_solid(solid, offset).map_err(|message| {
330 KernelRefusal::internal(
331 KernelStage::Collect,
332 "translation_control",
333 format!("fillet_edges: the translation control could not move the solid: {message}"),
334 )
335 })
336 };
337 let moved = translate(solid, offset)?;
338 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
339 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
340 Ok((translate(&result, offset.scale(-1.0))?, selection))
341}
342
343pub fn fillet_edges_reported(
344 solid: &BrepSolid,
345 edge_points: &[Vec3],
346 edge_names: Option<&[String]>,
347 radius: f64,
348 chamfer: bool,
349 name: Option<&str>,
350) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
351 if let Some(offset) = crate::recentre::control_offset_at_entry() {
354 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
355 }
356 let bounds = crate::recentre::solid_bounds(solid);
359 if let Some(offset) = crate::recentre::guard_offset(bounds) {
360 crate::recentre::profile_fired("fillet", offset, bounds);
361 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
362 }
363 let _operation = crate::blend::BlendOperation::enter();
366 let _total = crate::blend::BlendTimer::start("timing.total_ms");
369 if !(radius > 0.0) || !radius.is_finite() {
370 return Err(KernelRefusal::input(
371 KernelStage::Collect,
372 "radius",
373 "fillet_edges: radius must be positive",
374 ));
375 }
376 if edge_points.is_empty() {
377 return Err(KernelRefusal::input(
378 KernelStage::Collect,
379 "selection",
380 "fillet_edges: no edges selected",
381 ));
382 }
383 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
391 let mut selected_ids = Vec::with_capacity(edge_points.len());
392 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
393 for point in edge_points {
394 let edge_id = resolve_edge_by_point(solid, *point)?;
395 check_mixed_concavity(solid, edge_id, entry)?;
396 check_support_extent(solid, edge_id, radius, entry)?;
397 selected_ids.push(edge_id);
398 }
399 selection_checks.stop();
400 if !chamfer {
439 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
444 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
445 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
446 corner_analysis.stop();
447 if !corners.is_empty() {
448 let composed = rounded_boss_composition(
449 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
450 ).or_else(|error| {
451 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
452 eprintln!("rounded boss composition refused: {error}");
453 }
454 runout_composition(
455 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
456 )
457 });
458 match composed {
459 Ok(result) => {
460 let missing = walls_missing(
461 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry,
462 )?;
463 if !missing.is_empty() {
464 return Err(KernelRefusal::internal(
465 KernelStage::Validate,
466 "runout_walls_missing",
467 format!(
468 "{entry}: the runout composition built a solid with no wall on {}",
469 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
470 ),
471 ));
472 }
473 let n = edge_points.len();
474 return Ok((
475 result,
476 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
477 ));
478 }
479 Err(error) => {
480 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
481 eprintln!("runout composition refused: {error}");
482 }
483 check_mixed_corner_convexity(
484 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
485 )?;
486 }
487 }
488 }
489 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
495 check_degenerate_corner_setbacks(
496 solid,
497 &selected_ids,
498 edge_points,
499 edge_names,
500 radius,
501 name,
502 )?;
503 corner_analysis.stop();
504 }
505
506 let n = edge_points.len();
507 let complete = |applied: Vec<usize>| BlendSelection {
508 requested: n,
509 applied,
510 rejected: Vec::new(),
511 reason: None,
512 };
513 let mut memo = ComponentMemo::default();
524 let group_err = match fillet_edges_group_in(
525 solid,
526 edge_points,
527 edge_names,
528 radius,
529 chamfer,
530 name,
531 Some(&mut memo),
532 ) {
533 Ok(result) => {
534 let missing =
539 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
540 if missing.is_empty() {
541 return Ok((result, complete((0..n).collect())));
542 }
543 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
544 return Ok((
545 result,
546 BlendSelection {
547 requested: n,
548 applied,
549 rejected: missing,
550 reason: None,
551 },
552 ));
553 }
554 Err(error) => error,
555 };
556 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err) {
565 return Err(group_err);
566 }
567 if n < 2 || n > 12 {
572 return Err(group_err);
573 }
574 for drop in 1..n {
581 for dropped in index_combinations(n, drop) {
582 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
583 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
584 let kept_names: Option<Vec<String>> =
587 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
588 let Ok(result) = fillet_edges_group_in(
589 solid,
590 &points,
591 kept_names.as_deref(),
592 radius,
593 chamfer,
594 name,
595 Some(&mut memo),
596 ) else {
597 continue;
598 };
599 if !result.validate().is_empty() {
600 continue;
601 }
602 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
603 continue;
604 }
605 return Ok((
606 result,
607 BlendSelection {
608 requested: n,
609 applied: keep,
610 rejected: dropped,
611 reason: Some(group_err.message),
612 },
613 ));
614 }
615 }
616 Err(group_err)
617}
618
619pub(super) fn walls_missing(
645 original: &BrepSolid,
646 result: &BrepSolid,
647 edge_points: &[Vec3],
648 selected: &[usize],
649 size: f64,
650 chamfer: bool,
651 entry: &str,
652) -> Result<Vec<usize>, KernelRefusal> {
653 use crate::{PointClass, SolidClassifier};
654 if selected.is_empty() {
655 return Ok(Vec::new());
656 }
657 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
658 crate::classification::classify_profile_begin();
659 crate::classification::containment_profile_begin();
660 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
661 let visible = band * 100.0;
664 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
665 let classifier = SolidClassifier::new(result, band)
666 .or_refuse(KernelStage::Validate, "wall_classifier")?;
667 build_timing.stop();
668 const STATIONS: usize = 9;
669 let mut missing = Vec::new();
670 for &index in selected {
671 let point = edge_points[index];
672 let edge_id = resolve_edge_by_point(original, point)?;
673 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
674 let mut best: Option<(Vec3, f64)> = None;
676 for step in 1..=STATIONS {
677 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
678 let Ok(station) = edge.curve.evaluate(t) else {
679 continue;
680 };
681 let clearance = match faces[..] {
682 [first, second] => {
683 let (Ok((_, a)), Ok((_, b))) = (
684 super::analyze::face_plane_normal(first, station),
685 super::analyze::face_plane_normal(second, station),
686 ) else {
687 continue;
688 };
689 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
690 if chamfer {
691 size * half.sin()
692 } else {
693 size * (1.0 / half.cos().max(1e-12) - 1.0)
694 }
695 }
696 _ => f64::INFINITY,
698 };
699 if best.map_or(true, |(_, known)| clearance > known) {
700 best = Some((station, clearance));
701 }
702 }
703 let Some((station, clearance)) = best else {
704 return Err(KernelRefusal::internal(
705 KernelStage::Validate,
706 "wall_attribution_stations",
707 format!(
708 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
709 station of it could be evaluated",
710 point.x, point.y, point.z
711 ),
712 ));
713 };
714 if !(clearance > visible) {
715 return Err(KernelRefusal::ill_posed(
719 KernelStage::Validate,
720 "wall_attribution_band",
721 format!(
722 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
723 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
724 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
725 selection is refused rather than reported on a guess",
726 point.x, point.y, point.z
727 ),
728 ));
729 }
730 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
731 let certified = station_clear_of_skin(result, station, band * 10.0, visible);
738 let on = if certified {
739 crate::blend::record_count("count.walls_missing.certified", 1);
740 false
741 } else {
742 crate::blend::record_count("count.walls_missing.classified", 1);
743 classifier.classify(station).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On
744 };
745 classify_timing.stop();
746 if on {
747 missing.push(index);
748 }
749 }
750 crate::classification::classify_profile_report("blend.walls_missing");
751 crate::classification::containment_profile_report("blend.walls_missing");
752 Ok(missing)
753}
754
755
756fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
786 use crate::AnalyticSurface;
787 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
790 let offset = station.sub(frame.origin);
791 let axial = offset.dot(frame.axis);
792 let radial = offset.sub(frame.axis.scale(axial));
793 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
794 if azimuth < 0.0 {
795 azimuth += std::f64::consts::TAU;
796 }
797 (radial.length(), axial, azimuth)
798 };
799 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
803 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
804 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
805 return 0.0;
806 }
807 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
809 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
810 };
811 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
814 let (du, dv) = (rho1 - rho0, h1 - h0);
815 let length = (du * du + dv * dv).sqrt();
816 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
817 };
818 let clear = |distance: f64| distance.is_finite() && distance > margin;
819 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
820 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
821 if !controls.expanded(on_band).contains(station) {
822 continue;
823 }
824 let certified = match face.surface.analytic() {
825 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
826 let (radial, axial, _) = meridian(frame);
827 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
828 clear(line.max(axial_clearance(axial, 0.0, *height)))
829 }
830 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
831 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
832 let (radial, axial, _) = meridian(frame);
833 let tube = radial - major_radius;
834 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
835 }
836 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
837 let [a, b] = generatrix.control_points.as_slice() else { return false };
844 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
845 return false;
846 }
847 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
848 let offset = point.sub(frame.origin);
849 let axial = offset.dot(frame.axis);
850 let radial = offset.sub(frame.axis.scale(axial));
851 let rho = radial.length();
852 let on_meridian = radial.dot(frame.y_axis).abs() <= 1e-9 * rho.max(1.0) && radial.dot(frame.x_axis) >= -1e-9 * rho.max(1.0);
853 on_meridian.then_some((rho, axial))
854 };
855 let (Some(p0), Some(p1)) = (meridian_of(Vec3::new(a.x, a.y, a.z)), meridian_of(Vec3::new(b.x, b.y, b.z))) else {
856 return false;
857 };
858 let (radial, axial, azimuth) = meridian(frame);
859 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
860 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
865 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
866 }
867 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
868 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
869 if clear(station.sub(*origin).dot(normal).abs()) {
870 true
871 } else {
872 planar_trims_clear_of(face, plane, station, on_band, margin)
873 }
874 }
875 None => false,
876 };
877 if !certified {
878 return false;
879 }
880 }
881 true
882}
883
884fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
894 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
895 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
896 let pcurve = &coedge.pcurve;
897 let [a, b] = pcurve.control_points.as_slice() else { return None };
898 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
899 return None;
900 }
901 for control in [a, b] {
902 u_low = u_low.min(control.x);
903 u_high = u_high.max(control.x);
904 v_low = v_low.min(control.y);
905 v_high = v_high.max(control.y);
906 }
907 }
908 if !(u_low <= u_high && v_low <= v_high) {
909 return None;
910 }
911 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
912 let corners = [axial_at(u_low, v_low)?, axial_at(u_high, v_low)?, axial_at(u_low, v_high)?, axial_at(u_high, v_high)?];
913 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
914 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
915 let surface = &face.surface;
916 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
918 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
919 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
920 && surface.degree_u == 1 && surface.control_points.len() == 2;
921 if !(affine_in_v || affine_in_u) {
922 return None;
923 }
924 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
925 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
926 (low.is_finite() && high.is_finite()).then_some((low, high))
927}
928
929fn planar_trims_clear_of(
933 face: &FaceRecord,
934 plane: &crate::AnalyticSurface,
935 station: Vec3,
936 on_band: f64,
937 margin: f64,
938) -> bool {
939 use crate::PolygonClass;
940 let Some(foot) = plane.project(&face.surface, station) else { return false };
941 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
942 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
943 Ok(PolygonClass::Outside) => {}
944 _ => return false,
945 }
946 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
947 let pcurve = &coedge.pcurve;
948 let [a, b] = pcurve.control_points.as_slice() else { return false };
949 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
950 return false;
951 }
952 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
953 let chord = end.sub(start);
954 let length_squared = chord.dot(chord);
955 let offset = station.sub(start);
956 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
957 let distance = offset.sub(chord.scale(along)).length();
958 if !(distance.is_finite() && distance > margin) {
959 return false;
960 }
961 }
962 true
963}
964
965fn per_edge_name<'a>(
970 edge_names: Option<&'a [String]>,
971 base: Option<&'a str>,
972 i: usize,
973) -> Option<&'a str> {
974 match edge_names {
975 Some(names) => names.get(i).map(|value| value.as_str()),
976 None => base,
977 }
978}
979
980fn corner_face_name(
988 edge_names: Option<&[String]>,
989 base: Option<&str>,
990 adjacent: &[usize],
991) -> Option<String> {
992 match (edge_names, base) {
993 (Some(names), Some(base)) => {
994 let prefix = format!("{base}:");
995 let mut raws: Vec<&str> = adjacent
996 .iter()
997 .filter_map(|&i| names.get(i))
998 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
999 .collect();
1000 raws.sort_unstable();
1001 raws.dedup();
1002 Some(format!("{base}:CORNER:{}", raws.join("+")))
1003 }
1004 _ => base.map(|value| value.to_string()),
1005 }
1006}
1007
1008#[allow(clippy::too_many_arguments)]
1018fn rounded_boss_composition(
1019 solid: &BrepSolid,
1020 selected_ids: &[u64],
1021 edge_points: &[Vec3],
1022 edge_names: Option<&[String]>,
1023 radius: f64,
1024 name: Option<&str>,
1025 corners: &[crate::blend::MixedCorner],
1026) -> Result<BrepSolid, KernelRefusal> {
1027 if edge_names.is_none() {
1032 let prefix = "FILLET:COMPOSITION";
1033 let names: Vec<_> = selected_ids
1034 .iter()
1035 .map(|id| format!("{prefix}:{id}"))
1036 .collect();
1037 let mut result = rounded_boss_composition(
1038 solid,
1039 selected_ids,
1040 edge_points,
1041 Some(&names),
1042 radius,
1043 Some(prefix),
1044 corners,
1045 )?;
1046 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1047 if face
1048 .name
1049 .as_deref()
1050 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1051 {
1052 face.name = name.map(str::to_owned);
1053 }
1054 }
1055 return Ok(result);
1056 }
1057 let decline = || {
1058 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1059 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1060 };
1061 if std::env::var("BREP_NO_NETWORK").is_ok() {
1062 return Err(decline());
1063 }
1064 let corner = corners
1067 .iter()
1068 .filter(|corner| {
1069 corner.convex.len() == 1
1070 && corner.concave.len() == 2
1071 && corners
1072 .iter()
1073 .filter(|other| other.convex.contains(&corner.convex[0]))
1074 .count()
1075 == 1
1076 })
1077 .min_by_key(|corner| selected_ids[corner.convex[0]])
1078 .ok_or_else(decline)?;
1079 let convexity =
1080 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1081 if !convexity[corner.convex[0]] {
1082 return Err(decline());
1083 }
1084 let spine_index = corner.convex[0];
1085 let selected_edges: Vec<&EdgeRecord> = selected_ids
1088 .iter()
1089 .map(|id| {
1090 solid
1091 .edges
1092 .iter()
1093 .find(|edge| edge.id == *id)
1094 .ok_or_else(|| {
1095 KernelRefusal::internal(
1096 KernelStage::Collect,
1097 "rounded_boss_selected_edge",
1098 format!("rounded boss composition: selected edge {id} is missing"),
1099 )
1100 })
1101 })
1102 .collect::<Result<_, _>>()?;
1103 let mut convex_indices = vec![spine_index];
1106 loop {
1107 let next = (0..selected_ids.len()).find(|index| {
1108 convexity[*index]
1109 && !convex_indices.contains(index)
1110 && convex_indices.iter().any(|other| {
1111 let edge = selected_edges[*index];
1112 let mate = selected_edges[*other];
1113 [edge.start_vertex_id, edge.end_vertex_id]
1114 .iter()
1115 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1116 })
1117 });
1118 match next {
1119 Some(index) => convex_indices.push(index),
1120 None => break,
1121 }
1122 }
1123 convex_indices.sort_by_key(|index| selected_ids[*index]);
1124 let mixed: Vec<_> = corners
1125 .iter()
1126 .filter(|corner| {
1127 corner
1128 .convex
1129 .iter()
1130 .any(|index| convex_indices.contains(index))
1131 })
1132 .collect();
1133 if mixed.iter().any(|corner| {
1134 corner.convex.len() != 1
1135 || corner.concave.len() != 2
1136 || corners
1137 .iter()
1138 .filter(|other| other.convex.contains(&corner.convex[0]))
1139 .count()
1140 != 1
1141 }) {
1142 return Err(decline());
1143 }
1144 let uses = |face: &FaceRecord, edge: u64| {
1145 face.loops
1146 .iter()
1147 .flat_map(|rim| &rim.coedges)
1148 .any(|coedge| coedge.edge_id == edge)
1149 };
1150 let mut caps = Vec::new();
1151 for corner in &mixed {
1152 let spine_id = selected_ids[corner.convex[0]];
1153 let cap = solid
1154 .shells
1155 .iter()
1156 .flat_map(|shell| &shell.faces)
1157 .find(|face| {
1158 corner
1159 .concave
1160 .iter()
1161 .all(|index| uses(face, selected_ids[*index]))
1162 })
1163 .ok_or_else(decline)?;
1164 let faces: Vec<_> = solid
1165 .shells
1166 .iter()
1167 .flat_map(|shell| &shell.faces)
1168 .filter(|face| face.id == cap.id || uses(face, spine_id))
1169 .collect();
1170 let normals: Option<Vec<Vec3>> = faces
1179 .iter()
1180 .map(|face| match face.surface.analytic() {
1181 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
1182 u_dir.cross(*v_dir).normalized().ok()
1183 }
1184 _ => {
1185 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
1186 face.surface.normal(uv.u, uv.v).ok()
1187 }
1188 })
1189 .collect();
1190 let normals = normals.ok_or_else(decline)?;
1191 if normals.len() != 3
1197 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
1198 {
1199 return Err(decline());
1200 }
1201 caps.push(cap.id);
1202 }
1203 let convex_names: Vec<String> = convex_indices
1206 .iter()
1207 .map(|index| {
1208 per_edge_name(edge_names, name, *index)
1209 .map(str::to_owned)
1210 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
1211 })
1212 .collect();
1213 let convex_points: Vec<Vec3> = convex_indices
1214 .iter()
1215 .map(|index| edge_points[*index])
1216 .collect();
1217 let rounded = fillet_edges_group_in(
1218 solid,
1219 &convex_points,
1220 Some(&convex_names),
1221 radius,
1222 false,
1223 name,
1224 None,
1225 )?;
1226 let mut bridges = Vec::new();
1227 for (corner, cap) in mixed.iter().zip(&caps) {
1228 let wall_index = convex_indices
1229 .iter()
1230 .position(|index| *index == corner.convex[0])
1231 .ok_or_else(|| {
1232 KernelRefusal::internal(
1233 KernelStage::Classify,
1234 "rounded_boss_bridge_wall",
1235 format!(
1236 "rounded boss composition: no convex wall for corner {}",
1237 corner.vertex
1238 ),
1239 )
1240 })?;
1241 let candidates: Vec<_> = rounded
1242 .edges
1243 .iter()
1244 .filter(|edge| {
1245 let faces: Vec<_> = rounded
1246 .shells
1247 .iter()
1248 .flat_map(|shell| &shell.faces)
1249 .filter(|face| uses(face, edge.id))
1250 .collect();
1251 faces.iter().any(|face| face.id == *cap)
1252 && faces
1253 .iter()
1254 .any(|face| face.name.as_deref() == Some(&convex_names[wall_index]))
1255 })
1256 .collect();
1257 if candidates.len() != 1 {
1258 return Err(decline());
1259 }
1260 bridges.push(candidates[0]);
1261 }
1262 let rim_ids: Vec<u64> = selected_ids
1263 .iter()
1264 .enumerate()
1265 .filter(|(index, _)| !convex_indices.contains(index))
1266 .map(|(_, id)| *id)
1267 .collect();
1268 if !bridges.iter().all(|bridge| {
1271 [bridge.start_vertex_id, bridge.end_vertex_id]
1272 .iter()
1273 .all(|vertex| {
1274 rounded.edges.iter().any(|edge| {
1275 rim_ids.contains(&edge.id)
1276 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
1277 })
1278 })
1279 }) {
1280 return Err(decline());
1281 }
1282 let mut points = Vec::new();
1283 let mut names = Vec::new();
1284 for (index, id) in selected_ids.iter().enumerate() {
1285 if convex_indices.contains(&index) {
1286 continue;
1287 }
1288 let edge = rounded
1289 .edges
1290 .iter()
1291 .find(|edge| edge.id == *id)
1292 .ok_or_else(decline)?;
1293 points.push(
1294 edge.curve
1295 .evaluate(0.5 * (edge.t0 + edge.t1))
1296 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
1297 );
1298 names.push(
1299 per_edge_name(edge_names, name, index)
1300 .unwrap_or("FILLET:RIM")
1301 .to_owned(),
1302 );
1303 }
1304 for (corner, bridge) in mixed.iter().zip(&bridges) {
1305 points.push(
1306 bridge
1307 .curve
1308 .evaluate(0.5 * (bridge.t0 + bridge.t1))
1309 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
1310 );
1311 let mut adjacent = corner.concave.clone();
1312 adjacent.push(corner.convex[0]);
1313 names.push(
1314 corner_face_name(edge_names, name, &adjacent)
1315 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
1316 );
1317 }
1318 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
1321 |(result, selection)| {
1322 if selection.applied.len() != points.len() {
1323 Err(decline())
1324 } else {
1325 Ok(result)
1326 }
1327 },
1328 )
1329}
1330
1331#[allow(clippy::too_many_arguments)]
1364fn runout_composition(
1365 solid: &BrepSolid,
1366 selected_ids: &[u64],
1367 edge_points: &[Vec3],
1368 edge_names: Option<&[String]>,
1369 radius: f64,
1370 name: Option<&str>,
1371 corners: &[crate::blend::MixedCorner],
1372 entry: &str,
1373) -> Result<BrepSolid, KernelRefusal> {
1374 if std::env::var("BREP_NO_NETWORK").is_ok() {
1375 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
1376 }
1377 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
1378 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
1379 let mut spine_ends = vec![0usize; selected_ids.len()];
1380 for corner in corners {
1381 if corner.convex.len() != 1 {
1382 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
1383 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
1384 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
1385 )));
1386 }
1387 spine_ends[corner.convex[0]] += 1;
1388 }
1389 let mut spines: Vec<usize> = Vec::new();
1390 let mut concave: Vec<usize> = Vec::new();
1391 for (index, ends) in spine_ends.iter().enumerate() {
1392 match (*ends, convexity[index]) {
1393 (2, true) => spines.push(index),
1394 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
1395 (0, false) => concave.push(index),
1396 (ends, convex) => {
1397 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
1398 "{} is {} and meets a mixed corner at {ends} of its ends; the runout is built for a convex edge running out at both ends among concave edges",
1399 selection_label(index, edge_points, edge_names, name),
1400 if convex { "convex" } else { "concave" },
1401 )));
1402 }
1403 }
1404 }
1405 if concave.is_empty() {
1406 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
1407 }
1408 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
1410 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
1411 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
1412 .map_err(|error| error.with_message(|error| format!("the concave ring did not build: {error}")))?;
1413 let spine_walls: Vec<(u64, Option<String>)> = spines
1415 .iter()
1416 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
1417 .collect();
1418 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
1419 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
1420 heal_edge_vertex_gaps(&mut result, radius)?;
1421 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
1422 let issues = result.validate();
1423 if !issues.is_empty() {
1424 return Err(KernelRefusal::new(
1425 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
1426 KernelStage::Validate,
1427 format!("the runout walls do not validate: {issues:?}"),
1428 ));
1429 }
1430 check_blend_interference(&ring, &result, &spine_ids, entry)?;
1431 check_loop_self_crossings(&result, entry)?;
1432 crate::accept_sound(result, entry)
1433}
1434
1435fn check_mixed_corner_convexity(
1436 solid: &BrepSolid,
1437 edge_ids: &[u64],
1438 edge_points: &[Vec3],
1439 edge_names: Option<&[String]>,
1440 radius: f64,
1441 name: Option<&str>,
1442 composition_error: Option<&KernelRefusal>,
1443) -> Result<(), KernelRefusal> {
1444 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
1445 Some(corner) => {
1446 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
1452 Ok(plan) => Some(plan),
1453 Err(error) => {
1454 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1455 eprintln!("runout not measured: {error}");
1456 }
1457 None
1458 }
1459 };
1460 let mut message = mixed_corner_message(
1463 &corner,
1464 edge_points,
1465 edge_names,
1466 name,
1467 measured.as_ref(),
1468 );
1469 if let Some(error) = composition_error {
1470 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
1471 }
1472 Err(KernelRefusal::unsupported(
1473 KernelStage::Classify,
1474 "mixed_corner_convexity",
1475 message,
1476 ))
1477 }
1478 None => Ok(()),
1479 }
1480}
1481
1482fn check_degenerate_corner_setbacks(
1485 solid: &BrepSolid,
1486 edge_ids: &[u64],
1487 edge_points: &[Vec3],
1488 edge_names: Option<&[String]>,
1489 radius: f64,
1490 name: Option<&str>,
1491) -> Result<(), KernelRefusal> {
1492 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
1493 return Ok(());
1494 };
1495 let [from, to] = setback.stops;
1496 let how = if setback.crossed {
1497 format!(
1498 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
1499 setback.strip
1500 )
1501 } else {
1502 format!(
1503 "meet there ({:.3e} apart against a bar of {:.3e})",
1504 setback.strip, setback.bar
1505 )
1506 };
1507 Err(KernelRefusal::input(
1510 KernelStage::Classify,
1511 "degenerate_corner_setbacks",
1512 format!(
1513 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
1514 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
1515 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
1516 on are consumed whole. The radius is at or past the degenerate limit for this \
1517 selection; reduce it.",
1518 selection_label(setback.edge, edge_points, edge_names, name),
1519 setback.face_id,
1520 from.x,
1521 from.y,
1522 from.z,
1523 to.x,
1524 to.y,
1525 to.z,
1526 ),
1527 ))
1528}
1529
1530fn selection_label(
1534 index: usize,
1535 edge_points: &[Vec3],
1536 edge_names: Option<&[String]>,
1537 name: Option<&str>,
1538) -> String {
1539 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
1540 Some(base) => composed
1541 .strip_prefix(&format!("{base}:"))
1542 .unwrap_or(composed)
1543 .to_string(),
1544 None => composed.clone(),
1545 });
1546 named.unwrap_or_else(|| match edge_points.get(index) {
1547 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
1548 None => format!("selection #{}", index + 1),
1549 })
1550}
1551
1552fn mixed_corner_message(
1556 corner: &crate::blend::MixedCorner,
1557 edge_points: &[Vec3],
1558 edge_names: Option<&[String]>,
1559 name: Option<&str>,
1560 measured: Option<&crate::blend::RunoutPlan>,
1561) -> String {
1562 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
1563 let list = |indices: &[usize]| -> String {
1564 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
1565 match parts.split_last() {
1566 None => "none".to_string(),
1567 Some((last, [])) => last.clone(),
1568 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
1569 }
1570 };
1571 let mut message = format!(
1572 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
1573 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
1574 sides at once, so that corner has no closure: the convex blend runs out against the \
1575 concave ones partway along the edge, and this kernel does not build that runout yet. \
1576 Blending the concave edges in one fillet and the convex ones in another does not reach \
1577 a wall on every one of them either: the convex wall comes out at FULL length in either \
1578 order, and its contact leaves the faces it runs along where their own loops would then \
1579 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
1580 whose wall runs out into them; blend the convex ones first and the subset search drops \
1581 the concave edges it cannot compose instead, leaving those unfilleted.",
1582 corner.point.x,
1583 corner.point.y,
1584 corner.point.z,
1585 list(&corner.convex),
1586 list(&corner.concave),
1587 );
1588 if let Some(plan) = measured {
1593 message.push_str(&format!(
1594 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
1595 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
1596 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
1597 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
1598 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
1599 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
1600 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
1601 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
1602 the bar a march is refused at is a full span.",
1603 plan.convex_edge,
1604 plan.shared_face,
1605 plan.station.s,
1606 plan.station.center.x,
1607 plan.station.center.y,
1608 plan.station.center.z,
1609 plan.replaced_mate,
1610 plan.second_carrier,
1611 plan.station.stripe_residual,
1612 plan.second_carrier,
1613 plan.marched,
1614 plan.fit_residual,
1615 plan.carrier_excursion,
1616 ));
1617 match (&plan.stop, plan.first_carrier) {
1618 (Some(stop), Some(first)) => message.push_str(&format!(
1619 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
1620 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
1621 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
1622 stop.s, plan.kept_mate, first, stop.stripe_residual,
1623 )),
1624 _ => message.push_str(&format!(
1625 " Where the kept mate (face {}) runs out was not measurable on this selection.",
1626 plan.kept_mate
1627 )),
1628 }
1629 if let Some(pole) = plan.pole {
1630 message.push_str(&format!(
1631 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
1632 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
1633 section shrinks to a point. Two carrier switches and a degenerate terminus is \
1634 more than a stop station on a stripe, and none of it is composed yet.",
1635 pole.x, pole.y, pole.z, plan.shared_face,
1636 ));
1637 }
1638 }
1639 message
1640}
1641
1642fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
1644 let mut out = Vec::new();
1645 if k == 0 || k > n {
1646 return out;
1647 }
1648 let mut idx: Vec<usize> = (0..k).collect();
1649 loop {
1650 out.push(idx.clone());
1651 let mut i = k;
1653 loop {
1654 if i == 0 {
1655 return out;
1656 }
1657 i -= 1;
1658 if idx[i] != i + n - k {
1659 break;
1660 }
1661 }
1662 idx[i] += 1;
1663 for j in (i + 1)..k {
1664 idx[j] = idx[j - 1] + 1;
1665 }
1666 }
1667}
1668
1669thread_local! {
1670 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
1676 const { std::cell::Cell::new(0) };
1677}
1678
1679fn fillet_edges_group(
1696 solid: &BrepSolid,
1697 edge_points: &[Vec3],
1698 edge_names: Option<&[String]>,
1699 radius: f64,
1700 chamfer: bool,
1701 name: Option<&str>,
1702) -> Result<BrepSolid, KernelRefusal> {
1703 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
1704}
1705
1706thread_local! {
1707 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1710}
1711
1712type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
1715
1716#[derive(Default)]
1720struct ComponentMemo {
1721 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
1722}
1723
1724fn fillet_edges_group_in(
1727 solid: &BrepSolid,
1728 edge_points: &[Vec3],
1729 edge_names: Option<&[String]>,
1730 radius: f64,
1731 chamfer: bool,
1732 name: Option<&str>,
1733 memo: Option<&mut ComponentMemo>,
1734) -> Result<BrepSolid, KernelRefusal> {
1735 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
1736}
1737
1738fn fillet_edges_group_with_corner_policy(
1739 solid: &BrepSolid,
1740 edge_points: &[Vec3],
1741 edge_names: Option<&[String]>,
1742 radius: f64,
1743 chamfer: bool,
1744 name: Option<&str>,
1745 memo: Option<&mut ComponentMemo>,
1746 wrap_concave: bool,
1747 accept: bool,
1748) -> Result<BrepSolid, KernelRefusal> {
1749 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
1750 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
1751 let GroupBody { body: result, bowtie_clean } =
1752 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
1753 if !accept {
1754 return Ok(result);
1755 }
1756 if bowtie_clean {
1761 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
1762 } else {
1763 check_loop_self_crossings(&result, entry)?;
1764 }
1765 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
1766 let accepted = crate::accept_sound(result, entry);
1775 soundness.stop();
1776 accepted
1777}
1778
1779struct GroupBody {
1784 body: BrepSolid,
1785 bowtie_clean: bool,
1786}
1787
1788fn fillet_edges_group_unchecked(
1789 solid: &BrepSolid,
1790 edge_points: &[Vec3],
1791 edge_names: Option<&[String]>,
1792 radius: f64,
1793 chamfer: bool,
1794 name: Option<&str>,
1795 mut memo: Option<&mut ComponentMemo>,
1796 wrap_concave: bool,
1797) -> Result<GroupBody, KernelRefusal> {
1798 use rustc_hash::FxHashSet as HashSet;
1799
1800 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
1807 let mut healed_input = solid.clone();
1808 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
1809 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
1810 let solid = &healed_input;
1811 heal.stop();
1812 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
1813
1814 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
1817 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
1820 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
1821 for (i, point) in edge_points.iter().enumerate() {
1822 let edge_id = resolve_edge_by_point(solid, *point)?;
1823 let edge = solid
1824 .edges
1825 .iter()
1826 .find(|e| e.id == edge_id)
1827 .ok_or_else(|| {
1828 KernelRefusal::internal(
1829 KernelStage::Collect,
1830 "resolved_edge",
1831 "fillet_edges: resolved edge vanished",
1832 )
1833 })?;
1834 let (start, end) = (
1835 edge.curve
1836 .evaluate(edge.t0)
1837 .or_refuse(KernelStage::Collect, "edge_start")?,
1838 edge.curve
1839 .evaluate(edge.t1)
1840 .or_refuse(KernelStage::Collect, "edge_end")?,
1841 );
1842 endpoints.push((start, i));
1843 endpoints.push((end, i));
1844 original_extents.push((start, end));
1845 selected_edge_ids.push(edge_id);
1846 }
1847 let mut corners: Vec<Vec3> = Vec::new();
1848 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
1853 let mut chain_corner_count = 0usize;
1854 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
1855 let mut used = vec![false; endpoints.len()];
1856 for i in 0..endpoints.len() {
1857 if used[i] {
1858 continue;
1859 }
1860 used[i] = true;
1861 let mut edges_here: HashSet<usize> = HashSet::default();
1862 edges_here.insert(endpoints[i].1);
1863 for j in (i + 1)..endpoints.len() {
1864 if used[j] {
1865 continue;
1866 }
1867 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
1868 used[j] = true;
1869 edges_here.insert(endpoints[j].1);
1870 }
1871 }
1872 if edges_here.len() >= 3 {
1873 corners.push(endpoints[i].0);
1874 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
1875 adjacent.sort_unstable();
1876 corner_edges.push(adjacent);
1877 } else if edges_here.len() == 2 {
1878 chain_corner_count += 1;
1879 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
1880 adjacent.sort_unstable();
1881 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
1882 }
1883 }
1884
1885 let mut component_of = vec![usize::MAX; edge_points.len()];
1898 let mut components: Vec<Vec<usize>> = Vec::new();
1899 for seed in 0..edge_points.len() {
1900 if component_of[seed] != usize::MAX {
1901 continue;
1902 }
1903 let component_index = components.len();
1904 component_of[seed] = component_index;
1905 let mut component = vec![seed];
1906 let mut cursor = 0;
1907 while cursor < component.len() {
1908 let current = component[cursor];
1909 cursor += 1;
1910 for candidate in 0..edge_points.len() {
1911 if component_of[candidate] != usize::MAX {
1912 continue;
1913 }
1914 let connected = [original_extents[current].0, original_extents[current].1]
1915 .into_iter()
1916 .any(|a| {
1917 [original_extents[candidate].0, original_extents[candidate].1]
1918 .into_iter()
1919 .any(|b| a.sub(b).length() < 1e-6)
1920 });
1921 if connected {
1922 component_of[candidate] = component_index;
1923 component.push(candidate);
1924 }
1925 }
1926 }
1927 component.sort_unstable();
1928 components.push(component);
1929 }
1930 group_analysis.stop();
1931 if components.len() > 1 {
1932 let mut separated = solid.clone();
1933 let mut chain: Vec<ComponentKey> = Vec::with_capacity(components.len());
1934 for component in components {
1935 let points = component
1936 .iter()
1937 .map(|index| edge_points[*index])
1938 .collect::<Vec<_>>();
1939 let names = edge_names.map(|all| {
1940 component
1941 .iter()
1942 .map(|index| all[*index].clone())
1943 .collect::<Vec<_>>()
1944 });
1945 let accept_component = !crate::blend::accept_once_on();
1946 let Some(memo) = memo.as_deref_mut() else {
1947 separated = fillet_edges_group_with_corner_policy(
1948 &separated,
1949 &points,
1950 names.as_deref(),
1951 radius,
1952 chamfer,
1953 name,
1954 None,
1955 wrap_concave,
1956 accept_component,
1957 )?;
1958 continue;
1959 };
1960 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
1961 chain.push((bits.collect(), names.clone()));
1962 let built = match memo.built.get(&chain) {
1963 Some(built) => built.clone(),
1964 None => {
1965 let built = fillet_edges_group_with_corner_policy(
1966 &separated,
1967 &points,
1968 names.as_deref(),
1969 radius,
1970 chamfer,
1971 name,
1972 None,
1973 wrap_concave,
1974 accept_component,
1975 );
1976 memo.built.insert(chain.clone(), built.clone());
1977 built
1978 }
1979 };
1980 separated = built?;
1981 }
1982 return Ok(GroupBody { body: separated, bowtie_clean: false });
1983 }
1984
1985 if std::env::var("BREP_NO_NETWORK").is_err() {
2010 if selected_edge_ids.len() == 1 {
2011 let edge_id = selected_edge_ids[0];
2012 let closed = solid
2013 .edges
2014 .iter()
2015 .find(|edge| edge.id == edge_id)
2016 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
2017 if closed {
2018 let rim_name = per_edge_name(edge_names, name, 0);
2019 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
2020 Ok(mut rim) => {
2021 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2022 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
2023 let healed = heal_edge_vertex_gaps(&mut rim, radius);
2024 heal.stop();
2025 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
2034 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
2035 charts.stop();
2036 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
2037 let issues = rim.validate();
2038 validate.stop();
2039 let interference =
2040 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
2041 let bowtie = check_loop_self_crossings(&rim, entry);
2042 if healed.is_ok()
2043 && issues.is_empty()
2044 && interference.is_ok()
2045 && bowtie.is_ok()
2046 {
2047 return Ok(GroupBody { body: rim, bowtie_clean: true });
2048 }
2049 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2050 eprintln!(
2051 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
2052 interference={interference:?} bowtie={bowtie:?}"
2053 );
2054 }
2055 }
2056 Err(refusal) => {
2057 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2058 eprintln!("closed-rim refused: {refusal}");
2059 }
2060 }
2061 }
2062 }
2063 }
2064 let network_names: Vec<Option<String>> = (0..edge_points.len())
2065 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
2066 .collect();
2067 let network_corner_name =
2068 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
2069 match crate::blend::blend_star_network_with_corner_policy(
2070 solid,
2071 &selected_edge_ids,
2072 radius,
2073 chamfer,
2074 &network_names,
2075 &network_corner_name,
2076 wrap_concave,
2077 ) {
2078 Ok(mut network) => {
2079 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2087 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
2088 let healed = heal_edge_vertex_gaps(&mut network, radius);
2089 heal.stop();
2090 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
2094 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
2095 charts.stop();
2096 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
2097 let issues = network.validate();
2098 validate.stop();
2099 let interference =
2100 check_blend_interference(solid, &network, &selected_edge_ids, entry);
2101 let bowtie = check_loop_self_crossings(&network, entry);
2106 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
2107 return Ok(GroupBody { body: network, bowtie_clean: true });
2108 }
2109 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2110 eprintln!(
2111 "network result rejected: heal={healed:?} issues={issues:?} \
2112 interference={interference:?} bowtie={bowtie:?}"
2113 );
2114 dump_loops_debug("INPUT", solid);
2115 dump_loops_debug("RESULT", &network);
2116 }
2117 }
2118 Err(refusal) => {
2119 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2120 eprintln!("network refused: {refusal}");
2121 }
2122 if crate::blend::is_marched_fit_off_carriers(&refusal)
2158 || crate::blend::is_consumed_snap(&refusal)
2159 || crate::blend::is_rail_collapse(&refusal)
2160 || crate::blend::is_full_width_collapse(&refusal)
2161 || crate::blend::is_planar_chart_refusal(&refusal)
2162 || crate::blend::is_miter_support_overrun(&refusal)
2163 {
2164 return Err(refusal);
2165 }
2166 }
2167 }
2168 }
2169
2170 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
2171 crate::blend::record_count("count.cutter_fallback", 1);
2172 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
2173 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
2200 && (chamfer || corners.is_empty())
2201 {
2202 let mut any_convex = false;
2203 let mut any_concave = false;
2204 for point in edge_points {
2205 match resolve_edge_by_point(solid, *point)
2217 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
2218 {
2219 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
2222 any_convex |= profile.any_convex;
2223 any_concave |= profile.any_concave;
2224 }
2225 _ => {
2228 any_convex = true;
2229 any_concave = true;
2230 break;
2231 }
2232 }
2233 }
2234 match (any_convex, any_concave) {
2235 (true, false) => Some(crate::BooleanOperation::Intersect),
2236 (false, true) => Some(crate::BooleanOperation::Union),
2237 _ => None,
2238 }
2239 } else {
2240 None
2241 };
2242
2243 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
2254 Lane::GeneralFirst
2255 } else {
2256 Lane::CutterFirst
2257 };
2258 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
2269 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star);
2270 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
2276 let build_sequential = || -> Result<BrepSolid, KernelRefusal> {
2277 let mut sequential = solid.clone();
2278 for &index in &sequential_order {
2279 let point = &edge_points[index];
2280 let edge_id = resolve_edge_by_point(&sequential, *point)?;
2281 let ends = tool_ends_to_original_extent(
2288 &sequential,
2289 edge_id,
2290 radius,
2291 original_extents[index],
2292 if chamfer { &[] } else { &corners },
2293 );
2294 let edge_name = per_edge_name(edge_names, name, index);
2299 sequential = fillet_or_chamfer(
2300 &sequential,
2301 edge_id,
2302 radius,
2303 chamfer,
2304 edge_name,
2305 ends,
2306 composition_lane,
2307 )?;
2308 }
2309 Ok(sequential)
2310 };
2311
2312 let mut result = if let Some(operation) = miter_operation {
2313 let options = crate::BooleanOptions {
2317 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
2318 ..crate::BooleanOptions::default()
2319 };
2320 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
2321 for (index, point) in edge_points.iter().enumerate() {
2322 let edge_id = resolve_edge_by_point(solid, *point)?;
2323 let edge_name = per_edge_name(edge_names, name, index);
2324 let blended = {
2325 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2326 check_mixed_concavity(solid, edge_id, entry)?;
2327 check_support_extent(solid, edge_id, radius, entry)?;
2328 fillet_or_chamfer(
2329 solid,
2330 edge_id,
2331 radius,
2332 chamfer,
2333 edge_name,
2334 ToolEnds::default(),
2335 Lane::CutterFirst,
2336 )?
2337 };
2338 combined = match combined {
2339 Err(e) => Err(e),
2340 Ok(None) => Ok(Some(blended)),
2341 Ok(Some(previous)) => {
2342 crate::boolean_operation(&previous, &blended, operation, &options)
2343 .map(Some)
2344 .map_err(|error| {
2345 error.with_message(|error| {
2347 format!("fillet_edges: chain-corner miter composition failed: {error}")
2348 })
2349 })
2350 }
2351 };
2352 if combined.is_err() {
2353 break;
2354 }
2355 }
2356 match combined {
2368 Ok(Some(mitered)) => mitered,
2369 Ok(None) => {
2370 return Err(KernelRefusal::input(
2371 KernelStage::Collect,
2372 "miter_selection",
2373 "fillet_edges: no edges selected",
2374 ))
2375 }
2376 Err(miter_err) => match build_sequential() {
2377 Ok(seq) if seq.validate().is_empty() => seq,
2378 _ => return Err(miter_err),
2379 },
2380 }
2381 } else {
2382 build_sequential()?
2383 };
2384
2385 if !chamfer {
2392 for (corner, adjacent) in &chain_corners {
2393 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
2394 if selected_edge_ids.contains(&edge.id) {
2395 return false;
2396 }
2397 let Ok(a) = edge.curve.evaluate(edge.t0) else {
2398 return false;
2399 };
2400 let Ok(b) = edge.curve.evaluate(edge.t1) else {
2401 return false;
2402 };
2403 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
2404 && analyze_edge(solid, edge.id, radius)
2405 .map(|cross| !cross.convex)
2406 .unwrap_or(false)
2407 });
2408 if !has_concave_unselected_edge {
2409 continue;
2410 }
2411 let corner_name = corner_face_name(edge_names, name, adjacent);
2412 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
2413 &result,
2414 solid,
2415 *corner,
2416 [
2417 selected_edge_ids[adjacent[0]],
2418 selected_edge_ids[adjacent[1]],
2419 ],
2420 radius,
2421 corner_name.as_deref(),
2422 ) {
2423 result = rounded;
2424 }
2425 }
2426 }
2427
2428 if !chamfer {
2434 for (ci, corner) in corners.iter().enumerate() {
2435 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
2436 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
2437 Ok(rounded) => result = rounded,
2438 Err(error) => {
2439 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2440 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
2441 }
2442 }
2443 }
2444 }
2445 }
2446
2447 heal_edge_vertex_gaps(&mut result, radius)?;
2450 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
2451 Ok(GroupBody { body: settle_cutter_scaffold(result), bowtie_clean: false })
2452}
2453
2454fn concave_star_edges(
2461 solid: &BrepSolid,
2462 selected_edge_ids: &[u64],
2463 corner_edges: &[Vec<usize>],
2464 radius: f64,
2465 chamfer: bool,
2466) -> Vec<usize> {
2467 if chamfer || corner_edges.is_empty() || !concave_first_on() {
2468 return Vec::new();
2469 }
2470 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
2471 return Vec::new();
2472 };
2473 (0..selected_edge_ids.len())
2474 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
2475 .collect()
2476}
2477
2478fn sequential_build_order(count: usize, first: &[usize]) -> Vec<usize> {
2484 first.iter().copied().chain((0..count).filter(|index| !first.contains(index))).collect()
2485}
2486
2487fn corners_concave_first(
2491 corners: Vec<Vec3>,
2492 corner_edges: Vec<Vec<usize>>,
2493 first: &[usize],
2494) -> (Vec<Vec3>, Vec<Vec<usize>>) {
2495 if first.is_empty() {
2496 return (corners, corner_edges);
2497 }
2498 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
2499 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
2500 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
2501 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
2502 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
2503 ordered.into_iter().unzip()
2504}
2505
2506fn concave_first_on() -> bool {
2509 true
2510}
2511
2512
2513pub fn fillet_edges_variable(
2521 solid: &BrepSolid,
2522 edge_points: &[Vec3],
2523 edge_names: Option<&[String]>,
2524 radii: &[(f64, f64)],
2525 chamfer: bool,
2526 name: Option<&str>,
2527) -> Result<BrepSolid, KernelRefusal> {
2528 let _operation = crate::blend::BlendOperation::enter();
2531 if edge_points.is_empty() {
2532 return Err(KernelRefusal::input(
2533 KernelStage::Collect,
2534 "variable_selection",
2535 "fillet_edges_variable: no edges selected",
2536 ));
2537 }
2538 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
2539 fillet_edges_variable_impl(
2540 solid,
2541 edge_points,
2542 edge_names,
2543 &per_edge_stops,
2544 chamfer,
2545 name,
2546 "fillet_edges_variable",
2547 true,
2548 )
2549}
2550
2551#[allow(clippy::too_many_arguments)]
2563fn fillet_edges_variable_impl(
2564 solid: &BrepSolid,
2565 edge_points: &[Vec3],
2566 edge_names: Option<&[String]>,
2567 per_edge_stops: &[Vec<(f64, f64)>],
2568 chamfer: bool,
2569 name: Option<&str>,
2570 entry: &str,
2571 allow_tapered_sequential: bool,
2572) -> Result<BrepSolid, KernelRefusal> {
2573 let bounds = crate::recentre::solid_bounds(solid);
2576 let translated = match crate::recentre::control_offset_at_entry() {
2577 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
2578 None => crate::recentre::guard_offset(bounds).map(|offset| {
2579 crate::recentre::profile_fired(entry, offset, bounds);
2580 (offset, crate::recentre::EntryKind::Guard)
2581 }),
2582 };
2583 if let Some((offset, kind)) = translated {
2584 let _entry = crate::recentre::TranslatedEntry::open(kind);
2585 let translate = |solid: &BrepSolid, offset: Vec3| {
2586 crate::recentre::translate_solid(solid, offset).map_err(|message| {
2587 KernelRefusal::internal(
2588 KernelStage::Collect,
2589 "translation_control",
2590 format!("{entry}: the translation control could not move the solid: {message}"),
2591 )
2592 })
2593 };
2594 let moved = translate(solid, offset)?;
2595 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
2596 let result = fillet_edges_variable_impl(
2597 &moved,
2598 &points,
2599 edge_names,
2600 per_edge_stops,
2601 chamfer,
2602 name,
2603 entry,
2604 allow_tapered_sequential,
2605 )?;
2606 return translate(&result, offset.scale(-1.0));
2607 }
2608 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
2609 if let Some(constant) = per_edge_stops
2613 .first()
2614 .and_then(|stops| stops.first())
2615 .map(|(_, radius)| *radius)
2616 {
2617 let uniform = per_edge_stops.iter().all(|stops| {
2618 stops
2619 .iter()
2620 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
2621 });
2622 if uniform && constant > 0.0 {
2623 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
2624 }
2625 }
2626 let max_radius = per_edge_stops
2627 .iter()
2628 .flat_map(|stops| stops.iter())
2629 .map(|(_, r)| r.abs())
2630 .fold(0.0_f64, f64::max);
2631
2632 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
2637 if let Some(built) = fillet_variable_network(
2638 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
2639 )? {
2640 return Ok(built);
2641 }
2642 }
2643
2644 let mut chain_corner = false;
2651 {
2652 use rustc_hash::FxHashSet as HashSet;
2653 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2654 for (i, point) in edge_points.iter().enumerate() {
2655 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
2656 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
2657 if let (Ok(a), Ok(b)) =
2658 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
2659 {
2660 endpoints.push((a, i));
2661 endpoints.push((b, i));
2662 }
2663 }
2664 }
2665 }
2666 let mut used = vec![false; endpoints.len()];
2667 for i in 0..endpoints.len() {
2668 if used[i] {
2669 continue;
2670 }
2671 used[i] = true;
2672 let mut edges_here: HashSet<usize> = HashSet::default();
2673 edges_here.insert(endpoints[i].1);
2674 for j in (i + 1)..endpoints.len() {
2675 if used[j] {
2676 continue;
2677 }
2678 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
2679 used[j] = true;
2680 edges_here.insert(endpoints[j].1);
2681 }
2682 }
2683 if edges_here.len() == 2 {
2684 chain_corner = true;
2685 }
2686 }
2687 }
2688 let miter_operation = if chain_corner {
2689 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
2690 let mut any_convex = false;
2691 let mut any_concave = false;
2692 for point in edge_points {
2693 match resolve_edge_by_point(solid, *point)
2694 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
2695 {
2696 Ok(cross) if cross.convex => any_convex = true,
2697 Ok(_) => any_concave = true,
2698 Err(_) => {
2699 any_convex = true;
2700 any_concave = true;
2701 break;
2702 }
2703 }
2704 }
2705 match (any_convex, any_concave) {
2706 (true, false) => Some(crate::BooleanOperation::Intersect),
2707 (false, true) => Some(crate::BooleanOperation::Union),
2708 _ => None,
2709 }
2710 } else {
2711 None
2712 };
2713
2714 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
2721 let options = crate::BooleanOptions::default();
2722 let mut combined: Option<BrepSolid> = None;
2723 let mut failed = false;
2724 for (index, point) in edge_points.iter().enumerate() {
2725 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
2726 failed = true;
2727 break;
2728 };
2729 let edge_name = per_edge_name(edge_names, name, index);
2730 let Ok(blended) = crate::blend::blend_edge_variable(
2731 solid,
2732 edge_id,
2733 &per_edge_stops[index],
2734 chamfer,
2735 edge_name,
2736 ) else {
2737 failed = true;
2738 break;
2739 };
2740 let next = match combined.take() {
2741 None => blended,
2742 Some(previous) => {
2743 match crate::boolean_operation(&previous, &blended, operation, &options) {
2744 Ok(next) => next,
2745 Err(_) => {
2746 failed = true;
2747 break;
2748 }
2749 }
2750 }
2751 };
2752 combined = Some(next);
2753 }
2754 if failed {
2755 None
2756 } else {
2757 combined.filter(|s| s.validate().is_empty())
2758 }
2759 } else {
2760 None
2761 };
2762 let mut result = match miter_attempt {
2763 Some(mitered) => mitered,
2764 None => {
2765 let tapered = per_edge_stops.iter().any(|stops| {
2771 stops.first().is_some_and(|(_, first)| {
2772 stops
2773 .iter()
2774 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
2775 })
2776 });
2777 if chain_corner && tapered && !allow_tapered_sequential {
2778 return Err(KernelRefusal::unsupported(
2781 KernelStage::Sew,
2782 "tapered_chain_miter",
2783 format!(
2784 "{entry}: the tapered blends across the selected chain's shared corners \
2785 did not compose to a valid solid (the fitted blend boundaries could not \
2786 be mitered); fillet fewer edges per operation or reduce the taper"
2787 ),
2788 ));
2789 }
2790 let mut sequential = solid.clone();
2791 for (index, point) in edge_points.iter().enumerate() {
2792 let edge_id = resolve_edge_by_point(&sequential, *point)?;
2793 let edge_name = per_edge_name(edge_names, name, index);
2794 sequential = crate::blend::blend_edge_variable(
2795 &sequential,
2796 edge_id,
2797 &per_edge_stops[index],
2798 chamfer,
2799 edge_name,
2800 )?;
2801 }
2802 sequential
2803 }
2804 };
2805 heal_edge_vertex_gaps(&mut result, max_radius)?;
2808 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
2809 let issues = result.validate();
2814 if !issues.is_empty() {
2815 let (detail, class) = if allow_tapered_sequential {
2820 (
2821 "tapered blends meet at a shared chain vertex with \
2822 mismatched radii — the radius-transition corner patch is not implemented; \
2823 fillet the edges in separate operations or use matching stop radii",
2824 RefusalClass::UnsupportedGeometry {
2825 what: "taper_transition_patch".into(),
2826 },
2827 )
2828 } else {
2829 (
2830 "the composed radius-law blend produced invalid topology — the blends \
2831 across a shared chain corner failed to reassemble",
2832 RefusalClass::Internal {
2833 what: "law_chain_topology".into(),
2834 },
2835 )
2836 };
2837 return Err(KernelRefusal::new(
2838 class,
2839 KernelStage::Validate,
2840 format!(
2841 "{entry}: {detail} ({} validation issues, first: {})",
2842 issues.len(),
2843 issues
2844 .first()
2845 .map(|issue| issue.message.clone())
2846 .unwrap_or_default()
2847 ),
2848 ));
2849 }
2850 crate::accept_sound(result, entry)
2855}
2856
2857#[allow(clippy::too_many_arguments)]
2862fn fillet_variable_network(
2863 solid: &BrepSolid,
2864 edge_points: &[Vec3],
2865 edge_names: Option<&[String]>,
2866 per_edge_stops: &[Vec<(f64, f64)>],
2867 chamfer: bool,
2868 name: Option<&str>,
2869 entry: &str,
2870 max_radius: f64,
2871) -> Result<Option<BrepSolid>, KernelRefusal> {
2872 let mut edge_ids = Vec::with_capacity(edge_points.len());
2873 for point in edge_points {
2874 edge_ids.push(resolve_edge_by_point(solid, *point)?);
2875 }
2876 let laws: Vec<crate::blend::StripeRadius> =
2877 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
2878 let network_names: Vec<Option<String>> = (0..edge_points.len())
2879 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
2880 .collect();
2881 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
2882 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2883 let mut network = match crate::blend::blend_star_network_laws(
2884 solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true,
2885 ) {
2886 Ok(network) => network,
2887 Err(refusal) => {
2888 if debug {
2889 eprintln!("law network refused: {refusal}");
2890 }
2891 return Ok(None);
2892 }
2893 };
2894 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
2899 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
2900 let issues = network.validate();
2901 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
2902 let bowtie = check_loop_self_crossings(&network, entry);
2903 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
2904 match crate::accept_sound(network, entry) {
2905 Ok(sound) => return Ok(Some(sound)),
2906 Err(refusal) => {
2907 if debug {
2908 eprintln!("law network result unsound: {refusal}");
2909 }
2910 return Ok(None);
2911 }
2912 }
2913 }
2914 if debug {
2915 eprintln!(
2916 "law network result rejected: heal={healed:?} issues={issues:?} \
2917 interference={interference:?} bowtie={bowtie:?}"
2918 );
2919 }
2920 Ok(None)
2921}
2922
2923pub(crate) fn fillet_edges_variable_stops(
2924 solid: &BrepSolid,
2925 edge_points: &[Vec3],
2926 edge_names: Option<&[String]>,
2927 per_edge_stops: &[Vec<(f64, f64)>],
2928 chamfer: bool,
2929 name: Option<&str>,
2930) -> Result<BrepSolid, KernelRefusal> {
2931 let _operation = crate::blend::BlendOperation::enter();
2932 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
2933 return Err(KernelRefusal::input(
2934 KernelStage::Collect,
2935 "variable_selection",
2936 "fillet_edges_variable_stops: one stop list per selected edge is required",
2937 ));
2938 }
2939 fillet_edges_variable_impl(
2940 solid,
2941 edge_points,
2942 edge_names,
2943 per_edge_stops,
2944 chamfer,
2945 name,
2946 "fillet_edges_variable_stops",
2947 false,
2948 )
2949}
2950
2951struct ChainLink {
2955 input_index: usize,
2957 forward: bool,
2959 arc: Vec<(f64, f64)>,
2962 length: f64,
2964 abscissa: f64,
2967}
2968
2969fn edge_arc_table(
2980 curve: &NurbsCurve,
2981 t0: f64,
2982 t1: f64,
2983 tol: f64,
2984) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
2985 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
2986 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
2987 let mut table = Vec::with_capacity(n + 1);
2988 let mut cumulative = 0.0;
2989 let mut previous = evaluate(t0)?;
2990 table.push((0.0, 0.0));
2991 for j in 1..=n {
2992 let fraction = j as f64 / n as f64;
2993 let point = evaluate(t0 + (t1 - t0) * fraction)?;
2994 cumulative += point.sub(previous).length();
2995 previous = point;
2996 table.push((fraction, cumulative));
2997 }
2998 Ok((table, cumulative))
2999 };
3000 let mut n = 16usize;
3001 let (mut table, mut length) = build(n)?;
3002 while n < 4096 {
3003 n *= 2;
3004 let (next_table, next_length) = build(n)?;
3005 let converged = (next_length - length).abs() <= tol;
3006 table = next_table;
3007 length = next_length;
3008 if converged {
3009 break;
3010 }
3011 }
3012 Ok((table, length))
3013}
3014
3015fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
3018 let fraction = fraction.clamp(0.0, 1.0);
3019 let intervals = table.len() - 1;
3020 let scaled = fraction * intervals as f64;
3021 let index = (scaled.floor() as usize).min(intervals - 1);
3022 let local = scaled - index as f64;
3023 let (_, a) = table[index];
3024 let (_, b) = table[index + 1];
3025 a + (b - a) * local
3026}
3027
3028fn resolve_selected_chain(
3037 solid: &BrepSolid,
3038 edge_points: &[Vec3],
3039 entry: &str,
3040 tol: f64,
3041) -> Result<Vec<ChainLink>, KernelRefusal> {
3042 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
3045
3046 struct Resolved {
3047 edge_id: u64,
3048 start: Vec3,
3049 end: Vec3,
3050 arc: Vec<(f64, f64)>,
3051 length: f64,
3052 }
3053 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
3054 for point in edge_points {
3055 let edge_id = resolve_edge_by_point(solid, *point)?;
3056 if resolved.iter().any(|r| r.edge_id == edge_id) {
3057 return Err(KernelRefusal::input(
3058 KernelStage::Collect,
3059 "duplicate_edge",
3060 format!("{entry}: the same edge was selected more than once"),
3061 ));
3062 }
3063 let edge = solid
3064 .edges
3065 .iter()
3066 .find(|e| e.id == edge_id)
3067 .ok_or_else(|| {
3068 KernelRefusal::internal(
3069 KernelStage::Collect,
3070 "chain_resolved_edge",
3071 format!("{entry}: resolved edge vanished"),
3072 )
3073 })?;
3074 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
3075 if !(length > 0.0) {
3076 return Err(KernelRefusal::input(
3077 KernelStage::Collect,
3078 "zero_length_edge",
3079 format!("{entry}: selected edge has zero length"),
3080 ));
3081 }
3082 resolved.push(Resolved {
3083 edge_id,
3084 start: edge
3085 .curve
3086 .evaluate(edge.t0)
3087 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
3088 end: edge
3089 .curve
3090 .evaluate(edge.t1)
3091 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
3092 arc,
3093 length,
3094 });
3095 }
3096 let n = resolved.len();
3097 if n == 1 {
3098 let only = resolved.remove(0);
3099 return Ok(vec![ChainLink {
3100 input_index: 0,
3101 forward: true,
3102 arc: only.arc,
3103 length: only.length,
3104 abscissa: 0.0,
3105 }]);
3106 }
3107
3108 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
3110 for (i, r) in resolved.iter().enumerate() {
3111 for (point, is_start) in [(r.start, true), (r.end, false)] {
3112 match clusters
3113 .iter_mut()
3114 .find(|(anchor, _)| anchor.sub(point).length() < band)
3115 {
3116 Some((_, members)) => members.push((i, is_start)),
3117 None => clusters.push((point, vec![(i, is_start)])),
3118 }
3119 }
3120 }
3121 if clusters.iter().any(|(_, members)| members.len() > 2) {
3122 return Err(KernelRefusal::input(
3123 KernelStage::Collect,
3124 "chain_branching",
3125 format!(
3126 "{entry}: selected edges must form one open chain \
3127 (a vertex is shared by three or more selected edges)"
3128 ),
3129 ));
3130 }
3131 let free: Vec<usize> = clusters
3132 .iter()
3133 .enumerate()
3134 .filter(|(_, (_, members))| members.len() == 1)
3135 .map(|(c, _)| c)
3136 .collect();
3137 if free.len() != 2 {
3138 return Err(KernelRefusal::input(
3139 KernelStage::Collect,
3140 "chain_open",
3141 format!(
3142 "{entry}: selected edges must form one OPEN chain \
3143 (closed rings and disconnected selections are not supported)"
3144 ),
3145 ));
3146 }
3147 let start_cluster = *free
3149 .iter()
3150 .min_by_key(|&&c| clusters[c].1[0].0)
3151 .expect("two free ends");
3152
3153 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
3155 let mut visited = vec![false; n];
3156 let mut abscissa = 0.0_f64;
3157 let mut cluster = start_cluster;
3158 for _ in 0..n {
3159 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
3160 .1
3161 .iter()
3162 .find(|(edge_index, _)| !visited[*edge_index])
3163 else {
3164 return Err(KernelRefusal::input(
3165 KernelStage::Collect,
3166 "chain_walk",
3167 format!(
3168 "{entry}: selected edges are not connected into one chain"
3169 ),
3170 ));
3171 };
3172 visited[edge_index] = true;
3173 let r = &resolved[edge_index];
3174 links.push(ChainLink {
3175 input_index: edge_index,
3176 forward: entered_at_start,
3177 arc: r.arc.clone(),
3178 length: r.length,
3179 abscissa,
3180 });
3181 abscissa += r.length;
3182 let exit_point = if entered_at_start { r.end } else { r.start };
3183 cluster = clusters
3184 .iter()
3185 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
3186 .ok_or_else(|| {
3187 KernelRefusal::internal(
3188 KernelStage::Collect,
3189 "chain_cluster",
3190 format!("{entry}: chain walk lost an endpoint cluster"),
3191 )
3192 })?;
3193 }
3194 if visited.iter().any(|v| !v) {
3195 return Err(KernelRefusal::input(
3196 KernelStage::Collect,
3197 "chain_connected",
3198 format!(
3199 "{entry}: selected edges are not connected into one chain"
3200 ),
3201 ));
3202 }
3203 Ok(links)
3204}
3205
3206fn law_stops_for_link(
3225 link: &ChainLink,
3226 law: &crate::law::RadiusLaw,
3227 scale: f64,
3228 tol: f64,
3229) -> Vec<(f64, f64)> {
3230 let radius_at_fraction = |fraction: f64| -> f64 {
3231 let arc = arc_length_at_fraction(&link.arc, fraction);
3232 let chain_s = if link.forward {
3233 link.abscissa + arc
3234 } else {
3235 link.abscissa + (link.length - arc)
3236 };
3237 law.radius_at(chain_s * scale)
3238 };
3239 let curvature = law
3242 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
3243 * (scale * link.length).powi(2);
3244 let base = if curvature * 0.125 <= tol {
3245 1usize
3246 } else {
3247 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
3248 };
3249 let mut stops: Vec<(f64, f64)> = (0..=base)
3250 .map(|j| {
3251 let fraction = j as f64 / base as f64;
3252 (fraction, radius_at_fraction(fraction))
3253 })
3254 .collect();
3255 let mut depth = 0usize;
3257 while depth < 8 {
3258 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
3259 let mut inserted = false;
3260 for pair in stops.windows(2) {
3261 refined.push(pair[0]);
3262 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
3263 let law_mid = radius_at_fraction(mid_fraction);
3264 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
3265 if (law_mid - linear_mid).abs() > tol {
3266 refined.push((mid_fraction, law_mid));
3267 inserted = true;
3268 }
3269 }
3270 refined.push(*stops.last().expect("at least two stops"));
3271 stops = refined;
3272 if !inserted {
3273 break;
3274 }
3275 depth += 1;
3276 }
3277 stops
3278}
3279
3280pub fn fillet_edges_variable_law(
3292 solid: &BrepSolid,
3293 edge_points: &[Vec3],
3294 edge_names: Option<&[String]>,
3295 law: &crate::law::RadiusLaw,
3296 chamfer: bool,
3297 name: Option<&str>,
3298) -> Result<BrepSolid, KernelRefusal> {
3299 let _operation = crate::blend::BlendOperation::enter();
3302 const ENTRY: &str = "fillet_edges_variable_law";
3303 if edge_points.is_empty() {
3304 return Err(KernelRefusal::input(
3305 KernelStage::Collect,
3306 "law_selection",
3307 format!("{ENTRY}: no edges selected"),
3308 ));
3309 }
3310 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
3311 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
3312 fillet_variable_law_on_chain(
3313 solid,
3314 edge_points,
3315 edge_names,
3316 &chain,
3317 law,
3318 chamfer,
3319 name,
3320 ENTRY,
3321 tolerances.intersection_fit,
3322 )
3323}
3324
3325pub fn fillet_edges_variable_vertex_radii(
3331 solid: &BrepSolid,
3332 edge_points: &[Vec3],
3333 edge_names: Option<&[String]>,
3334 vertex_radii: &[f64],
3335 chamfer: bool,
3336 name: Option<&str>,
3337) -> Result<BrepSolid, KernelRefusal> {
3338 let _operation = crate::blend::BlendOperation::enter();
3341 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
3342 if edge_points.is_empty() {
3343 return Err(KernelRefusal::input(
3344 KernelStage::Collect,
3345 "vertex_radii_selection",
3346 format!("{ENTRY}: no edges selected"),
3347 ));
3348 }
3349 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
3350 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
3351 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
3352 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
3353 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
3354 fillet_variable_law_on_chain(
3355 solid,
3356 edge_points,
3357 edge_names,
3358 &chain,
3359 &law,
3360 chamfer,
3361 name,
3362 ENTRY,
3363 tolerances.intersection_fit,
3364 )
3365}
3366
3367#[allow(clippy::too_many_arguments)]
3371fn fillet_variable_law_on_chain(
3372 solid: &BrepSolid,
3373 edge_points: &[Vec3],
3374 edge_names: Option<&[String]>,
3375 chain: &[ChainLink],
3376 law: &crate::law::RadiusLaw,
3377 chamfer: bool,
3378 name: Option<&str>,
3379 entry: &str,
3380 tol: f64,
3381) -> Result<BrepSolid, KernelRefusal> {
3382 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
3383 let scale = law.total_length() / chain_length;
3384 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
3385 for link in chain {
3386 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
3387 }
3388 fillet_edges_variable_impl(
3389 solid,
3390 edge_points,
3391 edge_names,
3392 &per_edge_stops,
3393 chamfer,
3394 name,
3395 entry,
3396 false,
3397 )
3398}
3399
3400pub fn chamfer_edges_asymmetric(
3405 solid: &BrepSolid,
3406 edge_points: &[Vec3],
3407 edge_names: Option<&[String]>,
3408 d1: f64,
3409 d2: f64,
3410 name: Option<&str>,
3411) -> Result<BrepSolid, KernelRefusal> {
3412 let _operation = crate::blend::BlendOperation::enter();
3415 if edge_points.is_empty() {
3416 return Err(KernelRefusal::input(
3417 KernelStage::Collect,
3418 "asymmetric_selection",
3419 "chamfer_edges_asymmetric: no edges selected",
3420 ));
3421 }
3422 let mut result = solid.clone();
3423 for (index, point) in edge_points.iter().enumerate() {
3427 let edge_id = resolve_edge_by_point(&result, *point)?;
3428 let edge_name = per_edge_name(edge_names, name, index);
3429 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
3430 }
3431 Ok(result)
3432}
3433
3434pub fn chamfer_edges_angle(
3438 solid: &BrepSolid,
3439 edge_points: &[Vec3],
3440 edge_names: Option<&[String]>,
3441 d1: f64,
3442 angle_rad: f64,
3443 name: Option<&str>,
3444) -> Result<BrepSolid, KernelRefusal> {
3445 let _operation = crate::blend::BlendOperation::enter();
3448 if edge_points.is_empty() {
3449 return Err(KernelRefusal::input(
3450 KernelStage::Collect,
3451 "angle_selection",
3452 "chamfer_edges_angle: no edges selected",
3453 ));
3454 }
3455 let mut result = solid.clone();
3456 for (index, point) in edge_points.iter().enumerate() {
3457 let edge_id = resolve_edge_by_point(&result, *point)?;
3458 let edge_name = per_edge_name(edge_names, name, index);
3459 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
3460 }
3461 Ok(result)
3462}
3463
3464fn dump_loops_debug(label: &str, solid: &BrepSolid) {
3468 for shell in &solid.shells {
3469 for face in &shell.faces {
3470 for (index, loop_record) in face.loops.iter().enumerate() {
3471 let walk: Vec<String> = loop_record
3472 .coedges
3473 .iter()
3474 .map(|coedge| {
3475 let uv = coedge
3479 .pcurve
3480 .domain()
3481 .ok()
3482 .and_then(|[q0, q1]| {
3483 let a = coedge.pcurve.evaluate(q0).ok()?;
3484 let b = coedge.pcurve.evaluate(q1).ok()?;
3485 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
3486 })
3487 .unwrap_or_default();
3488 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
3489 Some(edge) => format!(
3490 "c{}:e{}{}({}->{}){uv}",
3491 coedge.id,
3492 edge.id,
3493 if coedge.forward { "+" } else { "-" },
3494 edge.start_vertex_id,
3495 edge.end_vertex_id
3496 ),
3497 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
3498 }
3499 })
3500 .collect();
3501 eprintln!(
3502 "{label} face {} ({:?}) loop {index}: {}",
3503 face.id,
3504 face.name,
3505 walk.join(" ")
3506 );
3507 }
3508 }
3509 }
3510}