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 let answered = 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)
297 .map_err(|merged_refusal| {
298 refusal.with_message(|message| {
299 format!("{message} The rim merged into one closed edge refused too: {}", merged_refusal.message)
300 })
301 }),
302 None => Err(refusal),
303 }
304 }
305 other => other,
306 };
307 match answered {
314 Err(refusal) if solid.kind == crate::BodyKind::Sheet && crate::blend::sheet_ends::sheet_free_end_on() => {
315 let edge_ids = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<Vec<_>, _>>();
316 let Ok(edge_ids) = edge_ids else { return Err(refusal) };
317 let Some(closed) = crate::blend::sheet_ends::close_free_ends(solid, &edge_ids) else { return Err(refusal) };
318 let count = crate::blend::sheet_ends::temporary_faces(&closed);
319 match fillet_edges_prepared(&closed, edge_points, edge_names, radius, chamfer, name) {
320 Ok(built) => crate::blend::sheet_ends::open_free_ends(built, count).ok_or(refusal),
321 Err(closed_refusal) => Err(refusal.with_message(|message| {
322 format!("{message} The sheet with its free ends closed refused too: {}", closed_refusal.message)
323 })),
324 }
325 }
326 other => other,
327 }
328 }
329 }
330}
331
332fn fillet_edges_prepared(
333 solid: &BrepSolid,
334 edge_points: &[Vec3],
335 edge_names: Option<&[String]>,
336 radius: f64,
337 chamfer: bool,
338 name: Option<&str>,
339) -> Result<BrepSolid, KernelRefusal> {
340 let _operation = crate::blend::BlendOperation::enter();
343 BrepSolid::clear_validate_memo();
346 let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
350 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
351 let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
354 let _cap = SubsetDropCap::set(cap);
355 let (result, selection) = match fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name) {
356 Err(fold) if !chamfer && crate::blend::is_wall_fold(&fold) && crate::blend::relief::radius_relief_on() => {
359 let misfit = crate::blend::relief::is_misfit_fold(&fold);
360 let relieved = relieve_fold(solid, edge_points, edge_names, radius, name, fold);
361 if relieved.is_err() && misfit {
365 let _off = crate::blend::relief::MisfitOff::enter();
366 return fillet_edges(solid, edge_points, edge_names, radius, chamfer, name);
367 }
368 return relieved;
369 }
370 reported => reported?,
371 };
372 if selection.is_complete() {
373 return Ok(result);
374 }
375 let dropped: Vec<String> = selection
378 .rejected
379 .iter()
380 .map(|index| {
381 let point = edge_points[*index];
382 let named = edge_names
383 .and_then(|names| names.get(*index))
384 .map(|name| format!("`{name}` "))
385 .unwrap_or_default();
386 format!(
387 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
388 point.x, point.y, point.z
389 )
390 })
391 .collect();
392 Err(KernelRefusal::ill_posed(
396 KernelStage::Select,
397 "incomplete_selection",
398 format!(
399 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
400 rest of the selection and carry no wall in the result ({}). The group refusal was: \
401 {}. Blend them separately, or call the reported entry point to accept the partial \
402 selection deliberately.",
403 selection.applied.len(),
404 selection.requested,
405 selection.rejected.len(),
406 dropped.join(", "),
407 selection
408 .reason
409 .as_deref()
410 .unwrap_or("the whole group built but not every edge grew a wall"),
411 ),
412 ))
413}
414
415fn relieve_fold(
423 solid: &BrepSolid,
424 edge_points: &[Vec3],
425 edge_names: Option<&[String]>,
426 radius: f64,
427 name: Option<&str>,
428 fold: KernelRefusal,
429) -> Result<BrepSolid, KernelRefusal> {
430 let _relieving = crate::blend::relief::Relieving::enter();
431 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
432 let selected: Vec<u64> = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<_, _>>()?;
433 let joined = crate::blend::relief::join_continuations(solid, &selected);
437 let solid = joined.as_ref().unwrap_or(solid);
438 let mut ids: Vec<u64> = Vec::with_capacity(edge_points.len());
439 let mut points: Vec<Vec3> = Vec::with_capacity(edge_points.len());
440 let mut names: Vec<String> = Vec::with_capacity(edge_points.len());
441 for (index, point) in edge_points.iter().enumerate() {
442 let id = resolve_edge_by_point(solid, *point)?;
443 if !ids.contains(&id) {
444 ids.push(id);
445 points.push(*point);
446 if let Some(name) = edge_names.and_then(|names| names.get(index)) {
447 names.push(name.clone());
448 }
449 }
450 }
451 let (edge_points, edge_names) = (&points[..], edge_names.map(|_| &names[..]));
452 if debug && joined.is_some() {
453 eprintln!("radius relief: joined continuation edges, {} selected edges become {}", selected.len(), ids.len());
454 }
455 let started = web_time::Instant::now();
456 let measured = match crate::blend::relief::measure_ceilings(solid, &ids, radius) {
457 Ok(measured) => measured,
458 Err(refusal) => return Err(fold.with_message(|message| format!("{message}; the relief could not measure the edges: {refusal}"))),
459 };
460 if debug {
461 eprintln!("radius relief: ceilings measured in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
462 }
463 let largest = measured.iter().flatten().map(|(_, ceiling)| *ceiling).fold(0.0_f64, f64::max);
468 if largest < radius {
469 return Err(fold.with_message(|message| {
470 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}")
471 }));
472 }
473 let mut last: Option<KernelRefusal> = None;
474 for attempt in 0..crate::blend::relief::ATTEMPTS {
475 let plan = match crate::blend::relief::plan_relief(solid, &ids, radius, &measured, attempt) {
476 Ok(Some(plan)) => plan,
477 Ok(None) => break,
480 Err(refusal) => {
481 last = Some(refusal);
482 break;
483 }
484 };
485 let mark = crate::blend::blend_notes_mark();
486 match build_relief_plan(solid, edge_points, edge_names, &ids, radius, name, &plan) {
487 Ok(body) => {
488 for zone in &plan.zones {
489 let near: Vec<String> = zone
490 .edges
491 .iter()
492 .filter_map(|index| solid.edges.iter().find(|edge| edge.id == ids[*index]))
493 .map(|edge| edge.name.clone().unwrap_or_else(|| format!("edge #{}", edge.id)))
494 .map(|label| format!("`{label}`"))
495 .collect();
496 let (low, high) = zone
501 .edges
502 .iter()
503 .flat_map(|index| plan.stops[*index].iter().map(|(_, r)| *r))
504 .fold((f64::INFINITY, 0.0_f64), |(low, high), r| (low.min(r), high.max(r)));
505 let note = if high < radius * (1.0 - 1e-9) {
506 format!(
507 "radius reduced over the whole of {}: {low:.2} to {high:.2}, so r {radius} is reached nowhere \
508 on them (where they turn tightest the largest ball that fits is {:.2})",
509 near.join(", "),
510 zone.ceiling,
511 )
512 } else {
513 format!(
514 "radius reduced to {:.2} locally near {}: r {radius} does not fit there (the largest ball that does is {:.2})",
515 zone.radius,
516 near.join(", "),
517 zone.ceiling,
518 )
519 };
520 crate::blend::record_blend_note(note);
521 }
522 return Ok(body);
523 }
524 Err(refusal) => {
525 crate::blend::blend_notes_truncate(mark);
526 if debug {
527 eprintln!("radius relief attempt {attempt} refused at {:.1} ms: {refusal}", started.elapsed().as_secs_f64() * 1e3);
528 }
529 last = Some(refusal);
530 }
531 }
532 }
533 Err(match last {
534 Some(last) => fold.with_message(|message| format!("{message}; a locally reduced radius did not build either: {last}")),
535 None => fold,
536 })
537}
538
539fn build_relief_plan(
541 solid: &BrepSolid,
542 edge_points: &[Vec3],
543 edge_names: Option<&[String]>,
544 ids: &[u64],
545 radius: f64,
546 name: Option<&str>,
547 plan: &crate::blend::relief::ReliefPlan,
548) -> Result<BrepSolid, KernelRefusal> {
549 let count = ids.len();
551 let mut group: Vec<usize> = (0..count).collect();
552 let find = |group: &mut Vec<usize>, mut index: usize| {
553 while group[index] != index {
554 group[index] = group[group[index]];
555 index = group[index];
556 }
557 index
558 };
559 let vertices: Vec<[u64; 2]> = ids
560 .iter()
561 .map(|id| {
562 solid.edges.iter().find(|edge| edge.id == *id).map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).unwrap_or([u64::MAX; 2])
563 })
564 .collect();
565 for a in 0..count {
566 for b in a + 1..count {
567 if vertices[a].iter().any(|vertex| *vertex != u64::MAX && vertices[b].contains(vertex)) {
568 let (root_a, root_b) = (find(&mut group, a), find(&mut group, b));
569 group[root_a] = root_b;
570 }
571 }
572 }
573 let reduced: Vec<bool> = plan.stops.iter().map(|stops| stops.iter().any(|(_, r)| *r < radius)).collect();
574 let relieved_group: Vec<bool> = (0..count)
575 .map(|index| {
576 let root = find(&mut group, index);
577 (0..count).any(|other| reduced[other] && find(&mut group, other) == root)
578 })
579 .collect();
580 let pick = |want: bool| -> (Vec<Vec3>, Option<Vec<String>>, Vec<Vec<(f64, f64)>>) {
581 let indices: Vec<usize> = (0..count).filter(|index| relieved_group[*index] == want).collect();
582 (
583 indices.iter().map(|index| edge_points[*index]).collect(),
584 edge_names.map(|names| indices.iter().filter_map(|index| names.get(*index).cloned()).collect()),
585 indices.iter().map(|index| plan.stops[*index].clone()).collect(),
586 )
587 };
588 let (constant_points, constant_names, _) = pick(false);
589 let (relieved_points, relieved_names, relieved_stops) = pick(true);
590 let mut body = solid.clone();
591 if !constant_points.is_empty() {
592 body = fillet_edges(&body, &constant_points, constant_names.as_deref(), radius, false, name)?;
593 }
594 let built = fillet_edges_variable_impl(
595 &body,
596 &relieved_points,
597 relieved_names.as_deref(),
598 &relieved_stops,
599 false,
600 name,
601 "fillet_edges",
602 false,
603 )?;
604 let issues = built.validate();
605 if !issues.is_empty() {
606 return Err(KernelRefusal::internal(
607 KernelStage::Validate,
608 "relief_validate",
609 format!("radius relief: the relieved fillet does not validate: {issues:?}"),
610 ));
611 }
612 Ok(built)
613}
614
615#[derive(Clone, Debug)]
622pub struct BlendSelection {
623 pub requested: usize,
625 pub applied: Vec<usize>,
627 pub rejected: Vec<usize>,
629 pub reason: Option<String>,
631}
632
633impl BlendSelection {
634 pub fn is_complete(&self) -> bool {
636 self.rejected.is_empty()
637 }
638}
639
640pub fn fillet_edges_reported_at_offset(
652 solid: &BrepSolid,
653 edge_points: &[Vec3],
654 edge_names: Option<&[String]>,
655 radius: f64,
656 chamfer: bool,
657 name: Option<&str>,
658 offset: Vec3,
659) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
660 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
661}
662
663#[allow(clippy::too_many_arguments)]
665fn fillet_translated(
666 solid: &BrepSolid,
667 edge_points: &[Vec3],
668 edge_names: Option<&[String]>,
669 radius: f64,
670 chamfer: bool,
671 name: Option<&str>,
672 offset: Vec3,
673 kind: crate::recentre::EntryKind,
674) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
675 let _entry = crate::recentre::TranslatedEntry::open(kind);
676 let translate = |solid: &BrepSolid, offset: Vec3| {
677 crate::recentre::translate_solid(solid, offset).map_err(|message| {
678 KernelRefusal::internal(
679 KernelStage::Collect,
680 "translation_control",
681 format!("fillet_edges: the translation control could not move the solid: {message}"),
682 )
683 })
684 };
685 let moved = translate(solid, offset)?;
686 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
687 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
688 Ok((translate(&result, offset.scale(-1.0))?, selection))
689}
690
691pub fn fillet_edges_reported(
692 solid: &BrepSolid,
693 edge_points: &[Vec3],
694 edge_names: Option<&[String]>,
695 radius: f64,
696 chamfer: bool,
697 name: Option<&str>,
698) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
699 if let Some(offset) = crate::recentre::control_offset_at_entry() {
702 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
703 }
704 let bounds = crate::recentre::solid_bounds(solid);
707 if let Some(offset) = crate::recentre::guard_offset(bounds) {
708 crate::recentre::profile_fired("fillet", offset, bounds);
709 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
710 }
711 let _operation = crate::blend::BlendOperation::enter();
714 let _total = crate::blend::BlendTimer::start("timing.total_ms");
717 if !(radius > 0.0) || !radius.is_finite() {
718 return Err(KernelRefusal::input(
719 KernelStage::Collect,
720 "radius",
721 "fillet_edges: radius must be positive",
722 ));
723 }
724 if edge_points.is_empty() {
725 return Err(KernelRefusal::input(
726 KernelStage::Collect,
727 "selection",
728 "fillet_edges: no edges selected",
729 ));
730 }
731 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
739 let mut selected_ids = Vec::with_capacity(edge_points.len());
740 if crate::blend::blend_operation_outermost() {
741 super::analyze::clear_rollover_only();
742 }
743 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
744 let _boundaries = super::analyze::SupportBoundaryScope::open();
745 for point in edge_points {
746 let resolve = crate::blend::BlendTimer::start("timing.selection_checks.resolve_ms");
747 let edge_id = resolve_edge_by_point(solid, *point)?;
748 resolve.stop();
749 let concavity = crate::blend::BlendTimer::start("timing.selection_checks.concavity_ms");
750 check_mixed_concavity(solid, edge_id, entry)?;
751 concavity.stop();
752 let _extent = crate::blend::BlendTimer::start("timing.selection_checks.extent_ms");
753 if edge_points.len() == 1 && !chamfer {
755 check_support_extent_clipping(solid, edge_id, radius, entry)?;
756 } else {
757 check_support_extent(solid, edge_id, radius, entry)?;
758 }
759 selected_ids.push(edge_id);
760 }
761 drop(_boundaries);
762 selection_checks.stop();
763 if !chamfer {
802 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
807 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
808 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
809 corner_analysis.stop();
810 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
811 for corner in &corners {
812 eprintln!(
813 "mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
814 corner.vertex, corner.point.x, corner.point.y, corner.point.z,
815 corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
816 corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
817 );
818 }
819 }
820 if !corners.is_empty() {
821 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
824 let composed = rounded_boss_composition(
826 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
827 ).or_else(|error| {
828 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
829 eprintln!("rounded boss composition refused: {error}");
830 }
831 runout_composition(
832 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
833 )
834 }).or_else(|error| {
835 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
836 eprintln!("runout composition refused: {error}");
837 }
838 open_pad_composition(
844 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
845 ).map_err(|pad_error| {
846 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
847 eprintln!("open-pad composition refused: {pad_error}");
848 }
849 let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
854 if declined { error } else { pad_error }
855 })
856 }).or_else(|error| {
857 cusp_composition(solid, &selected_ids, edge_points, edge_names, radius, name, &corners).map_err(|cusp_error| {
862 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
863 eprintln!("cusp composition refused: {cusp_error}");
864 }
865 error
866 })
867 });
868 match composed {
869 Ok(result) => {
870 let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
879 refuse_invalid_result(&result, entry)?;
883 let missing = walls_missing_ties(
884 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
885 )?;
886 if !missing.is_empty() {
888 return Err(KernelRefusal::internal(
889 KernelStage::Validate,
890 "runout_walls_missing",
891 format!(
892 "{entry}: the composition built a solid with no wall on {}",
893 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
894 ),
895 ));
896 }
897 let n = edge_points.len();
898 return Ok((
899 result,
900 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
901 ));
902 }
903 Err(error) => {
904 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
905 eprintln!("runout composition refused: {error}");
906 }
907 check_mixed_corner_convexity(
908 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
909 )?;
910 }
911 }
912 }
913 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
919 check_degenerate_corner_setbacks(
920 solid,
921 &selected_ids,
922 edge_points,
923 edge_names,
924 radius,
925 name,
926 )?;
927 corner_analysis.stop();
928 }
929
930 let n = edge_points.len();
931 let complete = |applied: Vec<usize>| BlendSelection {
932 requested: n,
933 applied,
934 rejected: Vec::new(),
935 reason: None,
936 };
937 let mut memo = ComponentMemo::default();
948 let profile = crate::profile::profile_enabled();
949 let group_started = web_time::Instant::now();
950 let group = fillet_edges_group_in(
951 solid,
952 edge_points,
953 edge_names,
954 radius,
955 chamfer,
956 name,
957 Some(&mut memo),
958 );
959 if profile {
960 profile_line!(
961 "blend.selection group edges={n} ok={} ms={:.3}",
962 group.is_ok(),
963 group_started.elapsed().as_secs_f64() * 1e3
964 );
965 }
966 let group_err = match group {
967 Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
972 refuse_invalid_result(&result, entry).unwrap_err()
973 }
974 Ok(result) => {
975 let missing =
980 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
981 if missing.is_empty() {
982 return Ok((result, complete((0..n).collect())));
983 }
984 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
985 return Ok((
986 result,
987 BlendSelection {
988 requested: n,
989 applied,
990 rejected: missing,
991 reason: None,
992 },
993 ));
994 }
995 Err(error) => error,
996 };
997 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err)
1007 || crate::blend::is_tangential_transverse_end(&group_err)
1008 {
1009 return Err(group_err);
1010 }
1011 if n < 2 || n > 12 {
1016 return Err(group_err);
1017 }
1018 let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
1025 for drop in 1..=max_drop {
1026 for dropped in index_combinations(n, drop) {
1027 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
1028 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
1029 let kept_names: Option<Vec<String>> =
1032 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
1033 crate::blend::record_count("count.subset.builds", 1);
1034 let subset_started = web_time::Instant::now();
1035 let subset = fillet_edges_group_in(
1036 solid,
1037 &points,
1038 kept_names.as_deref(),
1039 radius,
1040 chamfer,
1041 name,
1042 Some(&mut memo),
1043 );
1044 if profile {
1045 profile_line!(
1046 "blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
1047 subset.is_ok(),
1048 subset_started.elapsed().as_secs_f64() * 1e3
1049 );
1050 }
1051 let result = match subset {
1052 Ok(result) => result,
1053 Err(error) => {
1054 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1055 eprintln!("subset dropped={dropped:?} refused: {error}");
1056 }
1057 continue;
1058 }
1059 };
1060 if !result.validate().is_empty() {
1061 continue;
1062 }
1063 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
1064 continue;
1065 }
1066 return Ok((
1067 result,
1068 BlendSelection {
1069 requested: n,
1070 applied: keep,
1071 rejected: dropped,
1072 reason: Some(group_err.message),
1073 },
1074 ));
1075 }
1076 }
1077 Err(group_err)
1078}
1079
1080pub(crate) fn walls_missing_nearest_on() -> bool {
1085 switch!("BREP_WALLS_MISSING_NEAREST", true)
1086}
1087
1088
1089pub(super) fn walls_missing(
1115 original: &BrepSolid,
1116 result: &BrepSolid,
1117 edge_points: &[Vec3],
1118 selected: &[usize],
1119 size: f64,
1120 chamfer: bool,
1121 entry: &str,
1122) -> Result<Vec<usize>, KernelRefusal> {
1123 walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
1124}
1125
1126#[allow(clippy::too_many_arguments)]
1141pub(super) fn walls_missing_ties(
1142 original: &BrepSolid,
1143 result: &BrepSolid,
1144 edge_points: &[Vec3],
1145 selected: &[usize],
1146 size: f64,
1147 chamfer: bool,
1148 entry: &str,
1149 tie_majority: bool,
1150) -> Result<Vec<usize>, KernelRefusal> {
1151use crate::{PointClass, SolidClassifier};
1153 if selected.is_empty() {
1154 return Ok(Vec::new());
1155 }
1156 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
1157 crate::classification::classify_profile_begin();
1158 crate::classification::containment_profile_begin();
1159 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
1160 let visible = band * 100.0;
1163 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
1164 let classifier = SolidClassifier::new(result, band)
1165 .or_refuse(KernelStage::Validate, "wall_classifier")?;
1166 let nearest = if walls_missing_nearest_on() {
1171 Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
1172 } else {
1173 None
1174 };
1175 let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
1176 if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
1177 crate::blend::record_count("count.walls_missing.nearest", 1);
1178 return Ok(class == PointClass::On);
1179 }
1180 Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
1181 };
1182 build_timing.stop();
1183 const STATIONS: usize = 9;
1184 let mut missing = Vec::new();
1185 for &index in selected {
1186 let point = edge_points[index];
1187 let edge_id = resolve_edge_by_point(original, point)?;
1188 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
1189 let mut best: Option<(Vec3, f64)> = None;
1191 let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
1193 for step in 1..=STATIONS {
1195 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
1196 let Ok(station) = edge.curve.evaluate(t) else {
1197 continue;
1198 };
1199 let clearance = match faces[..] {
1200 [first, second] => {
1201 let (Ok((_, a)), Ok((_, b))) = (
1202 super::analyze::face_plane_normal(first, station),
1203 super::analyze::face_plane_normal(second, station),
1204 ) else {
1205 continue;
1206 };
1207 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
1208 if chamfer {
1209 size * half.sin()
1210 } else {
1211 size * (1.0 / half.cos().max(1e-12) - 1.0)
1212 }
1213 }
1214 _ => f64::INFINITY,
1216 };
1217 if best.map_or(true, |(_, known)| clearance > known) {
1218 best = Some((station, clearance));
1219 }
1220 read.push((station, clearance));
1222 }
1224 let Some((station, clearance)) = best else {
1225 return Err(KernelRefusal::internal(
1226 KernelStage::Validate,
1227 "wall_attribution_stations",
1228 format!(
1229 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
1230 station of it could be evaluated",
1231 point.x, point.y, point.z
1232 ),
1233 ));
1234 };
1235 if !(clearance > visible) {
1236 return Err(KernelRefusal::ill_posed(
1240 KernelStage::Validate,
1241 "wall_attribution_band",
1242 format!(
1243 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
1244 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
1245 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
1246 selection is refused rather than reported on a guess",
1247 point.x, point.y, point.z
1248 ),
1249 ));
1250 }
1251 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
1252 let certified = station_clear_of_skin(result, station, band * 10.0, visible)
1259 || station_clear_of_trimmed_skin(result, station, band * 10.0, visible);
1260 let on = if certified {
1261 crate::blend::record_count("count.walls_missing.certified", 1);
1262 false
1263 } else {
1264 crate::blend::record_count("count.walls_missing.classified", 1);
1265 reads_on(station)?
1266 };
1267 classify_timing.stop();
1268 let on = on && (!tie_majority || {
1270 let tolerance = 1e-12 * clearance.abs().max(1.0);
1273 let (mut tied, mut on_skin) = (1usize, 1usize);
1274 for (other, other_clearance) in read.iter().copied() {
1275 if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
1276 continue;
1277 }
1278 tied += 1;
1279 if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
1280 on_skin += 1;
1281 }
1282 }
1283 2 * on_skin > tied
1284 });
1285 if on {
1287 missing.push(index);
1288 }
1289 }
1290 crate::classification::classify_profile_report("blend.walls_missing");
1291 crate::classification::containment_profile_report("blend.walls_missing");
1292 Ok(missing)
1293}
1294
1295
1296
1297fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
1327 use crate::AnalyticSurface;
1328 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
1331 let offset = station.sub(frame.origin);
1332 let axial = offset.dot(frame.axis);
1333 let radial = offset.sub(frame.axis.scale(axial));
1334 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
1335 if azimuth < 0.0 {
1336 azimuth += std::f64::consts::TAU;
1337 }
1338 (radial.length(), axial, azimuth)
1339 };
1340 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
1344 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
1345 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
1346 return 0.0;
1347 }
1348 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
1350 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
1351 };
1352 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
1355 let (du, dv) = (rho1 - rho0, h1 - h0);
1356 let length = (du * du + dv * dv).sqrt();
1357 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
1358 };
1359 let clear = |distance: f64| distance.is_finite() && distance > margin;
1360 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1361 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
1362 if !controls.expanded(on_band).contains(station) {
1363 continue;
1364 }
1365 let certified = match face.surface.analytic() {
1366 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
1367 let (radial, axial, _) = meridian(frame);
1368 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
1369 clear(line.max(axial_clearance(axial, 0.0, *height)))
1370 }
1371 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
1372 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
1373 let (radial, axial, _) = meridian(frame);
1374 let tube = radial - major_radius;
1375 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
1376 }
1377 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
1378 let [a, b] = generatrix.control_points.as_slice() else { return false };
1385 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1386 return false;
1387 }
1388 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
1389 let offset = point.sub(frame.origin);
1390 let axial = offset.dot(frame.axis);
1391 let radial = offset.sub(frame.axis.scale(axial));
1392 let rho = radial.length();
1393 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);
1394 on_meridian.then_some((rho, axial))
1395 };
1396 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 {
1397 return false;
1398 };
1399 let (radial, axial, azimuth) = meridian(frame);
1400 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
1401 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
1406 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
1407 }
1408 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
1409 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
1410 if clear(station.sub(*origin).dot(normal).abs()) {
1411 true
1412 } else {
1413 planar_trims_clear_of(face, plane, station, on_band, margin)
1414 }
1415 }
1416 None => false,
1417 };
1418 if !certified {
1419 return false;
1420 }
1421 }
1422 true
1423}
1424
1425fn station_clear_of_trimmed_skin(result: &BrepSolid, point: Vec3, on_band: f64, margin: f64) -> bool {
1431 let _timer = crate::blend::BlendTimer::start("timing.walls_missing.trimmed_bound_ms");
1432 if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1433 || !margin.is_finite() || !(margin > on_band && on_band > 0.0)
1434 { return false; }
1435 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1436 let Ok(bounds) = crate::Aabb::from_surface_controls(&face.surface) else { return false; };
1437 if !bounds.expanded(margin).contains(point) { continue; }
1438 if face.surface.analytic().is_none() {
1439 if general_carrier_clear(&face.surface, point, margin) { continue; }
1440 return false;
1441 }
1442 match face.surface.analytic() {
1448 Some(crate::AnalyticSurface::Plane { .. }) => {},
1449 Some(crate::AnalyticSurface::Sphere { radius, .. }) if *radius > 4.0 * margin => {},
1450 Some(crate::AnalyticSurface::Torus { major_radius, minor_radius, .. })
1451 if *minor_radius > 4.0 * margin && *major_radius > *minor_radius + 4.0 * margin => {},
1452 _ => return false,
1453 }
1454 let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else { return false; };
1455 if !projection.distance.is_finite() { return false; }
1456 if projection.distance > margin { continue; }
1457 let band = crate::classification::surface_uv_band(&face.surface, projection.u, projection.v, on_band);
1458 let Ok(inside) = crate::parameter_point_in_face(face, crate::Vec2 { x: projection.u, y: projection.v }, band) else { return false; };
1459 if inside != crate::PolygonClass::Outside { return false; }
1460 let mut edges = 0;
1461 for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
1462 let Some(edge) = result.edges.iter().find(|edge| edge.id == coedge.edge_id) else { return false; };
1463 let Some(piece) = super::analyze::edge_piece(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1)) else { return false; };
1464 let Ok(foot) = crate::project_point_to_curve(&piece, point) else { return false; };
1465 if !foot.distance.is_finite() || foot.distance <= margin { return false; }
1466 edges += 1;
1467 }
1468 if edges == 0 { return false; }
1469 }
1470 true
1471}
1472
1473fn general_carrier_clear(surface: &crate::NurbsSurface, point: Vec3, margin: f64) -> bool {
1479 use crate::Vec4;
1480 let rows = surface.control_points.len();
1481 let columns = surface.control_points.first().map_or(0, Vec::len);
1482 if rows == 0 || columns == 0 || rows.saturating_mul(columns) > 4096
1483 || surface.degree_u > 16 || surface.degree_v > 16 || surface.control_points.iter().any(|row| row.len() != columns)
1484 || !margin.is_finite() || margin <= 0.0
1485 || !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1486 { return false; }
1487 let mut scale = 1.0_f64;
1488 for control in surface.control_points.iter().flatten() {
1489 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1490 let Ok(p) = control.point() else { return false; };
1491 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1492 scale = scale.max(p.x.abs()).max(p.y.abs()).max(p.z.abs());
1493 }
1494 let roundoff = 256.0 * f64::EPSILON * scale * (surface.degree_u + 1) as f64 * (surface.degree_v + 1) as f64;
1497 if roundoff >= margin * 0.01 { return false; }
1498 let zero = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
1499 let blend = |a: Vec4, b: Vec4, alpha: f64| a.scale(alpha).add(b.scale(1.0 - alpha));
1500 let mut fibres = Vec::with_capacity(columns);
1501 for column in 0..columns {
1502 let controls: Vec<_> = surface.control_points.iter().map(|row| row[column]).collect();
1503 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_u, &controls, surface.degree_u, zero, blend) else { return false; };
1504 fibres.push(spans);
1505 }
1506 let mut budget = 256usize;
1507 for u_span in 0..fibres[0].len() {
1508 let mut across = Vec::with_capacity(surface.degree_u + 1);
1509 for row in 0..=surface.degree_u {
1510 let controls: Vec<_> = fibres.iter().map(|fibre| fibre[u_span][row]).collect();
1511 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_v, &controls, surface.degree_v, zero, blend) else { return false; };
1512 across.push(spans);
1513 }
1514 for v_span in 0..across[0].len() {
1515 let net = across.iter().map(|row| row[v_span].clone()).collect();
1516 if !bezier_box_clear(net, point, margin + roundoff, 0, &mut budget) { return false; }
1517 }
1518 }
1519 true
1520}
1521
1522fn bezier_box_clear(net: Vec<Vec<crate::Vec4>>, point: Vec3, margin: f64, depth: usize, budget: &mut usize) -> bool {
1523 if *budget == 0 { return false; }
1524 *budget -= 1;
1525 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1526 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1527 for control in net.iter().flatten() {
1528 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1529 let Ok(p) = control.point() else { return false; };
1530 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1531 low.x = low.x.min(p.x); low.y = low.y.min(p.y); low.z = low.z.min(p.z);
1532 high.x = high.x.max(p.x); high.y = high.y.max(p.y); high.z = high.z.max(p.z);
1533 }
1534 if point.x < low.x - margin || point.x > high.x + margin
1536 || point.y < low.y - margin || point.y > high.y + margin
1537 || point.z < low.z - margin || point.z > high.z + margin
1538 { return true; }
1539 if depth == 10 { return false; }
1540 let rows = net.len();
1541 let columns = net[0].len();
1542 let mut left = net.clone();
1543 let mut right = net.clone();
1544 if depth % 2 == 0 {
1547 for column in 0..columns {
1548 let mut work: Vec<_> = net.iter().map(|row| row[column]).collect();
1549 for level in 1..rows {
1550 for i in 0..rows - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1551 left[level][column] = work[0];
1552 right[rows - level - 1][column] = work[rows - level - 1];
1553 }
1554 }
1555 } else {
1556 for row in 0..rows {
1557 let mut work = net[row].clone();
1558 for level in 1..columns {
1559 for i in 0..columns - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1560 left[row][level] = work[0];
1561 right[row][columns - level - 1] = work[columns - level - 1];
1562 }
1563 }
1564 }
1565 bezier_box_clear(left, point, margin, depth + 1, budget)
1566 && bezier_box_clear(right, point, margin, depth + 1, budget)
1567}
1568
1569fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
1579 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
1580 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1581 let pcurve = &coedge.pcurve;
1582 let [a, b] = pcurve.control_points.as_slice() else { return None };
1583 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1584 return None;
1585 }
1586 for control in [a, b] {
1587 u_low = u_low.min(control.x);
1588 u_high = u_high.max(control.x);
1589 v_low = v_low.min(control.y);
1590 v_high = v_high.max(control.y);
1591 }
1592 }
1593 if !(u_low <= u_high && v_low <= v_high) {
1594 return None;
1595 }
1596 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
1597 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)?];
1598 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
1599 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
1600 let surface = &face.surface;
1601 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
1603 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
1604 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
1605 && surface.degree_u == 1 && surface.control_points.len() == 2;
1606 if !(affine_in_v || affine_in_u) {
1607 return None;
1608 }
1609 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
1610 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1611 (low.is_finite() && high.is_finite()).then_some((low, high))
1612}
1613
1614fn planar_trims_clear_of(
1618 face: &FaceRecord,
1619 plane: &crate::AnalyticSurface,
1620 station: Vec3,
1621 on_band: f64,
1622 margin: f64,
1623) -> bool {
1624 use crate::PolygonClass;
1625 let Some(foot) = plane.project(&face.surface, station) else { return false };
1626 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
1627 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
1628 Ok(PolygonClass::Outside) => {}
1629 _ => return false,
1630 }
1631 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1632 let pcurve = &coedge.pcurve;
1633 let [a, b] = pcurve.control_points.as_slice() else { return false };
1634 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1635 return false;
1636 }
1637 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
1638 let chord = end.sub(start);
1639 let length_squared = chord.dot(chord);
1640 let offset = station.sub(start);
1641 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
1642 let distance = offset.sub(chord.scale(along)).length();
1643 if !(distance.is_finite() && distance > margin) {
1644 return false;
1645 }
1646 }
1647 true
1648}
1649
1650fn per_edge_name<'a>(
1655 edge_names: Option<&'a [String]>,
1656 base: Option<&'a str>,
1657 i: usize,
1658) -> Option<&'a str> {
1659 match edge_names {
1660 Some(names) => names.get(i).map(|value| value.as_str()),
1661 None => base,
1662 }
1663}
1664
1665fn corner_face_name(
1673 edge_names: Option<&[String]>,
1674 base: Option<&str>,
1675 adjacent: &[usize],
1676) -> Option<String> {
1677 match (edge_names, base) {
1678 (Some(names), Some(base)) => {
1679 let prefix = format!("{base}:");
1680 let mut raws: Vec<&str> = adjacent
1681 .iter()
1682 .filter_map(|&i| names.get(i))
1683 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
1684 .collect();
1685 raws.sort_unstable();
1686 raws.dedup();
1687 Some(format!("{base}:CORNER:{}", raws.join("+")))
1688 }
1689 _ => base.map(|value| value.to_string()),
1690 }
1691}
1692
1693#[allow(clippy::too_many_arguments)]
1703fn rounded_boss_composition(
1704 solid: &BrepSolid,
1705 selected_ids: &[u64],
1706 edge_points: &[Vec3],
1707 edge_names: Option<&[String]>,
1708 radius: f64,
1709 name: Option<&str>,
1710 corners: &[crate::blend::MixedCorner],
1711) -> Result<BrepSolid, KernelRefusal> {
1712 if edge_names.is_none() {
1717 let prefix = "FILLET:COMPOSITION";
1718 let names: Vec<_> = selected_ids
1719 .iter()
1720 .map(|id| format!("{prefix}:{id}"))
1721 .collect();
1722 let mut result = rounded_boss_composition(
1723 solid,
1724 selected_ids,
1725 edge_points,
1726 Some(&names),
1727 radius,
1728 Some(prefix),
1729 corners,
1730 )?;
1731 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1732 if face
1733 .name
1734 .as_deref()
1735 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1736 {
1737 face.name = name.map(str::to_owned);
1738 }
1739 }
1740 return Ok(result);
1741 }
1742 let decline = || {
1743 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1744 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1745 };
1746 if std::env::var("BREP_NO_NETWORK").is_ok() {
1747 return Err(decline());
1748 }
1749 let corner = corners
1752 .iter()
1753 .filter(|corner| {
1754 corner.convex.len() == 1
1755 && corner.concave.len() == 2
1756 && corners
1757 .iter()
1758 .filter(|other| other.convex.contains(&corner.convex[0]))
1759 .count()
1760 == 1
1761 })
1762 .min_by_key(|corner| selected_ids[corner.convex[0]])
1763 .ok_or_else(decline)?;
1764 let convexity =
1765 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1766 if !convexity[corner.convex[0]] {
1767 return Err(decline());
1768 }
1769 let spine_index = corner.convex[0];
1770 let selected_edges: Vec<&EdgeRecord> = selected_ids
1773 .iter()
1774 .map(|id| {
1775 solid
1776 .edges
1777 .iter()
1778 .find(|edge| edge.id == *id)
1779 .ok_or_else(|| {
1780 KernelRefusal::internal(
1781 KernelStage::Collect,
1782 "rounded_boss_selected_edge",
1783 format!("rounded boss composition: selected edge {id} is missing"),
1784 )
1785 })
1786 })
1787 .collect::<Result<_, _>>()?;
1788 let mut rounded_indices = vec![spine_index];
1791 loop {
1792 let next = (0..selected_ids.len()).find(|index| {
1793 convexity[*index]
1794 && !rounded_indices.contains(index)
1795 && rounded_indices.iter().any(|other| {
1796 let edge = selected_edges[*index];
1797 let mate = selected_edges[*other];
1798 [edge.start_vertex_id, edge.end_vertex_id]
1799 .iter()
1800 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1801 })
1802 });
1803 match next {
1804 Some(index) => rounded_indices.push(index),
1805 None => break,
1806 }
1807 }
1808 loop {
1817 let next = (0..selected_ids.len()).find(|&index| {
1818 if convexity[index] || rounded_indices.contains(&index) {
1819 return false;
1820 }
1821 let edge = selected_edges[index];
1822 let mut has_star = false;
1823 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
1824 let neighbours: Vec<_> = selected_edges.iter().enumerate()
1825 .filter(|(other, mate)| *other != index
1826 && (mate.start_vertex_id == vertex || mate.end_vertex_id == vertex))
1827 .map(|(other, _)| other)
1828 .collect();
1829 if neighbours.iter().any(|other| !rounded_indices.contains(other)) {
1830 return false;
1831 }
1832 has_star |= neighbours.len() >= 2;
1833 }
1834 has_star
1835 });
1836 match next {
1837 Some(index) => rounded_indices.push(index),
1838 None => break,
1839 }
1840 }
1841 rounded_indices.sort_by_key(|index| selected_ids[*index]);
1842 let mixed: Vec<_> = corners
1843 .iter()
1844 .filter(|corner| {
1845 corner
1846 .convex
1847 .iter()
1848 .any(|index| rounded_indices.contains(index))
1849 })
1850 .collect();
1851 if mixed.iter().any(|corner| {
1852 corner.convex.len() != 1
1853 || corner.concave.len() != 2
1854 || corners
1855 .iter()
1856 .filter(|other| other.convex.contains(&corner.convex[0]))
1857 .count()
1858 != 1
1859 }) {
1860 return Err(decline());
1861 }
1862 let uses = |face: &FaceRecord, edge: u64| {
1863 face.loops
1864 .iter()
1865 .flat_map(|rim| &rim.coedges)
1866 .any(|coedge| coedge.edge_id == edge)
1867 };
1868 let mut caps = Vec::new();
1869 for corner in &mixed {
1870 let spine_id = selected_ids[corner.convex[0]];
1871 let cap = solid
1872 .shells
1873 .iter()
1874 .flat_map(|shell| &shell.faces)
1875 .find(|face| {
1876 corner
1877 .concave
1878 .iter()
1879 .all(|index| uses(face, selected_ids[*index]))
1880 })
1881 .ok_or_else(decline)?;
1882 let faces: Vec<_> = solid
1883 .shells
1884 .iter()
1885 .flat_map(|shell| &shell.faces)
1886 .filter(|face| face.id == cap.id || uses(face, spine_id))
1887 .collect();
1888 let normals: Option<Vec<Vec3>> = faces
1897 .iter()
1898 .map(|face| match face.surface.analytic() {
1899 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
1900 u_dir.cross(*v_dir).normalized().ok()
1901 }
1902 _ => {
1903 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
1904 face.surface.normal(uv.u, uv.v).ok()
1905 }
1906 })
1907 .collect();
1908 let normals = normals.ok_or_else(decline)?;
1909 if normals.len() != 3
1915 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
1916 {
1917 return Err(decline());
1918 }
1919 caps.push(cap.id);
1920 }
1921 let rounded_names: Vec<String> = rounded_indices
1924 .iter()
1925 .map(|index| {
1926 per_edge_name(edge_names, name, *index)
1927 .map(str::to_owned)
1928 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
1929 })
1930 .collect();
1931 let rounded_points: Vec<Vec3> = rounded_indices
1932 .iter()
1933 .map(|index| edge_points[*index])
1934 .collect();
1935 let rounded = fillet_edges_group_in(
1936 solid,
1937 &rounded_points,
1938 Some(&rounded_names),
1939 radius,
1940 false,
1941 name,
1942 None,
1943 )?;
1944 let mut bridges = Vec::new();
1945 for (corner, cap) in mixed.iter().zip(&caps) {
1946 let wall_index = rounded_indices
1947 .iter()
1948 .position(|index| *index == corner.convex[0])
1949 .ok_or_else(|| {
1950 KernelRefusal::internal(
1951 KernelStage::Classify,
1952 "rounded_boss_bridge_wall",
1953 format!(
1954 "rounded boss composition: no convex wall for corner {}",
1955 corner.vertex
1956 ),
1957 )
1958 })?;
1959 let candidates: Vec<_> = rounded
1960 .edges
1961 .iter()
1962 .filter(|edge| {
1963 let faces: Vec<_> = rounded
1964 .shells
1965 .iter()
1966 .flat_map(|shell| &shell.faces)
1967 .filter(|face| uses(face, edge.id))
1968 .collect();
1969 faces.iter().any(|face| face.id == *cap)
1970 && faces
1971 .iter()
1972 .any(|face| face.name.as_deref() == Some(&rounded_names[wall_index]))
1973 })
1974 .collect();
1975 if candidates.len() != 1 {
1976 return Err(decline());
1977 }
1978 bridges.push(candidates[0]);
1979 }
1980 let rim_ids: Vec<u64> = selected_ids
1981 .iter()
1982 .enumerate()
1983 .filter(|(index, _)| !rounded_indices.contains(index))
1984 .map(|(_, id)| *id)
1985 .collect();
1986 if !bridges.iter().all(|bridge| {
1989 [bridge.start_vertex_id, bridge.end_vertex_id]
1990 .iter()
1991 .all(|vertex| {
1992 rounded.edges.iter().any(|edge| {
1993 rim_ids.contains(&edge.id)
1994 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
1995 })
1996 })
1997 }) {
1998 return Err(decline());
1999 }
2000 let mut points = Vec::new();
2001 let mut names = Vec::new();
2002 for (index, id) in selected_ids.iter().enumerate() {
2003 if rounded_indices.contains(&index) {
2004 continue;
2005 }
2006 let edge = rounded
2007 .edges
2008 .iter()
2009 .find(|edge| edge.id == *id)
2010 .ok_or_else(decline)?;
2011 points.push(
2012 edge.curve
2013 .evaluate(0.5 * (edge.t0 + edge.t1))
2014 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
2015 );
2016 names.push(
2017 per_edge_name(edge_names, name, index)
2018 .unwrap_or("FILLET:RIM")
2019 .to_owned(),
2020 );
2021 }
2022 for (corner, bridge) in mixed.iter().zip(&bridges) {
2023 points.push(
2024 bridge
2025 .curve
2026 .evaluate(0.5 * (bridge.t0 + bridge.t1))
2027 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
2028 );
2029 let mut adjacent = corner.concave.clone();
2030 adjacent.push(corner.convex[0]);
2031 names.push(
2032 corner_face_name(edge_names, name, &adjacent)
2033 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
2034 );
2035 }
2036 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
2039 |(result, selection)| {
2040 if selection.applied.len() != points.len() {
2041 Err(decline())
2042 } else {
2043 Ok(result)
2044 }
2045 },
2046 )
2047}
2048
2049#[allow(clippy::too_many_arguments)]
2082fn open_pad_on() -> bool {
2084 switch!(live "BREP_BLEND_OPEN_PAD", true)
2085}
2086
2087#[allow(clippy::too_many_arguments)]
2116fn open_pad_composition(
2117 solid: &BrepSolid,
2118 selected_ids: &[u64],
2119 edge_points: &[Vec3],
2120 edge_names: Option<&[String]>,
2121 radius: f64,
2122 name: Option<&str>,
2123 corners: &[crate::blend::MixedCorner],
2124 entry: &str,
2125) -> Result<BrepSolid, KernelRefusal> {
2126 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
2127 if !open_pad_on() {
2128 return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
2129 }
2130 if std::env::var("BREP_NO_NETWORK").is_ok() {
2131 return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
2132 }
2133 if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
2134 return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
2135 }
2136 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2137 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2138 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2139 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2140 if concave.is_empty() || convex.is_empty() {
2141 return Err(shape("the open-pad composition needs convex and concave edges".into()));
2142 }
2143 let mates = crate::blend::selection_mates(solid, selected_ids, radius)
2146 .map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
2147 for corner in corners {
2148 let (_, convex_faces) = &mates[corner.convex[0]];
2149 let (_, concave_faces) = &mates[corner.concave[0]];
2150 let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
2151 return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
2152 };
2153 if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
2154 return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
2155 }
2156 }
2157 for &index in &concave {
2158 let edge = mates[index].0;
2159 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
2160 if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
2161 return Err(shape(format!(
2162 "concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
2163 selection_label(index, edge_points, edge_names, name)
2164 )));
2165 }
2166 }
2167 }
2168 let base = name.unwrap_or("FILLET:OPEN_PAD");
2169 let bare = |index: usize| -> String {
2170 per_edge_name(edge_names, name, index)
2171 .map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
2172 .unwrap_or_else(|| selected_ids[index].to_string())
2173 };
2174 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2175
2176 let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
2178 let convex_names: Vec<String> = convex
2179 .iter()
2180 .map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
2181 .collect();
2182 let stage1 = crate::blend::with_open_pad_network(|| {
2191 fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
2192 })
2193 .map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
2194 if debug {
2195 eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
2196 }
2197
2198 let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
2200 let mut points: Vec<Vec3> = Vec::new();
2201 let mut names: Vec<String> = Vec::new();
2202 for &index in &concave {
2203 let (edge, faces) = &mates[index];
2204 let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
2205 let pad = stage1
2206 .edges
2207 .iter()
2208 .find(|candidate| candidate.id == edge.id)
2209 .ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
2210 "open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
2211 )))?;
2212 points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
2213 names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
2214 for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
2217 let trims: Vec<&EdgeRecord> = stage1
2218 .edges
2219 .iter()
2220 .filter(|candidate| {
2221 candidate.id != pad.id
2222 && !candidate.degenerate
2223 && (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
2224 })
2225 .filter(|candidate| {
2226 let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
2227 faces.iter().any(|face| face.id == boss_id)
2228 && faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
2229 })
2230 .collect();
2231 match trims.as_slice() {
2232 [trim] => {
2233 let wall_name = stage1
2234 .shells
2235 .iter()
2236 .flat_map(|shell| &shell.faces)
2237 .filter(|face| uses(face, trim.id))
2238 .find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
2239 .unwrap_or_default();
2240 let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
2241 points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
2242 names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
2243 if debug {
2244 eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
2245 }
2246 }
2247 [] => {
2248 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2249 "open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
2250 selection_label(index, edge_points, edge_names, name)
2251 )));
2252 }
2253 _ => {
2254 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2255 "open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
2256 selection_label(index, edge_points, edge_names, name), trims.len()
2257 )));
2258 }
2259 }
2260 }
2261 }
2262 let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
2263 .map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
2264 let _ = entry;
2265 Ok(result)
2266}
2267
2268fn cusp_composition(
2288 solid: &BrepSolid,
2289 selected_ids: &[u64],
2290 edge_points: &[Vec3],
2291 edge_names: Option<&[String]>,
2292 radius: f64,
2293 name: Option<&str>,
2294 corners: &[crate::blend::MixedCorner],
2295) -> Result<BrepSolid, KernelRefusal> {
2296 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "cusp_composition_shape", message);
2297 if std::env::var("BREP_NO_NETWORK").is_ok() {
2298 return Err(shape("the cusp composition is a network construction and BREP_NO_NETWORK is set".into()));
2299 }
2300 let edge_of = |index: usize| solid.edges.iter().find(|edge| edge.id == selected_ids[index]);
2301 let away = |index: usize, vertex: u64| -> Option<Vec3> {
2303 let edge = edge_of(index)?;
2304 let at_start = edge.start_vertex_id == vertex;
2305 let tangent = edge.curve.unit_tangent(if at_start { edge.t0 } else { edge.t1 }, edge.t0, edge.t1).ok()?;
2306 Some(if at_start { tangent } else { tangent.scale(-1.0) })
2307 };
2308 if corners.is_empty() {
2309 return Err(shape("no mixed corners".into()));
2310 }
2311 for corner in corners {
2312 let cusped = corner.convex.iter().all(|&convex| {
2313 corner.concave.iter().any(|&concave| {
2314 matches!((away(convex, corner.vertex), away(concave, corner.vertex)), (Some(a), Some(b)) if a.dot(b) >= 1.0 - 1e-6)
2315 })
2316 });
2317 if !cusped || corner.convex.is_empty() || corner.concave.is_empty() {
2318 return Err(shape(format!("the mixed corner at vertex {} is not a cusp", corner.vertex)));
2319 }
2320 }
2321 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2322 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2323 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2324 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2325 if concave.is_empty() || convex.is_empty() {
2326 return Err(shape("the cusp composition needs convex and concave edges".into()));
2327 }
2328 let names_of = |indices: &[usize]| -> Option<Vec<String>> { edge_names.map(|names| indices.iter().map(|index| names[*index].clone()).collect()) };
2329 let points_of = |indices: &[usize]| -> Vec<Vec3> { indices.iter().map(|index| edge_points[*index]).collect() };
2330 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2331 let stage1 = fillet_edges_group_in(solid, &points_of(&concave), names_of(&concave).as_deref(), radius, false, name, None)
2332 .map_err(|error| error.with_message(|error| format!("cusp composition, the concave rounds: {error}")))?;
2333 if debug {
2334 eprintln!("cusp composition: stage 1 built the {} concave rounds", concave.len());
2335 }
2336 for &index in &convex {
2339 resolve_edge_by_point(&stage1, edge_points[index])
2340 .map_err(|error| error.with_message(|error| format!("cusp composition: a convex edge is not on the stage-1 body: {error}")))?;
2341 }
2342 let result = fillet_edges_group_in(&stage1, &points_of(&convex), names_of(&convex).as_deref(), radius, false, name, None)
2343 .map_err(|error| error.with_message(|error| format!("cusp composition, the convex rounds: {error}")))?;
2344 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(true));
2350 Ok(result)
2351}
2352
2353fn runout_composition(
2354 solid: &BrepSolid,
2355 selected_ids: &[u64],
2356 edge_points: &[Vec3],
2357 edge_names: Option<&[String]>,
2358 radius: f64,
2359 name: Option<&str>,
2360 corners: &[crate::blend::MixedCorner],
2361 entry: &str,
2362) -> Result<BrepSolid, KernelRefusal> {
2363 if std::env::var("BREP_NO_NETWORK").is_ok() {
2364 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
2365 }
2366 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2367 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
2368 let mut spine_ends = vec![0usize; selected_ids.len()];
2369 for corner in corners {
2370 if corner.convex.len() != 1 {
2371 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
2372 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
2373 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
2374 )));
2375 }
2376 spine_ends[corner.convex[0]] += 1;
2377 }
2378 let mut spines: Vec<usize> = Vec::new();
2379 let mut concave: Vec<usize> = Vec::new();
2380 for (index, ends) in spine_ends.iter().enumerate() {
2381 match (*ends, convexity[index]) {
2382 (2, true) => spines.push(index),
2383 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
2384 (0, false) => concave.push(index),
2385 (ends, convex) => {
2386 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
2387 "{} 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",
2388 selection_label(index, edge_points, edge_names, name),
2389 if convex { "convex" } else { "concave" },
2390 )));
2391 }
2392 }
2393 }
2394 if concave.is_empty() {
2395 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
2396 }
2397 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
2399 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
2400 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
2401 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
2402 .map_err(|error| {
2403 let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2409 match network {
2410 Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
2411 "the concave ring did not build: {network} (the cutter fallback then refused: {error})"
2412 )),
2413 _ => error.with_message(|error| format!("the concave ring did not build: {error}")),
2414 }
2415 })?;
2416 let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2425 let ring_is_network = network_declined.is_none();
2427 if let Some(network) = network_declined {
2429 if crate::blend::is_reentrant_concave(&network) {
2430 return Err(network.with_message(|network| format!(
2431 "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"
2432 )));
2433 }
2434 }
2435 let spine_walls: Vec<(u64, Option<String>)> = spines
2437 .iter()
2438 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
2439 .collect();
2440 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
2441 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
2442 heal_edge_vertex_gaps(&mut result, radius)?;
2443 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
2444 let issues = result.validate();
2445 if !issues.is_empty() {
2446 return Err(KernelRefusal::new(
2447 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2448 KernelStage::Validate,
2449 format!("the runout walls do not validate: {issues:?}"),
2450 ));
2451 }
2452 check_blend_interference(&ring, &result, &spine_ids, entry)?;
2453 check_loop_self_crossings(&result, entry)?;
2454 check_blend_wall_loops(&result, entry)?;
2455 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2456 let accepted = crate::accept_sound(result, entry)?;
2457 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
2459 Ok(accepted)
2461}
2462
2463thread_local! {
2465 static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2470}
2471fn check_mixed_corner_convexity(
2474 solid: &BrepSolid,
2475 edge_ids: &[u64],
2476 edge_points: &[Vec3],
2477 edge_names: Option<&[String]>,
2478 radius: f64,
2479 name: Option<&str>,
2480 composition_error: Option<&KernelRefusal>,
2481) -> Result<(), KernelRefusal> {
2482 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
2483 Some(corner) => {
2484 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
2490 Ok(plan) => Some(plan),
2491 Err(error) => {
2492 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2493 eprintln!("runout not measured: {error}");
2494 }
2495 None
2496 }
2497 };
2498 let mut message = mixed_corner_message(
2501 &corner,
2502 edge_points,
2503 edge_names,
2504 name,
2505 measured.as_ref(),
2506 );
2507 if let Some(error) = composition_error {
2508 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
2509 }
2510 Err(KernelRefusal::unsupported(
2511 KernelStage::Classify,
2512 "mixed_corner_convexity",
2513 message,
2514 ))
2515 }
2516 None => Ok(()),
2517 }
2518}
2519
2520fn check_degenerate_corner_setbacks(
2523 solid: &BrepSolid,
2524 edge_ids: &[u64],
2525 edge_points: &[Vec3],
2526 edge_names: Option<&[String]>,
2527 radius: f64,
2528 name: Option<&str>,
2529) -> Result<(), KernelRefusal> {
2530 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
2531 return Ok(());
2532 };
2533 let [from, to] = setback.stops;
2534 let how = if setback.crossed {
2535 format!(
2536 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
2537 setback.strip
2538 )
2539 } else {
2540 format!(
2541 "meet there ({:.3e} apart against a bar of {:.3e})",
2542 setback.strip, setback.bar
2543 )
2544 };
2545 Err(KernelRefusal::input(
2548 KernelStage::Classify,
2549 "degenerate_corner_setbacks",
2550 format!(
2551 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
2552 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
2553 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
2554 on are consumed whole. The radius is at or past the degenerate limit for this \
2555 selection; reduce it.",
2556 selection_label(setback.edge, edge_points, edge_names, name),
2557 setback.face_id,
2558 from.x,
2559 from.y,
2560 from.z,
2561 to.x,
2562 to.y,
2563 to.z,
2564 ),
2565 ))
2566}
2567
2568fn selection_label(
2572 index: usize,
2573 edge_points: &[Vec3],
2574 edge_names: Option<&[String]>,
2575 name: Option<&str>,
2576) -> String {
2577 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
2578 Some(base) => composed
2579 .strip_prefix(&format!("{base}:"))
2580 .unwrap_or(composed)
2581 .to_string(),
2582 None => composed.clone(),
2583 });
2584 named.unwrap_or_else(|| match edge_points.get(index) {
2585 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
2586 None => format!("selection #{}", index + 1),
2587 })
2588}
2589
2590fn mixed_corner_message(
2594 corner: &crate::blend::MixedCorner,
2595 edge_points: &[Vec3],
2596 edge_names: Option<&[String]>,
2597 name: Option<&str>,
2598 measured: Option<&crate::blend::RunoutPlan>,
2599) -> String {
2600 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
2601 let list = |indices: &[usize]| -> String {
2602 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
2603 match parts.split_last() {
2604 None => "none".to_string(),
2605 Some((last, [])) => last.clone(),
2606 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
2607 }
2608 };
2609 let mut message = format!(
2610 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
2611 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
2612 sides at once, so that corner has no closure: the convex blend runs out against the \
2613 concave ones partway along the edge, and this kernel does not build that runout yet. \
2614 Blending the concave edges in one fillet and the convex ones in another does not reach \
2615 a wall on every one of them either: the convex wall comes out at FULL length in either \
2616 order, and its contact leaves the faces it runs along where their own loops would then \
2617 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
2618 whose wall runs out into them; blend the convex ones first and the subset search drops \
2619 the concave edges it cannot compose instead, leaving those unfilleted.",
2620 corner.point.x,
2621 corner.point.y,
2622 corner.point.z,
2623 list(&corner.convex),
2624 list(&corner.concave),
2625 );
2626 if let Some(plan) = measured {
2631 message.push_str(&format!(
2632 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
2633 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
2634 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
2635 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
2636 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
2637 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
2638 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
2639 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
2640 the bar a march is refused at is a full span.",
2641 plan.convex_edge,
2642 plan.shared_face,
2643 plan.station.s,
2644 plan.station.center.x,
2645 plan.station.center.y,
2646 plan.station.center.z,
2647 plan.replaced_mate,
2648 plan.second_carrier,
2649 plan.station.stripe_residual,
2650 plan.second_carrier,
2651 plan.marched,
2652 plan.fit_residual,
2653 plan.carrier_excursion,
2654 ));
2655 match (&plan.stop, plan.first_carrier) {
2656 (Some(stop), Some(first)) => message.push_str(&format!(
2657 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
2658 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
2659 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
2660 stop.s, plan.kept_mate, first, stop.stripe_residual,
2661 )),
2662 _ => message.push_str(&format!(
2663 " Where the kept mate (face {}) runs out was not measurable on this selection.",
2664 plan.kept_mate
2665 )),
2666 }
2667 if let Some(pole) = plan.pole {
2668 message.push_str(&format!(
2669 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
2670 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
2671 section shrinks to a point. Two carrier switches and a degenerate terminus is \
2672 more than a stop station on a stripe, and none of it is composed yet.",
2673 pole.x, pole.y, pole.z, plan.shared_face,
2674 ));
2675 }
2676 }
2677 message
2678}
2679
2680fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
2682 let mut out = Vec::new();
2683 if k == 0 || k > n {
2684 return out;
2685 }
2686 let mut idx: Vec<usize> = (0..k).collect();
2687 loop {
2688 out.push(idx.clone());
2689 let mut i = k;
2691 loop {
2692 if i == 0 {
2693 return out;
2694 }
2695 i -= 1;
2696 if idx[i] != i + n - k {
2697 break;
2698 }
2699 }
2700 idx[i] += 1;
2701 for j in (i + 1)..k {
2702 idx[j] = idx[j - 1] + 1;
2703 }
2704 }
2705}
2706
2707thread_local! {
2708 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
2714 const { std::cell::Cell::new(0) };
2715}
2716
2717fn fillet_edges_group(
2734 solid: &BrepSolid,
2735 edge_points: &[Vec3],
2736 edge_names: Option<&[String]>,
2737 radius: f64,
2738 chamfer: bool,
2739 name: Option<&str>,
2740) -> Result<BrepSolid, KernelRefusal> {
2741 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
2742}
2743
2744thread_local! {
2745 static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
2752}
2753
2754struct SubsetDropCap(Option<usize>);
2756
2757impl SubsetDropCap {
2758 fn set(cap: Option<usize>) -> Self {
2759 Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
2760 }
2761}
2762
2763impl Drop for SubsetDropCap {
2764 fn drop(&mut self) {
2765 SUBSET_DROP_CAP.with(|current| current.set(self.0));
2766 }
2767}
2768
2769thread_local! {
2770 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2773 static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2776 static GROUP_NETWORK_FOLD: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2780}
2781
2782type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
2785
2786type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
2793
2794#[derive(Default)]
2822struct ComponentMemo {
2823 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
2824 prefix: Vec<ComponentKey>,
2827 sequential: std::collections::HashMap<
2828 (Vec<ComponentKey>, Vec<SequentialKey>),
2829 Result<BrepSolid, KernelRefusal>,
2830 >,
2831}
2832
2833fn fillet_edges_group_in(
2838 solid: &BrepSolid,
2839 edge_points: &[Vec3],
2840 edge_names: Option<&[String]>,
2841 radius: f64,
2842 chamfer: bool,
2843 name: Option<&str>,
2844 memo: Option<&mut ComponentMemo>,
2845) -> Result<BrepSolid, KernelRefusal> {
2846 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
2847}
2848
2849fn fillet_edges_group_with_corner_policy(
2850 solid: &BrepSolid,
2851 edge_points: &[Vec3],
2852 edge_names: Option<&[String]>,
2853 radius: f64,
2854 chamfer: bool,
2855 name: Option<&str>,
2856 memo: Option<&mut ComponentMemo>,
2857 wrap_concave: bool,
2858 accept: bool,
2859) -> Result<BrepSolid, KernelRefusal> {
2860 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
2861 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2862 let GroupBody { body: result, bowtie_clean } =
2863 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
2864 if !accept {
2865 return Ok(result);
2866 }
2867 if bowtie_clean {
2872 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
2873 } else {
2874 check_loop_self_crossings(&result, entry)?;
2875 check_blend_wall_loops(&result, entry)?;
2876 }
2877 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
2878 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2887 let accepted = crate::accept_sound(result, entry);
2888 soundness.stop();
2889 accepted
2890}
2891
2892struct GroupBody {
2897 body: BrepSolid,
2898 bowtie_clean: bool,
2899}
2900
2901fn fillet_edges_group_unchecked(
2908 solid: &BrepSolid,
2909 edge_points: &[Vec3],
2910 edge_names: Option<&[String]>,
2911 radius: f64,
2912 chamfer: bool,
2913 name: Option<&str>,
2914 memo: Option<&mut ComponentMemo>,
2915 wrap_concave: bool,
2916) -> Result<GroupBody, KernelRefusal> {
2917 let outer = GROUP_NETWORK_FOLD.with(|fold| fold.borrow_mut().take());
2918 let built = fillet_edges_group_lanes(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave);
2919 let fold = GROUP_NETWORK_FOLD.with(|fold| std::mem::replace(&mut *fold.borrow_mut(), outer));
2920 match (built, fold) {
2921 (Err(error), Some(fold)) if !crate::blend::is_wall_fold(&error) => Err(fold),
2922 (built, _) => built,
2923 }
2924}
2925
2926fn fillet_edges_group_lanes(
2927 solid: &BrepSolid,
2928 edge_points: &[Vec3],
2929 edge_names: Option<&[String]>,
2930 radius: f64,
2931 chamfer: bool,
2932 name: Option<&str>,
2933 mut memo: Option<&mut ComponentMemo>,
2934 wrap_concave: bool,
2935) -> Result<GroupBody, KernelRefusal> {
2936 use rustc_hash::FxHashSet as HashSet;
2937
2938 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
2945 let mut healed_input = solid.clone();
2946 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
2947 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
2948 let solid = &healed_input;
2949 heal.stop();
2950 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
2951
2952 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2955 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
2958 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
2959 for (i, point) in edge_points.iter().enumerate() {
2960 let edge_id = resolve_edge_by_point(solid, *point)?;
2961 let edge = solid
2962 .edges
2963 .iter()
2964 .find(|e| e.id == edge_id)
2965 .ok_or_else(|| {
2966 KernelRefusal::internal(
2967 KernelStage::Collect,
2968 "resolved_edge",
2969 "fillet_edges: resolved edge vanished",
2970 )
2971 })?;
2972 let (start, end) = (
2973 edge.curve
2974 .evaluate(edge.t0)
2975 .or_refuse(KernelStage::Collect, "edge_start")?,
2976 edge.curve
2977 .evaluate(edge.t1)
2978 .or_refuse(KernelStage::Collect, "edge_end")?,
2979 );
2980 endpoints.push((start, i));
2981 endpoints.push((end, i));
2982 original_extents.push((start, end));
2983 selected_edge_ids.push(edge_id);
2984 }
2985 let mut corners: Vec<Vec3> = Vec::new();
2986 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
2991 let mut chain_corner_count = 0usize;
2992 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
2993 let mut used = vec![false; endpoints.len()];
2994 for i in 0..endpoints.len() {
2995 if used[i] {
2996 continue;
2997 }
2998 used[i] = true;
2999 let mut edges_here: HashSet<usize> = HashSet::default();
3000 edges_here.insert(endpoints[i].1);
3001 for j in (i + 1)..endpoints.len() {
3002 if used[j] {
3003 continue;
3004 }
3005 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
3006 used[j] = true;
3007 edges_here.insert(endpoints[j].1);
3008 }
3009 }
3010 if edges_here.len() >= 3 {
3011 corners.push(endpoints[i].0);
3012 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
3013 adjacent.sort_unstable();
3014 corner_edges.push(adjacent);
3015 } else if edges_here.len() == 2 {
3016 chain_corner_count += 1;
3017 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
3018 adjacent.sort_unstable();
3019 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
3020 }
3021 }
3022
3023 let mut component_of = vec![usize::MAX; edge_points.len()];
3036 let mut components: Vec<Vec<usize>> = Vec::new();
3037 for seed in 0..edge_points.len() {
3038 if component_of[seed] != usize::MAX {
3039 continue;
3040 }
3041 let component_index = components.len();
3042 component_of[seed] = component_index;
3043 let mut component = vec![seed];
3044 let mut cursor = 0;
3045 while cursor < component.len() {
3046 let current = component[cursor];
3047 cursor += 1;
3048 for candidate in 0..edge_points.len() {
3049 if component_of[candidate] != usize::MAX {
3050 continue;
3051 }
3052 let connected = [original_extents[current].0, original_extents[current].1]
3053 .into_iter()
3054 .any(|a| {
3055 [original_extents[candidate].0, original_extents[candidate].1]
3056 .into_iter()
3057 .any(|b| a.sub(b).length() < 1e-6)
3058 });
3059 if connected {
3060 component_of[candidate] = component_index;
3061 component.push(candidate);
3062 }
3063 }
3064 }
3065 component.sort_unstable();
3066 components.push(component);
3067 }
3068 group_analysis.stop();
3069 if components.len() > 1 {
3070 let mut separated = solid.clone();
3071 let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
3074 chain.reserve(components.len());
3075 for component in components {
3076 let points = component
3077 .iter()
3078 .map(|index| edge_points[*index])
3079 .collect::<Vec<_>>();
3080 let names = edge_names.map(|all| {
3081 component
3082 .iter()
3083 .map(|index| all[*index].clone())
3084 .collect::<Vec<_>>()
3085 });
3086 let accept_component = !crate::blend::accept_once_on();
3087 let Some(memo) = memo.as_deref_mut() else {
3088 separated = fillet_edges_group_with_corner_policy(
3089 &separated,
3090 &points,
3091 names.as_deref(),
3092 radius,
3093 chamfer,
3094 name,
3095 None,
3096 wrap_concave,
3097 accept_component,
3098 )?;
3099 continue;
3100 };
3101 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
3102 chain.push((bits.collect(), names.clone()));
3103 let built = match memo.built.get(&chain) {
3104 Some(built) => built.clone(),
3105 None => {
3106 let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
3109 let built = fillet_edges_group_with_corner_policy(
3110 &separated,
3111 &points,
3112 names.as_deref(),
3113 radius,
3114 chamfer,
3115 name,
3116 Some(&mut *memo),
3117 wrap_concave,
3118 accept_component,
3119 );
3120 memo.prefix = outer_prefix;
3121 memo.built.insert(chain.clone(), built.clone());
3122 built
3123 }
3124 };
3125 separated = built?;
3126 }
3127 return Ok(GroupBody { body: separated, bowtie_clean: false });
3128 }
3129
3130 if std::env::var("BREP_NO_NETWORK").is_err() {
3155 if selected_edge_ids.len() == 1 {
3156 let edge_id = selected_edge_ids[0];
3157 let closed = solid
3158 .edges
3159 .iter()
3160 .find(|edge| edge.id == edge_id)
3161 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
3162 if closed {
3163 let rim_name = per_edge_name(edge_names, name, 0);
3164 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
3165 Ok(mut rim) => {
3166 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3167 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3168 let healed = heal_edge_vertex_gaps(&mut rim, radius);
3169 heal.stop();
3170 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3179 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
3180 charts.stop();
3181 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3182 let issues = rim.validate_memoised();
3183 validate.stop();
3184 let interference =
3185 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
3186 let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
3187 if healed.is_ok()
3188 && issues.is_empty()
3189 && interference.is_ok()
3190 && bowtie.is_ok()
3191 {
3192 return Ok(GroupBody { body: rim, bowtie_clean: true });
3193 }
3194 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3195 eprintln!(
3196 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
3197 interference={interference:?} bowtie={bowtie:?}"
3198 );
3199 }
3200 }
3201 Err(refusal) => {
3202 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3203 eprintln!("closed-rim refused: {refusal}");
3204 }
3205 }
3206 }
3207 }
3208 }
3209 let network_names: Vec<Option<String>> = (0..edge_points.len())
3210 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
3211 .collect();
3212 let network_corner_name =
3213 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
3214 let group_network = || {
3220 crate::blend::blend_star_network_with_corner_policy(
3221 solid,
3222 &selected_edge_ids,
3223 radius,
3224 chamfer,
3225 &network_names,
3226 &network_corner_name,
3227 wrap_concave,
3228 )
3229 };
3230 let network_result = group_network().or_else(|first| {
3231 if crate::blend::is_pcurve_floor_refusal(&first) {
3232 crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
3233 } else {
3234 Err(first)
3235 }
3236 });
3237 let accept_network = |mut network: BrepSolid, built_radius: f64| -> Result<Option<GroupBody>, KernelRefusal> {
3241 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3249 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3250 let healed = heal_edge_vertex_gaps(&mut network, built_radius);
3251 heal.stop();
3252 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3256 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
3257 charts.stop();
3258 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3259 let issues = network.validate_memoised();
3260 validate.stop();
3261 let interference =
3262 check_blend_interference(solid, &network, &selected_edge_ids, entry);
3263 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
3268 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
3269 return Ok(Some(GroupBody { body: network, bowtie_clean: true }));
3270 }
3271 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3272 eprintln!(
3273 "network result rejected: heal={healed:?} issues={issues:?} \
3274 interference={interference:?} bowtie={bowtie:?}"
3275 );
3276 dump_loops_debug("INPUT", solid);
3277 dump_loops_debug("RESULT", &network);
3278 }
3279 Ok(None)
3280 };
3281 match network_result {
3282 Ok(network) => {
3283 if let Some(body) = accept_network(network, radius)? {
3284 return Ok(body);
3285 }
3286 }
3287 Err(refusal) => {
3288 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3289 eprintln!("network refused: {refusal}");
3290 }
3291 let pinched_taper = matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what }
3302 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT);
3303 if !chamfer && pinched_taper && crate::blend::relief::radius_relief_on() {
3304 let labels: Vec<String> =
3305 (0..edge_points.len()).map(|index| selection_label(index, edge_points, edge_names, name)).collect();
3306 for step in 1..=10u32 {
3307 let reduced = radius * (1.0 - 0.05 * f64::from(step));
3308 let built = crate::blend::blend_star_network_with_corner_policy(
3309 solid, &selected_edge_ids, reduced, chamfer, &network_names, &network_corner_name, wrap_concave,
3310 );
3311 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3312 eprintln!(
3313 "taper pinch ladder: r {reduced:.4} -> {}",
3314 match &built {
3315 Ok(_) => "built".to_string(),
3316 Err(error) => format!("refused: {error}"),
3317 }
3318 );
3319 }
3320 match built {
3321 Ok(network) => {
3322 let Some(body) = accept_network(network, reduced)? else { break };
3323 crate::blend::record_blend_note(format!(
3324 "radius reduced over the whole of {} to {reduced:.4}: at r {radius} the round tapering to \
3325 its tip reaches the full radius only inside the corner setback at its other end, so \
3326 r {radius} is reached nowhere",
3327 labels.join(", ")
3328 ));
3329 return Ok(body);
3330 }
3331 Err(again) if matches!(&again.class, crate::RefusalClass::UnsupportedGeometry { what }
3332 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT) => continue,
3333 Err(_) => break,
3334 }
3335 }
3336 }
3337 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
3341 if crate::blend::is_wall_fold(&refusal) {
3342 GROUP_NETWORK_FOLD.with(|fold| *fold.borrow_mut() = Some(refusal.clone()));
3343 }
3344 if !chamfer && !crate::blend::is_wall_fold(&refusal) && crate::blend::relief::radius_relief_on() {
3350 let started = web_time::Instant::now();
3351 let misfit = crate::blend::relief::first_misfit(solid, &selected_edge_ids, radius);
3352 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3353 eprintln!("radius relief: misfit probe {misfit:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
3354 }
3355 if let Some((edge_id, least)) = misfit {
3356 return Err(crate::blend::relief::misfit_fold(edge_id, least, radius, &refusal));
3357 }
3358 }
3359 if crate::blend::is_rail_fold(&refusal)
3400 || crate::blend::is_marched_fit_off_carriers(&refusal)
3401 || crate::blend::is_consumed_snap(&refusal)
3402 || crate::blend::is_rail_collapse(&refusal)
3403 || crate::blend::is_full_width_collapse(&refusal)
3404 || crate::blend::is_planar_chart_refusal(&refusal)
3405 || crate::blend::is_miter_support_overrun(&refusal)
3406 || crate::blend::is_tangential_transverse_end(&refusal)
3407 {
3408 return Err(refusal);
3409 }
3410 }
3411 }
3412 }
3413
3414 if let Some(refusal) = super::analyze::rollover_only_refusal(&selected_edge_ids) {
3420 return Err(refusal);
3421 }
3422 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
3423 crate::blend::record_count("count.cutter_fallback", 1);
3424 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
3425 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
3452 && (chamfer || corners.is_empty())
3453 {
3454 let mut any_convex = false;
3455 let mut any_concave = false;
3456 for point in edge_points {
3457 match resolve_edge_by_point(solid, *point)
3469 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
3470 {
3471 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
3474 any_convex |= profile.any_convex;
3475 any_concave |= profile.any_concave;
3476 }
3477 _ => {
3480 any_convex = true;
3481 any_concave = true;
3482 break;
3483 }
3484 }
3485 }
3486 match (any_convex, any_concave) {
3487 (true, false) => Some(crate::BooleanOperation::Intersect),
3488 (false, true) => Some(crate::BooleanOperation::Union),
3489 _ => None,
3490 }
3491 } else {
3492 None
3493 };
3494
3495 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
3506 Lane::GeneralFirst
3507 } else {
3508 Lane::CutterFirst
3509 };
3510 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3521 let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3526 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
3527 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
3533 let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
3534 let mut sequential = solid.clone();
3535 let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
3538 for &index in &sequential_order {
3539 let point = &edge_points[index];
3540 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3541 let ends = tool_ends_to_original_extent(
3548 &sequential,
3549 edge_id,
3550 radius,
3551 original_extents[index],
3552 if chamfer { &[] } else { &corners },
3553 );
3554 let edge_name = per_edge_name(edge_names, name, index);
3559 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3560 eprintln!(
3561 "sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
3562 selection_label(index, edge_points, edge_names, name)
3563 );
3564 }
3565 chain.push((
3570 [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
3571 edge_name.map(str::to_string),
3572 ends.key_bits(),
3573 u8::from(composition_lane == Lane::CutterFirst),
3574 ));
3575 if let Some(memo) = memo.as_deref_mut() {
3576 let key = (memo.prefix.clone(), chain.clone());
3577 let hit = memo.sequential.get(&key);
3578 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3579 eprintln!(
3580 "sequential memo: {} components before, step {} {} ({} chains stored)",
3581 memo.prefix.len(),
3582 chain.len(),
3583 if hit.is_some() { "HIT" } else { "miss" },
3584 memo.sequential.len(),
3585 );
3586 }
3587 if let Some(built) = hit {
3588 crate::blend::record_count("count.sequential.memo_hits", 1);
3589 sequential = built.clone()?;
3590 continue;
3591 }
3592 }
3593 crate::blend::record_count("count.sequential.steps_built", 1);
3594 let built = fillet_or_chamfer(
3595 &sequential,
3596 edge_id,
3597 radius,
3598 chamfer,
3599 edge_name,
3600 ends,
3601 composition_lane,
3602 );
3603 if let Some(memo) = memo.as_deref_mut() {
3604 memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
3605 }
3606 sequential = built?;
3607 }
3608 Ok(sequential)
3609 };
3610
3611 let mut result = if let Some(operation) = miter_operation {
3612 let options = crate::BooleanOptions {
3616 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
3617 ..crate::BooleanOptions::default()
3618 };
3619 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
3620 for (index, point) in edge_points.iter().enumerate() {
3621 let edge_id = resolve_edge_by_point(solid, *point)?;
3622 let edge_name = per_edge_name(edge_names, name, index);
3623 let blended = {
3624 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3625 check_mixed_concavity(solid, edge_id, entry)?;
3626 check_support_extent(solid, edge_id, radius, entry)?;
3627 fillet_or_chamfer(
3628 solid,
3629 edge_id,
3630 radius,
3631 chamfer,
3632 edge_name,
3633 ToolEnds::default(),
3634 Lane::CutterFirst,
3635 )?
3636 };
3637 combined = match combined {
3638 Err(e) => Err(e),
3639 Ok(None) => Ok(Some(blended)),
3640 Ok(Some(previous)) => {
3641 crate::boolean_operation(&previous, &blended, operation, &options)
3642 .map(Some)
3643 .map_err(|error| {
3644 error.with_message(|error| {
3646 format!("fillet_edges: chain-corner miter composition failed: {error}")
3647 })
3648 })
3649 }
3650 };
3651 if combined.is_err() {
3652 break;
3653 }
3654 }
3655 match combined {
3667 Ok(Some(mitered)) => mitered,
3668 Ok(None) => {
3669 return Err(KernelRefusal::input(
3670 KernelStage::Collect,
3671 "miter_selection",
3672 "fillet_edges: no edges selected",
3673 ))
3674 }
3675 Err(miter_err) => match build_sequential() {
3676 Ok(seq) if seq.validate().is_empty() => seq,
3677 _ => return Err(miter_err),
3678 },
3679 }
3680 } else {
3681 build_sequential()?
3682 };
3683
3684 let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
3692 if !chamfer {
3694 for (corner, adjacent) in &chain_corners {
3695 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
3696 if selected_edge_ids.contains(&edge.id) {
3697 return false;
3698 }
3699 let Ok(a) = edge.curve.evaluate(edge.t0) else {
3700 return false;
3701 };
3702 let Ok(b) = edge.curve.evaluate(edge.t1) else {
3703 return false;
3704 };
3705 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3706 && analyze_edge(solid, edge.id, radius)
3707 .map(|cross| !cross.convex)
3708 .unwrap_or(false)
3709 });
3710 if !has_concave_unselected_edge {
3721 let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
3722 let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
3723 let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
3724 let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
3725 if selected_edge_ids.contains(&edge.id) {
3726 return false;
3727 }
3728 let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
3729 return false;
3730 };
3731 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3732 && analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
3733 });
3734 if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
3735 open_mixed_corners.push((
3736 *corner,
3737 [selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
3738 corner_face_name(edge_names, name, adjacent),
3739 ));
3740 }
3741 continue;
3742 }
3743 let corner_name = corner_face_name(edge_names, name, adjacent);
3745 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
3746 &result,
3747 solid,
3748 *corner,
3749 [
3750 selected_edge_ids[adjacent[0]],
3751 selected_edge_ids[adjacent[1]],
3752 ],
3753 radius,
3754 corner_name.as_deref(),
3755 ) {
3756 result = rounded;
3757 }
3758 }
3759 }
3760
3761 if !chamfer {
3767 for (ci, corner) in corners.iter().enumerate() {
3768 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
3769 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
3770 Ok(rounded) => result = rounded,
3771 Err(error) => {
3772 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3773 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
3774 }
3775 }
3776 }
3777 }
3778 }
3779
3780 heal_edge_vertex_gaps(&mut result, radius)?;
3783 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3784 let mut body = settle_cutter_scaffold(result);
3786 for (corner, pair, corner_name) in &open_mixed_corners {
3787 match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
3788 Ok(rounded) => body = rounded,
3789 Err(error) => {
3790 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3791 eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
3792 }
3793 }
3794 }
3795 }
3796 Ok(GroupBody { body, bowtie_clean: false })
3797 }
3799
3800fn concave_star_edges(
3807 solid: &BrepSolid,
3808 selected_edge_ids: &[u64],
3809 corner_edges: &[Vec<usize>],
3810 radius: f64,
3811 chamfer: bool,
3812) -> Vec<usize> {
3813 if chamfer || corner_edges.is_empty() || !concave_first_on() {
3814 return Vec::new();
3815 }
3816 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
3817 return Vec::new();
3818 };
3819 (0..selected_edge_ids.len())
3820 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
3821 .collect()
3822}
3823
3824#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3831enum CutterClass {
3832 Marched,
3833 Straight,
3834 Circular,
3835}
3836
3837fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
3838 match super::analyze::analyze_edge(solid, edge_id, radius) {
3839 Ok(cross) => match cross.path {
3840 super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
3841 super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
3842 },
3843 Err(_) => CutterClass::Marched,
3844 }
3845}
3846
3847fn mixed_star_class_ranks(
3869 solid: &BrepSolid,
3870 selected_edge_ids: &[u64],
3871 corner_edges: &[Vec<usize>],
3872 radius: f64,
3873 chamfer: bool,
3874) -> Vec<u8> {
3875 let mut ranks = vec![2u8; selected_edge_ids.len()];
3876 if chamfer || corner_edges.is_empty() || !class_order_on() {
3877 return ranks;
3878 }
3879 let classes: Vec<CutterClass> = selected_edge_ids
3880 .iter()
3881 .map(|&edge_id| cutter_class(solid, edge_id, radius))
3882 .collect();
3883 for adjacent in corner_edges {
3884 let Some(&head) = adjacent.first() else {
3885 continue;
3886 };
3887 if adjacent.iter().all(|&index| classes[index] == classes[head]) {
3888 continue;
3889 }
3890 for &index in adjacent {
3891 match classes[index] {
3892 CutterClass::Marched => ranks[index] = 1,
3893 CutterClass::Circular => ranks[index] = 3,
3894 CutterClass::Straight => {}
3895 }
3896 }
3897 }
3898 ranks
3899}
3900
3901pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
3908 let mut order: Vec<usize> = (0..count).collect();
3909 order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
3911 order
3912}
3913
3914fn class_order_on() -> bool {
3918 true
3919}
3920
3921
3922fn corners_concave_first(
3926 corners: Vec<Vec3>,
3927 corner_edges: Vec<Vec<usize>>,
3928 first: &[usize],
3929) -> (Vec<Vec3>, Vec<Vec<usize>>) {
3930 if first.is_empty() {
3931 return (corners, corner_edges);
3932 }
3933 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
3934 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
3935 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
3936 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
3937 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
3938 ordered.into_iter().unzip()
3939}
3940
3941fn concave_first_on() -> bool {
3944 true
3945}
3946
3947
3948pub fn fillet_edges_variable(
3956 solid: &BrepSolid,
3957 edge_points: &[Vec3],
3958 edge_names: Option<&[String]>,
3959 radii: &[(f64, f64)],
3960 chamfer: bool,
3961 name: Option<&str>,
3962) -> Result<BrepSolid, KernelRefusal> {
3963 let _operation = crate::blend::BlendOperation::enter();
3966 if edge_points.is_empty() {
3967 return Err(KernelRefusal::input(
3968 KernelStage::Collect,
3969 "variable_selection",
3970 "fillet_edges_variable: no edges selected",
3971 ));
3972 }
3973 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
3974 fillet_edges_variable_impl(
3975 solid,
3976 edge_points,
3977 edge_names,
3978 &per_edge_stops,
3979 chamfer,
3980 name,
3981 "fillet_edges_variable",
3982 true,
3983 )
3984}
3985
3986#[allow(clippy::too_many_arguments)]
3998fn fillet_edges_variable_impl(
3999 solid: &BrepSolid,
4000 edge_points: &[Vec3],
4001 edge_names: Option<&[String]>,
4002 per_edge_stops: &[Vec<(f64, f64)>],
4003 chamfer: bool,
4004 name: Option<&str>,
4005 entry: &str,
4006 allow_tapered_sequential: bool,
4007) -> Result<BrepSolid, KernelRefusal> {
4008 let bounds = crate::recentre::solid_bounds(solid);
4011 let translated = match crate::recentre::control_offset_at_entry() {
4012 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
4013 None => crate::recentre::guard_offset(bounds).map(|offset| {
4014 crate::recentre::profile_fired(entry, offset, bounds);
4015 (offset, crate::recentre::EntryKind::Guard)
4016 }),
4017 };
4018 if let Some((offset, kind)) = translated {
4019 let _entry = crate::recentre::TranslatedEntry::open(kind);
4020 let translate = |solid: &BrepSolid, offset: Vec3| {
4021 crate::recentre::translate_solid(solid, offset).map_err(|message| {
4022 KernelRefusal::internal(
4023 KernelStage::Collect,
4024 "translation_control",
4025 format!("{entry}: the translation control could not move the solid: {message}"),
4026 )
4027 })
4028 };
4029 let moved = translate(solid, offset)?;
4030 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
4031 let result = fillet_edges_variable_impl(
4032 &moved,
4033 &points,
4034 edge_names,
4035 per_edge_stops,
4036 chamfer,
4037 name,
4038 entry,
4039 allow_tapered_sequential,
4040 )?;
4041 return translate(&result, offset.scale(-1.0));
4042 }
4043 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
4044 if let Some(constant) = per_edge_stops
4048 .first()
4049 .and_then(|stops| stops.first())
4050 .map(|(_, radius)| *radius)
4051 {
4052 let uniform = per_edge_stops.iter().all(|stops| {
4053 stops
4054 .iter()
4055 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
4056 });
4057 if uniform && constant > 0.0 {
4058 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
4059 }
4060 }
4061 let max_radius = per_edge_stops
4062 .iter()
4063 .flat_map(|stops| stops.iter())
4064 .map(|(_, r)| r.abs())
4065 .fold(0.0_f64, f64::max);
4066
4067 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
4072 match fillet_variable_network(
4073 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
4074 )? {
4075 Ok(built) => return Ok(built),
4076 Err(refusal) if crate::blend::relief::relieving() => return Err(refusal),
4080 Err(_) => {}
4081 }
4082 }
4083
4084 let mut chain_corner = false;
4091 {
4092 use rustc_hash::FxHashSet as HashSet;
4093 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
4094 for (i, point) in edge_points.iter().enumerate() {
4095 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
4096 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
4097 if let (Ok(a), Ok(b)) =
4098 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
4099 {
4100 endpoints.push((a, i));
4101 endpoints.push((b, i));
4102 }
4103 }
4104 }
4105 }
4106 let mut used = vec![false; endpoints.len()];
4107 for i in 0..endpoints.len() {
4108 if used[i] {
4109 continue;
4110 }
4111 used[i] = true;
4112 let mut edges_here: HashSet<usize> = HashSet::default();
4113 edges_here.insert(endpoints[i].1);
4114 for j in (i + 1)..endpoints.len() {
4115 if used[j] {
4116 continue;
4117 }
4118 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
4119 used[j] = true;
4120 edges_here.insert(endpoints[j].1);
4121 }
4122 }
4123 if edges_here.len() == 2 {
4124 chain_corner = true;
4125 }
4126 }
4127 }
4128 let miter_operation = if chain_corner {
4129 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
4130 let mut any_convex = false;
4131 let mut any_concave = false;
4132 for point in edge_points {
4133 match resolve_edge_by_point(solid, *point)
4134 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
4135 {
4136 Ok(cross) if cross.convex => any_convex = true,
4137 Ok(_) => any_concave = true,
4138 Err(_) => {
4139 any_convex = true;
4140 any_concave = true;
4141 break;
4142 }
4143 }
4144 }
4145 match (any_convex, any_concave) {
4146 (true, false) => Some(crate::BooleanOperation::Intersect),
4147 (false, true) => Some(crate::BooleanOperation::Union),
4148 _ => None,
4149 }
4150 } else {
4151 None
4152 };
4153
4154 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
4161 let options = crate::BooleanOptions::default();
4162 let mut combined: Option<BrepSolid> = None;
4163 let mut failed = false;
4164 for (index, point) in edge_points.iter().enumerate() {
4165 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
4166 failed = true;
4167 break;
4168 };
4169 let edge_name = per_edge_name(edge_names, name, index);
4170 let Ok(blended) = crate::blend::blend_edge_variable(
4171 solid,
4172 edge_id,
4173 &per_edge_stops[index],
4174 chamfer,
4175 edge_name,
4176 ) else {
4177 failed = true;
4178 break;
4179 };
4180 let next = match combined.take() {
4181 None => blended,
4182 Some(previous) => {
4183 match crate::boolean_operation(&previous, &blended, operation, &options) {
4184 Ok(next) => next,
4185 Err(_) => {
4186 failed = true;
4187 break;
4188 }
4189 }
4190 }
4191 };
4192 combined = Some(next);
4193 }
4194 if failed {
4195 None
4196 } else {
4197 combined.filter(|s| s.validate().is_empty())
4198 }
4199 } else {
4200 None
4201 };
4202 let mut result = match miter_attempt {
4203 Some(mitered) => mitered,
4204 None => {
4205 let tapered = per_edge_stops.iter().any(|stops| {
4211 stops.first().is_some_and(|(_, first)| {
4212 stops
4213 .iter()
4214 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
4215 })
4216 });
4217 if chain_corner && tapered && !allow_tapered_sequential {
4218 return Err(KernelRefusal::unsupported(
4221 KernelStage::Sew,
4222 "tapered_chain_miter",
4223 format!(
4224 "{entry}: the tapered blends across the selected chain's shared corners \
4225 did not compose to a valid solid (the fitted blend boundaries could not \
4226 be mitered); fillet fewer edges per operation or reduce the taper"
4227 ),
4228 ));
4229 }
4230 let mut sequential = solid.clone();
4231 for (index, point) in edge_points.iter().enumerate() {
4232 let edge_id = resolve_edge_by_point(&sequential, *point)?;
4233 let edge_name = per_edge_name(edge_names, name, index);
4234 sequential = crate::blend::blend_edge_variable(
4235 &sequential,
4236 edge_id,
4237 &per_edge_stops[index],
4238 chamfer,
4239 edge_name,
4240 )?;
4241 }
4242 sequential
4243 }
4244 };
4245 heal_edge_vertex_gaps(&mut result, max_radius)?;
4248 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
4249 let issues = result.validate();
4254 if !issues.is_empty() {
4255 let (detail, class) = if allow_tapered_sequential {
4260 (
4261 "tapered blends meet at a shared chain vertex with \
4262 mismatched radii — the radius-transition corner patch is not implemented; \
4263 fillet the edges in separate operations or use matching stop radii",
4264 RefusalClass::UnsupportedGeometry {
4265 what: "taper_transition_patch".into(),
4266 },
4267 )
4268 } else {
4269 (
4270 "the composed radius-law blend produced invalid topology — the blends \
4271 across a shared chain corner failed to reassemble",
4272 RefusalClass::Internal {
4273 what: "law_chain_topology".into(),
4274 },
4275 )
4276 };
4277 return Err(KernelRefusal::new(
4278 class,
4279 KernelStage::Validate,
4280 format!(
4281 "{entry}: {detail} ({} validation issues, first: {})",
4282 issues.len(),
4283 issues
4284 .first()
4285 .map(|issue| issue.message.clone())
4286 .unwrap_or_default()
4287 ),
4288 ));
4289 }
4290 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
4295 crate::accept_sound(result, entry)
4296}
4297
4298#[allow(clippy::too_many_arguments)]
4303fn fillet_variable_network(
4304 solid: &BrepSolid,
4305 edge_points: &[Vec3],
4306 edge_names: Option<&[String]>,
4307 per_edge_stops: &[Vec<(f64, f64)>],
4308 chamfer: bool,
4309 name: Option<&str>,
4310 entry: &str,
4311 max_radius: f64,
4312) -> Result<Result<BrepSolid, KernelRefusal>, KernelRefusal> {
4313 let mut edge_ids = Vec::with_capacity(edge_points.len());
4314 for point in edge_points {
4315 edge_ids.push(resolve_edge_by_point(solid, *point)?);
4316 }
4317 let laws: Vec<crate::blend::StripeRadius> =
4318 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
4319 let network_names: Vec<Option<String>> = (0..edge_points.len())
4320 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
4321 .collect();
4322 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
4323 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
4324 let law_network = || {
4333 crate::blend::blend_star_network_laws(solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true)
4334 };
4335 let network_result = law_network().or_else(|first| {
4336 if crate::blend::is_pcurve_floor_refusal(&first) {
4337 crate::blend::with_pcurve_knot_retry(law_network).map_err(|_| first)
4338 } else {
4339 Err(first)
4340 }
4341 });
4342 let mut network = match network_result {
4343 Ok(network) => network,
4344 Err(refusal) => {
4345 if debug {
4346 eprintln!("law network refused: {refusal}");
4347 }
4348 return Ok(Err(refusal));
4349 }
4350 };
4351 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
4356 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
4357 let issues = network.validate();
4358 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
4359 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
4360 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
4361 crate::soundness::census::profile_operation(&network, &[solid], None, entry);
4362 match crate::accept_sound(network, entry) {
4363 Ok(sound) => return Ok(Ok(sound)),
4364 Err(refusal) => {
4365 if debug {
4366 eprintln!("law network result unsound: {refusal}");
4367 }
4368 return Ok(Err(refusal));
4369 }
4370 }
4371 }
4372 if debug {
4373 eprintln!(
4374 "law network result rejected: heal={healed:?} issues={issues:?} \
4375 interference={interference:?} bowtie={bowtie:?}"
4376 );
4377 }
4378 Ok(Err(KernelRefusal::unsupported(
4379 KernelStage::Validate,
4380 "law_network_rejected",
4381 format!(
4382 "{entry}: the tapered network's result was rejected (heal {}, {} validation issues, interference {}, loops {})",
4383 if healed.is_ok() { "ok" } else { "failed" },
4384 issues.len(),
4385 if interference.is_ok() { "ok" } else { "found" },
4386 if bowtie.is_ok() { "ok" } else { "found" },
4387 ),
4388 )))
4389}
4390
4391pub(crate) fn fillet_edges_variable_stops(
4392 solid: &BrepSolid,
4393 edge_points: &[Vec3],
4394 edge_names: Option<&[String]>,
4395 per_edge_stops: &[Vec<(f64, f64)>],
4396 chamfer: bool,
4397 name: Option<&str>,
4398) -> Result<BrepSolid, KernelRefusal> {
4399 let _operation = crate::blend::BlendOperation::enter();
4400 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
4401 return Err(KernelRefusal::input(
4402 KernelStage::Collect,
4403 "variable_selection",
4404 "fillet_edges_variable_stops: one stop list per selected edge is required",
4405 ));
4406 }
4407 fillet_edges_variable_impl(
4408 solid,
4409 edge_points,
4410 edge_names,
4411 per_edge_stops,
4412 chamfer,
4413 name,
4414 "fillet_edges_variable_stops",
4415 false,
4416 )
4417}
4418
4419struct ChainLink {
4423 input_index: usize,
4425 forward: bool,
4427 arc: Vec<(f64, f64)>,
4430 length: f64,
4432 abscissa: f64,
4435}
4436
4437fn edge_arc_table(
4448 curve: &NurbsCurve,
4449 t0: f64,
4450 t1: f64,
4451 tol: f64,
4452) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4453 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
4454 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4455 let mut table = Vec::with_capacity(n + 1);
4456 let mut cumulative = 0.0;
4457 let mut previous = evaluate(t0)?;
4458 table.push((0.0, 0.0));
4459 for j in 1..=n {
4460 let fraction = j as f64 / n as f64;
4461 let point = evaluate(t0 + (t1 - t0) * fraction)?;
4462 cumulative += point.sub(previous).length();
4463 previous = point;
4464 table.push((fraction, cumulative));
4465 }
4466 Ok((table, cumulative))
4467 };
4468 let mut n = 16usize;
4469 let (mut table, mut length) = build(n)?;
4470 while n < 4096 {
4471 n *= 2;
4472 let (next_table, next_length) = build(n)?;
4473 let converged = (next_length - length).abs() <= tol;
4474 table = next_table;
4475 length = next_length;
4476 if converged {
4477 break;
4478 }
4479 }
4480 Ok((table, length))
4481}
4482
4483fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
4486 let fraction = fraction.clamp(0.0, 1.0);
4487 let intervals = table.len() - 1;
4488 let scaled = fraction * intervals as f64;
4489 let index = (scaled.floor() as usize).min(intervals - 1);
4490 let local = scaled - index as f64;
4491 let (_, a) = table[index];
4492 let (_, b) = table[index + 1];
4493 a + (b - a) * local
4494}
4495
4496fn resolve_selected_chain(
4505 solid: &BrepSolid,
4506 edge_points: &[Vec3],
4507 entry: &str,
4508 tol: f64,
4509) -> Result<Vec<ChainLink>, KernelRefusal> {
4510 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
4513
4514 struct Resolved {
4515 edge_id: u64,
4516 start: Vec3,
4517 end: Vec3,
4518 arc: Vec<(f64, f64)>,
4519 length: f64,
4520 }
4521 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
4522 for point in edge_points {
4523 let edge_id = resolve_edge_by_point(solid, *point)?;
4524 if resolved.iter().any(|r| r.edge_id == edge_id) {
4525 return Err(KernelRefusal::input(
4526 KernelStage::Collect,
4527 "duplicate_edge",
4528 format!("{entry}: the same edge was selected more than once"),
4529 ));
4530 }
4531 let edge = solid
4532 .edges
4533 .iter()
4534 .find(|e| e.id == edge_id)
4535 .ok_or_else(|| {
4536 KernelRefusal::internal(
4537 KernelStage::Collect,
4538 "chain_resolved_edge",
4539 format!("{entry}: resolved edge vanished"),
4540 )
4541 })?;
4542 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
4543 if !(length > 0.0) {
4544 return Err(KernelRefusal::input(
4545 KernelStage::Collect,
4546 "zero_length_edge",
4547 format!("{entry}: selected edge has zero length"),
4548 ));
4549 }
4550 resolved.push(Resolved {
4551 edge_id,
4552 start: edge
4553 .curve
4554 .evaluate(edge.t0)
4555 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
4556 end: edge
4557 .curve
4558 .evaluate(edge.t1)
4559 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
4560 arc,
4561 length,
4562 });
4563 }
4564 let n = resolved.len();
4565 if n == 1 {
4566 let only = resolved.remove(0);
4567 return Ok(vec![ChainLink {
4568 input_index: 0,
4569 forward: true,
4570 arc: only.arc,
4571 length: only.length,
4572 abscissa: 0.0,
4573 }]);
4574 }
4575
4576 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
4578 for (i, r) in resolved.iter().enumerate() {
4579 for (point, is_start) in [(r.start, true), (r.end, false)] {
4580 match clusters
4581 .iter_mut()
4582 .find(|(anchor, _)| anchor.sub(point).length() < band)
4583 {
4584 Some((_, members)) => members.push((i, is_start)),
4585 None => clusters.push((point, vec![(i, is_start)])),
4586 }
4587 }
4588 }
4589 if clusters.iter().any(|(_, members)| members.len() > 2) {
4590 return Err(KernelRefusal::input(
4591 KernelStage::Collect,
4592 "chain_branching",
4593 format!(
4594 "{entry}: selected edges must form one open chain \
4595 (a vertex is shared by three or more selected edges)"
4596 ),
4597 ));
4598 }
4599 let free: Vec<usize> = clusters
4600 .iter()
4601 .enumerate()
4602 .filter(|(_, (_, members))| members.len() == 1)
4603 .map(|(c, _)| c)
4604 .collect();
4605 if free.len() != 2 {
4606 return Err(KernelRefusal::input(
4607 KernelStage::Collect,
4608 "chain_open",
4609 format!(
4610 "{entry}: selected edges must form one OPEN chain \
4611 (closed rings and disconnected selections are not supported)"
4612 ),
4613 ));
4614 }
4615 let start_cluster = *free
4617 .iter()
4618 .min_by_key(|&&c| clusters[c].1[0].0)
4619 .expect("two free ends");
4620
4621 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
4623 let mut visited = vec![false; n];
4624 let mut abscissa = 0.0_f64;
4625 let mut cluster = start_cluster;
4626 for _ in 0..n {
4627 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
4628 .1
4629 .iter()
4630 .find(|(edge_index, _)| !visited[*edge_index])
4631 else {
4632 return Err(KernelRefusal::input(
4633 KernelStage::Collect,
4634 "chain_walk",
4635 format!(
4636 "{entry}: selected edges are not connected into one chain"
4637 ),
4638 ));
4639 };
4640 visited[edge_index] = true;
4641 let r = &resolved[edge_index];
4642 links.push(ChainLink {
4643 input_index: edge_index,
4644 forward: entered_at_start,
4645 arc: r.arc.clone(),
4646 length: r.length,
4647 abscissa,
4648 });
4649 abscissa += r.length;
4650 let exit_point = if entered_at_start { r.end } else { r.start };
4651 cluster = clusters
4652 .iter()
4653 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
4654 .ok_or_else(|| {
4655 KernelRefusal::internal(
4656 KernelStage::Collect,
4657 "chain_cluster",
4658 format!("{entry}: chain walk lost an endpoint cluster"),
4659 )
4660 })?;
4661 }
4662 if visited.iter().any(|v| !v) {
4663 return Err(KernelRefusal::input(
4664 KernelStage::Collect,
4665 "chain_connected",
4666 format!(
4667 "{entry}: selected edges are not connected into one chain"
4668 ),
4669 ));
4670 }
4671 Ok(links)
4672}
4673
4674fn law_stops_for_link(
4693 link: &ChainLink,
4694 law: &crate::law::RadiusLaw,
4695 scale: f64,
4696 tol: f64,
4697) -> Vec<(f64, f64)> {
4698 let radius_at_fraction = |fraction: f64| -> f64 {
4699 let arc = arc_length_at_fraction(&link.arc, fraction);
4700 let chain_s = if link.forward {
4701 link.abscissa + arc
4702 } else {
4703 link.abscissa + (link.length - arc)
4704 };
4705 law.radius_at(chain_s * scale)
4706 };
4707 let curvature = law
4710 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
4711 * (scale * link.length).powi(2);
4712 let base = if curvature * 0.125 <= tol {
4713 1usize
4714 } else {
4715 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
4716 };
4717 let mut stops: Vec<(f64, f64)> = (0..=base)
4718 .map(|j| {
4719 let fraction = j as f64 / base as f64;
4720 (fraction, radius_at_fraction(fraction))
4721 })
4722 .collect();
4723 let mut depth = 0usize;
4725 while depth < 8 {
4726 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
4727 let mut inserted = false;
4728 for pair in stops.windows(2) {
4729 refined.push(pair[0]);
4730 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
4731 let law_mid = radius_at_fraction(mid_fraction);
4732 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
4733 if (law_mid - linear_mid).abs() > tol {
4734 refined.push((mid_fraction, law_mid));
4735 inserted = true;
4736 }
4737 }
4738 refined.push(*stops.last().expect("at least two stops"));
4739 stops = refined;
4740 if !inserted {
4741 break;
4742 }
4743 depth += 1;
4744 }
4745 stops
4746}
4747
4748pub fn fillet_edges_variable_law(
4760 solid: &BrepSolid,
4761 edge_points: &[Vec3],
4762 edge_names: Option<&[String]>,
4763 law: &crate::law::RadiusLaw,
4764 chamfer: bool,
4765 name: Option<&str>,
4766) -> Result<BrepSolid, KernelRefusal> {
4767 let _operation = crate::blend::BlendOperation::enter();
4770 const ENTRY: &str = "fillet_edges_variable_law";
4771 if edge_points.is_empty() {
4772 return Err(KernelRefusal::input(
4773 KernelStage::Collect,
4774 "law_selection",
4775 format!("{ENTRY}: no edges selected"),
4776 ));
4777 }
4778 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4779 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4780 fillet_variable_law_on_chain(
4781 solid,
4782 edge_points,
4783 edge_names,
4784 &chain,
4785 law,
4786 chamfer,
4787 name,
4788 ENTRY,
4789 tolerances.intersection_fit,
4790 )
4791}
4792
4793pub fn fillet_edges_variable_vertex_radii(
4799 solid: &BrepSolid,
4800 edge_points: &[Vec3],
4801 edge_names: Option<&[String]>,
4802 vertex_radii: &[f64],
4803 chamfer: bool,
4804 name: Option<&str>,
4805) -> Result<BrepSolid, KernelRefusal> {
4806 let _operation = crate::blend::BlendOperation::enter();
4809 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
4810 if edge_points.is_empty() {
4811 return Err(KernelRefusal::input(
4812 KernelStage::Collect,
4813 "vertex_radii_selection",
4814 format!("{ENTRY}: no edges selected"),
4815 ));
4816 }
4817 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4818 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4819 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
4820 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
4821 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
4822 fillet_variable_law_on_chain(
4823 solid,
4824 edge_points,
4825 edge_names,
4826 &chain,
4827 &law,
4828 chamfer,
4829 name,
4830 ENTRY,
4831 tolerances.intersection_fit,
4832 )
4833}
4834
4835#[allow(clippy::too_many_arguments)]
4839fn fillet_variable_law_on_chain(
4840 solid: &BrepSolid,
4841 edge_points: &[Vec3],
4842 edge_names: Option<&[String]>,
4843 chain: &[ChainLink],
4844 law: &crate::law::RadiusLaw,
4845 chamfer: bool,
4846 name: Option<&str>,
4847 entry: &str,
4848 tol: f64,
4849) -> Result<BrepSolid, KernelRefusal> {
4850 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
4851 let scale = law.total_length() / chain_length;
4852 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
4853 for link in chain {
4854 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
4855 }
4856 fillet_edges_variable_impl(
4857 solid,
4858 edge_points,
4859 edge_names,
4860 &per_edge_stops,
4861 chamfer,
4862 name,
4863 entry,
4864 false,
4865 )
4866}
4867
4868pub fn chamfer_edges_asymmetric(
4873 solid: &BrepSolid,
4874 edge_points: &[Vec3],
4875 edge_names: Option<&[String]>,
4876 d1: f64,
4877 d2: f64,
4878 name: Option<&str>,
4879) -> Result<BrepSolid, KernelRefusal> {
4880 let _operation = crate::blend::BlendOperation::enter();
4883 if edge_points.is_empty() {
4884 return Err(KernelRefusal::input(
4885 KernelStage::Collect,
4886 "asymmetric_selection",
4887 "chamfer_edges_asymmetric: no edges selected",
4888 ));
4889 }
4890 let mut result = solid.clone();
4891 for (index, point) in edge_points.iter().enumerate() {
4895 let edge_id = resolve_edge_by_point(&result, *point)?;
4896 let edge_name = per_edge_name(edge_names, name, index);
4897 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
4898 }
4899 refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
4900 Ok(result)
4901}
4902
4903pub fn chamfer_edges_angle(
4907 solid: &BrepSolid,
4908 edge_points: &[Vec3],
4909 edge_names: Option<&[String]>,
4910 d1: f64,
4911 angle_rad: f64,
4912 name: Option<&str>,
4913) -> Result<BrepSolid, KernelRefusal> {
4914 let _operation = crate::blend::BlendOperation::enter();
4917 if edge_points.is_empty() {
4918 return Err(KernelRefusal::input(
4919 KernelStage::Collect,
4920 "angle_selection",
4921 "chamfer_edges_angle: no edges selected",
4922 ));
4923 }
4924 let mut result = solid.clone();
4925 for (index, point) in edge_points.iter().enumerate() {
4926 let edge_id = resolve_edge_by_point(&result, *point)?;
4927 let edge_name = per_edge_name(edge_names, name, index);
4928 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
4929 }
4930 refuse_invalid_result(&result, "chamfer_edges_angle")?;
4931 Ok(result)
4932}
4933
4934fn dump_loops_debug(label: &str, solid: &BrepSolid) {
4938 for shell in &solid.shells {
4939 for face in &shell.faces {
4940 for (index, loop_record) in face.loops.iter().enumerate() {
4941 let walk: Vec<String> = loop_record
4942 .coedges
4943 .iter()
4944 .map(|coedge| {
4945 let uv = coedge
4949 .pcurve
4950 .domain()
4951 .ok()
4952 .and_then(|[q0, q1]| {
4953 let a = coedge.pcurve.evaluate(q0).ok()?;
4954 let b = coedge.pcurve.evaluate(q1).ok()?;
4955 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
4956 })
4957 .unwrap_or_default();
4958 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
4959 Some(edge) => format!(
4960 "c{}:e{}{}({}->{}){uv}",
4961 coedge.id,
4962 edge.id,
4963 if coedge.forward { "+" } else { "-" },
4964 edge.start_vertex_id,
4965 edge.end_vertex_id
4966 ),
4967 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
4968 }
4969 })
4970 .collect();
4971 eprintln!(
4972 "{label} face {} ({:?}) loop {index}: {}",
4973 face.id,
4974 face.name,
4975 walk.join(" ")
4976 );
4977 }
4978 }
4979 }
4980}
4981
4982
4983