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
181pub(super) fn 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 switch!("BREP_FILLET_COEDGE_MEMO", true)
205}
206
207
208pub fn fillet_edges(
240 solid: &BrepSolid,
241 edge_points: &[Vec3],
242 edge_names: Option<&[String]>,
243 radius: f64,
244 chamfer: bool,
245 name: Option<&str>,
246) -> Result<BrepSolid, KernelRefusal> {
247 let prepared = if crate::blend::creased_rim::creased_rim_split_on() {
254 crate::blend::creased_rim::split_creased_rims(solid, edge_points, edge_names, &resolve_edge_by_point)?
255 } else {
256 None
257 };
258 match prepared {
259 Some(prepared) => {
260 let names = prepared.names.as_deref();
261 let built = fillet_edges_prepared(&prepared.solid, &prepared.points, names, radius, chamfer, name)?;
262 Ok(prepared.rejoin(built))
263 }
264 None => {
265 if chamfer && super::chamfer_star::chamfer_mixed_star_on() {
271 let build = |body: &BrepSolid, points: &[Vec3], names: Option<&[String]>| {
272 fillet_edges_prepared(body, points, names, radius, true, name)
273 };
274 if let Some(built) = super::chamfer_star::chamfer_mixed_stars(solid, edge_points, edge_names, &build) {
275 return Ok(built);
276 }
277 }
278 let ordinary = fillet_edges_prepared(solid, edge_points, edge_names, radius, chamfer, name);
279 match ordinary {
287 Err(refusal)
288 if crate::blend::split_rim::split_rim_merge_on()
289 && matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what } if what == super::analyze::TANGENT_ROLLOVER_ONLY_WHAT) =>
290 {
291 match crate::blend::split_rim::merge_split_rims(solid, edge_points, edge_names, &resolve_edge_by_point) {
292 Some(merged) => fillet_edges_prepared(&merged.solid, &merged.points, merged.names.as_deref(), radius, chamfer, name)
293 .map_err(|_| refusal),
294 None => Err(refusal),
295 }
296 }
297 other => other,
298 }
299 }
300 }
301}
302
303fn fillet_edges_prepared(
304 solid: &BrepSolid,
305 edge_points: &[Vec3],
306 edge_names: Option<&[String]>,
307 radius: f64,
308 chamfer: bool,
309 name: Option<&str>,
310) -> Result<BrepSolid, KernelRefusal> {
311 let _operation = crate::blend::BlendOperation::enter();
314 BrepSolid::clear_validate_memo();
317 let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
321 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
322 let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
325 let _cap = SubsetDropCap::set(cap);
326 let (result, selection) = match fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name) {
327 Err(fold) if !chamfer && crate::blend::is_wall_fold(&fold) && crate::blend::relief::radius_relief_on() => {
330 let misfit = crate::blend::relief::is_misfit_fold(&fold);
331 let relieved = relieve_fold(solid, edge_points, edge_names, radius, name, fold);
332 if relieved.is_err() && misfit {
336 let _off = crate::blend::relief::MisfitOff::enter();
337 return fillet_edges(solid, edge_points, edge_names, radius, chamfer, name);
338 }
339 return relieved;
340 }
341 reported => reported?,
342 };
343 if selection.is_complete() {
344 return Ok(result);
345 }
346 let dropped: Vec<String> = selection
349 .rejected
350 .iter()
351 .map(|index| {
352 let point = edge_points[*index];
353 let named = edge_names
354 .and_then(|names| names.get(*index))
355 .map(|name| format!("`{name}` "))
356 .unwrap_or_default();
357 format!(
358 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
359 point.x, point.y, point.z
360 )
361 })
362 .collect();
363 Err(KernelRefusal::ill_posed(
367 KernelStage::Select,
368 "incomplete_selection",
369 format!(
370 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
371 rest of the selection and carry no wall in the result ({}). The group refusal was: \
372 {}. Blend them separately, or call the reported entry point to accept the partial \
373 selection deliberately.",
374 selection.applied.len(),
375 selection.requested,
376 selection.rejected.len(),
377 dropped.join(", "),
378 selection
379 .reason
380 .as_deref()
381 .unwrap_or("the whole group built but not every edge grew a wall"),
382 ),
383 ))
384}
385
386fn relieve_fold(
394 solid: &BrepSolid,
395 edge_points: &[Vec3],
396 edge_names: Option<&[String]>,
397 radius: f64,
398 name: Option<&str>,
399 fold: KernelRefusal,
400) -> Result<BrepSolid, KernelRefusal> {
401 let _relieving = crate::blend::relief::Relieving::enter();
402 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
403 let selected: Vec<u64> = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<_, _>>()?;
404 let joined = crate::blend::relief::join_continuations(solid, &selected);
408 let solid = joined.as_ref().unwrap_or(solid);
409 let mut ids: Vec<u64> = Vec::with_capacity(edge_points.len());
410 let mut points: Vec<Vec3> = Vec::with_capacity(edge_points.len());
411 let mut names: Vec<String> = Vec::with_capacity(edge_points.len());
412 for (index, point) in edge_points.iter().enumerate() {
413 let id = resolve_edge_by_point(solid, *point)?;
414 if !ids.contains(&id) {
415 ids.push(id);
416 points.push(*point);
417 if let Some(name) = edge_names.and_then(|names| names.get(index)) {
418 names.push(name.clone());
419 }
420 }
421 }
422 let (edge_points, edge_names) = (&points[..], edge_names.map(|_| &names[..]));
423 if debug && joined.is_some() {
424 eprintln!("radius relief: joined continuation edges, {} selected edges become {}", selected.len(), ids.len());
425 }
426 let started = web_time::Instant::now();
427 let measured = match crate::blend::relief::measure_ceilings(solid, &ids, radius) {
428 Ok(measured) => measured,
429 Err(refusal) => return Err(fold.with_message(|message| format!("{message}; the relief could not measure the edges: {refusal}"))),
430 };
431 if debug {
432 eprintln!("radius relief: ceilings measured in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
433 }
434 let largest = measured.iter().flatten().map(|(_, ceiling)| *ceiling).fold(0.0_f64, f64::max);
439 if largest < radius {
440 return Err(fold.with_message(|message| {
441 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}")
442 }));
443 }
444 let mut last: Option<KernelRefusal> = None;
445 for attempt in 0..crate::blend::relief::ATTEMPTS {
446 let plan = match crate::blend::relief::plan_relief(solid, &ids, radius, &measured, attempt) {
447 Ok(Some(plan)) => plan,
448 Ok(None) => break,
451 Err(refusal) => {
452 last = Some(refusal);
453 break;
454 }
455 };
456 let mark = crate::blend::blend_notes_mark();
457 match build_relief_plan(solid, edge_points, edge_names, &ids, radius, name, &plan) {
458 Ok(body) => {
459 for zone in &plan.zones {
460 let near: Vec<String> = zone
461 .edges
462 .iter()
463 .filter_map(|index| solid.edges.iter().find(|edge| edge.id == ids[*index]))
464 .map(|edge| edge.name.clone().unwrap_or_else(|| format!("edge #{}", edge.id)))
465 .map(|label| format!("`{label}`"))
466 .collect();
467 let (low, high) = zone
472 .edges
473 .iter()
474 .flat_map(|index| plan.stops[*index].iter().map(|(_, r)| *r))
475 .fold((f64::INFINITY, 0.0_f64), |(low, high), r| (low.min(r), high.max(r)));
476 let note = if high < radius * (1.0 - 1e-9) {
477 format!(
478 "radius reduced over the whole of {}: {low:.2} to {high:.2}, so r {radius} is reached nowhere \
479 on them (where they turn tightest the largest ball that fits is {:.2})",
480 near.join(", "),
481 zone.ceiling,
482 )
483 } else {
484 format!(
485 "radius reduced to {:.2} locally near {}: r {radius} does not fit there (the largest ball that does is {:.2})",
486 zone.radius,
487 near.join(", "),
488 zone.ceiling,
489 )
490 };
491 crate::blend::record_blend_note(note);
492 }
493 return Ok(body);
494 }
495 Err(refusal) => {
496 crate::blend::blend_notes_truncate(mark);
497 if debug {
498 eprintln!("radius relief attempt {attempt} refused at {:.1} ms: {refusal}", started.elapsed().as_secs_f64() * 1e3);
499 }
500 last = Some(refusal);
501 }
502 }
503 }
504 Err(match last {
505 Some(last) => fold.with_message(|message| format!("{message}; a locally reduced radius did not build either: {last}")),
506 None => fold,
507 })
508}
509
510fn build_relief_plan(
512 solid: &BrepSolid,
513 edge_points: &[Vec3],
514 edge_names: Option<&[String]>,
515 ids: &[u64],
516 radius: f64,
517 name: Option<&str>,
518 plan: &crate::blend::relief::ReliefPlan,
519) -> Result<BrepSolid, KernelRefusal> {
520 let count = ids.len();
522 let mut group: Vec<usize> = (0..count).collect();
523 let find = |group: &mut Vec<usize>, mut index: usize| {
524 while group[index] != index {
525 group[index] = group[group[index]];
526 index = group[index];
527 }
528 index
529 };
530 let vertices: Vec<[u64; 2]> = ids
531 .iter()
532 .map(|id| {
533 solid.edges.iter().find(|edge| edge.id == *id).map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).unwrap_or([u64::MAX; 2])
534 })
535 .collect();
536 for a in 0..count {
537 for b in a + 1..count {
538 if vertices[a].iter().any(|vertex| *vertex != u64::MAX && vertices[b].contains(vertex)) {
539 let (root_a, root_b) = (find(&mut group, a), find(&mut group, b));
540 group[root_a] = root_b;
541 }
542 }
543 }
544 let reduced: Vec<bool> = plan.stops.iter().map(|stops| stops.iter().any(|(_, r)| *r < radius)).collect();
545 let relieved_group: Vec<bool> = (0..count)
546 .map(|index| {
547 let root = find(&mut group, index);
548 (0..count).any(|other| reduced[other] && find(&mut group, other) == root)
549 })
550 .collect();
551 let pick = |want: bool| -> (Vec<Vec3>, Option<Vec<String>>, Vec<Vec<(f64, f64)>>) {
552 let indices: Vec<usize> = (0..count).filter(|index| relieved_group[*index] == want).collect();
553 (
554 indices.iter().map(|index| edge_points[*index]).collect(),
555 edge_names.map(|names| indices.iter().filter_map(|index| names.get(*index).cloned()).collect()),
556 indices.iter().map(|index| plan.stops[*index].clone()).collect(),
557 )
558 };
559 let (constant_points, constant_names, _) = pick(false);
560 let (relieved_points, relieved_names, relieved_stops) = pick(true);
561 let mut body = solid.clone();
562 if !constant_points.is_empty() {
563 body = fillet_edges(&body, &constant_points, constant_names.as_deref(), radius, false, name)?;
564 }
565 let built = fillet_edges_variable_impl(
566 &body,
567 &relieved_points,
568 relieved_names.as_deref(),
569 &relieved_stops,
570 false,
571 name,
572 "fillet_edges",
573 false,
574 )?;
575 let issues = built.validate();
576 if !issues.is_empty() {
577 return Err(KernelRefusal::internal(
578 KernelStage::Validate,
579 "relief_validate",
580 format!("radius relief: the relieved fillet does not validate: {issues:?}"),
581 ));
582 }
583 Ok(built)
584}
585
586#[derive(Clone, Debug)]
593pub struct BlendSelection {
594 pub requested: usize,
596 pub applied: Vec<usize>,
598 pub rejected: Vec<usize>,
600 pub reason: Option<String>,
602}
603
604impl BlendSelection {
605 pub fn is_complete(&self) -> bool {
607 self.rejected.is_empty()
608 }
609}
610
611pub fn fillet_edges_reported_at_offset(
623 solid: &BrepSolid,
624 edge_points: &[Vec3],
625 edge_names: Option<&[String]>,
626 radius: f64,
627 chamfer: bool,
628 name: Option<&str>,
629 offset: Vec3,
630) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
631 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
632}
633
634#[allow(clippy::too_many_arguments)]
636fn fillet_translated(
637 solid: &BrepSolid,
638 edge_points: &[Vec3],
639 edge_names: Option<&[String]>,
640 radius: f64,
641 chamfer: bool,
642 name: Option<&str>,
643 offset: Vec3,
644 kind: crate::recentre::EntryKind,
645) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
646 let _entry = crate::recentre::TranslatedEntry::open(kind);
647 let translate = |solid: &BrepSolid, offset: Vec3| {
648 crate::recentre::translate_solid(solid, offset).map_err(|message| {
649 KernelRefusal::internal(
650 KernelStage::Collect,
651 "translation_control",
652 format!("fillet_edges: the translation control could not move the solid: {message}"),
653 )
654 })
655 };
656 let moved = translate(solid, offset)?;
657 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
658 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
659 Ok((translate(&result, offset.scale(-1.0))?, selection))
660}
661
662pub fn fillet_edges_reported(
663 solid: &BrepSolid,
664 edge_points: &[Vec3],
665 edge_names: Option<&[String]>,
666 radius: f64,
667 chamfer: bool,
668 name: Option<&str>,
669) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
670 if let Some(offset) = crate::recentre::control_offset_at_entry() {
673 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
674 }
675 let bounds = crate::recentre::solid_bounds(solid);
678 if let Some(offset) = crate::recentre::guard_offset(bounds) {
679 crate::recentre::profile_fired("fillet", offset, bounds);
680 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
681 }
682 let _operation = crate::blend::BlendOperation::enter();
685 let _total = crate::blend::BlendTimer::start("timing.total_ms");
688 if !(radius > 0.0) || !radius.is_finite() {
689 return Err(KernelRefusal::input(
690 KernelStage::Collect,
691 "radius",
692 "fillet_edges: radius must be positive",
693 ));
694 }
695 if edge_points.is_empty() {
696 return Err(KernelRefusal::input(
697 KernelStage::Collect,
698 "selection",
699 "fillet_edges: no edges selected",
700 ));
701 }
702 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
710 let mut selected_ids = Vec::with_capacity(edge_points.len());
711 if crate::blend::blend_operation_outermost() {
712 super::analyze::clear_rollover_only();
713 }
714 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
715 let _boundaries = super::analyze::SupportBoundaryScope::open();
716 for point in edge_points {
717 let resolve = crate::blend::BlendTimer::start("timing.selection_checks.resolve_ms");
718 let edge_id = resolve_edge_by_point(solid, *point)?;
719 resolve.stop();
720 let concavity = crate::blend::BlendTimer::start("timing.selection_checks.concavity_ms");
721 check_mixed_concavity(solid, edge_id, entry)?;
722 concavity.stop();
723 let _extent = crate::blend::BlendTimer::start("timing.selection_checks.extent_ms");
724 if edge_points.len() == 1 && !chamfer {
726 check_support_extent_clipping(solid, edge_id, radius, entry)?;
727 } else {
728 check_support_extent(solid, edge_id, radius, entry)?;
729 }
730 selected_ids.push(edge_id);
731 }
732 drop(_boundaries);
733 selection_checks.stop();
734 if !chamfer {
773 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
778 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
779 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
780 corner_analysis.stop();
781 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
782 for corner in &corners {
783 eprintln!(
784 "mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
785 corner.vertex, corner.point.x, corner.point.y, corner.point.z,
786 corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
787 corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
788 );
789 }
790 }
791 if !corners.is_empty() {
792 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
795 let composed = rounded_boss_composition(
797 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
798 ).or_else(|error| {
799 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
800 eprintln!("rounded boss composition refused: {error}");
801 }
802 runout_composition(
803 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
804 )
805 }).or_else(|error| {
806 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
807 eprintln!("runout composition refused: {error}");
808 }
809 open_pad_composition(
815 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
816 ).map_err(|pad_error| {
817 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
818 eprintln!("open-pad composition refused: {pad_error}");
819 }
820 let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
825 if declined { error } else { pad_error }
826 })
827 }).or_else(|error| {
828 cusp_composition(solid, &selected_ids, edge_points, edge_names, radius, name, &corners).map_err(|cusp_error| {
833 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
834 eprintln!("cusp composition refused: {cusp_error}");
835 }
836 error
837 })
838 });
839 match composed {
840 Ok(result) => {
841 let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
850 refuse_invalid_result(&result, entry)?;
854 let missing = walls_missing_ties(
855 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
856 )?;
857 if !missing.is_empty() {
859 return Err(KernelRefusal::internal(
860 KernelStage::Validate,
861 "runout_walls_missing",
862 format!(
863 "{entry}: the composition built a solid with no wall on {}",
864 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
865 ),
866 ));
867 }
868 let n = edge_points.len();
869 return Ok((
870 result,
871 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
872 ));
873 }
874 Err(error) => {
875 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
876 eprintln!("runout composition refused: {error}");
877 }
878 check_mixed_corner_convexity(
879 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
880 )?;
881 }
882 }
883 }
884 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
890 check_degenerate_corner_setbacks(
891 solid,
892 &selected_ids,
893 edge_points,
894 edge_names,
895 radius,
896 name,
897 )?;
898 corner_analysis.stop();
899 }
900
901 let n = edge_points.len();
902 let complete = |applied: Vec<usize>| BlendSelection {
903 requested: n,
904 applied,
905 rejected: Vec::new(),
906 reason: None,
907 };
908 let mut memo = ComponentMemo::default();
919 let profile = crate::profile::profile_enabled();
920 let group_started = web_time::Instant::now();
921 let group = fillet_edges_group_in(
922 solid,
923 edge_points,
924 edge_names,
925 radius,
926 chamfer,
927 name,
928 Some(&mut memo),
929 );
930 if profile {
931 profile_line!(
932 "blend.selection group edges={n} ok={} ms={:.3}",
933 group.is_ok(),
934 group_started.elapsed().as_secs_f64() * 1e3
935 );
936 }
937 let group_err = match group {
938 Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
943 refuse_invalid_result(&result, entry).unwrap_err()
944 }
945 Ok(result) => {
946 let missing =
951 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
952 if missing.is_empty() {
953 return Ok((result, complete((0..n).collect())));
954 }
955 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
956 return Ok((
957 result,
958 BlendSelection {
959 requested: n,
960 applied,
961 rejected: missing,
962 reason: None,
963 },
964 ));
965 }
966 Err(error) => error,
967 };
968 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err)
978 || crate::blend::is_tangential_transverse_end(&group_err)
979 {
980 return Err(group_err);
981 }
982 if n < 2 || n > 12 {
987 return Err(group_err);
988 }
989 let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
996 for drop in 1..=max_drop {
997 for dropped in index_combinations(n, drop) {
998 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
999 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
1000 let kept_names: Option<Vec<String>> =
1003 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
1004 crate::blend::record_count("count.subset.builds", 1);
1005 let subset_started = web_time::Instant::now();
1006 let subset = fillet_edges_group_in(
1007 solid,
1008 &points,
1009 kept_names.as_deref(),
1010 radius,
1011 chamfer,
1012 name,
1013 Some(&mut memo),
1014 );
1015 if profile {
1016 profile_line!(
1017 "blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
1018 subset.is_ok(),
1019 subset_started.elapsed().as_secs_f64() * 1e3
1020 );
1021 }
1022 let result = match subset {
1023 Ok(result) => result,
1024 Err(error) => {
1025 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1026 eprintln!("subset dropped={dropped:?} refused: {error}");
1027 }
1028 continue;
1029 }
1030 };
1031 if !result.validate().is_empty() {
1032 continue;
1033 }
1034 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
1035 continue;
1036 }
1037 return Ok((
1038 result,
1039 BlendSelection {
1040 requested: n,
1041 applied: keep,
1042 rejected: dropped,
1043 reason: Some(group_err.message),
1044 },
1045 ));
1046 }
1047 }
1048 Err(group_err)
1049}
1050
1051pub(crate) fn walls_missing_nearest_on() -> bool {
1056 switch!("BREP_WALLS_MISSING_NEAREST", true)
1057}
1058
1059
1060pub(super) fn walls_missing(
1086 original: &BrepSolid,
1087 result: &BrepSolid,
1088 edge_points: &[Vec3],
1089 selected: &[usize],
1090 size: f64,
1091 chamfer: bool,
1092 entry: &str,
1093) -> Result<Vec<usize>, KernelRefusal> {
1094 walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
1095}
1096
1097#[allow(clippy::too_many_arguments)]
1112pub(super) fn walls_missing_ties(
1113 original: &BrepSolid,
1114 result: &BrepSolid,
1115 edge_points: &[Vec3],
1116 selected: &[usize],
1117 size: f64,
1118 chamfer: bool,
1119 entry: &str,
1120 tie_majority: bool,
1121) -> Result<Vec<usize>, KernelRefusal> {
1122use crate::{PointClass, SolidClassifier};
1124 if selected.is_empty() {
1125 return Ok(Vec::new());
1126 }
1127 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
1128 crate::classification::classify_profile_begin();
1129 crate::classification::containment_profile_begin();
1130 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
1131 let visible = band * 100.0;
1134 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
1135 let classifier = SolidClassifier::new(result, band)
1136 .or_refuse(KernelStage::Validate, "wall_classifier")?;
1137 let nearest = if walls_missing_nearest_on() {
1142 Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
1143 } else {
1144 None
1145 };
1146 let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
1147 if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
1148 crate::blend::record_count("count.walls_missing.nearest", 1);
1149 return Ok(class == PointClass::On);
1150 }
1151 Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
1152 };
1153 build_timing.stop();
1154 const STATIONS: usize = 9;
1155 let mut missing = Vec::new();
1156 for &index in selected {
1157 let point = edge_points[index];
1158 let edge_id = resolve_edge_by_point(original, point)?;
1159 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
1160 let mut best: Option<(Vec3, f64)> = None;
1162 let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
1164 for step in 1..=STATIONS {
1166 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
1167 let Ok(station) = edge.curve.evaluate(t) else {
1168 continue;
1169 };
1170 let clearance = match faces[..] {
1171 [first, second] => {
1172 let (Ok((_, a)), Ok((_, b))) = (
1173 super::analyze::face_plane_normal(first, station),
1174 super::analyze::face_plane_normal(second, station),
1175 ) else {
1176 continue;
1177 };
1178 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
1179 if chamfer {
1180 size * half.sin()
1181 } else {
1182 size * (1.0 / half.cos().max(1e-12) - 1.0)
1183 }
1184 }
1185 _ => f64::INFINITY,
1187 };
1188 if best.map_or(true, |(_, known)| clearance > known) {
1189 best = Some((station, clearance));
1190 }
1191 read.push((station, clearance));
1193 }
1195 let Some((station, clearance)) = best else {
1196 return Err(KernelRefusal::internal(
1197 KernelStage::Validate,
1198 "wall_attribution_stations",
1199 format!(
1200 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
1201 station of it could be evaluated",
1202 point.x, point.y, point.z
1203 ),
1204 ));
1205 };
1206 if !(clearance > visible) {
1207 return Err(KernelRefusal::ill_posed(
1211 KernelStage::Validate,
1212 "wall_attribution_band",
1213 format!(
1214 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
1215 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
1216 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
1217 selection is refused rather than reported on a guess",
1218 point.x, point.y, point.z
1219 ),
1220 ));
1221 }
1222 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
1223 let certified = station_clear_of_skin(result, station, band * 10.0, visible)
1230 || station_clear_of_trimmed_skin(result, station, band * 10.0, visible);
1231 let on = if certified {
1232 crate::blend::record_count("count.walls_missing.certified", 1);
1233 false
1234 } else {
1235 crate::blend::record_count("count.walls_missing.classified", 1);
1236 reads_on(station)?
1237 };
1238 classify_timing.stop();
1239 let on = on && (!tie_majority || {
1241 let tolerance = 1e-12 * clearance.abs().max(1.0);
1244 let (mut tied, mut on_skin) = (1usize, 1usize);
1245 for (other, other_clearance) in read.iter().copied() {
1246 if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
1247 continue;
1248 }
1249 tied += 1;
1250 if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
1251 on_skin += 1;
1252 }
1253 }
1254 2 * on_skin > tied
1255 });
1256 if on {
1258 missing.push(index);
1259 }
1260 }
1261 crate::classification::classify_profile_report("blend.walls_missing");
1262 crate::classification::containment_profile_report("blend.walls_missing");
1263 Ok(missing)
1264}
1265
1266
1267
1268fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
1298 use crate::AnalyticSurface;
1299 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
1302 let offset = station.sub(frame.origin);
1303 let axial = offset.dot(frame.axis);
1304 let radial = offset.sub(frame.axis.scale(axial));
1305 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
1306 if azimuth < 0.0 {
1307 azimuth += std::f64::consts::TAU;
1308 }
1309 (radial.length(), axial, azimuth)
1310 };
1311 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
1315 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
1316 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
1317 return 0.0;
1318 }
1319 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
1321 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
1322 };
1323 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
1326 let (du, dv) = (rho1 - rho0, h1 - h0);
1327 let length = (du * du + dv * dv).sqrt();
1328 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
1329 };
1330 let clear = |distance: f64| distance.is_finite() && distance > margin;
1331 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1332 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
1333 if !controls.expanded(on_band).contains(station) {
1334 continue;
1335 }
1336 let certified = match face.surface.analytic() {
1337 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
1338 let (radial, axial, _) = meridian(frame);
1339 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
1340 clear(line.max(axial_clearance(axial, 0.0, *height)))
1341 }
1342 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
1343 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
1344 let (radial, axial, _) = meridian(frame);
1345 let tube = radial - major_radius;
1346 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
1347 }
1348 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
1349 let [a, b] = generatrix.control_points.as_slice() else { return false };
1356 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1357 return false;
1358 }
1359 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
1360 let offset = point.sub(frame.origin);
1361 let axial = offset.dot(frame.axis);
1362 let radial = offset.sub(frame.axis.scale(axial));
1363 let rho = radial.length();
1364 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);
1365 on_meridian.then_some((rho, axial))
1366 };
1367 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 {
1368 return false;
1369 };
1370 let (radial, axial, azimuth) = meridian(frame);
1371 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
1372 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
1377 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
1378 }
1379 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
1380 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
1381 if clear(station.sub(*origin).dot(normal).abs()) {
1382 true
1383 } else {
1384 planar_trims_clear_of(face, plane, station, on_band, margin)
1385 }
1386 }
1387 None => false,
1388 };
1389 if !certified {
1390 return false;
1391 }
1392 }
1393 true
1394}
1395
1396fn station_clear_of_trimmed_skin(result: &BrepSolid, point: Vec3, on_band: f64, margin: f64) -> bool {
1402 let _timer = crate::blend::BlendTimer::start("timing.walls_missing.trimmed_bound_ms");
1403 if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1404 || !margin.is_finite() || !(margin > on_band && on_band > 0.0)
1405 { return false; }
1406 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1407 let Ok(bounds) = crate::Aabb::from_surface_controls(&face.surface) else { return false; };
1408 if !bounds.expanded(margin).contains(point) { continue; }
1409 if face.surface.analytic().is_none() {
1410 if general_carrier_clear(&face.surface, point, margin) { continue; }
1411 return false;
1412 }
1413 match face.surface.analytic() {
1419 Some(crate::AnalyticSurface::Plane { .. }) => {},
1420 Some(crate::AnalyticSurface::Sphere { radius, .. }) if *radius > 4.0 * margin => {},
1421 Some(crate::AnalyticSurface::Torus { major_radius, minor_radius, .. })
1422 if *minor_radius > 4.0 * margin && *major_radius > *minor_radius + 4.0 * margin => {},
1423 _ => return false,
1424 }
1425 let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else { return false; };
1426 if !projection.distance.is_finite() { return false; }
1427 if projection.distance > margin { continue; }
1428 let band = crate::classification::surface_uv_band(&face.surface, projection.u, projection.v, on_band);
1429 let Ok(inside) = crate::parameter_point_in_face(face, crate::Vec2 { x: projection.u, y: projection.v }, band) else { return false; };
1430 if inside != crate::PolygonClass::Outside { return false; }
1431 let mut edges = 0;
1432 for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
1433 let Some(edge) = result.edges.iter().find(|edge| edge.id == coedge.edge_id) else { return false; };
1434 let Some(piece) = super::analyze::edge_piece(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1)) else { return false; };
1435 let Ok(foot) = crate::project_point_to_curve(&piece, point) else { return false; };
1436 if !foot.distance.is_finite() || foot.distance <= margin { return false; }
1437 edges += 1;
1438 }
1439 if edges == 0 { return false; }
1440 }
1441 true
1442}
1443
1444fn general_carrier_clear(surface: &crate::NurbsSurface, point: Vec3, margin: f64) -> bool {
1450 use crate::Vec4;
1451 let rows = surface.control_points.len();
1452 let columns = surface.control_points.first().map_or(0, Vec::len);
1453 if rows == 0 || columns == 0 || rows.saturating_mul(columns) > 4096
1454 || surface.degree_u > 16 || surface.degree_v > 16 || surface.control_points.iter().any(|row| row.len() != columns)
1455 || !margin.is_finite() || margin <= 0.0
1456 || !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1457 { return false; }
1458 let mut scale = 1.0_f64;
1459 for control in surface.control_points.iter().flatten() {
1460 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1461 let Ok(p) = control.point() else { return false; };
1462 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1463 scale = scale.max(p.x.abs()).max(p.y.abs()).max(p.z.abs());
1464 }
1465 let roundoff = 256.0 * f64::EPSILON * scale * (surface.degree_u + 1) as f64 * (surface.degree_v + 1) as f64;
1468 if roundoff >= margin * 0.01 { return false; }
1469 let zero = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
1470 let blend = |a: Vec4, b: Vec4, alpha: f64| a.scale(alpha).add(b.scale(1.0 - alpha));
1471 let mut fibres = Vec::with_capacity(columns);
1472 for column in 0..columns {
1473 let controls: Vec<_> = surface.control_points.iter().map(|row| row[column]).collect();
1474 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_u, &controls, surface.degree_u, zero, blend) else { return false; };
1475 fibres.push(spans);
1476 }
1477 let mut budget = 256usize;
1478 for u_span in 0..fibres[0].len() {
1479 let mut across = Vec::with_capacity(surface.degree_u + 1);
1480 for row in 0..=surface.degree_u {
1481 let controls: Vec<_> = fibres.iter().map(|fibre| fibre[u_span][row]).collect();
1482 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_v, &controls, surface.degree_v, zero, blend) else { return false; };
1483 across.push(spans);
1484 }
1485 for v_span in 0..across[0].len() {
1486 let net = across.iter().map(|row| row[v_span].clone()).collect();
1487 if !bezier_box_clear(net, point, margin + roundoff, 0, &mut budget) { return false; }
1488 }
1489 }
1490 true
1491}
1492
1493fn bezier_box_clear(net: Vec<Vec<crate::Vec4>>, point: Vec3, margin: f64, depth: usize, budget: &mut usize) -> bool {
1494 if *budget == 0 { return false; }
1495 *budget -= 1;
1496 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1497 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1498 for control in net.iter().flatten() {
1499 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1500 let Ok(p) = control.point() else { return false; };
1501 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1502 low.x = low.x.min(p.x); low.y = low.y.min(p.y); low.z = low.z.min(p.z);
1503 high.x = high.x.max(p.x); high.y = high.y.max(p.y); high.z = high.z.max(p.z);
1504 }
1505 if point.x < low.x - margin || point.x > high.x + margin
1507 || point.y < low.y - margin || point.y > high.y + margin
1508 || point.z < low.z - margin || point.z > high.z + margin
1509 { return true; }
1510 if depth == 10 { return false; }
1511 let rows = net.len();
1512 let columns = net[0].len();
1513 let mut left = net.clone();
1514 let mut right = net.clone();
1515 if depth % 2 == 0 {
1518 for column in 0..columns {
1519 let mut work: Vec<_> = net.iter().map(|row| row[column]).collect();
1520 for level in 1..rows {
1521 for i in 0..rows - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1522 left[level][column] = work[0];
1523 right[rows - level - 1][column] = work[rows - level - 1];
1524 }
1525 }
1526 } else {
1527 for row in 0..rows {
1528 let mut work = net[row].clone();
1529 for level in 1..columns {
1530 for i in 0..columns - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1531 left[row][level] = work[0];
1532 right[row][columns - level - 1] = work[columns - level - 1];
1533 }
1534 }
1535 }
1536 bezier_box_clear(left, point, margin, depth + 1, budget)
1537 && bezier_box_clear(right, point, margin, depth + 1, budget)
1538}
1539
1540fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
1550 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
1551 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1552 let pcurve = &coedge.pcurve;
1553 let [a, b] = pcurve.control_points.as_slice() else { return None };
1554 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1555 return None;
1556 }
1557 for control in [a, b] {
1558 u_low = u_low.min(control.x);
1559 u_high = u_high.max(control.x);
1560 v_low = v_low.min(control.y);
1561 v_high = v_high.max(control.y);
1562 }
1563 }
1564 if !(u_low <= u_high && v_low <= v_high) {
1565 return None;
1566 }
1567 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
1568 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)?];
1569 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
1570 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
1571 let surface = &face.surface;
1572 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
1574 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
1575 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
1576 && surface.degree_u == 1 && surface.control_points.len() == 2;
1577 if !(affine_in_v || affine_in_u) {
1578 return None;
1579 }
1580 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
1581 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1582 (low.is_finite() && high.is_finite()).then_some((low, high))
1583}
1584
1585fn planar_trims_clear_of(
1589 face: &FaceRecord,
1590 plane: &crate::AnalyticSurface,
1591 station: Vec3,
1592 on_band: f64,
1593 margin: f64,
1594) -> bool {
1595 use crate::PolygonClass;
1596 let Some(foot) = plane.project(&face.surface, station) else { return false };
1597 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
1598 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
1599 Ok(PolygonClass::Outside) => {}
1600 _ => return false,
1601 }
1602 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1603 let pcurve = &coedge.pcurve;
1604 let [a, b] = pcurve.control_points.as_slice() else { return false };
1605 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1606 return false;
1607 }
1608 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
1609 let chord = end.sub(start);
1610 let length_squared = chord.dot(chord);
1611 let offset = station.sub(start);
1612 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
1613 let distance = offset.sub(chord.scale(along)).length();
1614 if !(distance.is_finite() && distance > margin) {
1615 return false;
1616 }
1617 }
1618 true
1619}
1620
1621fn per_edge_name<'a>(
1626 edge_names: Option<&'a [String]>,
1627 base: Option<&'a str>,
1628 i: usize,
1629) -> Option<&'a str> {
1630 match edge_names {
1631 Some(names) => names.get(i).map(|value| value.as_str()),
1632 None => base,
1633 }
1634}
1635
1636fn corner_face_name(
1644 edge_names: Option<&[String]>,
1645 base: Option<&str>,
1646 adjacent: &[usize],
1647) -> Option<String> {
1648 match (edge_names, base) {
1649 (Some(names), Some(base)) => {
1650 let prefix = format!("{base}:");
1651 let mut raws: Vec<&str> = adjacent
1652 .iter()
1653 .filter_map(|&i| names.get(i))
1654 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
1655 .collect();
1656 raws.sort_unstable();
1657 raws.dedup();
1658 Some(format!("{base}:CORNER:{}", raws.join("+")))
1659 }
1660 _ => base.map(|value| value.to_string()),
1661 }
1662}
1663
1664#[allow(clippy::too_many_arguments)]
1674fn rounded_boss_composition(
1675 solid: &BrepSolid,
1676 selected_ids: &[u64],
1677 edge_points: &[Vec3],
1678 edge_names: Option<&[String]>,
1679 radius: f64,
1680 name: Option<&str>,
1681 corners: &[crate::blend::MixedCorner],
1682) -> Result<BrepSolid, KernelRefusal> {
1683 if edge_names.is_none() {
1688 let prefix = "FILLET:COMPOSITION";
1689 let names: Vec<_> = selected_ids
1690 .iter()
1691 .map(|id| format!("{prefix}:{id}"))
1692 .collect();
1693 let mut result = rounded_boss_composition(
1694 solid,
1695 selected_ids,
1696 edge_points,
1697 Some(&names),
1698 radius,
1699 Some(prefix),
1700 corners,
1701 )?;
1702 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1703 if face
1704 .name
1705 .as_deref()
1706 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1707 {
1708 face.name = name.map(str::to_owned);
1709 }
1710 }
1711 return Ok(result);
1712 }
1713 let decline = || {
1714 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1715 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1716 };
1717 if std::env::var("BREP_NO_NETWORK").is_ok() {
1718 return Err(decline());
1719 }
1720 let corner = corners
1723 .iter()
1724 .filter(|corner| {
1725 corner.convex.len() == 1
1726 && corner.concave.len() == 2
1727 && corners
1728 .iter()
1729 .filter(|other| other.convex.contains(&corner.convex[0]))
1730 .count()
1731 == 1
1732 })
1733 .min_by_key(|corner| selected_ids[corner.convex[0]])
1734 .ok_or_else(decline)?;
1735 let convexity =
1736 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1737 if !convexity[corner.convex[0]] {
1738 return Err(decline());
1739 }
1740 let spine_index = corner.convex[0];
1741 let selected_edges: Vec<&EdgeRecord> = selected_ids
1744 .iter()
1745 .map(|id| {
1746 solid
1747 .edges
1748 .iter()
1749 .find(|edge| edge.id == *id)
1750 .ok_or_else(|| {
1751 KernelRefusal::internal(
1752 KernelStage::Collect,
1753 "rounded_boss_selected_edge",
1754 format!("rounded boss composition: selected edge {id} is missing"),
1755 )
1756 })
1757 })
1758 .collect::<Result<_, _>>()?;
1759 let mut rounded_indices = vec![spine_index];
1762 loop {
1763 let next = (0..selected_ids.len()).find(|index| {
1764 convexity[*index]
1765 && !rounded_indices.contains(index)
1766 && rounded_indices.iter().any(|other| {
1767 let edge = selected_edges[*index];
1768 let mate = selected_edges[*other];
1769 [edge.start_vertex_id, edge.end_vertex_id]
1770 .iter()
1771 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1772 })
1773 });
1774 match next {
1775 Some(index) => rounded_indices.push(index),
1776 None => break,
1777 }
1778 }
1779 loop {
1788 let next = (0..selected_ids.len()).find(|&index| {
1789 if convexity[index] || rounded_indices.contains(&index) {
1790 return false;
1791 }
1792 let edge = selected_edges[index];
1793 let mut has_star = false;
1794 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
1795 let neighbours: Vec<_> = selected_edges.iter().enumerate()
1796 .filter(|(other, mate)| *other != index
1797 && (mate.start_vertex_id == vertex || mate.end_vertex_id == vertex))
1798 .map(|(other, _)| other)
1799 .collect();
1800 if neighbours.iter().any(|other| !rounded_indices.contains(other)) {
1801 return false;
1802 }
1803 has_star |= neighbours.len() >= 2;
1804 }
1805 has_star
1806 });
1807 match next {
1808 Some(index) => rounded_indices.push(index),
1809 None => break,
1810 }
1811 }
1812 rounded_indices.sort_by_key(|index| selected_ids[*index]);
1813 let mixed: Vec<_> = corners
1814 .iter()
1815 .filter(|corner| {
1816 corner
1817 .convex
1818 .iter()
1819 .any(|index| rounded_indices.contains(index))
1820 })
1821 .collect();
1822 if mixed.iter().any(|corner| {
1823 corner.convex.len() != 1
1824 || corner.concave.len() != 2
1825 || corners
1826 .iter()
1827 .filter(|other| other.convex.contains(&corner.convex[0]))
1828 .count()
1829 != 1
1830 }) {
1831 return Err(decline());
1832 }
1833 let uses = |face: &FaceRecord, edge: u64| {
1834 face.loops
1835 .iter()
1836 .flat_map(|rim| &rim.coedges)
1837 .any(|coedge| coedge.edge_id == edge)
1838 };
1839 let mut caps = Vec::new();
1840 for corner in &mixed {
1841 let spine_id = selected_ids[corner.convex[0]];
1842 let cap = solid
1843 .shells
1844 .iter()
1845 .flat_map(|shell| &shell.faces)
1846 .find(|face| {
1847 corner
1848 .concave
1849 .iter()
1850 .all(|index| uses(face, selected_ids[*index]))
1851 })
1852 .ok_or_else(decline)?;
1853 let faces: Vec<_> = solid
1854 .shells
1855 .iter()
1856 .flat_map(|shell| &shell.faces)
1857 .filter(|face| face.id == cap.id || uses(face, spine_id))
1858 .collect();
1859 let normals: Option<Vec<Vec3>> = faces
1868 .iter()
1869 .map(|face| match face.surface.analytic() {
1870 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
1871 u_dir.cross(*v_dir).normalized().ok()
1872 }
1873 _ => {
1874 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
1875 face.surface.normal(uv.u, uv.v).ok()
1876 }
1877 })
1878 .collect();
1879 let normals = normals.ok_or_else(decline)?;
1880 if normals.len() != 3
1886 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
1887 {
1888 return Err(decline());
1889 }
1890 caps.push(cap.id);
1891 }
1892 let rounded_names: Vec<String> = rounded_indices
1895 .iter()
1896 .map(|index| {
1897 per_edge_name(edge_names, name, *index)
1898 .map(str::to_owned)
1899 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
1900 })
1901 .collect();
1902 let rounded_points: Vec<Vec3> = rounded_indices
1903 .iter()
1904 .map(|index| edge_points[*index])
1905 .collect();
1906 let rounded = fillet_edges_group_in(
1907 solid,
1908 &rounded_points,
1909 Some(&rounded_names),
1910 radius,
1911 false,
1912 name,
1913 None,
1914 )?;
1915 let mut bridges = Vec::new();
1916 for (corner, cap) in mixed.iter().zip(&caps) {
1917 let wall_index = rounded_indices
1918 .iter()
1919 .position(|index| *index == corner.convex[0])
1920 .ok_or_else(|| {
1921 KernelRefusal::internal(
1922 KernelStage::Classify,
1923 "rounded_boss_bridge_wall",
1924 format!(
1925 "rounded boss composition: no convex wall for corner {}",
1926 corner.vertex
1927 ),
1928 )
1929 })?;
1930 let candidates: Vec<_> = rounded
1931 .edges
1932 .iter()
1933 .filter(|edge| {
1934 let faces: Vec<_> = rounded
1935 .shells
1936 .iter()
1937 .flat_map(|shell| &shell.faces)
1938 .filter(|face| uses(face, edge.id))
1939 .collect();
1940 faces.iter().any(|face| face.id == *cap)
1941 && faces
1942 .iter()
1943 .any(|face| face.name.as_deref() == Some(&rounded_names[wall_index]))
1944 })
1945 .collect();
1946 if candidates.len() != 1 {
1947 return Err(decline());
1948 }
1949 bridges.push(candidates[0]);
1950 }
1951 let rim_ids: Vec<u64> = selected_ids
1952 .iter()
1953 .enumerate()
1954 .filter(|(index, _)| !rounded_indices.contains(index))
1955 .map(|(_, id)| *id)
1956 .collect();
1957 if !bridges.iter().all(|bridge| {
1960 [bridge.start_vertex_id, bridge.end_vertex_id]
1961 .iter()
1962 .all(|vertex| {
1963 rounded.edges.iter().any(|edge| {
1964 rim_ids.contains(&edge.id)
1965 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
1966 })
1967 })
1968 }) {
1969 return Err(decline());
1970 }
1971 let mut points = Vec::new();
1972 let mut names = Vec::new();
1973 for (index, id) in selected_ids.iter().enumerate() {
1974 if rounded_indices.contains(&index) {
1975 continue;
1976 }
1977 let edge = rounded
1978 .edges
1979 .iter()
1980 .find(|edge| edge.id == *id)
1981 .ok_or_else(decline)?;
1982 points.push(
1983 edge.curve
1984 .evaluate(0.5 * (edge.t0 + edge.t1))
1985 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
1986 );
1987 names.push(
1988 per_edge_name(edge_names, name, index)
1989 .unwrap_or("FILLET:RIM")
1990 .to_owned(),
1991 );
1992 }
1993 for (corner, bridge) in mixed.iter().zip(&bridges) {
1994 points.push(
1995 bridge
1996 .curve
1997 .evaluate(0.5 * (bridge.t0 + bridge.t1))
1998 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
1999 );
2000 let mut adjacent = corner.concave.clone();
2001 adjacent.push(corner.convex[0]);
2002 names.push(
2003 corner_face_name(edge_names, name, &adjacent)
2004 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
2005 );
2006 }
2007 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
2010 |(result, selection)| {
2011 if selection.applied.len() != points.len() {
2012 Err(decline())
2013 } else {
2014 Ok(result)
2015 }
2016 },
2017 )
2018}
2019
2020#[allow(clippy::too_many_arguments)]
2053fn open_pad_on() -> bool {
2055 switch!(live "BREP_BLEND_OPEN_PAD", true)
2056}
2057
2058#[allow(clippy::too_many_arguments)]
2087fn open_pad_composition(
2088 solid: &BrepSolid,
2089 selected_ids: &[u64],
2090 edge_points: &[Vec3],
2091 edge_names: Option<&[String]>,
2092 radius: f64,
2093 name: Option<&str>,
2094 corners: &[crate::blend::MixedCorner],
2095 entry: &str,
2096) -> Result<BrepSolid, KernelRefusal> {
2097 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
2098 if !open_pad_on() {
2099 return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
2100 }
2101 if std::env::var("BREP_NO_NETWORK").is_ok() {
2102 return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
2103 }
2104 if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
2105 return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
2106 }
2107 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2108 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2109 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2110 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2111 if concave.is_empty() || convex.is_empty() {
2112 return Err(shape("the open-pad composition needs convex and concave edges".into()));
2113 }
2114 let mates = crate::blend::selection_mates(solid, selected_ids, radius)
2117 .map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
2118 for corner in corners {
2119 let (_, convex_faces) = &mates[corner.convex[0]];
2120 let (_, concave_faces) = &mates[corner.concave[0]];
2121 let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
2122 return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
2123 };
2124 if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
2125 return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
2126 }
2127 }
2128 for &index in &concave {
2129 let edge = mates[index].0;
2130 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
2131 if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
2132 return Err(shape(format!(
2133 "concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
2134 selection_label(index, edge_points, edge_names, name)
2135 )));
2136 }
2137 }
2138 }
2139 let base = name.unwrap_or("FILLET:OPEN_PAD");
2140 let bare = |index: usize| -> String {
2141 per_edge_name(edge_names, name, index)
2142 .map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
2143 .unwrap_or_else(|| selected_ids[index].to_string())
2144 };
2145 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2146
2147 let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
2149 let convex_names: Vec<String> = convex
2150 .iter()
2151 .map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
2152 .collect();
2153 let stage1 = crate::blend::with_open_pad_network(|| {
2162 fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
2163 })
2164 .map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
2165 if debug {
2166 eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
2167 }
2168
2169 let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
2171 let mut points: Vec<Vec3> = Vec::new();
2172 let mut names: Vec<String> = Vec::new();
2173 for &index in &concave {
2174 let (edge, faces) = &mates[index];
2175 let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
2176 let pad = stage1
2177 .edges
2178 .iter()
2179 .find(|candidate| candidate.id == edge.id)
2180 .ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
2181 "open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
2182 )))?;
2183 points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
2184 names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
2185 for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
2188 let trims: Vec<&EdgeRecord> = stage1
2189 .edges
2190 .iter()
2191 .filter(|candidate| {
2192 candidate.id != pad.id
2193 && !candidate.degenerate
2194 && (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
2195 })
2196 .filter(|candidate| {
2197 let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
2198 faces.iter().any(|face| face.id == boss_id)
2199 && faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
2200 })
2201 .collect();
2202 match trims.as_slice() {
2203 [trim] => {
2204 let wall_name = stage1
2205 .shells
2206 .iter()
2207 .flat_map(|shell| &shell.faces)
2208 .filter(|face| uses(face, trim.id))
2209 .find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
2210 .unwrap_or_default();
2211 let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
2212 points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
2213 names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
2214 if debug {
2215 eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
2216 }
2217 }
2218 [] => {
2219 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2220 "open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
2221 selection_label(index, edge_points, edge_names, name)
2222 )));
2223 }
2224 _ => {
2225 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2226 "open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
2227 selection_label(index, edge_points, edge_names, name), trims.len()
2228 )));
2229 }
2230 }
2231 }
2232 }
2233 let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
2234 .map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
2235 let _ = entry;
2236 Ok(result)
2237}
2238
2239fn cusp_composition(
2259 solid: &BrepSolid,
2260 selected_ids: &[u64],
2261 edge_points: &[Vec3],
2262 edge_names: Option<&[String]>,
2263 radius: f64,
2264 name: Option<&str>,
2265 corners: &[crate::blend::MixedCorner],
2266) -> Result<BrepSolid, KernelRefusal> {
2267 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "cusp_composition_shape", message);
2268 if std::env::var("BREP_NO_NETWORK").is_ok() {
2269 return Err(shape("the cusp composition is a network construction and BREP_NO_NETWORK is set".into()));
2270 }
2271 let edge_of = |index: usize| solid.edges.iter().find(|edge| edge.id == selected_ids[index]);
2272 let away = |index: usize, vertex: u64| -> Option<Vec3> {
2274 let edge = edge_of(index)?;
2275 let at_start = edge.start_vertex_id == vertex;
2276 let tangent = edge.curve.unit_tangent(if at_start { edge.t0 } else { edge.t1 }, edge.t0, edge.t1).ok()?;
2277 Some(if at_start { tangent } else { tangent.scale(-1.0) })
2278 };
2279 if corners.is_empty() {
2280 return Err(shape("no mixed corners".into()));
2281 }
2282 for corner in corners {
2283 let cusped = corner.convex.iter().all(|&convex| {
2284 corner.concave.iter().any(|&concave| {
2285 matches!((away(convex, corner.vertex), away(concave, corner.vertex)), (Some(a), Some(b)) if a.dot(b) >= 1.0 - 1e-6)
2286 })
2287 });
2288 if !cusped || corner.convex.is_empty() || corner.concave.is_empty() {
2289 return Err(shape(format!("the mixed corner at vertex {} is not a cusp", corner.vertex)));
2290 }
2291 }
2292 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2293 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2294 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2295 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2296 if concave.is_empty() || convex.is_empty() {
2297 return Err(shape("the cusp composition needs convex and concave edges".into()));
2298 }
2299 let names_of = |indices: &[usize]| -> Option<Vec<String>> { edge_names.map(|names| indices.iter().map(|index| names[*index].clone()).collect()) };
2300 let points_of = |indices: &[usize]| -> Vec<Vec3> { indices.iter().map(|index| edge_points[*index]).collect() };
2301 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2302 let stage1 = fillet_edges_group_in(solid, &points_of(&concave), names_of(&concave).as_deref(), radius, false, name, None)
2303 .map_err(|error| error.with_message(|error| format!("cusp composition, the concave rounds: {error}")))?;
2304 if debug {
2305 eprintln!("cusp composition: stage 1 built the {} concave rounds", concave.len());
2306 }
2307 for &index in &convex {
2310 resolve_edge_by_point(&stage1, edge_points[index])
2311 .map_err(|error| error.with_message(|error| format!("cusp composition: a convex edge is not on the stage-1 body: {error}")))?;
2312 }
2313 let result = fillet_edges_group_in(&stage1, &points_of(&convex), names_of(&convex).as_deref(), radius, false, name, None)
2314 .map_err(|error| error.with_message(|error| format!("cusp composition, the convex rounds: {error}")))?;
2315 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(true));
2321 Ok(result)
2322}
2323
2324fn runout_composition(
2325 solid: &BrepSolid,
2326 selected_ids: &[u64],
2327 edge_points: &[Vec3],
2328 edge_names: Option<&[String]>,
2329 radius: f64,
2330 name: Option<&str>,
2331 corners: &[crate::blend::MixedCorner],
2332 entry: &str,
2333) -> Result<BrepSolid, KernelRefusal> {
2334 if std::env::var("BREP_NO_NETWORK").is_ok() {
2335 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
2336 }
2337 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2338 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
2339 let mut spine_ends = vec![0usize; selected_ids.len()];
2340 for corner in corners {
2341 if corner.convex.len() != 1 {
2342 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
2343 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
2344 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
2345 )));
2346 }
2347 spine_ends[corner.convex[0]] += 1;
2348 }
2349 let mut spines: Vec<usize> = Vec::new();
2350 let mut concave: Vec<usize> = Vec::new();
2351 for (index, ends) in spine_ends.iter().enumerate() {
2352 match (*ends, convexity[index]) {
2353 (2, true) => spines.push(index),
2354 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
2355 (0, false) => concave.push(index),
2356 (ends, convex) => {
2357 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
2358 "{} 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",
2359 selection_label(index, edge_points, edge_names, name),
2360 if convex { "convex" } else { "concave" },
2361 )));
2362 }
2363 }
2364 }
2365 if concave.is_empty() {
2366 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
2367 }
2368 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
2370 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
2371 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
2372 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
2373 .map_err(|error| {
2374 let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2380 match network {
2381 Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
2382 "the concave ring did not build: {network} (the cutter fallback then refused: {error})"
2383 )),
2384 _ => error.with_message(|error| format!("the concave ring did not build: {error}")),
2385 }
2386 })?;
2387 let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2396 let ring_is_network = network_declined.is_none();
2398 if let Some(network) = network_declined {
2400 if crate::blend::is_reentrant_concave(&network) {
2401 return Err(network.with_message(|network| format!(
2402 "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"
2403 )));
2404 }
2405 }
2406 let spine_walls: Vec<(u64, Option<String>)> = spines
2408 .iter()
2409 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
2410 .collect();
2411 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
2412 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
2413 heal_edge_vertex_gaps(&mut result, radius)?;
2414 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
2415 let issues = result.validate();
2416 if !issues.is_empty() {
2417 return Err(KernelRefusal::new(
2418 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2419 KernelStage::Validate,
2420 format!("the runout walls do not validate: {issues:?}"),
2421 ));
2422 }
2423 check_blend_interference(&ring, &result, &spine_ids, entry)?;
2424 check_loop_self_crossings(&result, entry)?;
2425 check_blend_wall_loops(&result, entry)?;
2426 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2427 let accepted = crate::accept_sound(result, entry)?;
2428 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
2430 Ok(accepted)
2432}
2433
2434thread_local! {
2436 static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2441}
2442fn check_mixed_corner_convexity(
2445 solid: &BrepSolid,
2446 edge_ids: &[u64],
2447 edge_points: &[Vec3],
2448 edge_names: Option<&[String]>,
2449 radius: f64,
2450 name: Option<&str>,
2451 composition_error: Option<&KernelRefusal>,
2452) -> Result<(), KernelRefusal> {
2453 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
2454 Some(corner) => {
2455 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
2461 Ok(plan) => Some(plan),
2462 Err(error) => {
2463 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2464 eprintln!("runout not measured: {error}");
2465 }
2466 None
2467 }
2468 };
2469 let mut message = mixed_corner_message(
2472 &corner,
2473 edge_points,
2474 edge_names,
2475 name,
2476 measured.as_ref(),
2477 );
2478 if let Some(error) = composition_error {
2479 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
2480 }
2481 Err(KernelRefusal::unsupported(
2482 KernelStage::Classify,
2483 "mixed_corner_convexity",
2484 message,
2485 ))
2486 }
2487 None => Ok(()),
2488 }
2489}
2490
2491fn check_degenerate_corner_setbacks(
2494 solid: &BrepSolid,
2495 edge_ids: &[u64],
2496 edge_points: &[Vec3],
2497 edge_names: Option<&[String]>,
2498 radius: f64,
2499 name: Option<&str>,
2500) -> Result<(), KernelRefusal> {
2501 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
2502 return Ok(());
2503 };
2504 let [from, to] = setback.stops;
2505 let how = if setback.crossed {
2506 format!(
2507 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
2508 setback.strip
2509 )
2510 } else {
2511 format!(
2512 "meet there ({:.3e} apart against a bar of {:.3e})",
2513 setback.strip, setback.bar
2514 )
2515 };
2516 Err(KernelRefusal::input(
2519 KernelStage::Classify,
2520 "degenerate_corner_setbacks",
2521 format!(
2522 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
2523 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
2524 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
2525 on are consumed whole. The radius is at or past the degenerate limit for this \
2526 selection; reduce it.",
2527 selection_label(setback.edge, edge_points, edge_names, name),
2528 setback.face_id,
2529 from.x,
2530 from.y,
2531 from.z,
2532 to.x,
2533 to.y,
2534 to.z,
2535 ),
2536 ))
2537}
2538
2539fn selection_label(
2543 index: usize,
2544 edge_points: &[Vec3],
2545 edge_names: Option<&[String]>,
2546 name: Option<&str>,
2547) -> String {
2548 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
2549 Some(base) => composed
2550 .strip_prefix(&format!("{base}:"))
2551 .unwrap_or(composed)
2552 .to_string(),
2553 None => composed.clone(),
2554 });
2555 named.unwrap_or_else(|| match edge_points.get(index) {
2556 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
2557 None => format!("selection #{}", index + 1),
2558 })
2559}
2560
2561fn mixed_corner_message(
2565 corner: &crate::blend::MixedCorner,
2566 edge_points: &[Vec3],
2567 edge_names: Option<&[String]>,
2568 name: Option<&str>,
2569 measured: Option<&crate::blend::RunoutPlan>,
2570) -> String {
2571 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
2572 let list = |indices: &[usize]| -> String {
2573 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
2574 match parts.split_last() {
2575 None => "none".to_string(),
2576 Some((last, [])) => last.clone(),
2577 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
2578 }
2579 };
2580 let mut message = format!(
2581 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
2582 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
2583 sides at once, so that corner has no closure: the convex blend runs out against the \
2584 concave ones partway along the edge, and this kernel does not build that runout yet. \
2585 Blending the concave edges in one fillet and the convex ones in another does not reach \
2586 a wall on every one of them either: the convex wall comes out at FULL length in either \
2587 order, and its contact leaves the faces it runs along where their own loops would then \
2588 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
2589 whose wall runs out into them; blend the convex ones first and the subset search drops \
2590 the concave edges it cannot compose instead, leaving those unfilleted.",
2591 corner.point.x,
2592 corner.point.y,
2593 corner.point.z,
2594 list(&corner.convex),
2595 list(&corner.concave),
2596 );
2597 if let Some(plan) = measured {
2602 message.push_str(&format!(
2603 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
2604 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
2605 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
2606 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
2607 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
2608 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
2609 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
2610 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
2611 the bar a march is refused at is a full span.",
2612 plan.convex_edge,
2613 plan.shared_face,
2614 plan.station.s,
2615 plan.station.center.x,
2616 plan.station.center.y,
2617 plan.station.center.z,
2618 plan.replaced_mate,
2619 plan.second_carrier,
2620 plan.station.stripe_residual,
2621 plan.second_carrier,
2622 plan.marched,
2623 plan.fit_residual,
2624 plan.carrier_excursion,
2625 ));
2626 match (&plan.stop, plan.first_carrier) {
2627 (Some(stop), Some(first)) => message.push_str(&format!(
2628 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
2629 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
2630 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
2631 stop.s, plan.kept_mate, first, stop.stripe_residual,
2632 )),
2633 _ => message.push_str(&format!(
2634 " Where the kept mate (face {}) runs out was not measurable on this selection.",
2635 plan.kept_mate
2636 )),
2637 }
2638 if let Some(pole) = plan.pole {
2639 message.push_str(&format!(
2640 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
2641 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
2642 section shrinks to a point. Two carrier switches and a degenerate terminus is \
2643 more than a stop station on a stripe, and none of it is composed yet.",
2644 pole.x, pole.y, pole.z, plan.shared_face,
2645 ));
2646 }
2647 }
2648 message
2649}
2650
2651fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
2653 let mut out = Vec::new();
2654 if k == 0 || k > n {
2655 return out;
2656 }
2657 let mut idx: Vec<usize> = (0..k).collect();
2658 loop {
2659 out.push(idx.clone());
2660 let mut i = k;
2662 loop {
2663 if i == 0 {
2664 return out;
2665 }
2666 i -= 1;
2667 if idx[i] != i + n - k {
2668 break;
2669 }
2670 }
2671 idx[i] += 1;
2672 for j in (i + 1)..k {
2673 idx[j] = idx[j - 1] + 1;
2674 }
2675 }
2676}
2677
2678thread_local! {
2679 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
2685 const { std::cell::Cell::new(0) };
2686}
2687
2688fn fillet_edges_group(
2705 solid: &BrepSolid,
2706 edge_points: &[Vec3],
2707 edge_names: Option<&[String]>,
2708 radius: f64,
2709 chamfer: bool,
2710 name: Option<&str>,
2711) -> Result<BrepSolid, KernelRefusal> {
2712 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
2713}
2714
2715thread_local! {
2716 static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
2723}
2724
2725struct SubsetDropCap(Option<usize>);
2727
2728impl SubsetDropCap {
2729 fn set(cap: Option<usize>) -> Self {
2730 Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
2731 }
2732}
2733
2734impl Drop for SubsetDropCap {
2735 fn drop(&mut self) {
2736 SUBSET_DROP_CAP.with(|current| current.set(self.0));
2737 }
2738}
2739
2740thread_local! {
2741 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2744 static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2747 static GROUP_NETWORK_FOLD: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2751}
2752
2753type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
2756
2757type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
2764
2765#[derive(Default)]
2793struct ComponentMemo {
2794 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
2795 prefix: Vec<ComponentKey>,
2798 sequential: std::collections::HashMap<
2799 (Vec<ComponentKey>, Vec<SequentialKey>),
2800 Result<BrepSolid, KernelRefusal>,
2801 >,
2802}
2803
2804fn fillet_edges_group_in(
2809 solid: &BrepSolid,
2810 edge_points: &[Vec3],
2811 edge_names: Option<&[String]>,
2812 radius: f64,
2813 chamfer: bool,
2814 name: Option<&str>,
2815 memo: Option<&mut ComponentMemo>,
2816) -> Result<BrepSolid, KernelRefusal> {
2817 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
2818}
2819
2820fn fillet_edges_group_with_corner_policy(
2821 solid: &BrepSolid,
2822 edge_points: &[Vec3],
2823 edge_names: Option<&[String]>,
2824 radius: f64,
2825 chamfer: bool,
2826 name: Option<&str>,
2827 memo: Option<&mut ComponentMemo>,
2828 wrap_concave: bool,
2829 accept: bool,
2830) -> Result<BrepSolid, KernelRefusal> {
2831 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
2832 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2833 let GroupBody { body: result, bowtie_clean } =
2834 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
2835 if !accept {
2836 return Ok(result);
2837 }
2838 if bowtie_clean {
2843 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
2844 } else {
2845 check_loop_self_crossings(&result, entry)?;
2846 check_blend_wall_loops(&result, entry)?;
2847 }
2848 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
2849 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2858 let accepted = crate::accept_sound(result, entry);
2859 soundness.stop();
2860 accepted
2861}
2862
2863struct GroupBody {
2868 body: BrepSolid,
2869 bowtie_clean: bool,
2870}
2871
2872fn fillet_edges_group_unchecked(
2879 solid: &BrepSolid,
2880 edge_points: &[Vec3],
2881 edge_names: Option<&[String]>,
2882 radius: f64,
2883 chamfer: bool,
2884 name: Option<&str>,
2885 memo: Option<&mut ComponentMemo>,
2886 wrap_concave: bool,
2887) -> Result<GroupBody, KernelRefusal> {
2888 let outer = GROUP_NETWORK_FOLD.with(|fold| fold.borrow_mut().take());
2889 let built = fillet_edges_group_lanes(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave);
2890 let fold = GROUP_NETWORK_FOLD.with(|fold| std::mem::replace(&mut *fold.borrow_mut(), outer));
2891 match (built, fold) {
2892 (Err(error), Some(fold)) if !crate::blend::is_wall_fold(&error) => Err(fold),
2893 (built, _) => built,
2894 }
2895}
2896
2897fn fillet_edges_group_lanes(
2898 solid: &BrepSolid,
2899 edge_points: &[Vec3],
2900 edge_names: Option<&[String]>,
2901 radius: f64,
2902 chamfer: bool,
2903 name: Option<&str>,
2904 mut memo: Option<&mut ComponentMemo>,
2905 wrap_concave: bool,
2906) -> Result<GroupBody, KernelRefusal> {
2907 use rustc_hash::FxHashSet as HashSet;
2908
2909 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
2916 let mut healed_input = solid.clone();
2917 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
2918 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
2919 let solid = &healed_input;
2920 heal.stop();
2921 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
2922
2923 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2926 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
2929 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
2930 for (i, point) in edge_points.iter().enumerate() {
2931 let edge_id = resolve_edge_by_point(solid, *point)?;
2932 let edge = solid
2933 .edges
2934 .iter()
2935 .find(|e| e.id == edge_id)
2936 .ok_or_else(|| {
2937 KernelRefusal::internal(
2938 KernelStage::Collect,
2939 "resolved_edge",
2940 "fillet_edges: resolved edge vanished",
2941 )
2942 })?;
2943 let (start, end) = (
2944 edge.curve
2945 .evaluate(edge.t0)
2946 .or_refuse(KernelStage::Collect, "edge_start")?,
2947 edge.curve
2948 .evaluate(edge.t1)
2949 .or_refuse(KernelStage::Collect, "edge_end")?,
2950 );
2951 endpoints.push((start, i));
2952 endpoints.push((end, i));
2953 original_extents.push((start, end));
2954 selected_edge_ids.push(edge_id);
2955 }
2956 let mut corners: Vec<Vec3> = Vec::new();
2957 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
2962 let mut chain_corner_count = 0usize;
2963 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
2964 let mut used = vec![false; endpoints.len()];
2965 for i in 0..endpoints.len() {
2966 if used[i] {
2967 continue;
2968 }
2969 used[i] = true;
2970 let mut edges_here: HashSet<usize> = HashSet::default();
2971 edges_here.insert(endpoints[i].1);
2972 for j in (i + 1)..endpoints.len() {
2973 if used[j] {
2974 continue;
2975 }
2976 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
2977 used[j] = true;
2978 edges_here.insert(endpoints[j].1);
2979 }
2980 }
2981 if edges_here.len() >= 3 {
2982 corners.push(endpoints[i].0);
2983 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
2984 adjacent.sort_unstable();
2985 corner_edges.push(adjacent);
2986 } else if edges_here.len() == 2 {
2987 chain_corner_count += 1;
2988 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
2989 adjacent.sort_unstable();
2990 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
2991 }
2992 }
2993
2994 let mut component_of = vec![usize::MAX; edge_points.len()];
3007 let mut components: Vec<Vec<usize>> = Vec::new();
3008 for seed in 0..edge_points.len() {
3009 if component_of[seed] != usize::MAX {
3010 continue;
3011 }
3012 let component_index = components.len();
3013 component_of[seed] = component_index;
3014 let mut component = vec![seed];
3015 let mut cursor = 0;
3016 while cursor < component.len() {
3017 let current = component[cursor];
3018 cursor += 1;
3019 for candidate in 0..edge_points.len() {
3020 if component_of[candidate] != usize::MAX {
3021 continue;
3022 }
3023 let connected = [original_extents[current].0, original_extents[current].1]
3024 .into_iter()
3025 .any(|a| {
3026 [original_extents[candidate].0, original_extents[candidate].1]
3027 .into_iter()
3028 .any(|b| a.sub(b).length() < 1e-6)
3029 });
3030 if connected {
3031 component_of[candidate] = component_index;
3032 component.push(candidate);
3033 }
3034 }
3035 }
3036 component.sort_unstable();
3037 components.push(component);
3038 }
3039 group_analysis.stop();
3040 if components.len() > 1 {
3041 let mut separated = solid.clone();
3042 let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
3045 chain.reserve(components.len());
3046 for component in components {
3047 let points = component
3048 .iter()
3049 .map(|index| edge_points[*index])
3050 .collect::<Vec<_>>();
3051 let names = edge_names.map(|all| {
3052 component
3053 .iter()
3054 .map(|index| all[*index].clone())
3055 .collect::<Vec<_>>()
3056 });
3057 let accept_component = !crate::blend::accept_once_on();
3058 let Some(memo) = memo.as_deref_mut() else {
3059 separated = fillet_edges_group_with_corner_policy(
3060 &separated,
3061 &points,
3062 names.as_deref(),
3063 radius,
3064 chamfer,
3065 name,
3066 None,
3067 wrap_concave,
3068 accept_component,
3069 )?;
3070 continue;
3071 };
3072 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
3073 chain.push((bits.collect(), names.clone()));
3074 let built = match memo.built.get(&chain) {
3075 Some(built) => built.clone(),
3076 None => {
3077 let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
3080 let built = fillet_edges_group_with_corner_policy(
3081 &separated,
3082 &points,
3083 names.as_deref(),
3084 radius,
3085 chamfer,
3086 name,
3087 Some(&mut *memo),
3088 wrap_concave,
3089 accept_component,
3090 );
3091 memo.prefix = outer_prefix;
3092 memo.built.insert(chain.clone(), built.clone());
3093 built
3094 }
3095 };
3096 separated = built?;
3097 }
3098 return Ok(GroupBody { body: separated, bowtie_clean: false });
3099 }
3100
3101 if std::env::var("BREP_NO_NETWORK").is_err() {
3126 if selected_edge_ids.len() == 1 {
3127 let edge_id = selected_edge_ids[0];
3128 let closed = solid
3129 .edges
3130 .iter()
3131 .find(|edge| edge.id == edge_id)
3132 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
3133 if closed {
3134 let rim_name = per_edge_name(edge_names, name, 0);
3135 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
3136 Ok(mut rim) => {
3137 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3138 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3139 let healed = heal_edge_vertex_gaps(&mut rim, radius);
3140 heal.stop();
3141 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3150 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
3151 charts.stop();
3152 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3153 let issues = rim.validate_memoised();
3154 validate.stop();
3155 let interference =
3156 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
3157 let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
3158 if healed.is_ok()
3159 && issues.is_empty()
3160 && interference.is_ok()
3161 && bowtie.is_ok()
3162 {
3163 return Ok(GroupBody { body: rim, bowtie_clean: true });
3164 }
3165 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3166 eprintln!(
3167 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
3168 interference={interference:?} bowtie={bowtie:?}"
3169 );
3170 }
3171 }
3172 Err(refusal) => {
3173 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3174 eprintln!("closed-rim refused: {refusal}");
3175 }
3176 }
3177 }
3178 }
3179 }
3180 let network_names: Vec<Option<String>> = (0..edge_points.len())
3181 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
3182 .collect();
3183 let network_corner_name =
3184 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
3185 let group_network = || {
3191 crate::blend::blend_star_network_with_corner_policy(
3192 solid,
3193 &selected_edge_ids,
3194 radius,
3195 chamfer,
3196 &network_names,
3197 &network_corner_name,
3198 wrap_concave,
3199 )
3200 };
3201 let network_result = group_network().or_else(|first| {
3202 if crate::blend::is_pcurve_floor_refusal(&first) {
3203 crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
3204 } else {
3205 Err(first)
3206 }
3207 });
3208 let accept_network = |mut network: BrepSolid, built_radius: f64| -> Result<Option<GroupBody>, KernelRefusal> {
3212 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3220 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3221 let healed = heal_edge_vertex_gaps(&mut network, built_radius);
3222 heal.stop();
3223 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3227 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
3228 charts.stop();
3229 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3230 let issues = network.validate_memoised();
3231 validate.stop();
3232 let interference =
3233 check_blend_interference(solid, &network, &selected_edge_ids, entry);
3234 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
3239 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
3240 return Ok(Some(GroupBody { body: network, bowtie_clean: true }));
3241 }
3242 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3243 eprintln!(
3244 "network result rejected: heal={healed:?} issues={issues:?} \
3245 interference={interference:?} bowtie={bowtie:?}"
3246 );
3247 dump_loops_debug("INPUT", solid);
3248 dump_loops_debug("RESULT", &network);
3249 }
3250 Ok(None)
3251 };
3252 match network_result {
3253 Ok(network) => {
3254 if let Some(body) = accept_network(network, radius)? {
3255 return Ok(body);
3256 }
3257 }
3258 Err(refusal) => {
3259 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3260 eprintln!("network refused: {refusal}");
3261 }
3262 let pinched_taper = matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what }
3273 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT);
3274 if !chamfer && pinched_taper && crate::blend::relief::radius_relief_on() {
3275 let labels: Vec<String> =
3276 (0..edge_points.len()).map(|index| selection_label(index, edge_points, edge_names, name)).collect();
3277 for step in 1..=10u32 {
3278 let reduced = radius * (1.0 - 0.05 * f64::from(step));
3279 let built = crate::blend::blend_star_network_with_corner_policy(
3280 solid, &selected_edge_ids, reduced, chamfer, &network_names, &network_corner_name, wrap_concave,
3281 );
3282 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3283 eprintln!(
3284 "taper pinch ladder: r {reduced:.4} -> {}",
3285 match &built {
3286 Ok(_) => "built".to_string(),
3287 Err(error) => format!("refused: {error}"),
3288 }
3289 );
3290 }
3291 match built {
3292 Ok(network) => {
3293 let Some(body) = accept_network(network, reduced)? else { break };
3294 crate::blend::record_blend_note(format!(
3295 "radius reduced over the whole of {} to {reduced:.4}: at r {radius} the round tapering to \
3296 its tip reaches the full radius only inside the corner setback at its other end, so \
3297 r {radius} is reached nowhere",
3298 labels.join(", ")
3299 ));
3300 return Ok(body);
3301 }
3302 Err(again) if matches!(&again.class, crate::RefusalClass::UnsupportedGeometry { what }
3303 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT) => continue,
3304 Err(_) => break,
3305 }
3306 }
3307 }
3308 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
3312 if crate::blend::is_wall_fold(&refusal) {
3313 GROUP_NETWORK_FOLD.with(|fold| *fold.borrow_mut() = Some(refusal.clone()));
3314 }
3315 if !chamfer && !crate::blend::is_wall_fold(&refusal) && crate::blend::relief::radius_relief_on() {
3321 let started = web_time::Instant::now();
3322 let misfit = crate::blend::relief::first_misfit(solid, &selected_edge_ids, radius);
3323 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3324 eprintln!("radius relief: misfit probe {misfit:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
3325 }
3326 if let Some((edge_id, least)) = misfit {
3327 return Err(crate::blend::relief::misfit_fold(edge_id, least, radius, &refusal));
3328 }
3329 }
3330 if crate::blend::is_rail_fold(&refusal)
3371 || crate::blend::is_marched_fit_off_carriers(&refusal)
3372 || crate::blend::is_consumed_snap(&refusal)
3373 || crate::blend::is_rail_collapse(&refusal)
3374 || crate::blend::is_full_width_collapse(&refusal)
3375 || crate::blend::is_planar_chart_refusal(&refusal)
3376 || crate::blend::is_miter_support_overrun(&refusal)
3377 || crate::blend::is_tangential_transverse_end(&refusal)
3378 {
3379 return Err(refusal);
3380 }
3381 }
3382 }
3383 }
3384
3385 if let Some(refusal) = super::analyze::rollover_only_refusal(&selected_edge_ids) {
3391 return Err(refusal);
3392 }
3393 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
3394 crate::blend::record_count("count.cutter_fallback", 1);
3395 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
3396 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
3423 && (chamfer || corners.is_empty())
3424 {
3425 let mut any_convex = false;
3426 let mut any_concave = false;
3427 for point in edge_points {
3428 match resolve_edge_by_point(solid, *point)
3440 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
3441 {
3442 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
3445 any_convex |= profile.any_convex;
3446 any_concave |= profile.any_concave;
3447 }
3448 _ => {
3451 any_convex = true;
3452 any_concave = true;
3453 break;
3454 }
3455 }
3456 }
3457 match (any_convex, any_concave) {
3458 (true, false) => Some(crate::BooleanOperation::Intersect),
3459 (false, true) => Some(crate::BooleanOperation::Union),
3460 _ => None,
3461 }
3462 } else {
3463 None
3464 };
3465
3466 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
3477 Lane::GeneralFirst
3478 } else {
3479 Lane::CutterFirst
3480 };
3481 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3492 let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3497 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
3498 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
3504 let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
3505 let mut sequential = solid.clone();
3506 let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
3509 for &index in &sequential_order {
3510 let point = &edge_points[index];
3511 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3512 let ends = tool_ends_to_original_extent(
3519 &sequential,
3520 edge_id,
3521 radius,
3522 original_extents[index],
3523 if chamfer { &[] } else { &corners },
3524 );
3525 let edge_name = per_edge_name(edge_names, name, index);
3530 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3531 eprintln!(
3532 "sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
3533 selection_label(index, edge_points, edge_names, name)
3534 );
3535 }
3536 chain.push((
3541 [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
3542 edge_name.map(str::to_string),
3543 ends.key_bits(),
3544 u8::from(composition_lane == Lane::CutterFirst),
3545 ));
3546 if let Some(memo) = memo.as_deref_mut() {
3547 let key = (memo.prefix.clone(), chain.clone());
3548 let hit = memo.sequential.get(&key);
3549 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3550 eprintln!(
3551 "sequential memo: {} components before, step {} {} ({} chains stored)",
3552 memo.prefix.len(),
3553 chain.len(),
3554 if hit.is_some() { "HIT" } else { "miss" },
3555 memo.sequential.len(),
3556 );
3557 }
3558 if let Some(built) = hit {
3559 crate::blend::record_count("count.sequential.memo_hits", 1);
3560 sequential = built.clone()?;
3561 continue;
3562 }
3563 }
3564 crate::blend::record_count("count.sequential.steps_built", 1);
3565 let built = fillet_or_chamfer(
3566 &sequential,
3567 edge_id,
3568 radius,
3569 chamfer,
3570 edge_name,
3571 ends,
3572 composition_lane,
3573 );
3574 if let Some(memo) = memo.as_deref_mut() {
3575 memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
3576 }
3577 sequential = built?;
3578 }
3579 Ok(sequential)
3580 };
3581
3582 let mut result = if let Some(operation) = miter_operation {
3583 let options = crate::BooleanOptions {
3587 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
3588 ..crate::BooleanOptions::default()
3589 };
3590 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
3591 for (index, point) in edge_points.iter().enumerate() {
3592 let edge_id = resolve_edge_by_point(solid, *point)?;
3593 let edge_name = per_edge_name(edge_names, name, index);
3594 let blended = {
3595 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3596 check_mixed_concavity(solid, edge_id, entry)?;
3597 check_support_extent(solid, edge_id, radius, entry)?;
3598 fillet_or_chamfer(
3599 solid,
3600 edge_id,
3601 radius,
3602 chamfer,
3603 edge_name,
3604 ToolEnds::default(),
3605 Lane::CutterFirst,
3606 )?
3607 };
3608 combined = match combined {
3609 Err(e) => Err(e),
3610 Ok(None) => Ok(Some(blended)),
3611 Ok(Some(previous)) => {
3612 crate::boolean_operation(&previous, &blended, operation, &options)
3613 .map(Some)
3614 .map_err(|error| {
3615 error.with_message(|error| {
3617 format!("fillet_edges: chain-corner miter composition failed: {error}")
3618 })
3619 })
3620 }
3621 };
3622 if combined.is_err() {
3623 break;
3624 }
3625 }
3626 match combined {
3638 Ok(Some(mitered)) => mitered,
3639 Ok(None) => {
3640 return Err(KernelRefusal::input(
3641 KernelStage::Collect,
3642 "miter_selection",
3643 "fillet_edges: no edges selected",
3644 ))
3645 }
3646 Err(miter_err) => match build_sequential() {
3647 Ok(seq) if seq.validate().is_empty() => seq,
3648 _ => return Err(miter_err),
3649 },
3650 }
3651 } else {
3652 build_sequential()?
3653 };
3654
3655 let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
3663 if !chamfer {
3665 for (corner, adjacent) in &chain_corners {
3666 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
3667 if selected_edge_ids.contains(&edge.id) {
3668 return false;
3669 }
3670 let Ok(a) = edge.curve.evaluate(edge.t0) else {
3671 return false;
3672 };
3673 let Ok(b) = edge.curve.evaluate(edge.t1) else {
3674 return false;
3675 };
3676 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3677 && analyze_edge(solid, edge.id, radius)
3678 .map(|cross| !cross.convex)
3679 .unwrap_or(false)
3680 });
3681 if !has_concave_unselected_edge {
3692 let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
3693 let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
3694 let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
3695 let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
3696 if selected_edge_ids.contains(&edge.id) {
3697 return false;
3698 }
3699 let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
3700 return false;
3701 };
3702 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3703 && analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
3704 });
3705 if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
3706 open_mixed_corners.push((
3707 *corner,
3708 [selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
3709 corner_face_name(edge_names, name, adjacent),
3710 ));
3711 }
3712 continue;
3713 }
3714 let corner_name = corner_face_name(edge_names, name, adjacent);
3716 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
3717 &result,
3718 solid,
3719 *corner,
3720 [
3721 selected_edge_ids[adjacent[0]],
3722 selected_edge_ids[adjacent[1]],
3723 ],
3724 radius,
3725 corner_name.as_deref(),
3726 ) {
3727 result = rounded;
3728 }
3729 }
3730 }
3731
3732 if !chamfer {
3738 for (ci, corner) in corners.iter().enumerate() {
3739 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
3740 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
3741 Ok(rounded) => result = rounded,
3742 Err(error) => {
3743 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3744 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
3745 }
3746 }
3747 }
3748 }
3749 }
3750
3751 heal_edge_vertex_gaps(&mut result, radius)?;
3754 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3755 let mut body = settle_cutter_scaffold(result);
3757 for (corner, pair, corner_name) in &open_mixed_corners {
3758 match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
3759 Ok(rounded) => body = rounded,
3760 Err(error) => {
3761 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3762 eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
3763 }
3764 }
3765 }
3766 }
3767 Ok(GroupBody { body, bowtie_clean: false })
3768 }
3770
3771fn concave_star_edges(
3778 solid: &BrepSolid,
3779 selected_edge_ids: &[u64],
3780 corner_edges: &[Vec<usize>],
3781 radius: f64,
3782 chamfer: bool,
3783) -> Vec<usize> {
3784 if chamfer || corner_edges.is_empty() || !concave_first_on() {
3785 return Vec::new();
3786 }
3787 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
3788 return Vec::new();
3789 };
3790 (0..selected_edge_ids.len())
3791 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
3792 .collect()
3793}
3794
3795#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3802enum CutterClass {
3803 Marched,
3804 Straight,
3805 Circular,
3806}
3807
3808fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
3809 match super::analyze::analyze_edge(solid, edge_id, radius) {
3810 Ok(cross) => match cross.path {
3811 super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
3812 super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
3813 },
3814 Err(_) => CutterClass::Marched,
3815 }
3816}
3817
3818fn mixed_star_class_ranks(
3840 solid: &BrepSolid,
3841 selected_edge_ids: &[u64],
3842 corner_edges: &[Vec<usize>],
3843 radius: f64,
3844 chamfer: bool,
3845) -> Vec<u8> {
3846 let mut ranks = vec![2u8; selected_edge_ids.len()];
3847 if chamfer || corner_edges.is_empty() || !class_order_on() {
3848 return ranks;
3849 }
3850 let classes: Vec<CutterClass> = selected_edge_ids
3851 .iter()
3852 .map(|&edge_id| cutter_class(solid, edge_id, radius))
3853 .collect();
3854 for adjacent in corner_edges {
3855 let Some(&head) = adjacent.first() else {
3856 continue;
3857 };
3858 if adjacent.iter().all(|&index| classes[index] == classes[head]) {
3859 continue;
3860 }
3861 for &index in adjacent {
3862 match classes[index] {
3863 CutterClass::Marched => ranks[index] = 1,
3864 CutterClass::Circular => ranks[index] = 3,
3865 CutterClass::Straight => {}
3866 }
3867 }
3868 }
3869 ranks
3870}
3871
3872pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
3879 let mut order: Vec<usize> = (0..count).collect();
3880 order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
3882 order
3883}
3884
3885fn class_order_on() -> bool {
3889 true
3890}
3891
3892
3893fn corners_concave_first(
3897 corners: Vec<Vec3>,
3898 corner_edges: Vec<Vec<usize>>,
3899 first: &[usize],
3900) -> (Vec<Vec3>, Vec<Vec<usize>>) {
3901 if first.is_empty() {
3902 return (corners, corner_edges);
3903 }
3904 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
3905 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
3906 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
3907 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
3908 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
3909 ordered.into_iter().unzip()
3910}
3911
3912fn concave_first_on() -> bool {
3915 true
3916}
3917
3918
3919pub fn fillet_edges_variable(
3927 solid: &BrepSolid,
3928 edge_points: &[Vec3],
3929 edge_names: Option<&[String]>,
3930 radii: &[(f64, f64)],
3931 chamfer: bool,
3932 name: Option<&str>,
3933) -> Result<BrepSolid, KernelRefusal> {
3934 let _operation = crate::blend::BlendOperation::enter();
3937 if edge_points.is_empty() {
3938 return Err(KernelRefusal::input(
3939 KernelStage::Collect,
3940 "variable_selection",
3941 "fillet_edges_variable: no edges selected",
3942 ));
3943 }
3944 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
3945 fillet_edges_variable_impl(
3946 solid,
3947 edge_points,
3948 edge_names,
3949 &per_edge_stops,
3950 chamfer,
3951 name,
3952 "fillet_edges_variable",
3953 true,
3954 )
3955}
3956
3957#[allow(clippy::too_many_arguments)]
3969fn fillet_edges_variable_impl(
3970 solid: &BrepSolid,
3971 edge_points: &[Vec3],
3972 edge_names: Option<&[String]>,
3973 per_edge_stops: &[Vec<(f64, f64)>],
3974 chamfer: bool,
3975 name: Option<&str>,
3976 entry: &str,
3977 allow_tapered_sequential: bool,
3978) -> Result<BrepSolid, KernelRefusal> {
3979 let bounds = crate::recentre::solid_bounds(solid);
3982 let translated = match crate::recentre::control_offset_at_entry() {
3983 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
3984 None => crate::recentre::guard_offset(bounds).map(|offset| {
3985 crate::recentre::profile_fired(entry, offset, bounds);
3986 (offset, crate::recentre::EntryKind::Guard)
3987 }),
3988 };
3989 if let Some((offset, kind)) = translated {
3990 let _entry = crate::recentre::TranslatedEntry::open(kind);
3991 let translate = |solid: &BrepSolid, offset: Vec3| {
3992 crate::recentre::translate_solid(solid, offset).map_err(|message| {
3993 KernelRefusal::internal(
3994 KernelStage::Collect,
3995 "translation_control",
3996 format!("{entry}: the translation control could not move the solid: {message}"),
3997 )
3998 })
3999 };
4000 let moved = translate(solid, offset)?;
4001 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
4002 let result = fillet_edges_variable_impl(
4003 &moved,
4004 &points,
4005 edge_names,
4006 per_edge_stops,
4007 chamfer,
4008 name,
4009 entry,
4010 allow_tapered_sequential,
4011 )?;
4012 return translate(&result, offset.scale(-1.0));
4013 }
4014 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
4015 if let Some(constant) = per_edge_stops
4019 .first()
4020 .and_then(|stops| stops.first())
4021 .map(|(_, radius)| *radius)
4022 {
4023 let uniform = per_edge_stops.iter().all(|stops| {
4024 stops
4025 .iter()
4026 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
4027 });
4028 if uniform && constant > 0.0 {
4029 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
4030 }
4031 }
4032 let max_radius = per_edge_stops
4033 .iter()
4034 .flat_map(|stops| stops.iter())
4035 .map(|(_, r)| r.abs())
4036 .fold(0.0_f64, f64::max);
4037
4038 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
4043 match fillet_variable_network(
4044 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
4045 )? {
4046 Ok(built) => return Ok(built),
4047 Err(refusal) if crate::blend::relief::relieving() => return Err(refusal),
4051 Err(_) => {}
4052 }
4053 }
4054
4055 let mut chain_corner = false;
4062 {
4063 use rustc_hash::FxHashSet as HashSet;
4064 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
4065 for (i, point) in edge_points.iter().enumerate() {
4066 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
4067 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
4068 if let (Ok(a), Ok(b)) =
4069 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
4070 {
4071 endpoints.push((a, i));
4072 endpoints.push((b, i));
4073 }
4074 }
4075 }
4076 }
4077 let mut used = vec![false; endpoints.len()];
4078 for i in 0..endpoints.len() {
4079 if used[i] {
4080 continue;
4081 }
4082 used[i] = true;
4083 let mut edges_here: HashSet<usize> = HashSet::default();
4084 edges_here.insert(endpoints[i].1);
4085 for j in (i + 1)..endpoints.len() {
4086 if used[j] {
4087 continue;
4088 }
4089 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
4090 used[j] = true;
4091 edges_here.insert(endpoints[j].1);
4092 }
4093 }
4094 if edges_here.len() == 2 {
4095 chain_corner = true;
4096 }
4097 }
4098 }
4099 let miter_operation = if chain_corner {
4100 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
4101 let mut any_convex = false;
4102 let mut any_concave = false;
4103 for point in edge_points {
4104 match resolve_edge_by_point(solid, *point)
4105 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
4106 {
4107 Ok(cross) if cross.convex => any_convex = true,
4108 Ok(_) => any_concave = true,
4109 Err(_) => {
4110 any_convex = true;
4111 any_concave = true;
4112 break;
4113 }
4114 }
4115 }
4116 match (any_convex, any_concave) {
4117 (true, false) => Some(crate::BooleanOperation::Intersect),
4118 (false, true) => Some(crate::BooleanOperation::Union),
4119 _ => None,
4120 }
4121 } else {
4122 None
4123 };
4124
4125 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
4132 let options = crate::BooleanOptions::default();
4133 let mut combined: Option<BrepSolid> = None;
4134 let mut failed = false;
4135 for (index, point) in edge_points.iter().enumerate() {
4136 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
4137 failed = true;
4138 break;
4139 };
4140 let edge_name = per_edge_name(edge_names, name, index);
4141 let Ok(blended) = crate::blend::blend_edge_variable(
4142 solid,
4143 edge_id,
4144 &per_edge_stops[index],
4145 chamfer,
4146 edge_name,
4147 ) else {
4148 failed = true;
4149 break;
4150 };
4151 let next = match combined.take() {
4152 None => blended,
4153 Some(previous) => {
4154 match crate::boolean_operation(&previous, &blended, operation, &options) {
4155 Ok(next) => next,
4156 Err(_) => {
4157 failed = true;
4158 break;
4159 }
4160 }
4161 }
4162 };
4163 combined = Some(next);
4164 }
4165 if failed {
4166 None
4167 } else {
4168 combined.filter(|s| s.validate().is_empty())
4169 }
4170 } else {
4171 None
4172 };
4173 let mut result = match miter_attempt {
4174 Some(mitered) => mitered,
4175 None => {
4176 let tapered = per_edge_stops.iter().any(|stops| {
4182 stops.first().is_some_and(|(_, first)| {
4183 stops
4184 .iter()
4185 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
4186 })
4187 });
4188 if chain_corner && tapered && !allow_tapered_sequential {
4189 return Err(KernelRefusal::unsupported(
4192 KernelStage::Sew,
4193 "tapered_chain_miter",
4194 format!(
4195 "{entry}: the tapered blends across the selected chain's shared corners \
4196 did not compose to a valid solid (the fitted blend boundaries could not \
4197 be mitered); fillet fewer edges per operation or reduce the taper"
4198 ),
4199 ));
4200 }
4201 let mut sequential = solid.clone();
4202 for (index, point) in edge_points.iter().enumerate() {
4203 let edge_id = resolve_edge_by_point(&sequential, *point)?;
4204 let edge_name = per_edge_name(edge_names, name, index);
4205 sequential = crate::blend::blend_edge_variable(
4206 &sequential,
4207 edge_id,
4208 &per_edge_stops[index],
4209 chamfer,
4210 edge_name,
4211 )?;
4212 }
4213 sequential
4214 }
4215 };
4216 heal_edge_vertex_gaps(&mut result, max_radius)?;
4219 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
4220 let issues = result.validate();
4225 if !issues.is_empty() {
4226 let (detail, class) = if allow_tapered_sequential {
4231 (
4232 "tapered blends meet at a shared chain vertex with \
4233 mismatched radii — the radius-transition corner patch is not implemented; \
4234 fillet the edges in separate operations or use matching stop radii",
4235 RefusalClass::UnsupportedGeometry {
4236 what: "taper_transition_patch".into(),
4237 },
4238 )
4239 } else {
4240 (
4241 "the composed radius-law blend produced invalid topology — the blends \
4242 across a shared chain corner failed to reassemble",
4243 RefusalClass::Internal {
4244 what: "law_chain_topology".into(),
4245 },
4246 )
4247 };
4248 return Err(KernelRefusal::new(
4249 class,
4250 KernelStage::Validate,
4251 format!(
4252 "{entry}: {detail} ({} validation issues, first: {})",
4253 issues.len(),
4254 issues
4255 .first()
4256 .map(|issue| issue.message.clone())
4257 .unwrap_or_default()
4258 ),
4259 ));
4260 }
4261 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
4266 crate::accept_sound(result, entry)
4267}
4268
4269#[allow(clippy::too_many_arguments)]
4274fn fillet_variable_network(
4275 solid: &BrepSolid,
4276 edge_points: &[Vec3],
4277 edge_names: Option<&[String]>,
4278 per_edge_stops: &[Vec<(f64, f64)>],
4279 chamfer: bool,
4280 name: Option<&str>,
4281 entry: &str,
4282 max_radius: f64,
4283) -> Result<Result<BrepSolid, KernelRefusal>, KernelRefusal> {
4284 let mut edge_ids = Vec::with_capacity(edge_points.len());
4285 for point in edge_points {
4286 edge_ids.push(resolve_edge_by_point(solid, *point)?);
4287 }
4288 let laws: Vec<crate::blend::StripeRadius> =
4289 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
4290 let network_names: Vec<Option<String>> = (0..edge_points.len())
4291 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
4292 .collect();
4293 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
4294 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
4295 let law_network = || {
4304 crate::blend::blend_star_network_laws(solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true)
4305 };
4306 let network_result = law_network().or_else(|first| {
4307 if crate::blend::is_pcurve_floor_refusal(&first) {
4308 crate::blend::with_pcurve_knot_retry(law_network).map_err(|_| first)
4309 } else {
4310 Err(first)
4311 }
4312 });
4313 let mut network = match network_result {
4314 Ok(network) => network,
4315 Err(refusal) => {
4316 if debug {
4317 eprintln!("law network refused: {refusal}");
4318 }
4319 return Ok(Err(refusal));
4320 }
4321 };
4322 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
4327 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
4328 let issues = network.validate();
4329 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
4330 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
4331 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
4332 crate::soundness::census::profile_operation(&network, &[solid], None, entry);
4333 match crate::accept_sound(network, entry) {
4334 Ok(sound) => return Ok(Ok(sound)),
4335 Err(refusal) => {
4336 if debug {
4337 eprintln!("law network result unsound: {refusal}");
4338 }
4339 return Ok(Err(refusal));
4340 }
4341 }
4342 }
4343 if debug {
4344 eprintln!(
4345 "law network result rejected: heal={healed:?} issues={issues:?} \
4346 interference={interference:?} bowtie={bowtie:?}"
4347 );
4348 }
4349 Ok(Err(KernelRefusal::unsupported(
4350 KernelStage::Validate,
4351 "law_network_rejected",
4352 format!(
4353 "{entry}: the tapered network's result was rejected (heal {}, {} validation issues, interference {}, loops {})",
4354 if healed.is_ok() { "ok" } else { "failed" },
4355 issues.len(),
4356 if interference.is_ok() { "ok" } else { "found" },
4357 if bowtie.is_ok() { "ok" } else { "found" },
4358 ),
4359 )))
4360}
4361
4362pub(crate) fn fillet_edges_variable_stops(
4363 solid: &BrepSolid,
4364 edge_points: &[Vec3],
4365 edge_names: Option<&[String]>,
4366 per_edge_stops: &[Vec<(f64, f64)>],
4367 chamfer: bool,
4368 name: Option<&str>,
4369) -> Result<BrepSolid, KernelRefusal> {
4370 let _operation = crate::blend::BlendOperation::enter();
4371 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
4372 return Err(KernelRefusal::input(
4373 KernelStage::Collect,
4374 "variable_selection",
4375 "fillet_edges_variable_stops: one stop list per selected edge is required",
4376 ));
4377 }
4378 fillet_edges_variable_impl(
4379 solid,
4380 edge_points,
4381 edge_names,
4382 per_edge_stops,
4383 chamfer,
4384 name,
4385 "fillet_edges_variable_stops",
4386 false,
4387 )
4388}
4389
4390struct ChainLink {
4394 input_index: usize,
4396 forward: bool,
4398 arc: Vec<(f64, f64)>,
4401 length: f64,
4403 abscissa: f64,
4406}
4407
4408fn edge_arc_table(
4419 curve: &NurbsCurve,
4420 t0: f64,
4421 t1: f64,
4422 tol: f64,
4423) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4424 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
4425 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4426 let mut table = Vec::with_capacity(n + 1);
4427 let mut cumulative = 0.0;
4428 let mut previous = evaluate(t0)?;
4429 table.push((0.0, 0.0));
4430 for j in 1..=n {
4431 let fraction = j as f64 / n as f64;
4432 let point = evaluate(t0 + (t1 - t0) * fraction)?;
4433 cumulative += point.sub(previous).length();
4434 previous = point;
4435 table.push((fraction, cumulative));
4436 }
4437 Ok((table, cumulative))
4438 };
4439 let mut n = 16usize;
4440 let (mut table, mut length) = build(n)?;
4441 while n < 4096 {
4442 n *= 2;
4443 let (next_table, next_length) = build(n)?;
4444 let converged = (next_length - length).abs() <= tol;
4445 table = next_table;
4446 length = next_length;
4447 if converged {
4448 break;
4449 }
4450 }
4451 Ok((table, length))
4452}
4453
4454fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
4457 let fraction = fraction.clamp(0.0, 1.0);
4458 let intervals = table.len() - 1;
4459 let scaled = fraction * intervals as f64;
4460 let index = (scaled.floor() as usize).min(intervals - 1);
4461 let local = scaled - index as f64;
4462 let (_, a) = table[index];
4463 let (_, b) = table[index + 1];
4464 a + (b - a) * local
4465}
4466
4467fn resolve_selected_chain(
4476 solid: &BrepSolid,
4477 edge_points: &[Vec3],
4478 entry: &str,
4479 tol: f64,
4480) -> Result<Vec<ChainLink>, KernelRefusal> {
4481 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
4484
4485 struct Resolved {
4486 edge_id: u64,
4487 start: Vec3,
4488 end: Vec3,
4489 arc: Vec<(f64, f64)>,
4490 length: f64,
4491 }
4492 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
4493 for point in edge_points {
4494 let edge_id = resolve_edge_by_point(solid, *point)?;
4495 if resolved.iter().any(|r| r.edge_id == edge_id) {
4496 return Err(KernelRefusal::input(
4497 KernelStage::Collect,
4498 "duplicate_edge",
4499 format!("{entry}: the same edge was selected more than once"),
4500 ));
4501 }
4502 let edge = solid
4503 .edges
4504 .iter()
4505 .find(|e| e.id == edge_id)
4506 .ok_or_else(|| {
4507 KernelRefusal::internal(
4508 KernelStage::Collect,
4509 "chain_resolved_edge",
4510 format!("{entry}: resolved edge vanished"),
4511 )
4512 })?;
4513 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
4514 if !(length > 0.0) {
4515 return Err(KernelRefusal::input(
4516 KernelStage::Collect,
4517 "zero_length_edge",
4518 format!("{entry}: selected edge has zero length"),
4519 ));
4520 }
4521 resolved.push(Resolved {
4522 edge_id,
4523 start: edge
4524 .curve
4525 .evaluate(edge.t0)
4526 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
4527 end: edge
4528 .curve
4529 .evaluate(edge.t1)
4530 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
4531 arc,
4532 length,
4533 });
4534 }
4535 let n = resolved.len();
4536 if n == 1 {
4537 let only = resolved.remove(0);
4538 return Ok(vec![ChainLink {
4539 input_index: 0,
4540 forward: true,
4541 arc: only.arc,
4542 length: only.length,
4543 abscissa: 0.0,
4544 }]);
4545 }
4546
4547 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
4549 for (i, r) in resolved.iter().enumerate() {
4550 for (point, is_start) in [(r.start, true), (r.end, false)] {
4551 match clusters
4552 .iter_mut()
4553 .find(|(anchor, _)| anchor.sub(point).length() < band)
4554 {
4555 Some((_, members)) => members.push((i, is_start)),
4556 None => clusters.push((point, vec![(i, is_start)])),
4557 }
4558 }
4559 }
4560 if clusters.iter().any(|(_, members)| members.len() > 2) {
4561 return Err(KernelRefusal::input(
4562 KernelStage::Collect,
4563 "chain_branching",
4564 format!(
4565 "{entry}: selected edges must form one open chain \
4566 (a vertex is shared by three or more selected edges)"
4567 ),
4568 ));
4569 }
4570 let free: Vec<usize> = clusters
4571 .iter()
4572 .enumerate()
4573 .filter(|(_, (_, members))| members.len() == 1)
4574 .map(|(c, _)| c)
4575 .collect();
4576 if free.len() != 2 {
4577 return Err(KernelRefusal::input(
4578 KernelStage::Collect,
4579 "chain_open",
4580 format!(
4581 "{entry}: selected edges must form one OPEN chain \
4582 (closed rings and disconnected selections are not supported)"
4583 ),
4584 ));
4585 }
4586 let start_cluster = *free
4588 .iter()
4589 .min_by_key(|&&c| clusters[c].1[0].0)
4590 .expect("two free ends");
4591
4592 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
4594 let mut visited = vec![false; n];
4595 let mut abscissa = 0.0_f64;
4596 let mut cluster = start_cluster;
4597 for _ in 0..n {
4598 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
4599 .1
4600 .iter()
4601 .find(|(edge_index, _)| !visited[*edge_index])
4602 else {
4603 return Err(KernelRefusal::input(
4604 KernelStage::Collect,
4605 "chain_walk",
4606 format!(
4607 "{entry}: selected edges are not connected into one chain"
4608 ),
4609 ));
4610 };
4611 visited[edge_index] = true;
4612 let r = &resolved[edge_index];
4613 links.push(ChainLink {
4614 input_index: edge_index,
4615 forward: entered_at_start,
4616 arc: r.arc.clone(),
4617 length: r.length,
4618 abscissa,
4619 });
4620 abscissa += r.length;
4621 let exit_point = if entered_at_start { r.end } else { r.start };
4622 cluster = clusters
4623 .iter()
4624 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
4625 .ok_or_else(|| {
4626 KernelRefusal::internal(
4627 KernelStage::Collect,
4628 "chain_cluster",
4629 format!("{entry}: chain walk lost an endpoint cluster"),
4630 )
4631 })?;
4632 }
4633 if visited.iter().any(|v| !v) {
4634 return Err(KernelRefusal::input(
4635 KernelStage::Collect,
4636 "chain_connected",
4637 format!(
4638 "{entry}: selected edges are not connected into one chain"
4639 ),
4640 ));
4641 }
4642 Ok(links)
4643}
4644
4645fn law_stops_for_link(
4664 link: &ChainLink,
4665 law: &crate::law::RadiusLaw,
4666 scale: f64,
4667 tol: f64,
4668) -> Vec<(f64, f64)> {
4669 let radius_at_fraction = |fraction: f64| -> f64 {
4670 let arc = arc_length_at_fraction(&link.arc, fraction);
4671 let chain_s = if link.forward {
4672 link.abscissa + arc
4673 } else {
4674 link.abscissa + (link.length - arc)
4675 };
4676 law.radius_at(chain_s * scale)
4677 };
4678 let curvature = law
4681 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
4682 * (scale * link.length).powi(2);
4683 let base = if curvature * 0.125 <= tol {
4684 1usize
4685 } else {
4686 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
4687 };
4688 let mut stops: Vec<(f64, f64)> = (0..=base)
4689 .map(|j| {
4690 let fraction = j as f64 / base as f64;
4691 (fraction, radius_at_fraction(fraction))
4692 })
4693 .collect();
4694 let mut depth = 0usize;
4696 while depth < 8 {
4697 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
4698 let mut inserted = false;
4699 for pair in stops.windows(2) {
4700 refined.push(pair[0]);
4701 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
4702 let law_mid = radius_at_fraction(mid_fraction);
4703 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
4704 if (law_mid - linear_mid).abs() > tol {
4705 refined.push((mid_fraction, law_mid));
4706 inserted = true;
4707 }
4708 }
4709 refined.push(*stops.last().expect("at least two stops"));
4710 stops = refined;
4711 if !inserted {
4712 break;
4713 }
4714 depth += 1;
4715 }
4716 stops
4717}
4718
4719pub fn fillet_edges_variable_law(
4731 solid: &BrepSolid,
4732 edge_points: &[Vec3],
4733 edge_names: Option<&[String]>,
4734 law: &crate::law::RadiusLaw,
4735 chamfer: bool,
4736 name: Option<&str>,
4737) -> Result<BrepSolid, KernelRefusal> {
4738 let _operation = crate::blend::BlendOperation::enter();
4741 const ENTRY: &str = "fillet_edges_variable_law";
4742 if edge_points.is_empty() {
4743 return Err(KernelRefusal::input(
4744 KernelStage::Collect,
4745 "law_selection",
4746 format!("{ENTRY}: no edges selected"),
4747 ));
4748 }
4749 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4750 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4751 fillet_variable_law_on_chain(
4752 solid,
4753 edge_points,
4754 edge_names,
4755 &chain,
4756 law,
4757 chamfer,
4758 name,
4759 ENTRY,
4760 tolerances.intersection_fit,
4761 )
4762}
4763
4764pub fn fillet_edges_variable_vertex_radii(
4770 solid: &BrepSolid,
4771 edge_points: &[Vec3],
4772 edge_names: Option<&[String]>,
4773 vertex_radii: &[f64],
4774 chamfer: bool,
4775 name: Option<&str>,
4776) -> Result<BrepSolid, KernelRefusal> {
4777 let _operation = crate::blend::BlendOperation::enter();
4780 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
4781 if edge_points.is_empty() {
4782 return Err(KernelRefusal::input(
4783 KernelStage::Collect,
4784 "vertex_radii_selection",
4785 format!("{ENTRY}: no edges selected"),
4786 ));
4787 }
4788 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4789 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4790 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
4791 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
4792 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
4793 fillet_variable_law_on_chain(
4794 solid,
4795 edge_points,
4796 edge_names,
4797 &chain,
4798 &law,
4799 chamfer,
4800 name,
4801 ENTRY,
4802 tolerances.intersection_fit,
4803 )
4804}
4805
4806#[allow(clippy::too_many_arguments)]
4810fn fillet_variable_law_on_chain(
4811 solid: &BrepSolid,
4812 edge_points: &[Vec3],
4813 edge_names: Option<&[String]>,
4814 chain: &[ChainLink],
4815 law: &crate::law::RadiusLaw,
4816 chamfer: bool,
4817 name: Option<&str>,
4818 entry: &str,
4819 tol: f64,
4820) -> Result<BrepSolid, KernelRefusal> {
4821 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
4822 let scale = law.total_length() / chain_length;
4823 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
4824 for link in chain {
4825 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
4826 }
4827 fillet_edges_variable_impl(
4828 solid,
4829 edge_points,
4830 edge_names,
4831 &per_edge_stops,
4832 chamfer,
4833 name,
4834 entry,
4835 false,
4836 )
4837}
4838
4839pub fn chamfer_edges_asymmetric(
4844 solid: &BrepSolid,
4845 edge_points: &[Vec3],
4846 edge_names: Option<&[String]>,
4847 d1: f64,
4848 d2: f64,
4849 name: Option<&str>,
4850) -> Result<BrepSolid, KernelRefusal> {
4851 let _operation = crate::blend::BlendOperation::enter();
4854 if edge_points.is_empty() {
4855 return Err(KernelRefusal::input(
4856 KernelStage::Collect,
4857 "asymmetric_selection",
4858 "chamfer_edges_asymmetric: no edges selected",
4859 ));
4860 }
4861 let mut result = solid.clone();
4862 for (index, point) in edge_points.iter().enumerate() {
4866 let edge_id = resolve_edge_by_point(&result, *point)?;
4867 let edge_name = per_edge_name(edge_names, name, index);
4868 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
4869 }
4870 refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
4871 Ok(result)
4872}
4873
4874pub fn chamfer_edges_angle(
4878 solid: &BrepSolid,
4879 edge_points: &[Vec3],
4880 edge_names: Option<&[String]>,
4881 d1: f64,
4882 angle_rad: f64,
4883 name: Option<&str>,
4884) -> Result<BrepSolid, KernelRefusal> {
4885 let _operation = crate::blend::BlendOperation::enter();
4888 if edge_points.is_empty() {
4889 return Err(KernelRefusal::input(
4890 KernelStage::Collect,
4891 "angle_selection",
4892 "chamfer_edges_angle: no edges selected",
4893 ));
4894 }
4895 let mut result = solid.clone();
4896 for (index, point) in edge_points.iter().enumerate() {
4897 let edge_id = resolve_edge_by_point(&result, *point)?;
4898 let edge_name = per_edge_name(edge_names, name, index);
4899 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
4900 }
4901 refuse_invalid_result(&result, "chamfer_edges_angle")?;
4902 Ok(result)
4903}
4904
4905fn dump_loops_debug(label: &str, solid: &BrepSolid) {
4909 for shell in &solid.shells {
4910 for face in &shell.faces {
4911 for (index, loop_record) in face.loops.iter().enumerate() {
4912 let walk: Vec<String> = loop_record
4913 .coedges
4914 .iter()
4915 .map(|coedge| {
4916 let uv = coedge
4920 .pcurve
4921 .domain()
4922 .ok()
4923 .and_then(|[q0, q1]| {
4924 let a = coedge.pcurve.evaluate(q0).ok()?;
4925 let b = coedge.pcurve.evaluate(q1).ok()?;
4926 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
4927 })
4928 .unwrap_or_default();
4929 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
4930 Some(edge) => format!(
4931 "c{}:e{}{}({}->{}){uv}",
4932 coedge.id,
4933 edge.id,
4934 if coedge.forward { "+" } else { "-" },
4935 edge.start_vertex_id,
4936 edge.end_vertex_id
4937 ),
4938 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
4939 }
4940 })
4941 .collect();
4942 eprintln!(
4943 "{label} face {} ({:?}) loop {index}: {}",
4944 face.id,
4945 face.name,
4946 walk.join(" ")
4947 );
4948 }
4949 }
4950 }
4951}
4952
4953
4954