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_clipping(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_clipping(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 refuse_invalid_result(result: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
165 let errors: Vec<_> = result.validate_memoised().into_iter().filter(|issue| issue.severity == "error").collect();
166 let Some(first) = errors.first() else {
167 return Ok(());
168 };
169 Err(KernelRefusal::new(
170 RefusalClass::InvalidResultTopology { issues: errors.len() as u32 },
171 KernelStage::Validate,
172 format!(
173 "{entry}: the blend built a solid that fails validation ({} errors, first: {}); \
174 refused rather than returned",
175 errors.len(),
176 first.message
177 ),
178 ))
179}
180
181fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
183 match crate::topology::nearest_edge(solid, point) {
184 Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
185 Some((_, distance)) => Err(KernelRefusal::input(
186 KernelStage::Collect,
187 "edge_point",
188 format!(
189 "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
190 ),
191 )),
192 None => Err(KernelRefusal::input(
193 KernelStage::Collect,
194 "no_edges",
195 "fillet_edges: solid has no edges",
196 )),
197 }
198}
199
200pub(crate) fn fillet_coedge_memo_on() -> bool {
204 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
205 *ON.get_or_init(|| !matches!(std::env::var("BREP_FILLET_COEDGE_MEMO").as_deref(), Ok("0") | Ok("off")))
206}
207
208
209pub fn fillet_edges(
241 solid: &BrepSolid,
242 edge_points: &[Vec3],
243 edge_names: Option<&[String]>,
244 radius: f64,
245 chamfer: bool,
246 name: Option<&str>,
247) -> Result<BrepSolid, KernelRefusal> {
248 let _operation = crate::blend::BlendOperation::enter();
251 BrepSolid::clear_validate_memo();
254 let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
258 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
259 let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
262 let _cap = SubsetDropCap::set(cap);
263 let (result, selection) = match fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name) {
264 Err(fold) if !chamfer && crate::blend::is_wall_fold(&fold) && crate::blend::relief::radius_relief_on() => {
267 let misfit = crate::blend::relief::is_misfit_fold(&fold);
268 let relieved = relieve_fold(solid, edge_points, edge_names, radius, name, fold);
269 if relieved.is_err() && misfit {
273 let _off = crate::blend::relief::MisfitOff::enter();
274 return fillet_edges(solid, edge_points, edge_names, radius, chamfer, name);
275 }
276 return relieved;
277 }
278 reported => reported?,
279 };
280 if selection.is_complete() {
281 return Ok(result);
282 }
283 let dropped: Vec<String> = selection
286 .rejected
287 .iter()
288 .map(|index| {
289 let point = edge_points[*index];
290 let named = edge_names
291 .and_then(|names| names.get(*index))
292 .map(|name| format!("`{name}` "))
293 .unwrap_or_default();
294 format!(
295 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
296 point.x, point.y, point.z
297 )
298 })
299 .collect();
300 Err(KernelRefusal::ill_posed(
304 KernelStage::Select,
305 "incomplete_selection",
306 format!(
307 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
308 rest of the selection and carry no wall in the result ({}). The group refusal was: \
309 {}. Blend them separately, or call the reported entry point to accept the partial \
310 selection deliberately.",
311 selection.applied.len(),
312 selection.requested,
313 selection.rejected.len(),
314 dropped.join(", "),
315 selection
316 .reason
317 .as_deref()
318 .unwrap_or("the whole group built but not every edge grew a wall"),
319 ),
320 ))
321}
322
323fn relieve_fold(
331 solid: &BrepSolid,
332 edge_points: &[Vec3],
333 edge_names: Option<&[String]>,
334 radius: f64,
335 name: Option<&str>,
336 fold: KernelRefusal,
337) -> Result<BrepSolid, KernelRefusal> {
338 let _relieving = crate::blend::relief::Relieving::enter();
339 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
340 let selected: Vec<u64> = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<_, _>>()?;
341 let joined = crate::blend::relief::join_continuations(solid, &selected);
345 let solid = joined.as_ref().unwrap_or(solid);
346 let mut ids: Vec<u64> = Vec::with_capacity(edge_points.len());
347 let mut points: Vec<Vec3> = Vec::with_capacity(edge_points.len());
348 let mut names: Vec<String> = Vec::with_capacity(edge_points.len());
349 for (index, point) in edge_points.iter().enumerate() {
350 let id = resolve_edge_by_point(solid, *point)?;
351 if !ids.contains(&id) {
352 ids.push(id);
353 points.push(*point);
354 if let Some(name) = edge_names.and_then(|names| names.get(index)) {
355 names.push(name.clone());
356 }
357 }
358 }
359 let (edge_points, edge_names) = (&points[..], edge_names.map(|_| &names[..]));
360 if debug && joined.is_some() {
361 eprintln!("radius relief: joined continuation edges, {} selected edges become {}", selected.len(), ids.len());
362 }
363 let started = web_time::Instant::now();
364 let measured = match crate::blend::relief::measure_ceilings(solid, &ids, radius) {
365 Ok(measured) => measured,
366 Err(refusal) => return Err(fold.with_message(|message| format!("{message}; the relief could not measure the edges: {refusal}"))),
367 };
368 if debug {
369 eprintln!("radius relief: ceilings measured in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
370 }
371 let largest = measured.iter().flatten().map(|(_, ceiling)| *ceiling).fold(0.0_f64, f64::max);
376 if largest < radius {
377 return Err(fold.with_message(|message| {
378 format!("{message}; r {radius} fits nowhere along the selection, so it is not reduced locally: the largest ball that fits anywhere on it is {largest:.6}")
379 }));
380 }
381 let mut last: Option<KernelRefusal> = None;
382 for attempt in 0..crate::blend::relief::ATTEMPTS {
383 let plan = match crate::blend::relief::plan_relief(solid, &ids, radius, &measured, attempt) {
384 Ok(Some(plan)) => plan,
385 Ok(None) => break,
388 Err(refusal) => {
389 last = Some(refusal);
390 break;
391 }
392 };
393 let mark = crate::blend::blend_notes_mark();
394 match build_relief_plan(solid, edge_points, edge_names, &ids, radius, name, &plan) {
395 Ok(body) => {
396 for zone in &plan.zones {
397 let near: Vec<String> = zone
398 .edges
399 .iter()
400 .filter_map(|index| solid.edges.iter().find(|edge| edge.id == ids[*index]))
401 .map(|edge| edge.name.clone().unwrap_or_else(|| format!("edge #{}", edge.id)))
402 .map(|label| format!("`{label}`"))
403 .collect();
404 let (low, high) = zone
409 .edges
410 .iter()
411 .flat_map(|index| plan.stops[*index].iter().map(|(_, r)| *r))
412 .fold((f64::INFINITY, 0.0_f64), |(low, high), r| (low.min(r), high.max(r)));
413 let note = if high < radius * (1.0 - 1e-9) {
414 format!(
415 "radius reduced over the whole of {}: {low:.2} to {high:.2}, so r {radius} is reached nowhere \
416 on them (where they turn tightest the largest ball that fits is {:.2})",
417 near.join(", "),
418 zone.ceiling,
419 )
420 } else {
421 format!(
422 "radius reduced to {:.2} locally near {}: r {radius} does not fit there (the largest ball that does is {:.2})",
423 zone.radius,
424 near.join(", "),
425 zone.ceiling,
426 )
427 };
428 crate::blend::record_blend_note(note);
429 }
430 return Ok(body);
431 }
432 Err(refusal) => {
433 crate::blend::blend_notes_truncate(mark);
434 if debug {
435 eprintln!("radius relief attempt {attempt} refused at {:.1} ms: {refusal}", started.elapsed().as_secs_f64() * 1e3);
436 }
437 last = Some(refusal);
438 }
439 }
440 }
441 Err(match last {
442 Some(last) => fold.with_message(|message| format!("{message}; a locally reduced radius did not build either: {last}")),
443 None => fold,
444 })
445}
446
447fn build_relief_plan(
449 solid: &BrepSolid,
450 edge_points: &[Vec3],
451 edge_names: Option<&[String]>,
452 ids: &[u64],
453 radius: f64,
454 name: Option<&str>,
455 plan: &crate::blend::relief::ReliefPlan,
456) -> Result<BrepSolid, KernelRefusal> {
457 let count = ids.len();
459 let mut group: Vec<usize> = (0..count).collect();
460 let find = |group: &mut Vec<usize>, mut index: usize| {
461 while group[index] != index {
462 group[index] = group[group[index]];
463 index = group[index];
464 }
465 index
466 };
467 let vertices: Vec<[u64; 2]> = ids
468 .iter()
469 .map(|id| {
470 solid.edges.iter().find(|edge| edge.id == *id).map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).unwrap_or([u64::MAX; 2])
471 })
472 .collect();
473 for a in 0..count {
474 for b in a + 1..count {
475 if vertices[a].iter().any(|vertex| *vertex != u64::MAX && vertices[b].contains(vertex)) {
476 let (root_a, root_b) = (find(&mut group, a), find(&mut group, b));
477 group[root_a] = root_b;
478 }
479 }
480 }
481 let reduced: Vec<bool> = plan.stops.iter().map(|stops| stops.iter().any(|(_, r)| *r < radius)).collect();
482 let relieved_group: Vec<bool> = (0..count)
483 .map(|index| {
484 let root = find(&mut group, index);
485 (0..count).any(|other| reduced[other] && find(&mut group, other) == root)
486 })
487 .collect();
488 let pick = |want: bool| -> (Vec<Vec3>, Option<Vec<String>>, Vec<Vec<(f64, f64)>>) {
489 let indices: Vec<usize> = (0..count).filter(|index| relieved_group[*index] == want).collect();
490 (
491 indices.iter().map(|index| edge_points[*index]).collect(),
492 edge_names.map(|names| indices.iter().filter_map(|index| names.get(*index).cloned()).collect()),
493 indices.iter().map(|index| plan.stops[*index].clone()).collect(),
494 )
495 };
496 let (constant_points, constant_names, _) = pick(false);
497 let (relieved_points, relieved_names, relieved_stops) = pick(true);
498 let mut body = solid.clone();
499 if !constant_points.is_empty() {
500 body = fillet_edges(&body, &constant_points, constant_names.as_deref(), radius, false, name)?;
501 }
502 let built = fillet_edges_variable_impl(
503 &body,
504 &relieved_points,
505 relieved_names.as_deref(),
506 &relieved_stops,
507 false,
508 name,
509 "fillet_edges",
510 false,
511 )?;
512 let issues = built.validate();
513 if !issues.is_empty() {
514 return Err(KernelRefusal::internal(
515 KernelStage::Validate,
516 "relief_validate",
517 format!("radius relief: the relieved fillet does not validate: {issues:?}"),
518 ));
519 }
520 Ok(built)
521}
522
523#[derive(Clone, Debug)]
530pub struct BlendSelection {
531 pub requested: usize,
533 pub applied: Vec<usize>,
535 pub rejected: Vec<usize>,
537 pub reason: Option<String>,
539}
540
541impl BlendSelection {
542 pub fn is_complete(&self) -> bool {
544 self.rejected.is_empty()
545 }
546}
547
548pub fn fillet_edges_reported_at_offset(
560 solid: &BrepSolid,
561 edge_points: &[Vec3],
562 edge_names: Option<&[String]>,
563 radius: f64,
564 chamfer: bool,
565 name: Option<&str>,
566 offset: Vec3,
567) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
568 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
569}
570
571#[allow(clippy::too_many_arguments)]
573fn fillet_translated(
574 solid: &BrepSolid,
575 edge_points: &[Vec3],
576 edge_names: Option<&[String]>,
577 radius: f64,
578 chamfer: bool,
579 name: Option<&str>,
580 offset: Vec3,
581 kind: crate::recentre::EntryKind,
582) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
583 let _entry = crate::recentre::TranslatedEntry::open(kind);
584 let translate = |solid: &BrepSolid, offset: Vec3| {
585 crate::recentre::translate_solid(solid, offset).map_err(|message| {
586 KernelRefusal::internal(
587 KernelStage::Collect,
588 "translation_control",
589 format!("fillet_edges: the translation control could not move the solid: {message}"),
590 )
591 })
592 };
593 let moved = translate(solid, offset)?;
594 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
595 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
596 Ok((translate(&result, offset.scale(-1.0))?, selection))
597}
598
599pub fn fillet_edges_reported(
600 solid: &BrepSolid,
601 edge_points: &[Vec3],
602 edge_names: Option<&[String]>,
603 radius: f64,
604 chamfer: bool,
605 name: Option<&str>,
606) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
607 if let Some(offset) = crate::recentre::control_offset_at_entry() {
610 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
611 }
612 let bounds = crate::recentre::solid_bounds(solid);
615 if let Some(offset) = crate::recentre::guard_offset(bounds) {
616 crate::recentre::profile_fired("fillet", offset, bounds);
617 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
618 }
619 let _operation = crate::blend::BlendOperation::enter();
622 let _total = crate::blend::BlendTimer::start("timing.total_ms");
625 if !(radius > 0.0) || !radius.is_finite() {
626 return Err(KernelRefusal::input(
627 KernelStage::Collect,
628 "radius",
629 "fillet_edges: radius must be positive",
630 ));
631 }
632 if edge_points.is_empty() {
633 return Err(KernelRefusal::input(
634 KernelStage::Collect,
635 "selection",
636 "fillet_edges: no edges selected",
637 ));
638 }
639 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
647 let mut selected_ids = Vec::with_capacity(edge_points.len());
648 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
649 for point in edge_points {
650 let edge_id = resolve_edge_by_point(solid, *point)?;
651 check_mixed_concavity(solid, edge_id, entry)?;
652 if edge_points.len() == 1 && !chamfer {
654 check_support_extent_clipping(solid, edge_id, radius, entry)?;
655 } else {
656 check_support_extent(solid, edge_id, radius, entry)?;
657 }
658 selected_ids.push(edge_id);
659 }
660 selection_checks.stop();
661 if !chamfer {
700 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
705 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
706 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
707 corner_analysis.stop();
708 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
709 for corner in &corners {
710 eprintln!(
711 "mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
712 corner.vertex, corner.point.x, corner.point.y, corner.point.z,
713 corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
714 corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
715 );
716 }
717 }
718 if !corners.is_empty() {
719 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
722 let composed = rounded_boss_composition(
724 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
725 ).or_else(|error| {
726 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
727 eprintln!("rounded boss composition refused: {error}");
728 }
729 runout_composition(
730 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
731 )
732 }).or_else(|error| {
733 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
734 eprintln!("runout composition refused: {error}");
735 }
736 open_pad_composition(
742 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
743 ).map_err(|pad_error| {
744 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
745 eprintln!("open-pad composition refused: {pad_error}");
746 }
747 let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
752 if declined { error } else { pad_error }
753 })
754 }).or_else(|error| {
755 cusp_composition(solid, &selected_ids, edge_points, edge_names, radius, name, &corners).map_err(|cusp_error| {
760 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
761 eprintln!("cusp composition refused: {cusp_error}");
762 }
763 error
764 })
765 });
766 match composed {
767 Ok(result) => {
768 let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
777 refuse_invalid_result(&result, entry)?;
781 let missing = walls_missing_ties(
782 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
783 )?;
784 if !missing.is_empty() {
786 return Err(KernelRefusal::internal(
787 KernelStage::Validate,
788 "runout_walls_missing",
789 format!(
790 "{entry}: the composition built a solid with no wall on {}",
791 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
792 ),
793 ));
794 }
795 let n = edge_points.len();
796 return Ok((
797 result,
798 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
799 ));
800 }
801 Err(error) => {
802 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
803 eprintln!("runout composition refused: {error}");
804 }
805 check_mixed_corner_convexity(
806 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
807 )?;
808 }
809 }
810 }
811 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
817 check_degenerate_corner_setbacks(
818 solid,
819 &selected_ids,
820 edge_points,
821 edge_names,
822 radius,
823 name,
824 )?;
825 corner_analysis.stop();
826 }
827
828 let n = edge_points.len();
829 let complete = |applied: Vec<usize>| BlendSelection {
830 requested: n,
831 applied,
832 rejected: Vec::new(),
833 reason: None,
834 };
835 let mut memo = ComponentMemo::default();
846 let profile = std::env::var_os("BREP_PROFILE").is_some();
847 let group_started = web_time::Instant::now();
848 let group = fillet_edges_group_in(
849 solid,
850 edge_points,
851 edge_names,
852 radius,
853 chamfer,
854 name,
855 Some(&mut memo),
856 );
857 if profile {
858 eprintln!(
859 "blend.selection group edges={n} ok={} ms={:.3}",
860 group.is_ok(),
861 group_started.elapsed().as_secs_f64() * 1e3
862 );
863 }
864 let group_err = match group {
865 Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
870 refuse_invalid_result(&result, entry).unwrap_err()
871 }
872 Ok(result) => {
873 let missing =
878 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
879 if missing.is_empty() {
880 return Ok((result, complete((0..n).collect())));
881 }
882 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
883 return Ok((
884 result,
885 BlendSelection {
886 requested: n,
887 applied,
888 rejected: missing,
889 reason: None,
890 },
891 ));
892 }
893 Err(error) => error,
894 };
895 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err)
905 || crate::blend::is_tangential_transverse_end(&group_err)
906 {
907 return Err(group_err);
908 }
909 if n < 2 || n > 12 {
914 return Err(group_err);
915 }
916 let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
923 for drop in 1..=max_drop {
924 for dropped in index_combinations(n, drop) {
925 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
926 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
927 let kept_names: Option<Vec<String>> =
930 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
931 crate::blend::record_count("count.subset.builds", 1);
932 let subset_started = web_time::Instant::now();
933 let subset = fillet_edges_group_in(
934 solid,
935 &points,
936 kept_names.as_deref(),
937 radius,
938 chamfer,
939 name,
940 Some(&mut memo),
941 );
942 if profile {
943 eprintln!(
944 "blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
945 subset.is_ok(),
946 subset_started.elapsed().as_secs_f64() * 1e3
947 );
948 }
949 let result = match subset {
950 Ok(result) => result,
951 Err(error) => {
952 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
953 eprintln!("subset dropped={dropped:?} refused: {error}");
954 }
955 continue;
956 }
957 };
958 if !result.validate().is_empty() {
959 continue;
960 }
961 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
962 continue;
963 }
964 return Ok((
965 result,
966 BlendSelection {
967 requested: n,
968 applied: keep,
969 rejected: dropped,
970 reason: Some(group_err.message),
971 },
972 ));
973 }
974 }
975 Err(group_err)
976}
977
978pub(crate) fn walls_missing_nearest_on() -> bool {
983 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
984 *ON.get_or_init(|| !matches!(std::env::var("BREP_WALLS_MISSING_NEAREST").as_deref(), Ok("0") | Ok("off")))
985}
986
987
988pub(super) fn walls_missing(
1014 original: &BrepSolid,
1015 result: &BrepSolid,
1016 edge_points: &[Vec3],
1017 selected: &[usize],
1018 size: f64,
1019 chamfer: bool,
1020 entry: &str,
1021) -> Result<Vec<usize>, KernelRefusal> {
1022 walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
1023}
1024
1025#[allow(clippy::too_many_arguments)]
1040pub(super) fn walls_missing_ties(
1041 original: &BrepSolid,
1042 result: &BrepSolid,
1043 edge_points: &[Vec3],
1044 selected: &[usize],
1045 size: f64,
1046 chamfer: bool,
1047 entry: &str,
1048 tie_majority: bool,
1049) -> Result<Vec<usize>, KernelRefusal> {
1050use crate::{PointClass, SolidClassifier};
1052 if selected.is_empty() {
1053 return Ok(Vec::new());
1054 }
1055 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
1056 crate::classification::classify_profile_begin();
1057 crate::classification::containment_profile_begin();
1058 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
1059 let visible = band * 100.0;
1062 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
1063 let classifier = SolidClassifier::new(result, band)
1064 .or_refuse(KernelStage::Validate, "wall_classifier")?;
1065 let nearest = if walls_missing_nearest_on() {
1070 Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
1071 } else {
1072 None
1073 };
1074 let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
1075 if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
1076 crate::blend::record_count("count.walls_missing.nearest", 1);
1077 return Ok(class == PointClass::On);
1078 }
1079 Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
1080 };
1081 build_timing.stop();
1082 const STATIONS: usize = 9;
1083 let mut missing = Vec::new();
1084 for &index in selected {
1085 let point = edge_points[index];
1086 let edge_id = resolve_edge_by_point(original, point)?;
1087 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
1088 let mut best: Option<(Vec3, f64)> = None;
1090 let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
1092 for step in 1..=STATIONS {
1094 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
1095 let Ok(station) = edge.curve.evaluate(t) else {
1096 continue;
1097 };
1098 let clearance = match faces[..] {
1099 [first, second] => {
1100 let (Ok((_, a)), Ok((_, b))) = (
1101 super::analyze::face_plane_normal(first, station),
1102 super::analyze::face_plane_normal(second, station),
1103 ) else {
1104 continue;
1105 };
1106 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
1107 if chamfer {
1108 size * half.sin()
1109 } else {
1110 size * (1.0 / half.cos().max(1e-12) - 1.0)
1111 }
1112 }
1113 _ => f64::INFINITY,
1115 };
1116 if best.map_or(true, |(_, known)| clearance > known) {
1117 best = Some((station, clearance));
1118 }
1119 read.push((station, clearance));
1121 }
1123 let Some((station, clearance)) = best else {
1124 return Err(KernelRefusal::internal(
1125 KernelStage::Validate,
1126 "wall_attribution_stations",
1127 format!(
1128 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
1129 station of it could be evaluated",
1130 point.x, point.y, point.z
1131 ),
1132 ));
1133 };
1134 if !(clearance > visible) {
1135 return Err(KernelRefusal::ill_posed(
1139 KernelStage::Validate,
1140 "wall_attribution_band",
1141 format!(
1142 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
1143 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
1144 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
1145 selection is refused rather than reported on a guess",
1146 point.x, point.y, point.z
1147 ),
1148 ));
1149 }
1150 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
1151 let certified = station_clear_of_skin(result, station, band * 10.0, visible);
1158 let on = if certified {
1159 crate::blend::record_count("count.walls_missing.certified", 1);
1160 false
1161 } else {
1162 crate::blend::record_count("count.walls_missing.classified", 1);
1163 reads_on(station)?
1164 };
1165 classify_timing.stop();
1166 let on = on && (!tie_majority || {
1168 let tolerance = 1e-12 * clearance.abs().max(1.0);
1171 let (mut tied, mut on_skin) = (1usize, 1usize);
1172 for (other, other_clearance) in read.iter().copied() {
1173 if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
1174 continue;
1175 }
1176 tied += 1;
1177 if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
1178 on_skin += 1;
1179 }
1180 }
1181 2 * on_skin > tied
1182 });
1183 if on {
1185 missing.push(index);
1186 }
1187 }
1188 crate::classification::classify_profile_report("blend.walls_missing");
1189 crate::classification::containment_profile_report("blend.walls_missing");
1190 Ok(missing)
1191}
1192
1193
1194
1195fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
1225 use crate::AnalyticSurface;
1226 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
1229 let offset = station.sub(frame.origin);
1230 let axial = offset.dot(frame.axis);
1231 let radial = offset.sub(frame.axis.scale(axial));
1232 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
1233 if azimuth < 0.0 {
1234 azimuth += std::f64::consts::TAU;
1235 }
1236 (radial.length(), axial, azimuth)
1237 };
1238 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
1242 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
1243 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
1244 return 0.0;
1245 }
1246 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
1248 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
1249 };
1250 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
1253 let (du, dv) = (rho1 - rho0, h1 - h0);
1254 let length = (du * du + dv * dv).sqrt();
1255 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
1256 };
1257 let clear = |distance: f64| distance.is_finite() && distance > margin;
1258 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1259 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
1260 if !controls.expanded(on_band).contains(station) {
1261 continue;
1262 }
1263 let certified = match face.surface.analytic() {
1264 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
1265 let (radial, axial, _) = meridian(frame);
1266 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
1267 clear(line.max(axial_clearance(axial, 0.0, *height)))
1268 }
1269 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
1270 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
1271 let (radial, axial, _) = meridian(frame);
1272 let tube = radial - major_radius;
1273 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
1274 }
1275 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
1276 let [a, b] = generatrix.control_points.as_slice() else { return false };
1283 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1284 return false;
1285 }
1286 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
1287 let offset = point.sub(frame.origin);
1288 let axial = offset.dot(frame.axis);
1289 let radial = offset.sub(frame.axis.scale(axial));
1290 let rho = radial.length();
1291 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);
1292 on_meridian.then_some((rho, axial))
1293 };
1294 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 {
1295 return false;
1296 };
1297 let (radial, axial, azimuth) = meridian(frame);
1298 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
1299 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
1304 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
1305 }
1306 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
1307 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
1308 if clear(station.sub(*origin).dot(normal).abs()) {
1309 true
1310 } else {
1311 planar_trims_clear_of(face, plane, station, on_band, margin)
1312 }
1313 }
1314 None => false,
1315 };
1316 if !certified {
1317 return false;
1318 }
1319 }
1320 true
1321}
1322
1323fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
1333 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
1334 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1335 let pcurve = &coedge.pcurve;
1336 let [a, b] = pcurve.control_points.as_slice() else { return None };
1337 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1338 return None;
1339 }
1340 for control in [a, b] {
1341 u_low = u_low.min(control.x);
1342 u_high = u_high.max(control.x);
1343 v_low = v_low.min(control.y);
1344 v_high = v_high.max(control.y);
1345 }
1346 }
1347 if !(u_low <= u_high && v_low <= v_high) {
1348 return None;
1349 }
1350 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
1351 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)?];
1352 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
1353 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
1354 let surface = &face.surface;
1355 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
1357 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
1358 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
1359 && surface.degree_u == 1 && surface.control_points.len() == 2;
1360 if !(affine_in_v || affine_in_u) {
1361 return None;
1362 }
1363 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
1364 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1365 (low.is_finite() && high.is_finite()).then_some((low, high))
1366}
1367
1368fn planar_trims_clear_of(
1372 face: &FaceRecord,
1373 plane: &crate::AnalyticSurface,
1374 station: Vec3,
1375 on_band: f64,
1376 margin: f64,
1377) -> bool {
1378 use crate::PolygonClass;
1379 let Some(foot) = plane.project(&face.surface, station) else { return false };
1380 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
1381 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
1382 Ok(PolygonClass::Outside) => {}
1383 _ => return false,
1384 }
1385 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1386 let pcurve = &coedge.pcurve;
1387 let [a, b] = pcurve.control_points.as_slice() else { return false };
1388 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1389 return false;
1390 }
1391 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
1392 let chord = end.sub(start);
1393 let length_squared = chord.dot(chord);
1394 let offset = station.sub(start);
1395 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
1396 let distance = offset.sub(chord.scale(along)).length();
1397 if !(distance.is_finite() && distance > margin) {
1398 return false;
1399 }
1400 }
1401 true
1402}
1403
1404fn per_edge_name<'a>(
1409 edge_names: Option<&'a [String]>,
1410 base: Option<&'a str>,
1411 i: usize,
1412) -> Option<&'a str> {
1413 match edge_names {
1414 Some(names) => names.get(i).map(|value| value.as_str()),
1415 None => base,
1416 }
1417}
1418
1419fn corner_face_name(
1427 edge_names: Option<&[String]>,
1428 base: Option<&str>,
1429 adjacent: &[usize],
1430) -> Option<String> {
1431 match (edge_names, base) {
1432 (Some(names), Some(base)) => {
1433 let prefix = format!("{base}:");
1434 let mut raws: Vec<&str> = adjacent
1435 .iter()
1436 .filter_map(|&i| names.get(i))
1437 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
1438 .collect();
1439 raws.sort_unstable();
1440 raws.dedup();
1441 Some(format!("{base}:CORNER:{}", raws.join("+")))
1442 }
1443 _ => base.map(|value| value.to_string()),
1444 }
1445}
1446
1447#[allow(clippy::too_many_arguments)]
1457fn rounded_boss_composition(
1458 solid: &BrepSolid,
1459 selected_ids: &[u64],
1460 edge_points: &[Vec3],
1461 edge_names: Option<&[String]>,
1462 radius: f64,
1463 name: Option<&str>,
1464 corners: &[crate::blend::MixedCorner],
1465) -> Result<BrepSolid, KernelRefusal> {
1466 if edge_names.is_none() {
1471 let prefix = "FILLET:COMPOSITION";
1472 let names: Vec<_> = selected_ids
1473 .iter()
1474 .map(|id| format!("{prefix}:{id}"))
1475 .collect();
1476 let mut result = rounded_boss_composition(
1477 solid,
1478 selected_ids,
1479 edge_points,
1480 Some(&names),
1481 radius,
1482 Some(prefix),
1483 corners,
1484 )?;
1485 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1486 if face
1487 .name
1488 .as_deref()
1489 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1490 {
1491 face.name = name.map(str::to_owned);
1492 }
1493 }
1494 return Ok(result);
1495 }
1496 let decline = || {
1497 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1498 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1499 };
1500 if std::env::var("BREP_NO_NETWORK").is_ok() {
1501 return Err(decline());
1502 }
1503 let corner = corners
1506 .iter()
1507 .filter(|corner| {
1508 corner.convex.len() == 1
1509 && corner.concave.len() == 2
1510 && corners
1511 .iter()
1512 .filter(|other| other.convex.contains(&corner.convex[0]))
1513 .count()
1514 == 1
1515 })
1516 .min_by_key(|corner| selected_ids[corner.convex[0]])
1517 .ok_or_else(decline)?;
1518 let convexity =
1519 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1520 if !convexity[corner.convex[0]] {
1521 return Err(decline());
1522 }
1523 let spine_index = corner.convex[0];
1524 let selected_edges: Vec<&EdgeRecord> = selected_ids
1527 .iter()
1528 .map(|id| {
1529 solid
1530 .edges
1531 .iter()
1532 .find(|edge| edge.id == *id)
1533 .ok_or_else(|| {
1534 KernelRefusal::internal(
1535 KernelStage::Collect,
1536 "rounded_boss_selected_edge",
1537 format!("rounded boss composition: selected edge {id} is missing"),
1538 )
1539 })
1540 })
1541 .collect::<Result<_, _>>()?;
1542 let mut rounded_indices = vec![spine_index];
1545 loop {
1546 let next = (0..selected_ids.len()).find(|index| {
1547 convexity[*index]
1548 && !rounded_indices.contains(index)
1549 && rounded_indices.iter().any(|other| {
1550 let edge = selected_edges[*index];
1551 let mate = selected_edges[*other];
1552 [edge.start_vertex_id, edge.end_vertex_id]
1553 .iter()
1554 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1555 })
1556 });
1557 match next {
1558 Some(index) => rounded_indices.push(index),
1559 None => break,
1560 }
1561 }
1562 loop {
1571 let next = (0..selected_ids.len()).find(|&index| {
1572 if convexity[index] || rounded_indices.contains(&index) {
1573 return false;
1574 }
1575 let edge = selected_edges[index];
1576 let mut has_star = false;
1577 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
1578 let neighbours: Vec<_> = selected_edges.iter().enumerate()
1579 .filter(|(other, mate)| *other != index
1580 && (mate.start_vertex_id == vertex || mate.end_vertex_id == vertex))
1581 .map(|(other, _)| other)
1582 .collect();
1583 if neighbours.iter().any(|other| !rounded_indices.contains(other)) {
1584 return false;
1585 }
1586 has_star |= neighbours.len() >= 2;
1587 }
1588 has_star
1589 });
1590 match next {
1591 Some(index) => rounded_indices.push(index),
1592 None => break,
1593 }
1594 }
1595 rounded_indices.sort_by_key(|index| selected_ids[*index]);
1596 let mixed: Vec<_> = corners
1597 .iter()
1598 .filter(|corner| {
1599 corner
1600 .convex
1601 .iter()
1602 .any(|index| rounded_indices.contains(index))
1603 })
1604 .collect();
1605 if mixed.iter().any(|corner| {
1606 corner.convex.len() != 1
1607 || corner.concave.len() != 2
1608 || corners
1609 .iter()
1610 .filter(|other| other.convex.contains(&corner.convex[0]))
1611 .count()
1612 != 1
1613 }) {
1614 return Err(decline());
1615 }
1616 let uses = |face: &FaceRecord, edge: u64| {
1617 face.loops
1618 .iter()
1619 .flat_map(|rim| &rim.coedges)
1620 .any(|coedge| coedge.edge_id == edge)
1621 };
1622 let mut caps = Vec::new();
1623 for corner in &mixed {
1624 let spine_id = selected_ids[corner.convex[0]];
1625 let cap = solid
1626 .shells
1627 .iter()
1628 .flat_map(|shell| &shell.faces)
1629 .find(|face| {
1630 corner
1631 .concave
1632 .iter()
1633 .all(|index| uses(face, selected_ids[*index]))
1634 })
1635 .ok_or_else(decline)?;
1636 let faces: Vec<_> = solid
1637 .shells
1638 .iter()
1639 .flat_map(|shell| &shell.faces)
1640 .filter(|face| face.id == cap.id || uses(face, spine_id))
1641 .collect();
1642 let normals: Option<Vec<Vec3>> = faces
1651 .iter()
1652 .map(|face| match face.surface.analytic() {
1653 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
1654 u_dir.cross(*v_dir).normalized().ok()
1655 }
1656 _ => {
1657 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
1658 face.surface.normal(uv.u, uv.v).ok()
1659 }
1660 })
1661 .collect();
1662 let normals = normals.ok_or_else(decline)?;
1663 if normals.len() != 3
1669 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
1670 {
1671 return Err(decline());
1672 }
1673 caps.push(cap.id);
1674 }
1675 let rounded_names: Vec<String> = rounded_indices
1678 .iter()
1679 .map(|index| {
1680 per_edge_name(edge_names, name, *index)
1681 .map(str::to_owned)
1682 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
1683 })
1684 .collect();
1685 let rounded_points: Vec<Vec3> = rounded_indices
1686 .iter()
1687 .map(|index| edge_points[*index])
1688 .collect();
1689 let rounded = fillet_edges_group_in(
1690 solid,
1691 &rounded_points,
1692 Some(&rounded_names),
1693 radius,
1694 false,
1695 name,
1696 None,
1697 )?;
1698 let mut bridges = Vec::new();
1699 for (corner, cap) in mixed.iter().zip(&caps) {
1700 let wall_index = rounded_indices
1701 .iter()
1702 .position(|index| *index == corner.convex[0])
1703 .ok_or_else(|| {
1704 KernelRefusal::internal(
1705 KernelStage::Classify,
1706 "rounded_boss_bridge_wall",
1707 format!(
1708 "rounded boss composition: no convex wall for corner {}",
1709 corner.vertex
1710 ),
1711 )
1712 })?;
1713 let candidates: Vec<_> = rounded
1714 .edges
1715 .iter()
1716 .filter(|edge| {
1717 let faces: Vec<_> = rounded
1718 .shells
1719 .iter()
1720 .flat_map(|shell| &shell.faces)
1721 .filter(|face| uses(face, edge.id))
1722 .collect();
1723 faces.iter().any(|face| face.id == *cap)
1724 && faces
1725 .iter()
1726 .any(|face| face.name.as_deref() == Some(&rounded_names[wall_index]))
1727 })
1728 .collect();
1729 if candidates.len() != 1 {
1730 return Err(decline());
1731 }
1732 bridges.push(candidates[0]);
1733 }
1734 let rim_ids: Vec<u64> = selected_ids
1735 .iter()
1736 .enumerate()
1737 .filter(|(index, _)| !rounded_indices.contains(index))
1738 .map(|(_, id)| *id)
1739 .collect();
1740 if !bridges.iter().all(|bridge| {
1743 [bridge.start_vertex_id, bridge.end_vertex_id]
1744 .iter()
1745 .all(|vertex| {
1746 rounded.edges.iter().any(|edge| {
1747 rim_ids.contains(&edge.id)
1748 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
1749 })
1750 })
1751 }) {
1752 return Err(decline());
1753 }
1754 let mut points = Vec::new();
1755 let mut names = Vec::new();
1756 for (index, id) in selected_ids.iter().enumerate() {
1757 if rounded_indices.contains(&index) {
1758 continue;
1759 }
1760 let edge = rounded
1761 .edges
1762 .iter()
1763 .find(|edge| edge.id == *id)
1764 .ok_or_else(decline)?;
1765 points.push(
1766 edge.curve
1767 .evaluate(0.5 * (edge.t0 + edge.t1))
1768 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
1769 );
1770 names.push(
1771 per_edge_name(edge_names, name, index)
1772 .unwrap_or("FILLET:RIM")
1773 .to_owned(),
1774 );
1775 }
1776 for (corner, bridge) in mixed.iter().zip(&bridges) {
1777 points.push(
1778 bridge
1779 .curve
1780 .evaluate(0.5 * (bridge.t0 + bridge.t1))
1781 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
1782 );
1783 let mut adjacent = corner.concave.clone();
1784 adjacent.push(corner.convex[0]);
1785 names.push(
1786 corner_face_name(edge_names, name, &adjacent)
1787 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
1788 );
1789 }
1790 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
1793 |(result, selection)| {
1794 if selection.applied.len() != points.len() {
1795 Err(decline())
1796 } else {
1797 Ok(result)
1798 }
1799 },
1800 )
1801}
1802
1803#[allow(clippy::too_many_arguments)]
1836fn open_pad_on() -> bool {
1838 !matches!(std::env::var("BREP_BLEND_OPEN_PAD").as_deref(), Ok("0") | Ok("off"))
1839}
1840
1841#[allow(clippy::too_many_arguments)]
1870fn open_pad_composition(
1871 solid: &BrepSolid,
1872 selected_ids: &[u64],
1873 edge_points: &[Vec3],
1874 edge_names: Option<&[String]>,
1875 radius: f64,
1876 name: Option<&str>,
1877 corners: &[crate::blend::MixedCorner],
1878 entry: &str,
1879) -> Result<BrepSolid, KernelRefusal> {
1880 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
1881 if !open_pad_on() {
1882 return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
1883 }
1884 if std::env::var("BREP_NO_NETWORK").is_ok() {
1885 return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
1886 }
1887 if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
1888 return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
1889 }
1890 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
1891 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
1892 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
1893 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
1894 if concave.is_empty() || convex.is_empty() {
1895 return Err(shape("the open-pad composition needs convex and concave edges".into()));
1896 }
1897 let mates = crate::blend::selection_mates(solid, selected_ids, radius)
1900 .map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
1901 for corner in corners {
1902 let (_, convex_faces) = &mates[corner.convex[0]];
1903 let (_, concave_faces) = &mates[corner.concave[0]];
1904 let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
1905 return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
1906 };
1907 if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
1908 return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
1909 }
1910 }
1911 for &index in &concave {
1912 let edge = mates[index].0;
1913 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
1914 if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
1915 return Err(shape(format!(
1916 "concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
1917 selection_label(index, edge_points, edge_names, name)
1918 )));
1919 }
1920 }
1921 }
1922 let base = name.unwrap_or("FILLET:OPEN_PAD");
1923 let bare = |index: usize| -> String {
1924 per_edge_name(edge_names, name, index)
1925 .map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
1926 .unwrap_or_else(|| selected_ids[index].to_string())
1927 };
1928 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
1929
1930 let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
1932 let convex_names: Vec<String> = convex
1933 .iter()
1934 .map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
1935 .collect();
1936 let stage1 = crate::blend::with_open_pad_network(|| {
1945 fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
1946 })
1947 .map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
1948 if debug {
1949 eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
1950 }
1951
1952 let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
1954 let mut points: Vec<Vec3> = Vec::new();
1955 let mut names: Vec<String> = Vec::new();
1956 for &index in &concave {
1957 let (edge, faces) = &mates[index];
1958 let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
1959 let pad = stage1
1960 .edges
1961 .iter()
1962 .find(|candidate| candidate.id == edge.id)
1963 .ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
1964 "open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
1965 )))?;
1966 points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
1967 names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
1968 for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
1971 let trims: Vec<&EdgeRecord> = stage1
1972 .edges
1973 .iter()
1974 .filter(|candidate| {
1975 candidate.id != pad.id
1976 && !candidate.degenerate
1977 && (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
1978 })
1979 .filter(|candidate| {
1980 let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
1981 faces.iter().any(|face| face.id == boss_id)
1982 && faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
1983 })
1984 .collect();
1985 match trims.as_slice() {
1986 [trim] => {
1987 let wall_name = stage1
1988 .shells
1989 .iter()
1990 .flat_map(|shell| &shell.faces)
1991 .filter(|face| uses(face, trim.id))
1992 .find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
1993 .unwrap_or_default();
1994 let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
1995 points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
1996 names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
1997 if debug {
1998 eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
1999 }
2000 }
2001 [] => {
2002 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2003 "open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
2004 selection_label(index, edge_points, edge_names, name)
2005 )));
2006 }
2007 _ => {
2008 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2009 "open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
2010 selection_label(index, edge_points, edge_names, name), trims.len()
2011 )));
2012 }
2013 }
2014 }
2015 }
2016 let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
2017 .map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
2018 let _ = entry;
2019 Ok(result)
2020}
2021
2022fn cusp_composition(
2042 solid: &BrepSolid,
2043 selected_ids: &[u64],
2044 edge_points: &[Vec3],
2045 edge_names: Option<&[String]>,
2046 radius: f64,
2047 name: Option<&str>,
2048 corners: &[crate::blend::MixedCorner],
2049) -> Result<BrepSolid, KernelRefusal> {
2050 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "cusp_composition_shape", message);
2051 if std::env::var("BREP_NO_NETWORK").is_ok() {
2052 return Err(shape("the cusp composition is a network construction and BREP_NO_NETWORK is set".into()));
2053 }
2054 let edge_of = |index: usize| solid.edges.iter().find(|edge| edge.id == selected_ids[index]);
2055 let away = |index: usize, vertex: u64| -> Option<Vec3> {
2057 let edge = edge_of(index)?;
2058 let at_start = edge.start_vertex_id == vertex;
2059 let tangent = edge.curve.unit_tangent(if at_start { edge.t0 } else { edge.t1 }, edge.t0, edge.t1).ok()?;
2060 Some(if at_start { tangent } else { tangent.scale(-1.0) })
2061 };
2062 if corners.is_empty() {
2063 return Err(shape("no mixed corners".into()));
2064 }
2065 for corner in corners {
2066 let cusped = corner.convex.iter().all(|&convex| {
2067 corner.concave.iter().any(|&concave| {
2068 matches!((away(convex, corner.vertex), away(concave, corner.vertex)), (Some(a), Some(b)) if a.dot(b) >= 1.0 - 1e-6)
2069 })
2070 });
2071 if !cusped || corner.convex.is_empty() || corner.concave.is_empty() {
2072 return Err(shape(format!("the mixed corner at vertex {} is not a cusp", corner.vertex)));
2073 }
2074 }
2075 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2076 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2077 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2078 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2079 if concave.is_empty() || convex.is_empty() {
2080 return Err(shape("the cusp composition needs convex and concave edges".into()));
2081 }
2082 let names_of = |indices: &[usize]| -> Option<Vec<String>> { edge_names.map(|names| indices.iter().map(|index| names[*index].clone()).collect()) };
2083 let points_of = |indices: &[usize]| -> Vec<Vec3> { indices.iter().map(|index| edge_points[*index]).collect() };
2084 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2085 let stage1 = fillet_edges_group_in(solid, &points_of(&concave), names_of(&concave).as_deref(), radius, false, name, None)
2086 .map_err(|error| error.with_message(|error| format!("cusp composition, the concave rounds: {error}")))?;
2087 if debug {
2088 eprintln!("cusp composition: stage 1 built the {} concave rounds", concave.len());
2089 }
2090 for &index in &convex {
2093 resolve_edge_by_point(&stage1, edge_points[index])
2094 .map_err(|error| error.with_message(|error| format!("cusp composition: a convex edge is not on the stage-1 body: {error}")))?;
2095 }
2096 let result = fillet_edges_group_in(&stage1, &points_of(&convex), names_of(&convex).as_deref(), radius, false, name, None)
2097 .map_err(|error| error.with_message(|error| format!("cusp composition, the convex rounds: {error}")))?;
2098 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(true));
2104 Ok(result)
2105}
2106
2107fn runout_composition(
2108 solid: &BrepSolid,
2109 selected_ids: &[u64],
2110 edge_points: &[Vec3],
2111 edge_names: Option<&[String]>,
2112 radius: f64,
2113 name: Option<&str>,
2114 corners: &[crate::blend::MixedCorner],
2115 entry: &str,
2116) -> Result<BrepSolid, KernelRefusal> {
2117 if std::env::var("BREP_NO_NETWORK").is_ok() {
2118 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
2119 }
2120 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2121 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
2122 let mut spine_ends = vec![0usize; selected_ids.len()];
2123 for corner in corners {
2124 if corner.convex.len() != 1 {
2125 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
2126 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
2127 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
2128 )));
2129 }
2130 spine_ends[corner.convex[0]] += 1;
2131 }
2132 let mut spines: Vec<usize> = Vec::new();
2133 let mut concave: Vec<usize> = Vec::new();
2134 for (index, ends) in spine_ends.iter().enumerate() {
2135 match (*ends, convexity[index]) {
2136 (2, true) => spines.push(index),
2137 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
2138 (0, false) => concave.push(index),
2139 (ends, convex) => {
2140 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
2141 "{} 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",
2142 selection_label(index, edge_points, edge_names, name),
2143 if convex { "convex" } else { "concave" },
2144 )));
2145 }
2146 }
2147 }
2148 if concave.is_empty() {
2149 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
2150 }
2151 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
2153 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
2154 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
2155 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
2156 .map_err(|error| {
2157 let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2163 match network {
2164 Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
2165 "the concave ring did not build: {network} (the cutter fallback then refused: {error})"
2166 )),
2167 _ => error.with_message(|error| format!("the concave ring did not build: {error}")),
2168 }
2169 })?;
2170 let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2179 let ring_is_network = network_declined.is_none();
2181 if let Some(network) = network_declined {
2183 if crate::blend::is_reentrant_concave(&network) {
2184 return Err(network.with_message(|network| format!(
2185 "the concave ring did not build as the network's ring: {network}; the cutter fallback built a body for the concave edges without that closure, and the runout walls are marched only on the network's ring"
2186 )));
2187 }
2188 }
2189 let spine_walls: Vec<(u64, Option<String>)> = spines
2191 .iter()
2192 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
2193 .collect();
2194 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
2195 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
2196 heal_edge_vertex_gaps(&mut result, radius)?;
2197 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
2198 let issues = result.validate();
2199 if !issues.is_empty() {
2200 return Err(KernelRefusal::new(
2201 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2202 KernelStage::Validate,
2203 format!("the runout walls do not validate: {issues:?}"),
2204 ));
2205 }
2206 check_blend_interference(&ring, &result, &spine_ids, entry)?;
2207 check_loop_self_crossings(&result, entry)?;
2208 check_blend_wall_loops(&result, entry)?;
2209 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2210 let accepted = crate::accept_sound(result, entry)?;
2211 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
2213 Ok(accepted)
2215}
2216
2217thread_local! {
2219 static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2224}
2225fn check_mixed_corner_convexity(
2228 solid: &BrepSolid,
2229 edge_ids: &[u64],
2230 edge_points: &[Vec3],
2231 edge_names: Option<&[String]>,
2232 radius: f64,
2233 name: Option<&str>,
2234 composition_error: Option<&KernelRefusal>,
2235) -> Result<(), KernelRefusal> {
2236 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
2237 Some(corner) => {
2238 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
2244 Ok(plan) => Some(plan),
2245 Err(error) => {
2246 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2247 eprintln!("runout not measured: {error}");
2248 }
2249 None
2250 }
2251 };
2252 let mut message = mixed_corner_message(
2255 &corner,
2256 edge_points,
2257 edge_names,
2258 name,
2259 measured.as_ref(),
2260 );
2261 if let Some(error) = composition_error {
2262 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
2263 }
2264 Err(KernelRefusal::unsupported(
2265 KernelStage::Classify,
2266 "mixed_corner_convexity",
2267 message,
2268 ))
2269 }
2270 None => Ok(()),
2271 }
2272}
2273
2274fn check_degenerate_corner_setbacks(
2277 solid: &BrepSolid,
2278 edge_ids: &[u64],
2279 edge_points: &[Vec3],
2280 edge_names: Option<&[String]>,
2281 radius: f64,
2282 name: Option<&str>,
2283) -> Result<(), KernelRefusal> {
2284 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
2285 return Ok(());
2286 };
2287 let [from, to] = setback.stops;
2288 let how = if setback.crossed {
2289 format!(
2290 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
2291 setback.strip
2292 )
2293 } else {
2294 format!(
2295 "meet there ({:.3e} apart against a bar of {:.3e})",
2296 setback.strip, setback.bar
2297 )
2298 };
2299 Err(KernelRefusal::input(
2302 KernelStage::Classify,
2303 "degenerate_corner_setbacks",
2304 format!(
2305 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
2306 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
2307 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
2308 on are consumed whole. The radius is at or past the degenerate limit for this \
2309 selection; reduce it.",
2310 selection_label(setback.edge, edge_points, edge_names, name),
2311 setback.face_id,
2312 from.x,
2313 from.y,
2314 from.z,
2315 to.x,
2316 to.y,
2317 to.z,
2318 ),
2319 ))
2320}
2321
2322fn selection_label(
2326 index: usize,
2327 edge_points: &[Vec3],
2328 edge_names: Option<&[String]>,
2329 name: Option<&str>,
2330) -> String {
2331 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
2332 Some(base) => composed
2333 .strip_prefix(&format!("{base}:"))
2334 .unwrap_or(composed)
2335 .to_string(),
2336 None => composed.clone(),
2337 });
2338 named.unwrap_or_else(|| match edge_points.get(index) {
2339 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
2340 None => format!("selection #{}", index + 1),
2341 })
2342}
2343
2344fn mixed_corner_message(
2348 corner: &crate::blend::MixedCorner,
2349 edge_points: &[Vec3],
2350 edge_names: Option<&[String]>,
2351 name: Option<&str>,
2352 measured: Option<&crate::blend::RunoutPlan>,
2353) -> String {
2354 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
2355 let list = |indices: &[usize]| -> String {
2356 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
2357 match parts.split_last() {
2358 None => "none".to_string(),
2359 Some((last, [])) => last.clone(),
2360 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
2361 }
2362 };
2363 let mut message = format!(
2364 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
2365 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
2366 sides at once, so that corner has no closure: the convex blend runs out against the \
2367 concave ones partway along the edge, and this kernel does not build that runout yet. \
2368 Blending the concave edges in one fillet and the convex ones in another does not reach \
2369 a wall on every one of them either: the convex wall comes out at FULL length in either \
2370 order, and its contact leaves the faces it runs along where their own loops would then \
2371 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
2372 whose wall runs out into them; blend the convex ones first and the subset search drops \
2373 the concave edges it cannot compose instead, leaving those unfilleted.",
2374 corner.point.x,
2375 corner.point.y,
2376 corner.point.z,
2377 list(&corner.convex),
2378 list(&corner.concave),
2379 );
2380 if let Some(plan) = measured {
2385 message.push_str(&format!(
2386 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
2387 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
2388 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
2389 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
2390 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
2391 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
2392 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
2393 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
2394 the bar a march is refused at is a full span.",
2395 plan.convex_edge,
2396 plan.shared_face,
2397 plan.station.s,
2398 plan.station.center.x,
2399 plan.station.center.y,
2400 plan.station.center.z,
2401 plan.replaced_mate,
2402 plan.second_carrier,
2403 plan.station.stripe_residual,
2404 plan.second_carrier,
2405 plan.marched,
2406 plan.fit_residual,
2407 plan.carrier_excursion,
2408 ));
2409 match (&plan.stop, plan.first_carrier) {
2410 (Some(stop), Some(first)) => message.push_str(&format!(
2411 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
2412 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
2413 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
2414 stop.s, plan.kept_mate, first, stop.stripe_residual,
2415 )),
2416 _ => message.push_str(&format!(
2417 " Where the kept mate (face {}) runs out was not measurable on this selection.",
2418 plan.kept_mate
2419 )),
2420 }
2421 if let Some(pole) = plan.pole {
2422 message.push_str(&format!(
2423 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
2424 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
2425 section shrinks to a point. Two carrier switches and a degenerate terminus is \
2426 more than a stop station on a stripe, and none of it is composed yet.",
2427 pole.x, pole.y, pole.z, plan.shared_face,
2428 ));
2429 }
2430 }
2431 message
2432}
2433
2434fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
2436 let mut out = Vec::new();
2437 if k == 0 || k > n {
2438 return out;
2439 }
2440 let mut idx: Vec<usize> = (0..k).collect();
2441 loop {
2442 out.push(idx.clone());
2443 let mut i = k;
2445 loop {
2446 if i == 0 {
2447 return out;
2448 }
2449 i -= 1;
2450 if idx[i] != i + n - k {
2451 break;
2452 }
2453 }
2454 idx[i] += 1;
2455 for j in (i + 1)..k {
2456 idx[j] = idx[j - 1] + 1;
2457 }
2458 }
2459}
2460
2461thread_local! {
2462 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
2468 const { std::cell::Cell::new(0) };
2469}
2470
2471fn fillet_edges_group(
2488 solid: &BrepSolid,
2489 edge_points: &[Vec3],
2490 edge_names: Option<&[String]>,
2491 radius: f64,
2492 chamfer: bool,
2493 name: Option<&str>,
2494) -> Result<BrepSolid, KernelRefusal> {
2495 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
2496}
2497
2498thread_local! {
2499 static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
2506}
2507
2508struct SubsetDropCap(Option<usize>);
2510
2511impl SubsetDropCap {
2512 fn set(cap: Option<usize>) -> Self {
2513 Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
2514 }
2515}
2516
2517impl Drop for SubsetDropCap {
2518 fn drop(&mut self) {
2519 SUBSET_DROP_CAP.with(|current| current.set(self.0));
2520 }
2521}
2522
2523thread_local! {
2524 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2527 static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2530 static GROUP_NETWORK_FOLD: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2534}
2535
2536type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
2539
2540type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
2547
2548#[derive(Default)]
2576struct ComponentMemo {
2577 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
2578 prefix: Vec<ComponentKey>,
2581 sequential: std::collections::HashMap<
2582 (Vec<ComponentKey>, Vec<SequentialKey>),
2583 Result<BrepSolid, KernelRefusal>,
2584 >,
2585}
2586
2587fn fillet_edges_group_in(
2592 solid: &BrepSolid,
2593 edge_points: &[Vec3],
2594 edge_names: Option<&[String]>,
2595 radius: f64,
2596 chamfer: bool,
2597 name: Option<&str>,
2598 memo: Option<&mut ComponentMemo>,
2599) -> Result<BrepSolid, KernelRefusal> {
2600 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
2601}
2602
2603fn fillet_edges_group_with_corner_policy(
2604 solid: &BrepSolid,
2605 edge_points: &[Vec3],
2606 edge_names: Option<&[String]>,
2607 radius: f64,
2608 chamfer: bool,
2609 name: Option<&str>,
2610 memo: Option<&mut ComponentMemo>,
2611 wrap_concave: bool,
2612 accept: bool,
2613) -> Result<BrepSolid, KernelRefusal> {
2614 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
2615 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2616 let GroupBody { body: result, bowtie_clean } =
2617 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
2618 if !accept {
2619 return Ok(result);
2620 }
2621 if bowtie_clean {
2626 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
2627 } else {
2628 check_loop_self_crossings(&result, entry)?;
2629 check_blend_wall_loops(&result, entry)?;
2630 }
2631 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
2632 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2641 let accepted = crate::accept_sound(result, entry);
2642 soundness.stop();
2643 accepted
2644}
2645
2646struct GroupBody {
2651 body: BrepSolid,
2652 bowtie_clean: bool,
2653}
2654
2655fn fillet_edges_group_unchecked(
2662 solid: &BrepSolid,
2663 edge_points: &[Vec3],
2664 edge_names: Option<&[String]>,
2665 radius: f64,
2666 chamfer: bool,
2667 name: Option<&str>,
2668 memo: Option<&mut ComponentMemo>,
2669 wrap_concave: bool,
2670) -> Result<GroupBody, KernelRefusal> {
2671 let outer = GROUP_NETWORK_FOLD.with(|fold| fold.borrow_mut().take());
2672 let built = fillet_edges_group_lanes(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave);
2673 let fold = GROUP_NETWORK_FOLD.with(|fold| std::mem::replace(&mut *fold.borrow_mut(), outer));
2674 match (built, fold) {
2675 (Err(error), Some(fold)) if !crate::blend::is_wall_fold(&error) => Err(fold),
2676 (built, _) => built,
2677 }
2678}
2679
2680fn fillet_edges_group_lanes(
2681 solid: &BrepSolid,
2682 edge_points: &[Vec3],
2683 edge_names: Option<&[String]>,
2684 radius: f64,
2685 chamfer: bool,
2686 name: Option<&str>,
2687 mut memo: Option<&mut ComponentMemo>,
2688 wrap_concave: bool,
2689) -> Result<GroupBody, KernelRefusal> {
2690 use rustc_hash::FxHashSet as HashSet;
2691
2692 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
2699 let mut healed_input = solid.clone();
2700 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
2701 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
2702 let solid = &healed_input;
2703 heal.stop();
2704 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
2705
2706 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2709 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
2712 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
2713 for (i, point) in edge_points.iter().enumerate() {
2714 let edge_id = resolve_edge_by_point(solid, *point)?;
2715 let edge = solid
2716 .edges
2717 .iter()
2718 .find(|e| e.id == edge_id)
2719 .ok_or_else(|| {
2720 KernelRefusal::internal(
2721 KernelStage::Collect,
2722 "resolved_edge",
2723 "fillet_edges: resolved edge vanished",
2724 )
2725 })?;
2726 let (start, end) = (
2727 edge.curve
2728 .evaluate(edge.t0)
2729 .or_refuse(KernelStage::Collect, "edge_start")?,
2730 edge.curve
2731 .evaluate(edge.t1)
2732 .or_refuse(KernelStage::Collect, "edge_end")?,
2733 );
2734 endpoints.push((start, i));
2735 endpoints.push((end, i));
2736 original_extents.push((start, end));
2737 selected_edge_ids.push(edge_id);
2738 }
2739 let mut corners: Vec<Vec3> = Vec::new();
2740 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
2745 let mut chain_corner_count = 0usize;
2746 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
2747 let mut used = vec![false; endpoints.len()];
2748 for i in 0..endpoints.len() {
2749 if used[i] {
2750 continue;
2751 }
2752 used[i] = true;
2753 let mut edges_here: HashSet<usize> = HashSet::default();
2754 edges_here.insert(endpoints[i].1);
2755 for j in (i + 1)..endpoints.len() {
2756 if used[j] {
2757 continue;
2758 }
2759 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
2760 used[j] = true;
2761 edges_here.insert(endpoints[j].1);
2762 }
2763 }
2764 if edges_here.len() >= 3 {
2765 corners.push(endpoints[i].0);
2766 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
2767 adjacent.sort_unstable();
2768 corner_edges.push(adjacent);
2769 } else if edges_here.len() == 2 {
2770 chain_corner_count += 1;
2771 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
2772 adjacent.sort_unstable();
2773 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
2774 }
2775 }
2776
2777 let mut component_of = vec![usize::MAX; edge_points.len()];
2790 let mut components: Vec<Vec<usize>> = Vec::new();
2791 for seed in 0..edge_points.len() {
2792 if component_of[seed] != usize::MAX {
2793 continue;
2794 }
2795 let component_index = components.len();
2796 component_of[seed] = component_index;
2797 let mut component = vec![seed];
2798 let mut cursor = 0;
2799 while cursor < component.len() {
2800 let current = component[cursor];
2801 cursor += 1;
2802 for candidate in 0..edge_points.len() {
2803 if component_of[candidate] != usize::MAX {
2804 continue;
2805 }
2806 let connected = [original_extents[current].0, original_extents[current].1]
2807 .into_iter()
2808 .any(|a| {
2809 [original_extents[candidate].0, original_extents[candidate].1]
2810 .into_iter()
2811 .any(|b| a.sub(b).length() < 1e-6)
2812 });
2813 if connected {
2814 component_of[candidate] = component_index;
2815 component.push(candidate);
2816 }
2817 }
2818 }
2819 component.sort_unstable();
2820 components.push(component);
2821 }
2822 group_analysis.stop();
2823 if components.len() > 1 {
2824 let mut separated = solid.clone();
2825 let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
2828 chain.reserve(components.len());
2829 for component in components {
2830 let points = component
2831 .iter()
2832 .map(|index| edge_points[*index])
2833 .collect::<Vec<_>>();
2834 let names = edge_names.map(|all| {
2835 component
2836 .iter()
2837 .map(|index| all[*index].clone())
2838 .collect::<Vec<_>>()
2839 });
2840 let accept_component = !crate::blend::accept_once_on();
2841 let Some(memo) = memo.as_deref_mut() else {
2842 separated = fillet_edges_group_with_corner_policy(
2843 &separated,
2844 &points,
2845 names.as_deref(),
2846 radius,
2847 chamfer,
2848 name,
2849 None,
2850 wrap_concave,
2851 accept_component,
2852 )?;
2853 continue;
2854 };
2855 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
2856 chain.push((bits.collect(), names.clone()));
2857 let built = match memo.built.get(&chain) {
2858 Some(built) => built.clone(),
2859 None => {
2860 let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
2863 let built = fillet_edges_group_with_corner_policy(
2864 &separated,
2865 &points,
2866 names.as_deref(),
2867 radius,
2868 chamfer,
2869 name,
2870 Some(&mut *memo),
2871 wrap_concave,
2872 accept_component,
2873 );
2874 memo.prefix = outer_prefix;
2875 memo.built.insert(chain.clone(), built.clone());
2876 built
2877 }
2878 };
2879 separated = built?;
2880 }
2881 return Ok(GroupBody { body: separated, bowtie_clean: false });
2882 }
2883
2884 if std::env::var("BREP_NO_NETWORK").is_err() {
2909 if selected_edge_ids.len() == 1 {
2910 let edge_id = selected_edge_ids[0];
2911 let closed = solid
2912 .edges
2913 .iter()
2914 .find(|edge| edge.id == edge_id)
2915 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
2916 if closed {
2917 let rim_name = per_edge_name(edge_names, name, 0);
2918 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
2919 Ok(mut rim) => {
2920 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2921 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
2922 let healed = heal_edge_vertex_gaps(&mut rim, radius);
2923 heal.stop();
2924 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
2933 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
2934 charts.stop();
2935 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
2936 let issues = rim.validate_memoised();
2937 validate.stop();
2938 let interference =
2939 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
2940 let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
2941 if healed.is_ok()
2942 && issues.is_empty()
2943 && interference.is_ok()
2944 && bowtie.is_ok()
2945 {
2946 return Ok(GroupBody { body: rim, bowtie_clean: true });
2947 }
2948 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2949 eprintln!(
2950 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
2951 interference={interference:?} bowtie={bowtie:?}"
2952 );
2953 }
2954 }
2955 Err(refusal) => {
2956 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2957 eprintln!("closed-rim refused: {refusal}");
2958 }
2959 }
2960 }
2961 }
2962 }
2963 let network_names: Vec<Option<String>> = (0..edge_points.len())
2964 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
2965 .collect();
2966 let network_corner_name =
2967 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
2968 let group_network = || {
2974 crate::blend::blend_star_network_with_corner_policy(
2975 solid,
2976 &selected_edge_ids,
2977 radius,
2978 chamfer,
2979 &network_names,
2980 &network_corner_name,
2981 wrap_concave,
2982 )
2983 };
2984 let network_result = group_network().or_else(|first| {
2985 if crate::blend::is_pcurve_floor_refusal(&first) {
2986 crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
2987 } else {
2988 Err(first)
2989 }
2990 });
2991 let accept_network = |mut network: BrepSolid, built_radius: f64| -> Result<Option<GroupBody>, KernelRefusal> {
2995 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3003 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3004 let healed = heal_edge_vertex_gaps(&mut network, built_radius);
3005 heal.stop();
3006 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3010 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
3011 charts.stop();
3012 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3013 let issues = network.validate_memoised();
3014 validate.stop();
3015 let interference =
3016 check_blend_interference(solid, &network, &selected_edge_ids, entry);
3017 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
3022 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
3023 return Ok(Some(GroupBody { body: network, bowtie_clean: true }));
3024 }
3025 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3026 eprintln!(
3027 "network result rejected: heal={healed:?} issues={issues:?} \
3028 interference={interference:?} bowtie={bowtie:?}"
3029 );
3030 dump_loops_debug("INPUT", solid);
3031 dump_loops_debug("RESULT", &network);
3032 }
3033 Ok(None)
3034 };
3035 match network_result {
3036 Ok(network) => {
3037 if let Some(body) = accept_network(network, radius)? {
3038 return Ok(body);
3039 }
3040 }
3041 Err(refusal) => {
3042 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3043 eprintln!("network refused: {refusal}");
3044 }
3045 let pinched_taper = matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what }
3056 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT);
3057 if !chamfer && pinched_taper && crate::blend::relief::radius_relief_on() {
3058 let labels: Vec<String> =
3059 (0..edge_points.len()).map(|index| selection_label(index, edge_points, edge_names, name)).collect();
3060 for step in 1..=10u32 {
3061 let reduced = radius * (1.0 - 0.05 * f64::from(step));
3062 let built = crate::blend::blend_star_network_with_corner_policy(
3063 solid, &selected_edge_ids, reduced, chamfer, &network_names, &network_corner_name, wrap_concave,
3064 );
3065 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3066 eprintln!(
3067 "taper pinch ladder: r {reduced:.4} -> {}",
3068 match &built {
3069 Ok(_) => "built".to_string(),
3070 Err(error) => format!("refused: {error}"),
3071 }
3072 );
3073 }
3074 match built {
3075 Ok(network) => {
3076 let Some(body) = accept_network(network, reduced)? else { break };
3077 crate::blend::record_blend_note(format!(
3078 "radius reduced over the whole of {} to {reduced:.4}: at r {radius} the round tapering to \
3079 its tip reaches the full radius only inside the corner setback at its other end, so \
3080 r {radius} is reached nowhere",
3081 labels.join(", ")
3082 ));
3083 return Ok(body);
3084 }
3085 Err(again) if matches!(&again.class, crate::RefusalClass::UnsupportedGeometry { what }
3086 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT) => continue,
3087 Err(_) => break,
3088 }
3089 }
3090 }
3091 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
3095 if crate::blend::is_wall_fold(&refusal) {
3096 GROUP_NETWORK_FOLD.with(|fold| *fold.borrow_mut() = Some(refusal.clone()));
3097 }
3098 if !chamfer && !crate::blend::is_wall_fold(&refusal) && crate::blend::relief::radius_relief_on() {
3104 let started = web_time::Instant::now();
3105 let misfit = crate::blend::relief::first_misfit(solid, &selected_edge_ids, radius);
3106 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3107 eprintln!("radius relief: misfit probe {misfit:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
3108 }
3109 if let Some((edge_id, least)) = misfit {
3110 return Err(crate::blend::relief::misfit_fold(edge_id, least, radius, &refusal));
3111 }
3112 }
3113 if crate::blend::is_rail_fold(&refusal)
3154 || crate::blend::is_marched_fit_off_carriers(&refusal)
3155 || crate::blend::is_consumed_snap(&refusal)
3156 || crate::blend::is_rail_collapse(&refusal)
3157 || crate::blend::is_full_width_collapse(&refusal)
3158 || crate::blend::is_planar_chart_refusal(&refusal)
3159 || crate::blend::is_miter_support_overrun(&refusal)
3160 || crate::blend::is_tangential_transverse_end(&refusal)
3161 {
3162 return Err(refusal);
3163 }
3164 }
3165 }
3166 }
3167
3168 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
3169 crate::blend::record_count("count.cutter_fallback", 1);
3170 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
3171 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
3198 && (chamfer || corners.is_empty())
3199 {
3200 let mut any_convex = false;
3201 let mut any_concave = false;
3202 for point in edge_points {
3203 match resolve_edge_by_point(solid, *point)
3215 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
3216 {
3217 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
3220 any_convex |= profile.any_convex;
3221 any_concave |= profile.any_concave;
3222 }
3223 _ => {
3226 any_convex = true;
3227 any_concave = true;
3228 break;
3229 }
3230 }
3231 }
3232 match (any_convex, any_concave) {
3233 (true, false) => Some(crate::BooleanOperation::Intersect),
3234 (false, true) => Some(crate::BooleanOperation::Union),
3235 _ => None,
3236 }
3237 } else {
3238 None
3239 };
3240
3241 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
3252 Lane::GeneralFirst
3253 } else {
3254 Lane::CutterFirst
3255 };
3256 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3267 let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3272 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
3273 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
3279 let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
3280 let mut sequential = solid.clone();
3281 let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
3284 for &index in &sequential_order {
3285 let point = &edge_points[index];
3286 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3287 let ends = tool_ends_to_original_extent(
3294 &sequential,
3295 edge_id,
3296 radius,
3297 original_extents[index],
3298 if chamfer { &[] } else { &corners },
3299 );
3300 let edge_name = per_edge_name(edge_names, name, index);
3305 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3306 eprintln!(
3307 "sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
3308 selection_label(index, edge_points, edge_names, name)
3309 );
3310 }
3311 chain.push((
3316 [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
3317 edge_name.map(str::to_string),
3318 ends.key_bits(),
3319 u8::from(composition_lane == Lane::CutterFirst),
3320 ));
3321 if let Some(memo) = memo.as_deref_mut() {
3322 let key = (memo.prefix.clone(), chain.clone());
3323 let hit = memo.sequential.get(&key);
3324 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3325 eprintln!(
3326 "sequential memo: {} components before, step {} {} ({} chains stored)",
3327 memo.prefix.len(),
3328 chain.len(),
3329 if hit.is_some() { "HIT" } else { "miss" },
3330 memo.sequential.len(),
3331 );
3332 }
3333 if let Some(built) = hit {
3334 crate::blend::record_count("count.sequential.memo_hits", 1);
3335 sequential = built.clone()?;
3336 continue;
3337 }
3338 }
3339 crate::blend::record_count("count.sequential.steps_built", 1);
3340 let built = fillet_or_chamfer(
3341 &sequential,
3342 edge_id,
3343 radius,
3344 chamfer,
3345 edge_name,
3346 ends,
3347 composition_lane,
3348 );
3349 if let Some(memo) = memo.as_deref_mut() {
3350 memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
3351 }
3352 sequential = built?;
3353 }
3354 Ok(sequential)
3355 };
3356
3357 let mut result = if let Some(operation) = miter_operation {
3358 let options = crate::BooleanOptions {
3362 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
3363 ..crate::BooleanOptions::default()
3364 };
3365 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
3366 for (index, point) in edge_points.iter().enumerate() {
3367 let edge_id = resolve_edge_by_point(solid, *point)?;
3368 let edge_name = per_edge_name(edge_names, name, index);
3369 let blended = {
3370 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3371 check_mixed_concavity(solid, edge_id, entry)?;
3372 check_support_extent(solid, edge_id, radius, entry)?;
3373 fillet_or_chamfer(
3374 solid,
3375 edge_id,
3376 radius,
3377 chamfer,
3378 edge_name,
3379 ToolEnds::default(),
3380 Lane::CutterFirst,
3381 )?
3382 };
3383 combined = match combined {
3384 Err(e) => Err(e),
3385 Ok(None) => Ok(Some(blended)),
3386 Ok(Some(previous)) => {
3387 crate::boolean_operation(&previous, &blended, operation, &options)
3388 .map(Some)
3389 .map_err(|error| {
3390 error.with_message(|error| {
3392 format!("fillet_edges: chain-corner miter composition failed: {error}")
3393 })
3394 })
3395 }
3396 };
3397 if combined.is_err() {
3398 break;
3399 }
3400 }
3401 match combined {
3413 Ok(Some(mitered)) => mitered,
3414 Ok(None) => {
3415 return Err(KernelRefusal::input(
3416 KernelStage::Collect,
3417 "miter_selection",
3418 "fillet_edges: no edges selected",
3419 ))
3420 }
3421 Err(miter_err) => match build_sequential() {
3422 Ok(seq) if seq.validate().is_empty() => seq,
3423 _ => return Err(miter_err),
3424 },
3425 }
3426 } else {
3427 build_sequential()?
3428 };
3429
3430 let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
3438 if !chamfer {
3440 for (corner, adjacent) in &chain_corners {
3441 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
3442 if selected_edge_ids.contains(&edge.id) {
3443 return false;
3444 }
3445 let Ok(a) = edge.curve.evaluate(edge.t0) else {
3446 return false;
3447 };
3448 let Ok(b) = edge.curve.evaluate(edge.t1) else {
3449 return false;
3450 };
3451 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3452 && analyze_edge(solid, edge.id, radius)
3453 .map(|cross| !cross.convex)
3454 .unwrap_or(false)
3455 });
3456 if !has_concave_unselected_edge {
3467 let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
3468 let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
3469 let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
3470 let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
3471 if selected_edge_ids.contains(&edge.id) {
3472 return false;
3473 }
3474 let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
3475 return false;
3476 };
3477 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3478 && analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
3479 });
3480 if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
3481 open_mixed_corners.push((
3482 *corner,
3483 [selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
3484 corner_face_name(edge_names, name, adjacent),
3485 ));
3486 }
3487 continue;
3488 }
3489 let corner_name = corner_face_name(edge_names, name, adjacent);
3491 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
3492 &result,
3493 solid,
3494 *corner,
3495 [
3496 selected_edge_ids[adjacent[0]],
3497 selected_edge_ids[adjacent[1]],
3498 ],
3499 radius,
3500 corner_name.as_deref(),
3501 ) {
3502 result = rounded;
3503 }
3504 }
3505 }
3506
3507 if !chamfer {
3513 for (ci, corner) in corners.iter().enumerate() {
3514 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
3515 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
3516 Ok(rounded) => result = rounded,
3517 Err(error) => {
3518 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3519 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
3520 }
3521 }
3522 }
3523 }
3524 }
3525
3526 heal_edge_vertex_gaps(&mut result, radius)?;
3529 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3530 let mut body = settle_cutter_scaffold(result);
3532 for (corner, pair, corner_name) in &open_mixed_corners {
3533 match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
3534 Ok(rounded) => body = rounded,
3535 Err(error) => {
3536 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3537 eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
3538 }
3539 }
3540 }
3541 }
3542 Ok(GroupBody { body, bowtie_clean: false })
3543 }
3545
3546fn concave_star_edges(
3553 solid: &BrepSolid,
3554 selected_edge_ids: &[u64],
3555 corner_edges: &[Vec<usize>],
3556 radius: f64,
3557 chamfer: bool,
3558) -> Vec<usize> {
3559 if chamfer || corner_edges.is_empty() || !concave_first_on() {
3560 return Vec::new();
3561 }
3562 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
3563 return Vec::new();
3564 };
3565 (0..selected_edge_ids.len())
3566 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
3567 .collect()
3568}
3569
3570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3577enum CutterClass {
3578 Marched,
3579 Straight,
3580 Circular,
3581}
3582
3583fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
3584 match super::analyze::analyze_edge(solid, edge_id, radius) {
3585 Ok(cross) => match cross.path {
3586 super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
3587 super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
3588 },
3589 Err(_) => CutterClass::Marched,
3590 }
3591}
3592
3593fn mixed_star_class_ranks(
3615 solid: &BrepSolid,
3616 selected_edge_ids: &[u64],
3617 corner_edges: &[Vec<usize>],
3618 radius: f64,
3619 chamfer: bool,
3620) -> Vec<u8> {
3621 let mut ranks = vec![2u8; selected_edge_ids.len()];
3622 if chamfer || corner_edges.is_empty() || !class_order_on() {
3623 return ranks;
3624 }
3625 let classes: Vec<CutterClass> = selected_edge_ids
3626 .iter()
3627 .map(|&edge_id| cutter_class(solid, edge_id, radius))
3628 .collect();
3629 for adjacent in corner_edges {
3630 let Some(&head) = adjacent.first() else {
3631 continue;
3632 };
3633 if adjacent.iter().all(|&index| classes[index] == classes[head]) {
3634 continue;
3635 }
3636 for &index in adjacent {
3637 match classes[index] {
3638 CutterClass::Marched => ranks[index] = 1,
3639 CutterClass::Circular => ranks[index] = 3,
3640 CutterClass::Straight => {}
3641 }
3642 }
3643 }
3644 ranks
3645}
3646
3647pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
3654 let mut order: Vec<usize> = (0..count).collect();
3655 order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
3657 order
3658}
3659
3660fn class_order_on() -> bool {
3664 true
3665}
3666
3667
3668fn corners_concave_first(
3672 corners: Vec<Vec3>,
3673 corner_edges: Vec<Vec<usize>>,
3674 first: &[usize],
3675) -> (Vec<Vec3>, Vec<Vec<usize>>) {
3676 if first.is_empty() {
3677 return (corners, corner_edges);
3678 }
3679 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
3680 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
3681 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
3682 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
3683 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
3684 ordered.into_iter().unzip()
3685}
3686
3687fn concave_first_on() -> bool {
3690 true
3691}
3692
3693
3694pub fn fillet_edges_variable(
3702 solid: &BrepSolid,
3703 edge_points: &[Vec3],
3704 edge_names: Option<&[String]>,
3705 radii: &[(f64, f64)],
3706 chamfer: bool,
3707 name: Option<&str>,
3708) -> Result<BrepSolid, KernelRefusal> {
3709 let _operation = crate::blend::BlendOperation::enter();
3712 if edge_points.is_empty() {
3713 return Err(KernelRefusal::input(
3714 KernelStage::Collect,
3715 "variable_selection",
3716 "fillet_edges_variable: no edges selected",
3717 ));
3718 }
3719 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
3720 fillet_edges_variable_impl(
3721 solid,
3722 edge_points,
3723 edge_names,
3724 &per_edge_stops,
3725 chamfer,
3726 name,
3727 "fillet_edges_variable",
3728 true,
3729 )
3730}
3731
3732#[allow(clippy::too_many_arguments)]
3744fn fillet_edges_variable_impl(
3745 solid: &BrepSolid,
3746 edge_points: &[Vec3],
3747 edge_names: Option<&[String]>,
3748 per_edge_stops: &[Vec<(f64, f64)>],
3749 chamfer: bool,
3750 name: Option<&str>,
3751 entry: &str,
3752 allow_tapered_sequential: bool,
3753) -> Result<BrepSolid, KernelRefusal> {
3754 let bounds = crate::recentre::solid_bounds(solid);
3757 let translated = match crate::recentre::control_offset_at_entry() {
3758 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
3759 None => crate::recentre::guard_offset(bounds).map(|offset| {
3760 crate::recentre::profile_fired(entry, offset, bounds);
3761 (offset, crate::recentre::EntryKind::Guard)
3762 }),
3763 };
3764 if let Some((offset, kind)) = translated {
3765 let _entry = crate::recentre::TranslatedEntry::open(kind);
3766 let translate = |solid: &BrepSolid, offset: Vec3| {
3767 crate::recentre::translate_solid(solid, offset).map_err(|message| {
3768 KernelRefusal::internal(
3769 KernelStage::Collect,
3770 "translation_control",
3771 format!("{entry}: the translation control could not move the solid: {message}"),
3772 )
3773 })
3774 };
3775 let moved = translate(solid, offset)?;
3776 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
3777 let result = fillet_edges_variable_impl(
3778 &moved,
3779 &points,
3780 edge_names,
3781 per_edge_stops,
3782 chamfer,
3783 name,
3784 entry,
3785 allow_tapered_sequential,
3786 )?;
3787 return translate(&result, offset.scale(-1.0));
3788 }
3789 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
3790 if let Some(constant) = per_edge_stops
3794 .first()
3795 .and_then(|stops| stops.first())
3796 .map(|(_, radius)| *radius)
3797 {
3798 let uniform = per_edge_stops.iter().all(|stops| {
3799 stops
3800 .iter()
3801 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
3802 });
3803 if uniform && constant > 0.0 {
3804 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
3805 }
3806 }
3807 let max_radius = per_edge_stops
3808 .iter()
3809 .flat_map(|stops| stops.iter())
3810 .map(|(_, r)| r.abs())
3811 .fold(0.0_f64, f64::max);
3812
3813 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
3818 match fillet_variable_network(
3819 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
3820 )? {
3821 Ok(built) => return Ok(built),
3822 Err(refusal) if crate::blend::relief::relieving() => return Err(refusal),
3826 Err(_) => {}
3827 }
3828 }
3829
3830 let mut chain_corner = false;
3837 {
3838 use rustc_hash::FxHashSet as HashSet;
3839 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
3840 for (i, point) in edge_points.iter().enumerate() {
3841 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
3842 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
3843 if let (Ok(a), Ok(b)) =
3844 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
3845 {
3846 endpoints.push((a, i));
3847 endpoints.push((b, i));
3848 }
3849 }
3850 }
3851 }
3852 let mut used = vec![false; endpoints.len()];
3853 for i in 0..endpoints.len() {
3854 if used[i] {
3855 continue;
3856 }
3857 used[i] = true;
3858 let mut edges_here: HashSet<usize> = HashSet::default();
3859 edges_here.insert(endpoints[i].1);
3860 for j in (i + 1)..endpoints.len() {
3861 if used[j] {
3862 continue;
3863 }
3864 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
3865 used[j] = true;
3866 edges_here.insert(endpoints[j].1);
3867 }
3868 }
3869 if edges_here.len() == 2 {
3870 chain_corner = true;
3871 }
3872 }
3873 }
3874 let miter_operation = if chain_corner {
3875 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
3876 let mut any_convex = false;
3877 let mut any_concave = false;
3878 for point in edge_points {
3879 match resolve_edge_by_point(solid, *point)
3880 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
3881 {
3882 Ok(cross) if cross.convex => any_convex = true,
3883 Ok(_) => any_concave = true,
3884 Err(_) => {
3885 any_convex = true;
3886 any_concave = true;
3887 break;
3888 }
3889 }
3890 }
3891 match (any_convex, any_concave) {
3892 (true, false) => Some(crate::BooleanOperation::Intersect),
3893 (false, true) => Some(crate::BooleanOperation::Union),
3894 _ => None,
3895 }
3896 } else {
3897 None
3898 };
3899
3900 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
3907 let options = crate::BooleanOptions::default();
3908 let mut combined: Option<BrepSolid> = None;
3909 let mut failed = false;
3910 for (index, point) in edge_points.iter().enumerate() {
3911 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
3912 failed = true;
3913 break;
3914 };
3915 let edge_name = per_edge_name(edge_names, name, index);
3916 let Ok(blended) = crate::blend::blend_edge_variable(
3917 solid,
3918 edge_id,
3919 &per_edge_stops[index],
3920 chamfer,
3921 edge_name,
3922 ) else {
3923 failed = true;
3924 break;
3925 };
3926 let next = match combined.take() {
3927 None => blended,
3928 Some(previous) => {
3929 match crate::boolean_operation(&previous, &blended, operation, &options) {
3930 Ok(next) => next,
3931 Err(_) => {
3932 failed = true;
3933 break;
3934 }
3935 }
3936 }
3937 };
3938 combined = Some(next);
3939 }
3940 if failed {
3941 None
3942 } else {
3943 combined.filter(|s| s.validate().is_empty())
3944 }
3945 } else {
3946 None
3947 };
3948 let mut result = match miter_attempt {
3949 Some(mitered) => mitered,
3950 None => {
3951 let tapered = per_edge_stops.iter().any(|stops| {
3957 stops.first().is_some_and(|(_, first)| {
3958 stops
3959 .iter()
3960 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
3961 })
3962 });
3963 if chain_corner && tapered && !allow_tapered_sequential {
3964 return Err(KernelRefusal::unsupported(
3967 KernelStage::Sew,
3968 "tapered_chain_miter",
3969 format!(
3970 "{entry}: the tapered blends across the selected chain's shared corners \
3971 did not compose to a valid solid (the fitted blend boundaries could not \
3972 be mitered); fillet fewer edges per operation or reduce the taper"
3973 ),
3974 ));
3975 }
3976 let mut sequential = solid.clone();
3977 for (index, point) in edge_points.iter().enumerate() {
3978 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3979 let edge_name = per_edge_name(edge_names, name, index);
3980 sequential = crate::blend::blend_edge_variable(
3981 &sequential,
3982 edge_id,
3983 &per_edge_stops[index],
3984 chamfer,
3985 edge_name,
3986 )?;
3987 }
3988 sequential
3989 }
3990 };
3991 heal_edge_vertex_gaps(&mut result, max_radius)?;
3994 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3995 let issues = result.validate();
4000 if !issues.is_empty() {
4001 let (detail, class) = if allow_tapered_sequential {
4006 (
4007 "tapered blends meet at a shared chain vertex with \
4008 mismatched radii — the radius-transition corner patch is not implemented; \
4009 fillet the edges in separate operations or use matching stop radii",
4010 RefusalClass::UnsupportedGeometry {
4011 what: "taper_transition_patch".into(),
4012 },
4013 )
4014 } else {
4015 (
4016 "the composed radius-law blend produced invalid topology — the blends \
4017 across a shared chain corner failed to reassemble",
4018 RefusalClass::Internal {
4019 what: "law_chain_topology".into(),
4020 },
4021 )
4022 };
4023 return Err(KernelRefusal::new(
4024 class,
4025 KernelStage::Validate,
4026 format!(
4027 "{entry}: {detail} ({} validation issues, first: {})",
4028 issues.len(),
4029 issues
4030 .first()
4031 .map(|issue| issue.message.clone())
4032 .unwrap_or_default()
4033 ),
4034 ));
4035 }
4036 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
4041 crate::accept_sound(result, entry)
4042}
4043
4044#[allow(clippy::too_many_arguments)]
4049fn fillet_variable_network(
4050 solid: &BrepSolid,
4051 edge_points: &[Vec3],
4052 edge_names: Option<&[String]>,
4053 per_edge_stops: &[Vec<(f64, f64)>],
4054 chamfer: bool,
4055 name: Option<&str>,
4056 entry: &str,
4057 max_radius: f64,
4058) -> Result<Result<BrepSolid, KernelRefusal>, KernelRefusal> {
4059 let mut edge_ids = Vec::with_capacity(edge_points.len());
4060 for point in edge_points {
4061 edge_ids.push(resolve_edge_by_point(solid, *point)?);
4062 }
4063 let laws: Vec<crate::blend::StripeRadius> =
4064 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
4065 let network_names: Vec<Option<String>> = (0..edge_points.len())
4066 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
4067 .collect();
4068 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
4069 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
4070 let law_network = || {
4079 crate::blend::blend_star_network_laws(solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true)
4080 };
4081 let network_result = law_network().or_else(|first| {
4082 if crate::blend::is_pcurve_floor_refusal(&first) {
4083 crate::blend::with_pcurve_knot_retry(law_network).map_err(|_| first)
4084 } else {
4085 Err(first)
4086 }
4087 });
4088 let mut network = match network_result {
4089 Ok(network) => network,
4090 Err(refusal) => {
4091 if debug {
4092 eprintln!("law network refused: {refusal}");
4093 }
4094 return Ok(Err(refusal));
4095 }
4096 };
4097 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
4102 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
4103 let issues = network.validate();
4104 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
4105 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
4106 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
4107 crate::soundness::census::profile_operation(&network, &[solid], None, entry);
4108 match crate::accept_sound(network, entry) {
4109 Ok(sound) => return Ok(Ok(sound)),
4110 Err(refusal) => {
4111 if debug {
4112 eprintln!("law network result unsound: {refusal}");
4113 }
4114 return Ok(Err(refusal));
4115 }
4116 }
4117 }
4118 if debug {
4119 eprintln!(
4120 "law network result rejected: heal={healed:?} issues={issues:?} \
4121 interference={interference:?} bowtie={bowtie:?}"
4122 );
4123 }
4124 Ok(Err(KernelRefusal::unsupported(
4125 KernelStage::Validate,
4126 "law_network_rejected",
4127 format!(
4128 "{entry}: the tapered network's result was rejected (heal {}, {} validation issues, interference {}, loops {})",
4129 if healed.is_ok() { "ok" } else { "failed" },
4130 issues.len(),
4131 if interference.is_ok() { "ok" } else { "found" },
4132 if bowtie.is_ok() { "ok" } else { "found" },
4133 ),
4134 )))
4135}
4136
4137pub(crate) fn fillet_edges_variable_stops(
4138 solid: &BrepSolid,
4139 edge_points: &[Vec3],
4140 edge_names: Option<&[String]>,
4141 per_edge_stops: &[Vec<(f64, f64)>],
4142 chamfer: bool,
4143 name: Option<&str>,
4144) -> Result<BrepSolid, KernelRefusal> {
4145 let _operation = crate::blend::BlendOperation::enter();
4146 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
4147 return Err(KernelRefusal::input(
4148 KernelStage::Collect,
4149 "variable_selection",
4150 "fillet_edges_variable_stops: one stop list per selected edge is required",
4151 ));
4152 }
4153 fillet_edges_variable_impl(
4154 solid,
4155 edge_points,
4156 edge_names,
4157 per_edge_stops,
4158 chamfer,
4159 name,
4160 "fillet_edges_variable_stops",
4161 false,
4162 )
4163}
4164
4165struct ChainLink {
4169 input_index: usize,
4171 forward: bool,
4173 arc: Vec<(f64, f64)>,
4176 length: f64,
4178 abscissa: f64,
4181}
4182
4183fn edge_arc_table(
4194 curve: &NurbsCurve,
4195 t0: f64,
4196 t1: f64,
4197 tol: f64,
4198) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4199 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
4200 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4201 let mut table = Vec::with_capacity(n + 1);
4202 let mut cumulative = 0.0;
4203 let mut previous = evaluate(t0)?;
4204 table.push((0.0, 0.0));
4205 for j in 1..=n {
4206 let fraction = j as f64 / n as f64;
4207 let point = evaluate(t0 + (t1 - t0) * fraction)?;
4208 cumulative += point.sub(previous).length();
4209 previous = point;
4210 table.push((fraction, cumulative));
4211 }
4212 Ok((table, cumulative))
4213 };
4214 let mut n = 16usize;
4215 let (mut table, mut length) = build(n)?;
4216 while n < 4096 {
4217 n *= 2;
4218 let (next_table, next_length) = build(n)?;
4219 let converged = (next_length - length).abs() <= tol;
4220 table = next_table;
4221 length = next_length;
4222 if converged {
4223 break;
4224 }
4225 }
4226 Ok((table, length))
4227}
4228
4229fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
4232 let fraction = fraction.clamp(0.0, 1.0);
4233 let intervals = table.len() - 1;
4234 let scaled = fraction * intervals as f64;
4235 let index = (scaled.floor() as usize).min(intervals - 1);
4236 let local = scaled - index as f64;
4237 let (_, a) = table[index];
4238 let (_, b) = table[index + 1];
4239 a + (b - a) * local
4240}
4241
4242fn resolve_selected_chain(
4251 solid: &BrepSolid,
4252 edge_points: &[Vec3],
4253 entry: &str,
4254 tol: f64,
4255) -> Result<Vec<ChainLink>, KernelRefusal> {
4256 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
4259
4260 struct Resolved {
4261 edge_id: u64,
4262 start: Vec3,
4263 end: Vec3,
4264 arc: Vec<(f64, f64)>,
4265 length: f64,
4266 }
4267 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
4268 for point in edge_points {
4269 let edge_id = resolve_edge_by_point(solid, *point)?;
4270 if resolved.iter().any(|r| r.edge_id == edge_id) {
4271 return Err(KernelRefusal::input(
4272 KernelStage::Collect,
4273 "duplicate_edge",
4274 format!("{entry}: the same edge was selected more than once"),
4275 ));
4276 }
4277 let edge = solid
4278 .edges
4279 .iter()
4280 .find(|e| e.id == edge_id)
4281 .ok_or_else(|| {
4282 KernelRefusal::internal(
4283 KernelStage::Collect,
4284 "chain_resolved_edge",
4285 format!("{entry}: resolved edge vanished"),
4286 )
4287 })?;
4288 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
4289 if !(length > 0.0) {
4290 return Err(KernelRefusal::input(
4291 KernelStage::Collect,
4292 "zero_length_edge",
4293 format!("{entry}: selected edge has zero length"),
4294 ));
4295 }
4296 resolved.push(Resolved {
4297 edge_id,
4298 start: edge
4299 .curve
4300 .evaluate(edge.t0)
4301 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
4302 end: edge
4303 .curve
4304 .evaluate(edge.t1)
4305 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
4306 arc,
4307 length,
4308 });
4309 }
4310 let n = resolved.len();
4311 if n == 1 {
4312 let only = resolved.remove(0);
4313 return Ok(vec![ChainLink {
4314 input_index: 0,
4315 forward: true,
4316 arc: only.arc,
4317 length: only.length,
4318 abscissa: 0.0,
4319 }]);
4320 }
4321
4322 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
4324 for (i, r) in resolved.iter().enumerate() {
4325 for (point, is_start) in [(r.start, true), (r.end, false)] {
4326 match clusters
4327 .iter_mut()
4328 .find(|(anchor, _)| anchor.sub(point).length() < band)
4329 {
4330 Some((_, members)) => members.push((i, is_start)),
4331 None => clusters.push((point, vec![(i, is_start)])),
4332 }
4333 }
4334 }
4335 if clusters.iter().any(|(_, members)| members.len() > 2) {
4336 return Err(KernelRefusal::input(
4337 KernelStage::Collect,
4338 "chain_branching",
4339 format!(
4340 "{entry}: selected edges must form one open chain \
4341 (a vertex is shared by three or more selected edges)"
4342 ),
4343 ));
4344 }
4345 let free: Vec<usize> = clusters
4346 .iter()
4347 .enumerate()
4348 .filter(|(_, (_, members))| members.len() == 1)
4349 .map(|(c, _)| c)
4350 .collect();
4351 if free.len() != 2 {
4352 return Err(KernelRefusal::input(
4353 KernelStage::Collect,
4354 "chain_open",
4355 format!(
4356 "{entry}: selected edges must form one OPEN chain \
4357 (closed rings and disconnected selections are not supported)"
4358 ),
4359 ));
4360 }
4361 let start_cluster = *free
4363 .iter()
4364 .min_by_key(|&&c| clusters[c].1[0].0)
4365 .expect("two free ends");
4366
4367 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
4369 let mut visited = vec![false; n];
4370 let mut abscissa = 0.0_f64;
4371 let mut cluster = start_cluster;
4372 for _ in 0..n {
4373 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
4374 .1
4375 .iter()
4376 .find(|(edge_index, _)| !visited[*edge_index])
4377 else {
4378 return Err(KernelRefusal::input(
4379 KernelStage::Collect,
4380 "chain_walk",
4381 format!(
4382 "{entry}: selected edges are not connected into one chain"
4383 ),
4384 ));
4385 };
4386 visited[edge_index] = true;
4387 let r = &resolved[edge_index];
4388 links.push(ChainLink {
4389 input_index: edge_index,
4390 forward: entered_at_start,
4391 arc: r.arc.clone(),
4392 length: r.length,
4393 abscissa,
4394 });
4395 abscissa += r.length;
4396 let exit_point = if entered_at_start { r.end } else { r.start };
4397 cluster = clusters
4398 .iter()
4399 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
4400 .ok_or_else(|| {
4401 KernelRefusal::internal(
4402 KernelStage::Collect,
4403 "chain_cluster",
4404 format!("{entry}: chain walk lost an endpoint cluster"),
4405 )
4406 })?;
4407 }
4408 if visited.iter().any(|v| !v) {
4409 return Err(KernelRefusal::input(
4410 KernelStage::Collect,
4411 "chain_connected",
4412 format!(
4413 "{entry}: selected edges are not connected into one chain"
4414 ),
4415 ));
4416 }
4417 Ok(links)
4418}
4419
4420fn law_stops_for_link(
4439 link: &ChainLink,
4440 law: &crate::law::RadiusLaw,
4441 scale: f64,
4442 tol: f64,
4443) -> Vec<(f64, f64)> {
4444 let radius_at_fraction = |fraction: f64| -> f64 {
4445 let arc = arc_length_at_fraction(&link.arc, fraction);
4446 let chain_s = if link.forward {
4447 link.abscissa + arc
4448 } else {
4449 link.abscissa + (link.length - arc)
4450 };
4451 law.radius_at(chain_s * scale)
4452 };
4453 let curvature = law
4456 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
4457 * (scale * link.length).powi(2);
4458 let base = if curvature * 0.125 <= tol {
4459 1usize
4460 } else {
4461 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
4462 };
4463 let mut stops: Vec<(f64, f64)> = (0..=base)
4464 .map(|j| {
4465 let fraction = j as f64 / base as f64;
4466 (fraction, radius_at_fraction(fraction))
4467 })
4468 .collect();
4469 let mut depth = 0usize;
4471 while depth < 8 {
4472 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
4473 let mut inserted = false;
4474 for pair in stops.windows(2) {
4475 refined.push(pair[0]);
4476 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
4477 let law_mid = radius_at_fraction(mid_fraction);
4478 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
4479 if (law_mid - linear_mid).abs() > tol {
4480 refined.push((mid_fraction, law_mid));
4481 inserted = true;
4482 }
4483 }
4484 refined.push(*stops.last().expect("at least two stops"));
4485 stops = refined;
4486 if !inserted {
4487 break;
4488 }
4489 depth += 1;
4490 }
4491 stops
4492}
4493
4494pub fn fillet_edges_variable_law(
4506 solid: &BrepSolid,
4507 edge_points: &[Vec3],
4508 edge_names: Option<&[String]>,
4509 law: &crate::law::RadiusLaw,
4510 chamfer: bool,
4511 name: Option<&str>,
4512) -> Result<BrepSolid, KernelRefusal> {
4513 let _operation = crate::blend::BlendOperation::enter();
4516 const ENTRY: &str = "fillet_edges_variable_law";
4517 if edge_points.is_empty() {
4518 return Err(KernelRefusal::input(
4519 KernelStage::Collect,
4520 "law_selection",
4521 format!("{ENTRY}: no edges selected"),
4522 ));
4523 }
4524 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4525 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4526 fillet_variable_law_on_chain(
4527 solid,
4528 edge_points,
4529 edge_names,
4530 &chain,
4531 law,
4532 chamfer,
4533 name,
4534 ENTRY,
4535 tolerances.intersection_fit,
4536 )
4537}
4538
4539pub fn fillet_edges_variable_vertex_radii(
4545 solid: &BrepSolid,
4546 edge_points: &[Vec3],
4547 edge_names: Option<&[String]>,
4548 vertex_radii: &[f64],
4549 chamfer: bool,
4550 name: Option<&str>,
4551) -> Result<BrepSolid, KernelRefusal> {
4552 let _operation = crate::blend::BlendOperation::enter();
4555 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
4556 if edge_points.is_empty() {
4557 return Err(KernelRefusal::input(
4558 KernelStage::Collect,
4559 "vertex_radii_selection",
4560 format!("{ENTRY}: no edges selected"),
4561 ));
4562 }
4563 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4564 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4565 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
4566 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
4567 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
4568 fillet_variable_law_on_chain(
4569 solid,
4570 edge_points,
4571 edge_names,
4572 &chain,
4573 &law,
4574 chamfer,
4575 name,
4576 ENTRY,
4577 tolerances.intersection_fit,
4578 )
4579}
4580
4581#[allow(clippy::too_many_arguments)]
4585fn fillet_variable_law_on_chain(
4586 solid: &BrepSolid,
4587 edge_points: &[Vec3],
4588 edge_names: Option<&[String]>,
4589 chain: &[ChainLink],
4590 law: &crate::law::RadiusLaw,
4591 chamfer: bool,
4592 name: Option<&str>,
4593 entry: &str,
4594 tol: f64,
4595) -> Result<BrepSolid, KernelRefusal> {
4596 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
4597 let scale = law.total_length() / chain_length;
4598 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
4599 for link in chain {
4600 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
4601 }
4602 fillet_edges_variable_impl(
4603 solid,
4604 edge_points,
4605 edge_names,
4606 &per_edge_stops,
4607 chamfer,
4608 name,
4609 entry,
4610 false,
4611 )
4612}
4613
4614pub fn chamfer_edges_asymmetric(
4619 solid: &BrepSolid,
4620 edge_points: &[Vec3],
4621 edge_names: Option<&[String]>,
4622 d1: f64,
4623 d2: f64,
4624 name: Option<&str>,
4625) -> Result<BrepSolid, KernelRefusal> {
4626 let _operation = crate::blend::BlendOperation::enter();
4629 if edge_points.is_empty() {
4630 return Err(KernelRefusal::input(
4631 KernelStage::Collect,
4632 "asymmetric_selection",
4633 "chamfer_edges_asymmetric: no edges selected",
4634 ));
4635 }
4636 let mut result = solid.clone();
4637 for (index, point) in edge_points.iter().enumerate() {
4641 let edge_id = resolve_edge_by_point(&result, *point)?;
4642 let edge_name = per_edge_name(edge_names, name, index);
4643 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
4644 }
4645 refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
4646 Ok(result)
4647}
4648
4649pub fn chamfer_edges_angle(
4653 solid: &BrepSolid,
4654 edge_points: &[Vec3],
4655 edge_names: Option<&[String]>,
4656 d1: f64,
4657 angle_rad: f64,
4658 name: Option<&str>,
4659) -> Result<BrepSolid, KernelRefusal> {
4660 let _operation = crate::blend::BlendOperation::enter();
4663 if edge_points.is_empty() {
4664 return Err(KernelRefusal::input(
4665 KernelStage::Collect,
4666 "angle_selection",
4667 "chamfer_edges_angle: no edges selected",
4668 ));
4669 }
4670 let mut result = solid.clone();
4671 for (index, point) in edge_points.iter().enumerate() {
4672 let edge_id = resolve_edge_by_point(&result, *point)?;
4673 let edge_name = per_edge_name(edge_names, name, index);
4674 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
4675 }
4676 refuse_invalid_result(&result, "chamfer_edges_angle")?;
4677 Ok(result)
4678}
4679
4680fn dump_loops_debug(label: &str, solid: &BrepSolid) {
4684 for shell in &solid.shells {
4685 for face in &shell.faces {
4686 for (index, loop_record) in face.loops.iter().enumerate() {
4687 let walk: Vec<String> = loop_record
4688 .coedges
4689 .iter()
4690 .map(|coedge| {
4691 let uv = coedge
4695 .pcurve
4696 .domain()
4697 .ok()
4698 .and_then(|[q0, q1]| {
4699 let a = coedge.pcurve.evaluate(q0).ok()?;
4700 let b = coedge.pcurve.evaluate(q1).ok()?;
4701 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
4702 })
4703 .unwrap_or_default();
4704 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
4705 Some(edge) => format!(
4706 "c{}:e{}{}({}->{}){uv}",
4707 coedge.id,
4708 edge.id,
4709 if coedge.forward { "+" } else { "-" },
4710 edge.start_vertex_id,
4711 edge.end_vertex_id
4712 ),
4713 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
4714 }
4715 })
4716 .collect();
4717 eprintln!(
4718 "{label} face {} ({:?}) loop {index}: {}",
4719 face.id,
4720 face.name,
4721 walk.join(" ")
4722 );
4723 }
4724 }
4725 }
4726}
4727
4728