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 edge_id: u64,
61) -> Result<BrepSolid, KernelRefusal> {
62 if let Some(result) = super::tool::chamfer_through_end_faces(solid, edge_id, cross, profile, name) {
66 return Ok(settle_cutter_scaffold(result));
67 }
68 let mut tool = match &cross.path {
69 EdgePath::Straight { direction, length } => {
70 extrude_profile_brep(profile, *direction, *length)?
71 }
72 EdgePath::Circular { .. } => {
73 return Err(KernelRefusal::unsupported(
74 KernelStage::Classify,
75 "asymmetric_curved_edge",
76 "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
77 in this slice (asymmetric chamfer on general/curved edges is out of scope)",
78 ));
79 }
80 };
81 let mut side_index = 0usize;
85 for shell in &mut tool.shells {
86 for face in &mut shell.faces {
87 if side_index == 1 {
88 if face.name.is_none() {
89 face.name = name.map(str::to_string);
90 }
91 } else {
92 face.name = Some(scaffold_tag(name));
93 }
94 side_index += 1;
95 }
96 }
97 apply_tool(solid, &tool, cross.convex).map(settle_cutter_scaffold)
98}
99
100pub fn chamfer_edge_asymmetric(
105 solid: &BrepSolid,
106 edge_id: u64,
107 d1: f64,
108 d2: f64,
109 name: Option<&str>,
110) -> Result<BrepSolid, KernelRefusal> {
111 let _operation = crate::blend::BlendOperation::enter();
114 if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
115 return Err(KernelRefusal::input(
116 KernelStage::Collect,
117 "setback_distances",
118 "chamfer_edge_asymmetric: both setback distances must be positive",
119 ));
120 }
121 let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
124 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
125 apply_chamfer_offsets_profile(solid, &cross, &profile, name, edge_id)
126}
127
128pub fn chamfer_edge_angle(
134 solid: &BrepSolid,
135 edge_id: u64,
136 d1: f64,
137 angle_rad: f64,
138 name: Option<&str>,
139) -> Result<BrepSolid, KernelRefusal> {
140 let _operation = crate::blend::BlendOperation::enter();
143 if !(d1 > 0.0) || !d1.is_finite() {
144 return Err(KernelRefusal::input(
145 KernelStage::Collect,
146 "setback_d1",
147 "chamfer_edge_angle: setback distance d1 must be positive",
148 ));
149 }
150 let cross = analyze_edge(solid, edge_id, d1)?;
151 let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
152 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
153 apply_chamfer_offsets_profile(solid, &cross, &profile, name, edge_id)
154}
155
156pub(super) fn heal_edge_vertex_gaps(
160 solid: &mut BrepSolid,
161 radius: f64,
162) -> Result<(), KernelRefusal> {
163 let search = (radius.abs() * 1e-3).max(1e-7);
164 crate::boolean::commit_nearby_edge_endpoints(solid, search)
165}
166
167fn refuse_invalid_result(result: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
172 let errors: Vec<_> = result.validate_memoised().into_iter().filter(|issue| issue.severity == "error").collect();
173 let Some(first) = errors.first() else {
174 return Ok(());
175 };
176 Err(KernelRefusal::new(
177 RefusalClass::InvalidResultTopology { issues: errors.len() as u32 },
178 KernelStage::Validate,
179 format!(
180 "{entry}: the blend built a solid that fails validation ({} errors, first: {}); \
181 refused rather than returned",
182 errors.len(),
183 first.message
184 ),
185 ))
186}
187
188pub(super) fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
190 match crate::topology::nearest_edge(solid, point) {
191 Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
192 Some((_, distance)) => Err(KernelRefusal::input(
193 KernelStage::Collect,
194 "edge_point",
195 format!(
196 "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
197 ),
198 )),
199 None => Err(KernelRefusal::input(
200 KernelStage::Collect,
201 "no_edges",
202 "fillet_edges: solid has no edges",
203 )),
204 }
205}
206
207pub(crate) fn fillet_coedge_memo_on() -> bool {
211 switch!("BREP_FILLET_COEDGE_MEMO", true)
212}
213
214
215pub fn fillet_edges(
247 solid: &BrepSolid,
248 edge_points: &[Vec3],
249 edge_names: Option<&[String]>,
250 radius: f64,
251 chamfer: bool,
252 name: Option<&str>,
253) -> Result<BrepSolid, KernelRefusal> {
254 let prepared = if crate::blend::creased_rim::creased_rim_split_on() {
261 crate::blend::creased_rim::split_creased_rims(solid, edge_points, edge_names, &resolve_edge_by_point)?
262 } else {
263 None
264 };
265 match prepared {
266 Some(prepared) => {
267 let names = prepared.names.as_deref();
268 let built = fillet_edges_prepared(&prepared.solid, &prepared.points, names, radius, chamfer, name)?;
269 Ok(prepared.rejoin(built))
270 }
271 None => {
272 if chamfer && super::chamfer_star::chamfer_mixed_star_on() {
278 let build = |body: &BrepSolid, points: &[Vec3], names: Option<&[String]>| {
279 fillet_edges_prepared(body, points, names, radius, true, name)
280 };
281 if let Some(built) = super::chamfer_star::chamfer_mixed_stars(solid, edge_points, edge_names, &build) {
282 return Ok(built);
283 }
284 }
285 let ordinary = fillet_edges_prepared(solid, edge_points, edge_names, radius, chamfer, name);
286 let answered = match ordinary {
294 Err(refusal)
295 if crate::blend::split_rim::split_rim_merge_on()
296 && matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what } if what == super::analyze::TANGENT_ROLLOVER_ONLY_WHAT) =>
297 {
298 match crate::blend::split_rim::merge_split_rims(solid, edge_points, edge_names, &resolve_edge_by_point) {
299 Some(merged) => fillet_edges_prepared(&merged.solid, &merged.points, merged.names.as_deref(), radius, chamfer, name)
304 .map_err(|merged_refusal| {
305 refusal.with_message(|message| {
306 format!("{message} The rim merged into one closed edge refused too: {}", merged_refusal.message)
307 })
308 }),
309 None => Err(refusal),
310 }
311 }
312 other => other,
313 };
314 match answered {
321 Err(refusal) if solid.kind == crate::BodyKind::Sheet && crate::blend::sheet_ends::sheet_free_end_on() => {
322 let edge_ids = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<Vec<_>, _>>();
323 let Ok(edge_ids) = edge_ids else { return Err(refusal) };
324 let Some(closed) = crate::blend::sheet_ends::close_free_ends(solid, &edge_ids) else { return Err(refusal) };
325 let count = crate::blend::sheet_ends::temporary_faces(&closed);
326 match fillet_edges_prepared(&closed, edge_points, edge_names, radius, chamfer, name) {
327 Ok(built) => crate::blend::sheet_ends::open_free_ends(built, count).ok_or(refusal),
328 Err(closed_refusal) => Err(refusal.with_message(|message| {
329 format!("{message} The sheet with its free ends closed refused too: {}", closed_refusal.message)
330 })),
331 }
332 }
333 other => other,
334 }
335 }
336 }
337}
338
339fn fillet_edges_prepared(
340 solid: &BrepSolid,
341 edge_points: &[Vec3],
342 edge_names: Option<&[String]>,
343 radius: f64,
344 chamfer: bool,
345 name: Option<&str>,
346) -> Result<BrepSolid, KernelRefusal> {
347 let _operation = crate::blend::BlendOperation::enter();
350 BrepSolid::clear_validate_memo();
353 let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
357 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
358 let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
361 let _cap = SubsetDropCap::set(cap);
362 let (result, selection) = match fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name) {
363 Err(fold) if !chamfer && crate::blend::is_wall_fold(&fold) && crate::blend::relief::radius_relief_on() => {
366 let misfit = crate::blend::relief::is_misfit_fold(&fold);
367 let relieved = relieve_fold(solid, edge_points, edge_names, radius, name, fold);
368 if relieved.is_err() && misfit {
372 let _off = crate::blend::relief::MisfitOff::enter();
373 return fillet_edges(solid, edge_points, edge_names, radius, chamfer, name);
374 }
375 return relieved;
376 }
377 reported => reported?,
378 };
379 if selection.is_complete() {
380 return Ok(result);
381 }
382 let dropped: Vec<String> = selection
385 .rejected
386 .iter()
387 .map(|index| {
388 let point = edge_points[*index];
389 let named = edge_names
390 .and_then(|names| names.get(*index))
391 .map(|name| format!("`{name}` "))
392 .unwrap_or_default();
393 format!(
394 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
395 point.x, point.y, point.z
396 )
397 })
398 .collect();
399 Err(KernelRefusal::ill_posed(
403 KernelStage::Select,
404 "incomplete_selection",
405 format!(
406 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
407 rest of the selection and carry no wall in the result ({}). The group refusal was: \
408 {}. Blend them separately, or call the reported entry point to accept the partial \
409 selection deliberately.",
410 selection.applied.len(),
411 selection.requested,
412 selection.rejected.len(),
413 dropped.join(", "),
414 selection
415 .reason
416 .as_deref()
417 .unwrap_or("the whole group built but not every edge grew a wall"),
418 ),
419 ))
420}
421
422fn relieve_fold(
430 solid: &BrepSolid,
431 edge_points: &[Vec3],
432 edge_names: Option<&[String]>,
433 radius: f64,
434 name: Option<&str>,
435 fold: KernelRefusal,
436) -> Result<BrepSolid, KernelRefusal> {
437 let _relieving = crate::blend::relief::Relieving::enter();
438 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
439 let selected: Vec<u64> = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<_, _>>()?;
440 let joined = crate::blend::relief::join_continuations(solid, &selected);
444 let solid = joined.as_ref().unwrap_or(solid);
445 let mut ids: Vec<u64> = Vec::with_capacity(edge_points.len());
446 let mut points: Vec<Vec3> = Vec::with_capacity(edge_points.len());
447 let mut names: Vec<String> = Vec::with_capacity(edge_points.len());
448 for (index, point) in edge_points.iter().enumerate() {
449 let id = resolve_edge_by_point(solid, *point)?;
450 if !ids.contains(&id) {
451 ids.push(id);
452 points.push(*point);
453 if let Some(name) = edge_names.and_then(|names| names.get(index)) {
454 names.push(name.clone());
455 }
456 }
457 }
458 let (edge_points, edge_names) = (&points[..], edge_names.map(|_| &names[..]));
459 if debug && joined.is_some() {
460 eprintln!("radius relief: joined continuation edges, {} selected edges become {}", selected.len(), ids.len());
461 }
462 let started = web_time::Instant::now();
463 let measured = match crate::blend::relief::measure_ceilings(solid, &ids, radius) {
464 Ok(measured) => measured,
465 Err(refusal) => return Err(fold.with_message(|message| format!("{message}; the relief could not measure the edges: {refusal}"))),
466 };
467 if debug {
468 eprintln!("radius relief: ceilings measured in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
469 }
470 let largest = measured.iter().flatten().map(|(_, ceiling)| *ceiling).fold(0.0_f64, f64::max);
475 if largest < radius {
476 return Err(fold.with_message(|message| {
477 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}")
478 }));
479 }
480 let mut last: Option<KernelRefusal> = None;
481 for attempt in 0..crate::blend::relief::ATTEMPTS {
482 let plan = match crate::blend::relief::plan_relief(solid, &ids, radius, &measured, attempt) {
483 Ok(Some(plan)) => plan,
484 Ok(None) => break,
487 Err(refusal) => {
488 last = Some(refusal);
489 break;
490 }
491 };
492 let mark = crate::blend::blend_notes_mark();
493 match build_relief_plan(solid, edge_points, edge_names, &ids, radius, name, &plan) {
494 Ok(body) => {
495 for zone in &plan.zones {
496 let near: Vec<String> = zone
497 .edges
498 .iter()
499 .filter_map(|index| solid.edges.iter().find(|edge| edge.id == ids[*index]))
500 .map(|edge| edge.name.clone().unwrap_or_else(|| format!("edge #{}", edge.id)))
501 .map(|label| format!("`{label}`"))
502 .collect();
503 let (low, high) = zone
508 .edges
509 .iter()
510 .flat_map(|index| plan.stops[*index].iter().map(|(_, r)| *r))
511 .fold((f64::INFINITY, 0.0_f64), |(low, high), r| (low.min(r), high.max(r)));
512 let note = if high < radius * (1.0 - 1e-9) {
513 format!(
514 "radius reduced over the whole of {}: {low:.2} to {high:.2}, so r {radius} is reached nowhere \
515 on them (where they turn tightest the largest ball that fits is {:.2})",
516 near.join(", "),
517 zone.ceiling,
518 )
519 } else {
520 format!(
521 "radius reduced to {:.2} locally near {}: r {radius} does not fit there (the largest ball that does is {:.2})",
522 zone.radius,
523 near.join(", "),
524 zone.ceiling,
525 )
526 };
527 crate::blend::record_blend_note(note);
528 }
529 return Ok(body);
530 }
531 Err(refusal) => {
532 crate::blend::blend_notes_truncate(mark);
533 if debug {
534 eprintln!("radius relief attempt {attempt} refused at {:.1} ms: {refusal}", started.elapsed().as_secs_f64() * 1e3);
535 }
536 last = Some(refusal);
537 }
538 }
539 }
540 Err(match last {
541 Some(last) => fold.with_message(|message| format!("{message}; a locally reduced radius did not build either: {last}")),
542 None => fold,
543 })
544}
545
546fn build_relief_plan(
548 solid: &BrepSolid,
549 edge_points: &[Vec3],
550 edge_names: Option<&[String]>,
551 ids: &[u64],
552 radius: f64,
553 name: Option<&str>,
554 plan: &crate::blend::relief::ReliefPlan,
555) -> Result<BrepSolid, KernelRefusal> {
556 let count = ids.len();
558 let mut group: Vec<usize> = (0..count).collect();
559 let find = |group: &mut Vec<usize>, mut index: usize| {
560 while group[index] != index {
561 group[index] = group[group[index]];
562 index = group[index];
563 }
564 index
565 };
566 let vertices: Vec<[u64; 2]> = ids
567 .iter()
568 .map(|id| {
569 solid.edges.iter().find(|edge| edge.id == *id).map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).unwrap_or([u64::MAX; 2])
570 })
571 .collect();
572 for a in 0..count {
573 for b in a + 1..count {
574 if vertices[a].iter().any(|vertex| *vertex != u64::MAX && vertices[b].contains(vertex)) {
575 let (root_a, root_b) = (find(&mut group, a), find(&mut group, b));
576 group[root_a] = root_b;
577 }
578 }
579 }
580 let reduced: Vec<bool> = plan.stops.iter().map(|stops| stops.iter().any(|(_, r)| *r < radius)).collect();
581 let relieved_group: Vec<bool> = (0..count)
582 .map(|index| {
583 let root = find(&mut group, index);
584 (0..count).any(|other| reduced[other] && find(&mut group, other) == root)
585 })
586 .collect();
587 let pick = |want: bool| -> (Vec<Vec3>, Option<Vec<String>>, Vec<Vec<(f64, f64)>>) {
588 let indices: Vec<usize> = (0..count).filter(|index| relieved_group[*index] == want).collect();
589 (
590 indices.iter().map(|index| edge_points[*index]).collect(),
591 edge_names.map(|names| indices.iter().filter_map(|index| names.get(*index).cloned()).collect()),
592 indices.iter().map(|index| plan.stops[*index].clone()).collect(),
593 )
594 };
595 let (constant_points, constant_names, _) = pick(false);
596 let (relieved_points, relieved_names, relieved_stops) = pick(true);
597 let mut body = solid.clone();
598 if !constant_points.is_empty() {
599 body = fillet_edges(&body, &constant_points, constant_names.as_deref(), radius, false, name)?;
600 }
601 let built = fillet_edges_variable_impl(
602 &body,
603 &relieved_points,
604 relieved_names.as_deref(),
605 &relieved_stops,
606 false,
607 name,
608 "fillet_edges",
609 false,
610 )?;
611 let issues = built.validate();
612 if !issues.is_empty() {
613 return Err(KernelRefusal::internal(
614 KernelStage::Validate,
615 "relief_validate",
616 format!("radius relief: the relieved fillet does not validate: {issues:?}"),
617 ));
618 }
619 Ok(built)
620}
621
622#[derive(Clone, Debug)]
629pub struct BlendSelection {
630 pub requested: usize,
632 pub applied: Vec<usize>,
634 pub rejected: Vec<usize>,
636 pub reason: Option<String>,
638}
639
640impl BlendSelection {
641 pub fn is_complete(&self) -> bool {
643 self.rejected.is_empty()
644 }
645}
646
647pub fn fillet_edges_reported_at_offset(
659 solid: &BrepSolid,
660 edge_points: &[Vec3],
661 edge_names: Option<&[String]>,
662 radius: f64,
663 chamfer: bool,
664 name: Option<&str>,
665 offset: Vec3,
666) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
667 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
668}
669
670#[allow(clippy::too_many_arguments)]
672fn fillet_translated(
673 solid: &BrepSolid,
674 edge_points: &[Vec3],
675 edge_names: Option<&[String]>,
676 radius: f64,
677 chamfer: bool,
678 name: Option<&str>,
679 offset: Vec3,
680 kind: crate::recentre::EntryKind,
681) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
682 let _entry = crate::recentre::TranslatedEntry::open(kind);
683 let translate = |solid: &BrepSolid, offset: Vec3| {
684 crate::recentre::translate_solid(solid, offset).map_err(|message| {
685 KernelRefusal::internal(
686 KernelStage::Collect,
687 "translation_control",
688 format!("fillet_edges: the translation control could not move the solid: {message}"),
689 )
690 })
691 };
692 let moved = translate(solid, offset)?;
693 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
694 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
695 Ok((translate(&result, offset.scale(-1.0))?, selection))
696}
697
698pub fn fillet_edges_reported(
699 solid: &BrepSolid,
700 edge_points: &[Vec3],
701 edge_names: Option<&[String]>,
702 radius: f64,
703 chamfer: bool,
704 name: Option<&str>,
705) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
706 if let Some(offset) = crate::recentre::control_offset_at_entry() {
709 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
710 }
711 let bounds = crate::recentre::solid_bounds(solid);
714 if let Some(offset) = crate::recentre::guard_offset(bounds) {
715 crate::recentre::profile_fired("fillet", offset, bounds);
716 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
717 }
718 let _operation = crate::blend::BlendOperation::enter();
721 let _total = crate::blend::BlendTimer::start("timing.total_ms");
724 if !(radius > 0.0) || !radius.is_finite() {
725 return Err(KernelRefusal::input(
726 KernelStage::Collect,
727 "radius",
728 "fillet_edges: radius must be positive",
729 ));
730 }
731 if edge_points.is_empty() {
732 return Err(KernelRefusal::input(
733 KernelStage::Collect,
734 "selection",
735 "fillet_edges: no edges selected",
736 ));
737 }
738 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
746 let mut selected_ids = Vec::with_capacity(edge_points.len());
747 if crate::blend::blend_operation_outermost() {
748 super::analyze::clear_rollover_only();
749 }
750 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
751 let _boundaries = super::analyze::SupportBoundaryScope::open();
752 for point in edge_points {
753 let resolve = crate::blend::BlendTimer::start("timing.selection_checks.resolve_ms");
754 let edge_id = resolve_edge_by_point(solid, *point)?;
755 resolve.stop();
756 let concavity = crate::blend::BlendTimer::start("timing.selection_checks.concavity_ms");
757 check_mixed_concavity(solid, edge_id, entry)?;
758 concavity.stop();
759 let _extent = crate::blend::BlendTimer::start("timing.selection_checks.extent_ms");
760 if edge_points.len() == 1 && !chamfer {
762 check_support_extent_clipping(solid, edge_id, radius, entry)?;
763 } else {
764 check_support_extent(solid, edge_id, radius, entry)?;
765 }
766 selected_ids.push(edge_id);
767 }
768 drop(_boundaries);
769 selection_checks.stop();
770 if !chamfer {
809 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
814 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
815 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
816 corner_analysis.stop();
817 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
818 for corner in &corners {
819 eprintln!(
820 "mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
821 corner.vertex, corner.point.x, corner.point.y, corner.point.z,
822 corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
823 corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
824 );
825 }
826 }
827 if !corners.is_empty() {
828 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
831 let composed = rounded_boss_composition(
833 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
834 ).or_else(|error| {
835 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
836 eprintln!("rounded boss composition refused: {error}");
837 }
838 runout_composition(
839 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
840 )
841 }).or_else(|error| {
842 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
843 eprintln!("runout composition refused: {error}");
844 }
845 open_pad_composition(
851 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
852 ).map_err(|pad_error| {
853 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
854 eprintln!("open-pad composition refused: {pad_error}");
855 }
856 let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
861 if declined { error } else { pad_error }
862 })
863 }).or_else(|error| {
864 cusp_composition(solid, &selected_ids, edge_points, edge_names, radius, name, &corners).map_err(|cusp_error| {
869 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
870 eprintln!("cusp composition refused: {cusp_error}");
871 }
872 error
873 })
874 });
875 match composed {
876 Ok(result) => {
877 let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
886 refuse_invalid_result(&result, entry)?;
890 let missing = walls_missing_ties(
891 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
892 )?;
893 if !missing.is_empty() {
895 return Err(KernelRefusal::internal(
896 KernelStage::Validate,
897 "runout_walls_missing",
898 format!(
899 "{entry}: the composition built a solid with no wall on {}",
900 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
901 ),
902 ));
903 }
904 let n = edge_points.len();
905 return Ok((
906 result,
907 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
908 ));
909 }
910 Err(error) => {
911 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
912 eprintln!("runout composition refused: {error}");
913 }
914 check_mixed_corner_convexity(
915 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
916 )?;
917 }
918 }
919 }
920 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
926 check_degenerate_corner_setbacks(
927 solid,
928 &selected_ids,
929 edge_points,
930 edge_names,
931 radius,
932 name,
933 )?;
934 corner_analysis.stop();
935 }
936
937 let n = edge_points.len();
938 let complete = |applied: Vec<usize>| BlendSelection {
939 requested: n,
940 applied,
941 rejected: Vec::new(),
942 reason: None,
943 };
944 let mut memo = ComponentMemo::default();
955 let profile = crate::profile::profile_enabled();
956 let group_started = web_time::Instant::now();
957 let group = fillet_edges_group_in(
958 solid,
959 edge_points,
960 edge_names,
961 radius,
962 chamfer,
963 name,
964 Some(&mut memo),
965 );
966 if profile {
967 profile_line!(
968 "blend.selection group edges={n} ok={} ms={:.3}",
969 group.is_ok(),
970 group_started.elapsed().as_secs_f64() * 1e3
971 );
972 }
973 let group_err = match group {
974 Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
979 refuse_invalid_result(&result, entry).unwrap_err()
980 }
981 Ok(result) => {
982 let missing =
987 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
988 if missing.is_empty() {
989 return Ok((result, complete((0..n).collect())));
990 }
991 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
992 return Ok((
993 result,
994 BlendSelection {
995 requested: n,
996 applied,
997 rejected: missing,
998 reason: None,
999 },
1000 ));
1001 }
1002 Err(error) => error,
1003 };
1004 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err)
1014 || crate::blend::is_tangential_transverse_end(&group_err)
1015 || matches!(&group_err.class, RefusalClass::UnsupportedGeometry { what } if what == "canal_footprint_bound")
1016 {
1017 return Err(group_err);
1018 }
1019 if n < 2 || n > 12 {
1024 return Err(group_err);
1025 }
1026 let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
1033 for drop in 1..=max_drop {
1034 for dropped in index_combinations(n, drop) {
1035 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
1036 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
1037 let kept_names: Option<Vec<String>> =
1040 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
1041 crate::blend::record_count("count.subset.builds", 1);
1042 let subset_started = web_time::Instant::now();
1043 let subset = fillet_edges_group_in(
1044 solid,
1045 &points,
1046 kept_names.as_deref(),
1047 radius,
1048 chamfer,
1049 name,
1050 Some(&mut memo),
1051 );
1052 if profile {
1053 profile_line!(
1054 "blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
1055 subset.is_ok(),
1056 subset_started.elapsed().as_secs_f64() * 1e3
1057 );
1058 }
1059 let result = match subset {
1060 Ok(result) => result,
1061 Err(error) => {
1062 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1063 eprintln!("subset dropped={dropped:?} refused: {error}");
1064 }
1065 continue;
1066 }
1067 };
1068 if !result.validate().is_empty() {
1069 continue;
1070 }
1071 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
1072 continue;
1073 }
1074 return Ok((
1075 result,
1076 BlendSelection {
1077 requested: n,
1078 applied: keep,
1079 rejected: dropped,
1080 reason: Some(group_err.message),
1081 },
1082 ));
1083 }
1084 }
1085 Err(group_err)
1086}
1087
1088pub(crate) fn walls_missing_nearest_on() -> bool {
1093 switch!("BREP_WALLS_MISSING_NEAREST", true)
1094}
1095
1096
1097pub(super) fn walls_missing(
1123 original: &BrepSolid,
1124 result: &BrepSolid,
1125 edge_points: &[Vec3],
1126 selected: &[usize],
1127 size: f64,
1128 chamfer: bool,
1129 entry: &str,
1130) -> Result<Vec<usize>, KernelRefusal> {
1131 walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
1132}
1133
1134#[allow(clippy::too_many_arguments)]
1149pub(super) fn walls_missing_ties(
1150 original: &BrepSolid,
1151 result: &BrepSolid,
1152 edge_points: &[Vec3],
1153 selected: &[usize],
1154 size: f64,
1155 chamfer: bool,
1156 entry: &str,
1157 tie_majority: bool,
1158) -> Result<Vec<usize>, KernelRefusal> {
1159use crate::{PointClass, SolidClassifier};
1161 if selected.is_empty() {
1162 return Ok(Vec::new());
1163 }
1164 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
1165 crate::classification::classify_profile_begin();
1166 crate::classification::containment_profile_begin();
1167 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
1168 let visible = band * 100.0;
1171 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
1172 let classifier = SolidClassifier::new(result, band)
1173 .or_refuse(KernelStage::Validate, "wall_classifier")?;
1174 let nearest = if walls_missing_nearest_on() {
1179 Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
1180 } else {
1181 None
1182 };
1183 let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
1184 if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
1185 crate::blend::record_count("count.walls_missing.nearest", 1);
1186 return Ok(class == PointClass::On);
1187 }
1188 Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
1189 };
1190 build_timing.stop();
1191 const STATIONS: usize = 9;
1192 let mut missing = Vec::new();
1193 for &index in selected {
1194 let point = edge_points[index];
1195 let edge_id = resolve_edge_by_point(original, point)?;
1196 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
1197 let mut best: Option<(Vec3, f64)> = None;
1199 let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
1201 for step in 1..=STATIONS {
1203 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
1204 let Ok(station) = edge.curve.evaluate(t) else {
1205 continue;
1206 };
1207 let clearance = match faces[..] {
1208 [first, second] => {
1209 let (Ok((_, a)), Ok((_, b))) = (
1210 super::analyze::face_plane_normal(first, station),
1211 super::analyze::face_plane_normal(second, station),
1212 ) else {
1213 continue;
1214 };
1215 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
1216 if chamfer {
1217 size * half.sin()
1218 } else {
1219 size * (1.0 / half.cos().max(1e-12) - 1.0)
1220 }
1221 }
1222 _ => f64::INFINITY,
1224 };
1225 if best.map_or(true, |(_, known)| clearance > known) {
1226 best = Some((station, clearance));
1227 }
1228 read.push((station, clearance));
1230 }
1232 let Some((station, clearance)) = best else {
1233 return Err(KernelRefusal::internal(
1234 KernelStage::Validate,
1235 "wall_attribution_stations",
1236 format!(
1237 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
1238 station of it could be evaluated",
1239 point.x, point.y, point.z
1240 ),
1241 ));
1242 };
1243 if !(clearance > visible) {
1244 return Err(KernelRefusal::ill_posed(
1248 KernelStage::Validate,
1249 "wall_attribution_band",
1250 format!(
1251 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
1252 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
1253 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
1254 selection is refused rather than reported on a guess",
1255 point.x, point.y, point.z
1256 ),
1257 ));
1258 }
1259 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
1260 let certified = station_clear_of_skin(result, station, band * 10.0, visible)
1267 || station_clear_of_trimmed_skin(result, station, band * 10.0, visible);
1268 let on = if certified {
1269 crate::blend::record_count("count.walls_missing.certified", 1);
1270 false
1271 } else {
1272 crate::blend::record_count("count.walls_missing.classified", 1);
1273 reads_on(station)?
1274 };
1275 classify_timing.stop();
1276 let on = on && (!tie_majority || {
1278 let tolerance = 1e-12 * clearance.abs().max(1.0);
1281 let (mut tied, mut on_skin) = (1usize, 1usize);
1282 for (other, other_clearance) in read.iter().copied() {
1283 if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
1284 continue;
1285 }
1286 tied += 1;
1287 if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
1288 on_skin += 1;
1289 }
1290 }
1291 2 * on_skin > tied
1292 });
1293 if on {
1295 missing.push(index);
1296 }
1297 }
1298 crate::classification::classify_profile_report("blend.walls_missing");
1299 crate::classification::containment_profile_report("blend.walls_missing");
1300 Ok(missing)
1301}
1302
1303
1304
1305fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
1335 use crate::AnalyticSurface;
1336 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
1339 let offset = station.sub(frame.origin);
1340 let axial = offset.dot(frame.axis);
1341 let radial = offset.sub(frame.axis.scale(axial));
1342 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
1343 if azimuth < 0.0 {
1344 azimuth += std::f64::consts::TAU;
1345 }
1346 (radial.length(), axial, azimuth)
1347 };
1348 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
1352 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
1353 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
1354 return 0.0;
1355 }
1356 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
1358 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
1359 };
1360 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
1363 let (du, dv) = (rho1 - rho0, h1 - h0);
1364 let length = (du * du + dv * dv).sqrt();
1365 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
1366 };
1367 let clear = |distance: f64| distance.is_finite() && distance > margin;
1368 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1369 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
1370 if !controls.expanded(on_band).contains(station) {
1371 continue;
1372 }
1373 let certified = match face.surface.analytic() {
1374 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
1375 let (radial, axial, _) = meridian(frame);
1376 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
1377 clear(line.max(axial_clearance(axial, 0.0, *height)))
1378 }
1379 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
1380 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
1381 let (radial, axial, _) = meridian(frame);
1382 let tube = radial - major_radius;
1383 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
1384 }
1385 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
1386 let [a, b] = generatrix.control_points.as_slice() else { return false };
1393 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1394 return false;
1395 }
1396 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
1397 let offset = point.sub(frame.origin);
1398 let axial = offset.dot(frame.axis);
1399 let radial = offset.sub(frame.axis.scale(axial));
1400 let rho = radial.length();
1401 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);
1402 on_meridian.then_some((rho, axial))
1403 };
1404 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 {
1405 return false;
1406 };
1407 let (radial, axial, azimuth) = meridian(frame);
1408 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
1409 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
1414 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
1415 }
1416 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
1417 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
1418 if clear(station.sub(*origin).dot(normal).abs()) {
1419 true
1420 } else {
1421 planar_trims_clear_of(face, plane, station, on_band, margin)
1422 }
1423 }
1424 None => false,
1425 };
1426 if !certified {
1427 return false;
1428 }
1429 }
1430 true
1431}
1432
1433fn station_clear_of_trimmed_skin(result: &BrepSolid, point: Vec3, on_band: f64, margin: f64) -> bool {
1439 let _timer = crate::blend::BlendTimer::start("timing.walls_missing.trimmed_bound_ms");
1440 if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1441 || !margin.is_finite() || !(margin > on_band && on_band > 0.0)
1442 { return false; }
1443 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1444 let Ok(bounds) = crate::Aabb::from_surface_controls(&face.surface) else { return false; };
1445 if !bounds.expanded(margin).contains(point) { continue; }
1446 if face.surface.analytic().is_none() {
1447 if general_carrier_clear(&face.surface, point, margin) { continue; }
1448 return false;
1449 }
1450 match face.surface.analytic() {
1456 Some(crate::AnalyticSurface::Plane { .. }) => {},
1457 Some(crate::AnalyticSurface::Sphere { radius, .. }) if *radius > 4.0 * margin => {},
1458 Some(crate::AnalyticSurface::Torus { major_radius, minor_radius, .. })
1459 if *minor_radius > 4.0 * margin && *major_radius > *minor_radius + 4.0 * margin => {},
1460 _ => return false,
1461 }
1462 let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else { return false; };
1463 if !projection.distance.is_finite() { return false; }
1464 if projection.distance > margin { continue; }
1465 let band = crate::classification::surface_uv_band(&face.surface, projection.u, projection.v, on_band);
1466 let Ok(inside) = crate::parameter_point_in_face(face, crate::Vec2 { x: projection.u, y: projection.v }, band) else { return false; };
1467 if inside != crate::PolygonClass::Outside { return false; }
1468 let mut edges = 0;
1469 for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
1470 let Some(edge) = result.edges.iter().find(|edge| edge.id == coedge.edge_id) else { return false; };
1471 let Some(piece) = super::analyze::edge_piece(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1)) else { return false; };
1472 let Ok(foot) = crate::project_point_to_curve(&piece, point) else { return false; };
1473 if !foot.distance.is_finite() || foot.distance <= margin { return false; }
1474 edges += 1;
1475 }
1476 if edges == 0 { return false; }
1477 }
1478 true
1479}
1480
1481fn general_carrier_clear(surface: &crate::NurbsSurface, point: Vec3, margin: f64) -> bool {
1487 use crate::Vec4;
1488 let rows = surface.control_points.len();
1489 let columns = surface.control_points.first().map_or(0, Vec::len);
1490 if rows == 0 || columns == 0 || rows.saturating_mul(columns) > 4096
1491 || surface.degree_u > 16 || surface.degree_v > 16 || surface.control_points.iter().any(|row| row.len() != columns)
1492 || !margin.is_finite() || margin <= 0.0
1493 || !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1494 { return false; }
1495 let mut scale = 1.0_f64;
1496 for control in surface.control_points.iter().flatten() {
1497 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1498 let Ok(p) = control.point() else { return false; };
1499 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1500 scale = scale.max(p.x.abs()).max(p.y.abs()).max(p.z.abs());
1501 }
1502 let roundoff = 256.0 * f64::EPSILON * scale * (surface.degree_u + 1) as f64 * (surface.degree_v + 1) as f64;
1505 if roundoff >= margin * 0.01 { return false; }
1506 let zero = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
1507 let blend = |a: Vec4, b: Vec4, alpha: f64| a.scale(alpha).add(b.scale(1.0 - alpha));
1508 let mut fibres = Vec::with_capacity(columns);
1509 for column in 0..columns {
1510 let controls: Vec<_> = surface.control_points.iter().map(|row| row[column]).collect();
1511 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_u, &controls, surface.degree_u, zero, blend) else { return false; };
1512 fibres.push(spans);
1513 }
1514 let mut budget = 256usize;
1515 for u_span in 0..fibres[0].len() {
1516 let mut across = Vec::with_capacity(surface.degree_u + 1);
1517 for row in 0..=surface.degree_u {
1518 let controls: Vec<_> = fibres.iter().map(|fibre| fibre[u_span][row]).collect();
1519 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_v, &controls, surface.degree_v, zero, blend) else { return false; };
1520 across.push(spans);
1521 }
1522 for v_span in 0..across[0].len() {
1523 let net = across.iter().map(|row| row[v_span].clone()).collect();
1524 if !bezier_box_clear(net, point, margin + roundoff, 0, &mut budget) { return false; }
1525 }
1526 }
1527 true
1528}
1529
1530fn bezier_box_clear(net: Vec<Vec<crate::Vec4>>, point: Vec3, margin: f64, depth: usize, budget: &mut usize) -> bool {
1531 if *budget == 0 { return false; }
1532 *budget -= 1;
1533 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1534 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1535 for control in net.iter().flatten() {
1536 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1537 let Ok(p) = control.point() else { return false; };
1538 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1539 low.x = low.x.min(p.x); low.y = low.y.min(p.y); low.z = low.z.min(p.z);
1540 high.x = high.x.max(p.x); high.y = high.y.max(p.y); high.z = high.z.max(p.z);
1541 }
1542 if point.x < low.x - margin || point.x > high.x + margin
1544 || point.y < low.y - margin || point.y > high.y + margin
1545 || point.z < low.z - margin || point.z > high.z + margin
1546 { return true; }
1547 if depth == 10 { return false; }
1548 let rows = net.len();
1549 let columns = net[0].len();
1550 let mut left = net.clone();
1551 let mut right = net.clone();
1552 if depth % 2 == 0 {
1555 for column in 0..columns {
1556 let mut work: Vec<_> = net.iter().map(|row| row[column]).collect();
1557 for level in 1..rows {
1558 for i in 0..rows - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1559 left[level][column] = work[0];
1560 right[rows - level - 1][column] = work[rows - level - 1];
1561 }
1562 }
1563 } else {
1564 for row in 0..rows {
1565 let mut work = net[row].clone();
1566 for level in 1..columns {
1567 for i in 0..columns - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1568 left[row][level] = work[0];
1569 right[row][columns - level - 1] = work[columns - level - 1];
1570 }
1571 }
1572 }
1573 bezier_box_clear(left, point, margin, depth + 1, budget)
1574 && bezier_box_clear(right, point, margin, depth + 1, budget)
1575}
1576
1577fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
1587 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
1588 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1589 let pcurve = &coedge.pcurve;
1590 let [a, b] = pcurve.control_points.as_slice() else { return None };
1591 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1592 return None;
1593 }
1594 for control in [a, b] {
1595 u_low = u_low.min(control.x);
1596 u_high = u_high.max(control.x);
1597 v_low = v_low.min(control.y);
1598 v_high = v_high.max(control.y);
1599 }
1600 }
1601 if !(u_low <= u_high && v_low <= v_high) {
1602 return None;
1603 }
1604 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
1605 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)?];
1606 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
1607 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
1608 let surface = &face.surface;
1609 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
1611 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
1612 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
1613 && surface.degree_u == 1 && surface.control_points.len() == 2;
1614 if !(affine_in_v || affine_in_u) {
1615 return None;
1616 }
1617 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
1618 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1619 (low.is_finite() && high.is_finite()).then_some((low, high))
1620}
1621
1622fn planar_trims_clear_of(
1626 face: &FaceRecord,
1627 plane: &crate::AnalyticSurface,
1628 station: Vec3,
1629 on_band: f64,
1630 margin: f64,
1631) -> bool {
1632 use crate::PolygonClass;
1633 let Some(foot) = plane.project(&face.surface, station) else { return false };
1634 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
1635 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
1636 Ok(PolygonClass::Outside) => {}
1637 _ => return false,
1638 }
1639 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1640 let pcurve = &coedge.pcurve;
1641 let [a, b] = pcurve.control_points.as_slice() else { return false };
1642 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1643 return false;
1644 }
1645 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
1646 let chord = end.sub(start);
1647 let length_squared = chord.dot(chord);
1648 let offset = station.sub(start);
1649 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
1650 let distance = offset.sub(chord.scale(along)).length();
1651 if !(distance.is_finite() && distance > margin) {
1652 return false;
1653 }
1654 }
1655 true
1656}
1657
1658fn per_edge_name<'a>(
1663 edge_names: Option<&'a [String]>,
1664 base: Option<&'a str>,
1665 i: usize,
1666) -> Option<&'a str> {
1667 match edge_names {
1668 Some(names) => names.get(i).map(|value| value.as_str()),
1669 None => base,
1670 }
1671}
1672
1673fn corner_face_name(
1681 edge_names: Option<&[String]>,
1682 base: Option<&str>,
1683 adjacent: &[usize],
1684) -> Option<String> {
1685 match (edge_names, base) {
1686 (Some(names), Some(base)) => {
1687 let prefix = format!("{base}:");
1688 let mut raws: Vec<&str> = adjacent
1689 .iter()
1690 .filter_map(|&i| names.get(i))
1691 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
1692 .collect();
1693 raws.sort_unstable();
1694 raws.dedup();
1695 Some(format!("{base}:CORNER:{}", raws.join("+")))
1696 }
1697 _ => base.map(|value| value.to_string()),
1698 }
1699}
1700
1701#[allow(clippy::too_many_arguments)]
1711fn rounded_boss_composition(
1712 solid: &BrepSolid,
1713 selected_ids: &[u64],
1714 edge_points: &[Vec3],
1715 edge_names: Option<&[String]>,
1716 radius: f64,
1717 name: Option<&str>,
1718 corners: &[crate::blend::MixedCorner],
1719) -> Result<BrepSolid, KernelRefusal> {
1720 if edge_names.is_none() {
1725 let prefix = "FILLET:COMPOSITION";
1726 let names: Vec<_> = selected_ids
1727 .iter()
1728 .map(|id| format!("{prefix}:{id}"))
1729 .collect();
1730 let mut result = rounded_boss_composition(
1731 solid,
1732 selected_ids,
1733 edge_points,
1734 Some(&names),
1735 radius,
1736 Some(prefix),
1737 corners,
1738 )?;
1739 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1740 if face
1741 .name
1742 .as_deref()
1743 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1744 {
1745 face.name = name.map(str::to_owned);
1746 }
1747 }
1748 return Ok(result);
1749 }
1750 let decline = || {
1751 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1752 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1753 };
1754 if std::env::var("BREP_NO_NETWORK").is_ok() {
1755 return Err(decline());
1756 }
1757 let corner = corners
1760 .iter()
1761 .filter(|corner| {
1762 corner.convex.len() == 1
1763 && corner.concave.len() == 2
1764 && corners
1765 .iter()
1766 .filter(|other| other.convex.contains(&corner.convex[0]))
1767 .count()
1768 == 1
1769 })
1770 .min_by_key(|corner| selected_ids[corner.convex[0]])
1771 .ok_or_else(decline)?;
1772 let convexity =
1773 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1774 if !convexity[corner.convex[0]] {
1775 return Err(decline());
1776 }
1777 let spine_index = corner.convex[0];
1778 let selected_edges: Vec<&EdgeRecord> = selected_ids
1781 .iter()
1782 .map(|id| {
1783 solid
1784 .edges
1785 .iter()
1786 .find(|edge| edge.id == *id)
1787 .ok_or_else(|| {
1788 KernelRefusal::internal(
1789 KernelStage::Collect,
1790 "rounded_boss_selected_edge",
1791 format!("rounded boss composition: selected edge {id} is missing"),
1792 )
1793 })
1794 })
1795 .collect::<Result<_, _>>()?;
1796 let mut rounded_indices = vec![spine_index];
1799 loop {
1800 let next = (0..selected_ids.len()).find(|index| {
1801 convexity[*index]
1802 && !rounded_indices.contains(index)
1803 && rounded_indices.iter().any(|other| {
1804 let edge = selected_edges[*index];
1805 let mate = selected_edges[*other];
1806 [edge.start_vertex_id, edge.end_vertex_id]
1807 .iter()
1808 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1809 })
1810 });
1811 match next {
1812 Some(index) => rounded_indices.push(index),
1813 None => break,
1814 }
1815 }
1816 loop {
1825 let next = (0..selected_ids.len()).find(|&index| {
1826 if convexity[index] || rounded_indices.contains(&index) {
1827 return false;
1828 }
1829 let edge = selected_edges[index];
1830 let mut has_star = false;
1831 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
1832 let neighbours: Vec<_> = selected_edges.iter().enumerate()
1833 .filter(|(other, mate)| *other != index
1834 && (mate.start_vertex_id == vertex || mate.end_vertex_id == vertex))
1835 .map(|(other, _)| other)
1836 .collect();
1837 if neighbours.iter().any(|other| !rounded_indices.contains(other)) {
1838 return false;
1839 }
1840 has_star |= neighbours.len() >= 2;
1841 }
1842 has_star
1843 });
1844 match next {
1845 Some(index) => rounded_indices.push(index),
1846 None => break,
1847 }
1848 }
1849 rounded_indices.sort_by_key(|index| selected_ids[*index]);
1850 let mixed: Vec<_> = corners
1851 .iter()
1852 .filter(|corner| {
1853 corner
1854 .convex
1855 .iter()
1856 .any(|index| rounded_indices.contains(index))
1857 })
1858 .collect();
1859 if mixed.iter().any(|corner| {
1860 corner.convex.len() != 1
1861 || corner.concave.len() != 2
1862 || corners
1863 .iter()
1864 .filter(|other| other.convex.contains(&corner.convex[0]))
1865 .count()
1866 != 1
1867 }) {
1868 return Err(decline());
1869 }
1870 let uses = |face: &FaceRecord, edge: u64| {
1871 face.loops
1872 .iter()
1873 .flat_map(|rim| &rim.coedges)
1874 .any(|coedge| coedge.edge_id == edge)
1875 };
1876 let mut caps = Vec::new();
1877 for corner in &mixed {
1878 let spine_id = selected_ids[corner.convex[0]];
1879 let cap = solid
1880 .shells
1881 .iter()
1882 .flat_map(|shell| &shell.faces)
1883 .find(|face| {
1884 corner
1885 .concave
1886 .iter()
1887 .all(|index| uses(face, selected_ids[*index]))
1888 })
1889 .ok_or_else(decline)?;
1890 let faces: Vec<_> = solid
1891 .shells
1892 .iter()
1893 .flat_map(|shell| &shell.faces)
1894 .filter(|face| face.id == cap.id || uses(face, spine_id))
1895 .collect();
1896 let normals: Option<Vec<Vec3>> = faces
1905 .iter()
1906 .map(|face| match face.surface.analytic() {
1907 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
1908 u_dir.cross(*v_dir).normalized().ok()
1909 }
1910 _ => {
1911 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
1912 face.surface.normal(uv.u, uv.v).ok()
1913 }
1914 })
1915 .collect();
1916 let normals = normals.ok_or_else(decline)?;
1917 if normals.len() != 3
1923 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
1924 {
1925 return Err(decline());
1926 }
1927 caps.push(cap.id);
1928 }
1929 let rounded_names: Vec<String> = rounded_indices
1932 .iter()
1933 .map(|index| {
1934 per_edge_name(edge_names, name, *index)
1935 .map(str::to_owned)
1936 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
1937 })
1938 .collect();
1939 let rounded_points: Vec<Vec3> = rounded_indices
1940 .iter()
1941 .map(|index| edge_points[*index])
1942 .collect();
1943 let rounded = fillet_edges_group_in(
1944 solid,
1945 &rounded_points,
1946 Some(&rounded_names),
1947 radius,
1948 false,
1949 name,
1950 None,
1951 )?;
1952 let mut bridges = Vec::new();
1953 for (corner, cap) in mixed.iter().zip(&caps) {
1954 let wall_index = rounded_indices
1955 .iter()
1956 .position(|index| *index == corner.convex[0])
1957 .ok_or_else(|| {
1958 KernelRefusal::internal(
1959 KernelStage::Classify,
1960 "rounded_boss_bridge_wall",
1961 format!(
1962 "rounded boss composition: no convex wall for corner {}",
1963 corner.vertex
1964 ),
1965 )
1966 })?;
1967 let candidates: Vec<_> = rounded
1968 .edges
1969 .iter()
1970 .filter(|edge| {
1971 let faces: Vec<_> = rounded
1972 .shells
1973 .iter()
1974 .flat_map(|shell| &shell.faces)
1975 .filter(|face| uses(face, edge.id))
1976 .collect();
1977 faces.iter().any(|face| face.id == *cap)
1978 && faces
1979 .iter()
1980 .any(|face| face.name.as_deref() == Some(&rounded_names[wall_index]))
1981 })
1982 .collect();
1983 if candidates.len() != 1 {
1984 return Err(decline());
1985 }
1986 bridges.push(candidates[0]);
1987 }
1988 let rim_ids: Vec<u64> = selected_ids
1989 .iter()
1990 .enumerate()
1991 .filter(|(index, _)| !rounded_indices.contains(index))
1992 .map(|(_, id)| *id)
1993 .collect();
1994 if !bridges.iter().all(|bridge| {
1997 [bridge.start_vertex_id, bridge.end_vertex_id]
1998 .iter()
1999 .all(|vertex| {
2000 rounded.edges.iter().any(|edge| {
2001 rim_ids.contains(&edge.id)
2002 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
2003 })
2004 })
2005 }) {
2006 return Err(decline());
2007 }
2008 let mut points = Vec::new();
2009 let mut names = Vec::new();
2010 for (index, id) in selected_ids.iter().enumerate() {
2011 if rounded_indices.contains(&index) {
2012 continue;
2013 }
2014 let edge = rounded
2015 .edges
2016 .iter()
2017 .find(|edge| edge.id == *id)
2018 .ok_or_else(decline)?;
2019 points.push(
2020 edge.curve
2021 .evaluate(0.5 * (edge.t0 + edge.t1))
2022 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
2023 );
2024 names.push(
2025 per_edge_name(edge_names, name, index)
2026 .unwrap_or("FILLET:RIM")
2027 .to_owned(),
2028 );
2029 }
2030 for (corner, bridge) in mixed.iter().zip(&bridges) {
2031 points.push(
2032 bridge
2033 .curve
2034 .evaluate(0.5 * (bridge.t0 + bridge.t1))
2035 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
2036 );
2037 let mut adjacent = corner.concave.clone();
2038 adjacent.push(corner.convex[0]);
2039 names.push(
2040 corner_face_name(edge_names, name, &adjacent)
2041 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
2042 );
2043 }
2044 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
2047 |(result, selection)| {
2048 if selection.applied.len() != points.len() {
2049 Err(decline())
2050 } else {
2051 Ok(result)
2052 }
2053 },
2054 )
2055}
2056
2057#[allow(clippy::too_many_arguments)]
2090fn open_pad_on() -> bool {
2092 switch!(live "BREP_BLEND_OPEN_PAD", true)
2093}
2094
2095#[allow(clippy::too_many_arguments)]
2124fn open_pad_composition(
2125 solid: &BrepSolid,
2126 selected_ids: &[u64],
2127 edge_points: &[Vec3],
2128 edge_names: Option<&[String]>,
2129 radius: f64,
2130 name: Option<&str>,
2131 corners: &[crate::blend::MixedCorner],
2132 entry: &str,
2133) -> Result<BrepSolid, KernelRefusal> {
2134 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
2135 if !open_pad_on() {
2136 return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
2137 }
2138 if std::env::var("BREP_NO_NETWORK").is_ok() {
2139 return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
2140 }
2141 if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
2142 return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
2143 }
2144 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2145 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2146 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2147 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2148 if concave.is_empty() || convex.is_empty() {
2149 return Err(shape("the open-pad composition needs convex and concave edges".into()));
2150 }
2151 let mates = crate::blend::selection_mates(solid, selected_ids, radius)
2154 .map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
2155 for corner in corners {
2156 let (_, convex_faces) = &mates[corner.convex[0]];
2157 let (_, concave_faces) = &mates[corner.concave[0]];
2158 let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
2159 return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
2160 };
2161 if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
2162 return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
2163 }
2164 }
2165 for &index in &concave {
2166 let edge = mates[index].0;
2167 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
2168 if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
2169 return Err(shape(format!(
2170 "concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
2171 selection_label(index, edge_points, edge_names, name)
2172 )));
2173 }
2174 }
2175 }
2176 let base = name.unwrap_or("FILLET:OPEN_PAD");
2177 let bare = |index: usize| -> String {
2178 per_edge_name(edge_names, name, index)
2179 .map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
2180 .unwrap_or_else(|| selected_ids[index].to_string())
2181 };
2182 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2183
2184 let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
2186 let convex_names: Vec<String> = convex
2187 .iter()
2188 .map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
2189 .collect();
2190 let stage1 = crate::blend::with_open_pad_network(|| {
2199 fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
2200 })
2201 .map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
2202 if debug {
2203 eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
2204 }
2205
2206 let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
2208 let mut points: Vec<Vec3> = Vec::new();
2209 let mut names: Vec<String> = Vec::new();
2210 for &index in &concave {
2211 let (edge, faces) = &mates[index];
2212 let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
2213 let pad = stage1
2214 .edges
2215 .iter()
2216 .find(|candidate| candidate.id == edge.id)
2217 .ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
2218 "open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
2219 )))?;
2220 points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
2221 names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
2222 for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
2225 let trims: Vec<&EdgeRecord> = stage1
2226 .edges
2227 .iter()
2228 .filter(|candidate| {
2229 candidate.id != pad.id
2230 && !candidate.degenerate
2231 && (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
2232 })
2233 .filter(|candidate| {
2234 let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
2235 faces.iter().any(|face| face.id == boss_id)
2236 && faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
2237 })
2238 .collect();
2239 match trims.as_slice() {
2240 [trim] => {
2241 let wall_name = stage1
2242 .shells
2243 .iter()
2244 .flat_map(|shell| &shell.faces)
2245 .filter(|face| uses(face, trim.id))
2246 .find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
2247 .unwrap_or_default();
2248 let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
2249 points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
2250 names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
2251 if debug {
2252 eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
2253 }
2254 }
2255 [] => {
2256 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2257 "open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
2258 selection_label(index, edge_points, edge_names, name)
2259 )));
2260 }
2261 _ => {
2262 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2263 "open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
2264 selection_label(index, edge_points, edge_names, name), trims.len()
2265 )));
2266 }
2267 }
2268 }
2269 }
2270 let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
2271 .map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
2272 let _ = entry;
2273 Ok(result)
2274}
2275
2276fn cusp_composition(
2296 solid: &BrepSolid,
2297 selected_ids: &[u64],
2298 edge_points: &[Vec3],
2299 edge_names: Option<&[String]>,
2300 radius: f64,
2301 name: Option<&str>,
2302 corners: &[crate::blend::MixedCorner],
2303) -> Result<BrepSolid, KernelRefusal> {
2304 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "cusp_composition_shape", message);
2305 if std::env::var("BREP_NO_NETWORK").is_ok() {
2306 return Err(shape("the cusp composition is a network construction and BREP_NO_NETWORK is set".into()));
2307 }
2308 let edge_of = |index: usize| solid.edges.iter().find(|edge| edge.id == selected_ids[index]);
2309 let away = |index: usize, vertex: u64| -> Option<Vec3> {
2311 let edge = edge_of(index)?;
2312 let at_start = edge.start_vertex_id == vertex;
2313 let tangent = edge.curve.unit_tangent(if at_start { edge.t0 } else { edge.t1 }, edge.t0, edge.t1).ok()?;
2314 Some(if at_start { tangent } else { tangent.scale(-1.0) })
2315 };
2316 if corners.is_empty() {
2317 return Err(shape("no mixed corners".into()));
2318 }
2319 for corner in corners {
2320 let cusped = corner.convex.iter().all(|&convex| {
2321 corner.concave.iter().any(|&concave| {
2322 matches!((away(convex, corner.vertex), away(concave, corner.vertex)), (Some(a), Some(b)) if a.dot(b) >= 1.0 - 1e-6)
2323 })
2324 });
2325 if !cusped || corner.convex.is_empty() || corner.concave.is_empty() {
2326 return Err(shape(format!("the mixed corner at vertex {} is not a cusp", corner.vertex)));
2327 }
2328 }
2329 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2330 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2331 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2332 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2333 if concave.is_empty() || convex.is_empty() {
2334 return Err(shape("the cusp composition needs convex and concave edges".into()));
2335 }
2336 let names_of = |indices: &[usize]| -> Option<Vec<String>> { edge_names.map(|names| indices.iter().map(|index| names[*index].clone()).collect()) };
2337 let points_of = |indices: &[usize]| -> Vec<Vec3> { indices.iter().map(|index| edge_points[*index]).collect() };
2338 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2339 let stage1 = fillet_edges_group_in(solid, &points_of(&concave), names_of(&concave).as_deref(), radius, false, name, None)
2340 .map_err(|error| error.with_message(|error| format!("cusp composition, the concave rounds: {error}")))?;
2341 if debug {
2342 eprintln!("cusp composition: stage 1 built the {} concave rounds", concave.len());
2343 }
2344 for &index in &convex {
2347 resolve_edge_by_point(&stage1, edge_points[index])
2348 .map_err(|error| error.with_message(|error| format!("cusp composition: a convex edge is not on the stage-1 body: {error}")))?;
2349 }
2350 let result = fillet_edges_group_in(&stage1, &points_of(&convex), names_of(&convex).as_deref(), radius, false, name, None)
2351 .map_err(|error| error.with_message(|error| format!("cusp composition, the convex rounds: {error}")))?;
2352 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(true));
2358 Ok(result)
2359}
2360
2361fn runout_composition(
2362 solid: &BrepSolid,
2363 selected_ids: &[u64],
2364 edge_points: &[Vec3],
2365 edge_names: Option<&[String]>,
2366 radius: f64,
2367 name: Option<&str>,
2368 corners: &[crate::blend::MixedCorner],
2369 entry: &str,
2370) -> Result<BrepSolid, KernelRefusal> {
2371 if std::env::var("BREP_NO_NETWORK").is_ok() {
2372 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
2373 }
2374 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2375 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
2376 let mut spine_ends = vec![0usize; selected_ids.len()];
2377 for corner in corners {
2378 if corner.convex.len() != 1 {
2379 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
2380 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
2381 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
2382 )));
2383 }
2384 spine_ends[corner.convex[0]] += 1;
2385 }
2386 let mut spines: Vec<usize> = Vec::new();
2387 let mut concave: Vec<usize> = Vec::new();
2388 for (index, ends) in spine_ends.iter().enumerate() {
2389 match (*ends, convexity[index]) {
2390 (2, true) => spines.push(index),
2391 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
2392 (0, false) => concave.push(index),
2393 (ends, convex) => {
2394 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
2395 "{} 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",
2396 selection_label(index, edge_points, edge_names, name),
2397 if convex { "convex" } else { "concave" },
2398 )));
2399 }
2400 }
2401 }
2402 if concave.is_empty() {
2403 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
2404 }
2405 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
2407 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
2408 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
2409 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
2410 .map_err(|error| {
2411 let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2417 match network {
2418 Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
2419 "the concave ring did not build: {network} (the cutter fallback then refused: {error})"
2420 )),
2421 _ => error.with_message(|error| format!("the concave ring did not build: {error}")),
2422 }
2423 })?;
2424 let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2433 let ring_is_network = network_declined.is_none();
2435 if let Some(network) = network_declined {
2437 if crate::blend::is_reentrant_concave(&network) {
2438 return Err(network.with_message(|network| format!(
2439 "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"
2440 )));
2441 }
2442 }
2443 let spine_walls: Vec<(u64, Option<String>)> = spines
2445 .iter()
2446 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
2447 .collect();
2448 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
2449 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
2450 heal_edge_vertex_gaps(&mut result, radius)?;
2451 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
2452 let issues = result.validate();
2453 if !issues.is_empty() {
2454 return Err(KernelRefusal::new(
2455 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2456 KernelStage::Validate,
2457 format!("the runout walls do not validate: {issues:?}"),
2458 ));
2459 }
2460 check_blend_interference(&ring, &result, &spine_ids, entry)?;
2461 check_loop_self_crossings(&result, entry)?;
2462 check_blend_wall_loops(&result, entry)?;
2463 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2464 let accepted = crate::accept_sound(result, entry)?;
2465 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
2467 Ok(accepted)
2469}
2470
2471thread_local! {
2473 static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2478}
2479fn check_mixed_corner_convexity(
2482 solid: &BrepSolid,
2483 edge_ids: &[u64],
2484 edge_points: &[Vec3],
2485 edge_names: Option<&[String]>,
2486 radius: f64,
2487 name: Option<&str>,
2488 composition_error: Option<&KernelRefusal>,
2489) -> Result<(), KernelRefusal> {
2490 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
2491 Some(corner) => {
2492 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
2498 Ok(plan) => Some(plan),
2499 Err(error) => {
2500 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2501 eprintln!("runout not measured: {error}");
2502 }
2503 None
2504 }
2505 };
2506 let mut message = mixed_corner_message(
2509 &corner,
2510 edge_points,
2511 edge_names,
2512 name,
2513 measured.as_ref(),
2514 );
2515 if let Some(error) = composition_error {
2516 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
2517 }
2518 Err(KernelRefusal::unsupported(
2519 KernelStage::Classify,
2520 "mixed_corner_convexity",
2521 message,
2522 ))
2523 }
2524 None => Ok(()),
2525 }
2526}
2527
2528fn check_degenerate_corner_setbacks(
2531 solid: &BrepSolid,
2532 edge_ids: &[u64],
2533 edge_points: &[Vec3],
2534 edge_names: Option<&[String]>,
2535 radius: f64,
2536 name: Option<&str>,
2537) -> Result<(), KernelRefusal> {
2538 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
2539 return Ok(());
2540 };
2541 let [from, to] = setback.stops;
2542 let how = if setback.crossed {
2543 format!(
2544 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
2545 setback.strip
2546 )
2547 } else {
2548 format!(
2549 "meet there ({:.3e} apart against a bar of {:.3e})",
2550 setback.strip, setback.bar
2551 )
2552 };
2553 Err(KernelRefusal::input(
2556 KernelStage::Classify,
2557 "degenerate_corner_setbacks",
2558 format!(
2559 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
2560 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
2561 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
2562 on are consumed whole. The radius is at or past the degenerate limit for this \
2563 selection; reduce it.",
2564 selection_label(setback.edge, edge_points, edge_names, name),
2565 setback.face_id,
2566 from.x,
2567 from.y,
2568 from.z,
2569 to.x,
2570 to.y,
2571 to.z,
2572 ),
2573 ))
2574}
2575
2576fn selection_label(
2580 index: usize,
2581 edge_points: &[Vec3],
2582 edge_names: Option<&[String]>,
2583 name: Option<&str>,
2584) -> String {
2585 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
2586 Some(base) => composed
2587 .strip_prefix(&format!("{base}:"))
2588 .unwrap_or(composed)
2589 .to_string(),
2590 None => composed.clone(),
2591 });
2592 named.unwrap_or_else(|| match edge_points.get(index) {
2593 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
2594 None => format!("selection #{}", index + 1),
2595 })
2596}
2597
2598fn mixed_corner_message(
2602 corner: &crate::blend::MixedCorner,
2603 edge_points: &[Vec3],
2604 edge_names: Option<&[String]>,
2605 name: Option<&str>,
2606 measured: Option<&crate::blend::RunoutPlan>,
2607) -> String {
2608 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
2609 let list = |indices: &[usize]| -> String {
2610 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
2611 match parts.split_last() {
2612 None => "none".to_string(),
2613 Some((last, [])) => last.clone(),
2614 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
2615 }
2616 };
2617 let mut message = format!(
2618 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
2619 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
2620 sides at once, so that corner has no closure: the convex blend runs out against the \
2621 concave ones partway along the edge, and this kernel does not build that runout yet. \
2622 Blending the concave edges in one fillet and the convex ones in another does not reach \
2623 a wall on every one of them either: the convex wall comes out at FULL length in either \
2624 order, and its contact leaves the faces it runs along where their own loops would then \
2625 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
2626 whose wall runs out into them; blend the convex ones first and the subset search drops \
2627 the concave edges it cannot compose instead, leaving those unfilleted.",
2628 corner.point.x,
2629 corner.point.y,
2630 corner.point.z,
2631 list(&corner.convex),
2632 list(&corner.concave),
2633 );
2634 if let Some(plan) = measured {
2639 message.push_str(&format!(
2640 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
2641 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
2642 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
2643 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
2644 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
2645 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
2646 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
2647 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
2648 the bar a march is refused at is a full span.",
2649 plan.convex_edge,
2650 plan.shared_face,
2651 plan.station.s,
2652 plan.station.center.x,
2653 plan.station.center.y,
2654 plan.station.center.z,
2655 plan.replaced_mate,
2656 plan.second_carrier,
2657 plan.station.stripe_residual,
2658 plan.second_carrier,
2659 plan.marched,
2660 plan.fit_residual,
2661 plan.carrier_excursion,
2662 ));
2663 match (&plan.stop, plan.first_carrier) {
2664 (Some(stop), Some(first)) => message.push_str(&format!(
2665 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
2666 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
2667 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
2668 stop.s, plan.kept_mate, first, stop.stripe_residual,
2669 )),
2670 _ => message.push_str(&format!(
2671 " Where the kept mate (face {}) runs out was not measurable on this selection.",
2672 plan.kept_mate
2673 )),
2674 }
2675 if let Some(pole) = plan.pole {
2676 message.push_str(&format!(
2677 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
2678 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
2679 section shrinks to a point. Two carrier switches and a degenerate terminus is \
2680 more than a stop station on a stripe, and none of it is composed yet.",
2681 pole.x, pole.y, pole.z, plan.shared_face,
2682 ));
2683 }
2684 }
2685 message
2686}
2687
2688fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
2690 let mut out = Vec::new();
2691 if k == 0 || k > n {
2692 return out;
2693 }
2694 let mut idx: Vec<usize> = (0..k).collect();
2695 loop {
2696 out.push(idx.clone());
2697 let mut i = k;
2699 loop {
2700 if i == 0 {
2701 return out;
2702 }
2703 i -= 1;
2704 if idx[i] != i + n - k {
2705 break;
2706 }
2707 }
2708 idx[i] += 1;
2709 for j in (i + 1)..k {
2710 idx[j] = idx[j - 1] + 1;
2711 }
2712 }
2713}
2714
2715thread_local! {
2716 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
2722 const { std::cell::Cell::new(0) };
2723}
2724
2725fn fillet_edges_group(
2742 solid: &BrepSolid,
2743 edge_points: &[Vec3],
2744 edge_names: Option<&[String]>,
2745 radius: f64,
2746 chamfer: bool,
2747 name: Option<&str>,
2748) -> Result<BrepSolid, KernelRefusal> {
2749 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
2750}
2751
2752thread_local! {
2753 static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
2760}
2761
2762struct SubsetDropCap(Option<usize>);
2764
2765impl SubsetDropCap {
2766 fn set(cap: Option<usize>) -> Self {
2767 Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
2768 }
2769}
2770
2771impl Drop for SubsetDropCap {
2772 fn drop(&mut self) {
2773 SUBSET_DROP_CAP.with(|current| current.set(self.0));
2774 }
2775}
2776
2777thread_local! {
2778 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2781 static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2784 static GROUP_NETWORK_FOLD: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2788}
2789
2790type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
2793
2794type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
2801
2802#[derive(Default)]
2830struct ComponentMemo {
2831 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
2832 prefix: Vec<ComponentKey>,
2835 sequential: std::collections::HashMap<
2836 (Vec<ComponentKey>, Vec<SequentialKey>),
2837 Result<BrepSolid, KernelRefusal>,
2838 >,
2839}
2840
2841fn fillet_edges_group_in(
2846 solid: &BrepSolid,
2847 edge_points: &[Vec3],
2848 edge_names: Option<&[String]>,
2849 radius: f64,
2850 chamfer: bool,
2851 name: Option<&str>,
2852 memo: Option<&mut ComponentMemo>,
2853) -> Result<BrepSolid, KernelRefusal> {
2854 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
2855}
2856
2857fn fillet_edges_group_with_corner_policy(
2858 solid: &BrepSolid,
2859 edge_points: &[Vec3],
2860 edge_names: Option<&[String]>,
2861 radius: f64,
2862 chamfer: bool,
2863 name: Option<&str>,
2864 memo: Option<&mut ComponentMemo>,
2865 wrap_concave: bool,
2866 accept: bool,
2867) -> Result<BrepSolid, KernelRefusal> {
2868 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
2869 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2870 let GroupBody { body: result, bowtie_clean } =
2871 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
2872 if !accept {
2873 return Ok(result);
2874 }
2875 if bowtie_clean {
2880 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
2881 } else {
2882 check_loop_self_crossings(&result, entry)?;
2883 check_blend_wall_loops(&result, entry)?;
2884 }
2885 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
2886 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2895 let accepted = crate::accept_sound(result, entry);
2896 soundness.stop();
2897 accepted
2898}
2899
2900struct GroupBody {
2905 body: BrepSolid,
2906 bowtie_clean: bool,
2907}
2908
2909fn fillet_edges_group_unchecked(
2916 solid: &BrepSolid,
2917 edge_points: &[Vec3],
2918 edge_names: Option<&[String]>,
2919 radius: f64,
2920 chamfer: bool,
2921 name: Option<&str>,
2922 memo: Option<&mut ComponentMemo>,
2923 wrap_concave: bool,
2924) -> Result<GroupBody, KernelRefusal> {
2925 let outer = GROUP_NETWORK_FOLD.with(|fold| fold.borrow_mut().take());
2926 let built = fillet_edges_group_lanes(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave);
2927 let fold = GROUP_NETWORK_FOLD.with(|fold| std::mem::replace(&mut *fold.borrow_mut(), outer));
2928 match (built, fold) {
2929 (Err(error), Some(fold)) if !crate::blend::is_wall_fold(&error) => Err(fold),
2930 (built, _) => built,
2931 }
2932}
2933
2934fn fillet_edges_group_lanes(
2935 solid: &BrepSolid,
2936 edge_points: &[Vec3],
2937 edge_names: Option<&[String]>,
2938 radius: f64,
2939 chamfer: bool,
2940 name: Option<&str>,
2941 mut memo: Option<&mut ComponentMemo>,
2942 wrap_concave: bool,
2943) -> Result<GroupBody, KernelRefusal> {
2944 use rustc_hash::FxHashSet as HashSet;
2945
2946 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
2953 let mut healed_input = solid.clone();
2954 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
2955 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
2956 let solid = &healed_input;
2957 heal.stop();
2958 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
2959
2960 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2963 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
2966 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
2967 for (i, point) in edge_points.iter().enumerate() {
2968 let edge_id = resolve_edge_by_point(solid, *point)?;
2969 let edge = solid
2970 .edges
2971 .iter()
2972 .find(|e| e.id == edge_id)
2973 .ok_or_else(|| {
2974 KernelRefusal::internal(
2975 KernelStage::Collect,
2976 "resolved_edge",
2977 "fillet_edges: resolved edge vanished",
2978 )
2979 })?;
2980 let (start, end) = (
2981 edge.curve
2982 .evaluate(edge.t0)
2983 .or_refuse(KernelStage::Collect, "edge_start")?,
2984 edge.curve
2985 .evaluate(edge.t1)
2986 .or_refuse(KernelStage::Collect, "edge_end")?,
2987 );
2988 endpoints.push((start, i));
2989 endpoints.push((end, i));
2990 original_extents.push((start, end));
2991 selected_edge_ids.push(edge_id);
2992 }
2993 let mut corners: Vec<Vec3> = Vec::new();
2994 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
2999 let mut chain_corner_count = 0usize;
3000 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
3001 let mut used = vec![false; endpoints.len()];
3002 for i in 0..endpoints.len() {
3003 if used[i] {
3004 continue;
3005 }
3006 used[i] = true;
3007 let mut edges_here: HashSet<usize> = HashSet::default();
3008 edges_here.insert(endpoints[i].1);
3009 for j in (i + 1)..endpoints.len() {
3010 if used[j] {
3011 continue;
3012 }
3013 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
3014 used[j] = true;
3015 edges_here.insert(endpoints[j].1);
3016 }
3017 }
3018 if edges_here.len() >= 3 {
3019 corners.push(endpoints[i].0);
3020 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
3021 adjacent.sort_unstable();
3022 corner_edges.push(adjacent);
3023 } else if edges_here.len() == 2 {
3024 chain_corner_count += 1;
3025 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
3026 adjacent.sort_unstable();
3027 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
3028 }
3029 }
3030
3031 let mut component_of = vec![usize::MAX; edge_points.len()];
3044 let mut components: Vec<Vec<usize>> = Vec::new();
3045 for seed in 0..edge_points.len() {
3046 if component_of[seed] != usize::MAX {
3047 continue;
3048 }
3049 let component_index = components.len();
3050 component_of[seed] = component_index;
3051 let mut component = vec![seed];
3052 let mut cursor = 0;
3053 while cursor < component.len() {
3054 let current = component[cursor];
3055 cursor += 1;
3056 for candidate in 0..edge_points.len() {
3057 if component_of[candidate] != usize::MAX {
3058 continue;
3059 }
3060 let connected = [original_extents[current].0, original_extents[current].1]
3061 .into_iter()
3062 .any(|a| {
3063 [original_extents[candidate].0, original_extents[candidate].1]
3064 .into_iter()
3065 .any(|b| a.sub(b).length() < 1e-6)
3066 });
3067 if connected {
3068 component_of[candidate] = component_index;
3069 component.push(candidate);
3070 }
3071 }
3072 }
3073 component.sort_unstable();
3074 components.push(component);
3075 }
3076 group_analysis.stop();
3077 if components.len() > 1 {
3078 let mut separated = solid.clone();
3079 let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
3082 chain.reserve(components.len());
3083 for component in components {
3084 let points = component
3085 .iter()
3086 .map(|index| edge_points[*index])
3087 .collect::<Vec<_>>();
3088 let names = edge_names.map(|all| {
3089 component
3090 .iter()
3091 .map(|index| all[*index].clone())
3092 .collect::<Vec<_>>()
3093 });
3094 let accept_component = !crate::blend::accept_once_on();
3095 let Some(memo) = memo.as_deref_mut() else {
3096 separated = fillet_edges_group_with_corner_policy(
3097 &separated,
3098 &points,
3099 names.as_deref(),
3100 radius,
3101 chamfer,
3102 name,
3103 None,
3104 wrap_concave,
3105 accept_component,
3106 )?;
3107 continue;
3108 };
3109 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
3110 chain.push((bits.collect(), names.clone()));
3111 let built = match memo.built.get(&chain) {
3112 Some(built) => built.clone(),
3113 None => {
3114 let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
3117 let built = fillet_edges_group_with_corner_policy(
3118 &separated,
3119 &points,
3120 names.as_deref(),
3121 radius,
3122 chamfer,
3123 name,
3124 Some(&mut *memo),
3125 wrap_concave,
3126 accept_component,
3127 );
3128 memo.prefix = outer_prefix;
3129 memo.built.insert(chain.clone(), built.clone());
3130 built
3131 }
3132 };
3133 separated = built?;
3134 }
3135 return Ok(GroupBody { body: separated, bowtie_clean: false });
3136 }
3137
3138 if std::env::var("BREP_NO_NETWORK").is_err() {
3163 if selected_edge_ids.len() == 1 {
3164 let edge_id = selected_edge_ids[0];
3165 let closed = solid
3166 .edges
3167 .iter()
3168 .find(|edge| edge.id == edge_id)
3169 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
3170 if closed {
3171 let rim_name = per_edge_name(edge_names, name, 0);
3172 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
3173 Ok(mut rim) => {
3174 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3175 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3176 let healed = heal_edge_vertex_gaps(&mut rim, radius);
3177 heal.stop();
3178 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3187 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
3188 charts.stop();
3189 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3190 let issues = rim.validate_memoised();
3191 validate.stop();
3192 let interference =
3193 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
3194 let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
3195 if healed.is_ok()
3196 && issues.is_empty()
3197 && interference.is_ok()
3198 && bowtie.is_ok()
3199 {
3200 return Ok(GroupBody { body: rim, bowtie_clean: true });
3201 }
3202 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3203 eprintln!(
3204 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
3205 interference={interference:?} bowtie={bowtie:?}"
3206 );
3207 }
3208 }
3209 Err(refusal) => {
3210 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3211 eprintln!("closed-rim refused: {refusal}");
3212 }
3213 }
3214 }
3215 }
3216 }
3217 let network_names: Vec<Option<String>> = (0..edge_points.len())
3218 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
3219 .collect();
3220 let network_corner_name =
3221 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
3222 let group_network = || {
3228 crate::blend::blend_star_network_with_corner_policy(
3229 solid,
3230 &selected_edge_ids,
3231 radius,
3232 chamfer,
3233 &network_names,
3234 &network_corner_name,
3235 wrap_concave,
3236 )
3237 };
3238 let network_result = group_network().or_else(|first| {
3239 if crate::blend::is_pcurve_floor_refusal(&first) {
3240 crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
3241 } else {
3242 Err(first)
3243 }
3244 });
3245 let accept_network = |mut network: BrepSolid, built_radius: f64| -> Result<Option<GroupBody>, KernelRefusal> {
3249 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3257 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3258 let healed = heal_edge_vertex_gaps(&mut network, built_radius);
3259 heal.stop();
3260 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3264 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
3265 charts.stop();
3266 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3267 let issues = network.validate_memoised();
3268 validate.stop();
3269 let interference =
3270 check_blend_interference(solid, &network, &selected_edge_ids, entry);
3271 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
3276 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
3277 return Ok(Some(GroupBody { body: network, bowtie_clean: true }));
3278 }
3279 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3280 eprintln!(
3281 "network result rejected: heal={healed:?} issues={issues:?} \
3282 interference={interference:?} bowtie={bowtie:?}"
3283 );
3284 dump_loops_debug("INPUT", solid);
3285 dump_loops_debug("RESULT", &network);
3286 }
3287 Ok(None)
3288 };
3289 match network_result {
3290 Ok(network) => {
3291 if let Some(body) = accept_network(network, radius)? {
3292 return Ok(body);
3293 }
3294 }
3295 Err(refusal) => {
3296 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3297 eprintln!("network refused: {refusal}");
3298 }
3299 let pinched_taper = matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what }
3310 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT);
3311 if !chamfer && pinched_taper && crate::blend::relief::radius_relief_on() {
3312 let labels: Vec<String> =
3313 (0..edge_points.len()).map(|index| selection_label(index, edge_points, edge_names, name)).collect();
3314 for step in 1..=10u32 {
3315 let reduced = radius * (1.0 - 0.05 * f64::from(step));
3316 let built = crate::blend::blend_star_network_with_corner_policy(
3317 solid, &selected_edge_ids, reduced, chamfer, &network_names, &network_corner_name, wrap_concave,
3318 );
3319 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3320 eprintln!(
3321 "taper pinch ladder: r {reduced:.4} -> {}",
3322 match &built {
3323 Ok(_) => "built".to_string(),
3324 Err(error) => format!("refused: {error}"),
3325 }
3326 );
3327 }
3328 match built {
3329 Ok(network) => {
3330 let Some(body) = accept_network(network, reduced)? else { break };
3331 crate::blend::record_blend_note(format!(
3332 "radius reduced over the whole of {} to {reduced:.4}: at r {radius} the round tapering to \
3333 its tip reaches the full radius only inside the corner setback at its other end, so \
3334 r {radius} is reached nowhere",
3335 labels.join(", ")
3336 ));
3337 return Ok(body);
3338 }
3339 Err(again) if matches!(&again.class, crate::RefusalClass::UnsupportedGeometry { what }
3340 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT) => continue,
3341 Err(_) => break,
3342 }
3343 }
3344 }
3345 if matches!(&refusal.class, RefusalClass::UnsupportedGeometry { what }
3349 if what == "canal_footprint_bound") {
3350 return Err(refusal);
3351 }
3352 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
3356 if crate::blend::is_wall_fold(&refusal) {
3357 GROUP_NETWORK_FOLD.with(|fold| *fold.borrow_mut() = Some(refusal.clone()));
3358 }
3359 if !chamfer && !crate::blend::is_wall_fold(&refusal) && crate::blend::relief::radius_relief_on() {
3365 let started = web_time::Instant::now();
3366 let misfit = crate::blend::relief::first_misfit(solid, &selected_edge_ids, radius);
3367 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3368 eprintln!("radius relief: misfit probe {misfit:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
3369 }
3370 if let Some((edge_id, least)) = misfit {
3371 return Err(crate::blend::relief::misfit_fold(edge_id, least, radius, &refusal));
3372 }
3373 }
3374 if crate::blend::is_rail_fold(&refusal)
3415 || crate::blend::is_marched_fit_off_carriers(&refusal)
3416 || crate::blend::is_consumed_snap(&refusal)
3417 || crate::blend::is_rail_collapse(&refusal)
3418 || crate::blend::is_full_width_collapse(&refusal)
3419 || crate::blend::is_planar_chart_refusal(&refusal)
3420 || crate::blend::is_miter_support_overrun(&refusal)
3421 || crate::blend::is_tangential_transverse_end(&refusal)
3422 {
3423 return Err(refusal);
3424 }
3425 }
3426 }
3427 }
3428
3429 if let Some(refusal) = super::analyze::rollover_only_refusal(&selected_edge_ids) {
3435 return Err(refusal);
3436 }
3437 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
3438 crate::blend::record_count("count.cutter_fallback", 1);
3439 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
3440 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
3467 && (chamfer || corners.is_empty())
3468 {
3469 let mut any_convex = false;
3470 let mut any_concave = false;
3471 for point in edge_points {
3472 match resolve_edge_by_point(solid, *point)
3484 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
3485 {
3486 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
3489 any_convex |= profile.any_convex;
3490 any_concave |= profile.any_concave;
3491 }
3492 _ => {
3495 any_convex = true;
3496 any_concave = true;
3497 break;
3498 }
3499 }
3500 }
3501 match (any_convex, any_concave) {
3502 (true, false) => Some(crate::BooleanOperation::Intersect),
3503 (false, true) => Some(crate::BooleanOperation::Union),
3504 _ => None,
3505 }
3506 } else {
3507 None
3508 };
3509
3510 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
3521 Lane::GeneralFirst
3522 } else {
3523 Lane::CutterFirst
3524 };
3525 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3536 let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3541 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
3542 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
3548 let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
3549 let mut sequential = solid.clone();
3550 let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
3553 for &index in &sequential_order {
3554 let point = &edge_points[index];
3555 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3556 let ends = tool_ends_to_original_extent(
3563 &sequential,
3564 edge_id,
3565 radius,
3566 original_extents[index],
3567 if chamfer { &[] } else { &corners },
3568 );
3569 let edge_name = per_edge_name(edge_names, name, index);
3574 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3575 eprintln!(
3576 "sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
3577 selection_label(index, edge_points, edge_names, name)
3578 );
3579 }
3580 chain.push((
3585 [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
3586 edge_name.map(str::to_string),
3587 ends.key_bits(),
3588 u8::from(composition_lane == Lane::CutterFirst),
3589 ));
3590 if let Some(memo) = memo.as_deref_mut() {
3591 let key = (memo.prefix.clone(), chain.clone());
3592 let hit = memo.sequential.get(&key);
3593 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3594 eprintln!(
3595 "sequential memo: {} components before, step {} {} ({} chains stored)",
3596 memo.prefix.len(),
3597 chain.len(),
3598 if hit.is_some() { "HIT" } else { "miss" },
3599 memo.sequential.len(),
3600 );
3601 }
3602 if let Some(built) = hit {
3603 crate::blend::record_count("count.sequential.memo_hits", 1);
3604 sequential = built.clone()?;
3605 continue;
3606 }
3607 }
3608 crate::blend::record_count("count.sequential.steps_built", 1);
3609 let built = fillet_or_chamfer(
3610 &sequential,
3611 edge_id,
3612 radius,
3613 chamfer,
3614 edge_name,
3615 ends,
3616 composition_lane,
3617 );
3618 if let Some(memo) = memo.as_deref_mut() {
3619 memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
3620 }
3621 sequential = built?;
3622 }
3623 Ok(sequential)
3624 };
3625
3626 let mut result = if let Some(operation) = miter_operation {
3627 let options = crate::BooleanOptions {
3631 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
3632 ..crate::BooleanOptions::default()
3633 };
3634 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
3635 let units = miter_tangent_units(solid, &selected_edge_ids, &chain_corners, edge_points.len());
3639 for unit in &units {
3640 let index = unit[0];
3641 let point = &edge_points[index];
3642 let blended = if unit.len() > 1 {
3643 let points = unit.iter().map(|&member| edge_points[member]).collect::<Vec<_>>();
3644 let names = edge_names.map(|all| unit.iter().map(|&member| all[member].clone()).collect::<Vec<_>>());
3645 fillet_edges_group_with_corner_policy(
3646 solid,
3647 &points,
3648 names.as_deref(),
3649 radius,
3650 chamfer,
3651 name,
3652 None,
3653 wrap_concave,
3654 !crate::blend::accept_once_on(),
3655 )?
3656 } else {
3657 let edge_id = resolve_edge_by_point(solid, *point)?;
3658 let edge_name = per_edge_name(edge_names, name, index);
3659 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3660 check_mixed_concavity(solid, edge_id, entry)?;
3661 check_support_extent(solid, edge_id, radius, entry)?;
3662 fillet_or_chamfer(
3663 solid,
3664 edge_id,
3665 radius,
3666 chamfer,
3667 edge_name,
3668 ToolEnds::default(),
3669 Lane::CutterFirst,
3670 )?
3671 };
3672 combined = match combined {
3673 Err(e) => Err(e),
3674 Ok(None) => Ok(Some(blended)),
3675 Ok(Some(previous)) => {
3676 crate::boolean_operation(&previous, &blended, operation, &options)
3677 .map(Some)
3678 .map_err(|error| {
3679 error.with_message(|error| {
3681 format!("fillet_edges: chain-corner miter composition failed: {error}")
3682 })
3683 })
3684 }
3685 };
3686 if combined.is_err() {
3687 break;
3688 }
3689 }
3690 match combined {
3702 Ok(Some(mut mitered)) => {
3703 drop_slit_shells(&mut mitered);
3704 mitered
3705 }
3706 Ok(None) => {
3707 return Err(KernelRefusal::input(
3708 KernelStage::Collect,
3709 "miter_selection",
3710 "fillet_edges: no edges selected",
3711 ))
3712 }
3713 Err(miter_err) => match build_sequential() {
3714 Ok(seq) if seq.validate().is_empty() => seq,
3715 _ => return Err(miter_err),
3716 },
3717 }
3718 } else {
3719 build_sequential()?
3720 };
3721
3722 let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
3730 if !chamfer {
3732 for (corner, adjacent) in &chain_corners {
3733 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
3734 if selected_edge_ids.contains(&edge.id) {
3735 return false;
3736 }
3737 let Ok(a) = edge.curve.evaluate(edge.t0) else {
3738 return false;
3739 };
3740 let Ok(b) = edge.curve.evaluate(edge.t1) else {
3741 return false;
3742 };
3743 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3744 && analyze_edge(solid, edge.id, radius)
3745 .map(|cross| !cross.convex)
3746 .unwrap_or(false)
3747 });
3748 if !has_concave_unselected_edge {
3759 let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
3760 let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
3761 let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
3762 let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
3763 if selected_edge_ids.contains(&edge.id) {
3764 return false;
3765 }
3766 let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
3767 return false;
3768 };
3769 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3770 && analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
3771 });
3772 if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
3773 open_mixed_corners.push((
3774 *corner,
3775 [selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
3776 corner_face_name(edge_names, name, adjacent),
3777 ));
3778 }
3779 continue;
3780 }
3781 let corner_name = corner_face_name(edge_names, name, adjacent);
3783 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
3784 &result,
3785 solid,
3786 *corner,
3787 [
3788 selected_edge_ids[adjacent[0]],
3789 selected_edge_ids[adjacent[1]],
3790 ],
3791 radius,
3792 corner_name.as_deref(),
3793 ) {
3794 result = rounded;
3795 }
3796 }
3797 }
3798
3799 if !chamfer {
3805 for (ci, corner) in corners.iter().enumerate() {
3806 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
3807 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
3808 Ok(rounded) => result = rounded,
3809 Err(error) => {
3810 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3811 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
3812 }
3813 }
3814 }
3815 }
3816 }
3817
3818 heal_edge_vertex_gaps(&mut result, radius)?;
3821 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3822 let mut body = settle_cutter_scaffold(result);
3824 for (corner, pair, corner_name) in &open_mixed_corners {
3825 match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
3826 Ok(rounded) => body = rounded,
3827 Err(error) => {
3828 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3829 eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
3830 }
3831 }
3832 }
3833 }
3834 Ok(GroupBody { body, bowtie_clean: false })
3835 }
3837
3838fn drop_slit_shells_on() -> bool {
3847 switch!(live "BREP_FILLET_MITER_DROP_SLIT_SHELLS", true)
3848}
3849
3850fn drop_slit_shells(body: &mut BrepSolid) -> usize {
3861 if !drop_slit_shells_on() || body.shells.len() < 2 {
3862 return 0;
3863 }
3864 let mut users: std::collections::HashMap<u64, std::collections::HashSet<usize>> = std::collections::HashMap::new();
3865 for (index, shell) in body.shells.iter().enumerate() {
3866 for coedge in shell.faces.iter().flat_map(|face| &face.loops).flat_map(|lp| &lp.coedges) {
3867 users.entry(coedge.edge_id).or_default().insert(index);
3868 }
3869 }
3870 let slit_face = |face: &crate::topology::FaceRecord| {
3871 face.loops.iter().all(|lp| {
3872 let mut runs: std::collections::HashMap<u64, (usize, usize)> = std::collections::HashMap::new();
3873 for coedge in &lp.coedges {
3874 let entry = runs.entry(coedge.edge_id).or_default();
3875 if coedge.forward {
3876 entry.0 += 1;
3877 } else {
3878 entry.1 += 1;
3879 }
3880 }
3881 !runs.is_empty() && runs.values().all(|&(forward, back)| forward == 1 && back == 1)
3882 })
3883 };
3884 let mut index = 0usize;
3885 let mut dropped = 0usize;
3886 body.shells.retain(|shell| {
3887 let this = index;
3888 index += 1;
3889 let slit = this > 0
3890 && !shell.faces.is_empty()
3891 && shell.faces.iter().all(|face| slit_face(face))
3892 && shell
3893 .faces
3894 .iter()
3895 .flat_map(|face| &face.loops)
3896 .flat_map(|lp| &lp.coedges)
3897 .all(|coedge| users.get(&coedge.edge_id).is_some_and(|set| set.len() == 1));
3898 dropped += usize::from(slit);
3899 !slit
3900 });
3901 if dropped == 0 {
3902 return 0;
3903 }
3904 let used_edges: std::collections::HashSet<u64> = body
3905 .shells
3906 .iter()
3907 .flat_map(|shell| &shell.faces)
3908 .flat_map(|face| &face.loops)
3909 .flat_map(|lp| &lp.coedges)
3910 .map(|coedge| coedge.edge_id)
3911 .collect();
3912 body.edges.retain(|edge| used_edges.contains(&edge.id));
3913 let used_vertices: std::collections::HashSet<u64> =
3914 body.edges.iter().flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).collect();
3915 body.vertices.retain(|vertex| used_vertices.contains(&vertex.id));
3916 dropped
3917}
3918
3919fn miter_tangent_units_on() -> bool {
3922 switch!(live "BREP_FILLET_MITER_TANGENT_UNITS", true)
3923}
3924
3925fn miter_tangent_units(
3947 solid: &BrepSolid,
3948 selected_edge_ids: &[u64],
3949 chain_corners: &[(Vec3, [usize; 2])],
3950 count: usize,
3951) -> Vec<Vec<usize>> {
3952 let singles = || (0..count).map(|index| vec![index]).collect::<Vec<_>>();
3953 if !miter_tangent_units_on() || selected_edge_ids.len() != count {
3954 return singles();
3955 }
3956 let leaving = |edge_id: u64, corner: Vec3| -> Option<Vec3> {
3958 let edge = solid.edges.iter().find(|edge| edge.id == edge_id)?;
3959 if edge.degenerate {
3960 return None;
3961 }
3962 let start = edge.curve.evaluate(edge.t0).ok()?;
3963 let end = edge.curve.evaluate(edge.t1).ok()?;
3964 let at_start = start.sub(corner).length() <= end.sub(corner).length();
3965 let t = if at_start { edge.t0 } else { edge.t1 };
3966 let tangent = edge.curve.unit_tangent(t, edge.t0, edge.t1).ok()?;
3967 Some(if at_start { tangent } else { tangent.scale(-1.0) })
3968 };
3969 let mut parent: Vec<usize> = (0..count).collect();
3970 fn root(parent: &mut [usize], mut index: usize) -> usize {
3971 while parent[index] != index {
3972 parent[index] = parent[parent[index]];
3973 index = parent[index];
3974 }
3975 index
3976 }
3977 for (corner, [a, b]) in chain_corners {
3978 let (Some(ta), Some(tb)) = (leaving(selected_edge_ids[*a], *corner), leaving(selected_edge_ids[*b], *corner)) else {
3979 continue;
3980 };
3981 if ta.dot(tb) <= -(1.0 - 1e-6) {
3983 let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
3984 if ra != rb {
3985 parent[ra.max(rb)] = ra.min(rb);
3986 }
3987 }
3988 }
3989 let mut units: Vec<Vec<usize>> = Vec::new();
3990 let mut unit_of_root: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3991 for index in 0..count {
3992 let r = root(&mut parent, index);
3993 let slot = *unit_of_root.entry(r).or_insert_with(|| {
3994 units.push(Vec::new());
3995 units.len() - 1
3996 });
3997 units[slot].push(index);
3998 }
3999 if units.len() <= 1 || units.iter().all(|unit| unit.len() == 1) {
4000 return singles();
4001 }
4002 units
4003}
4004
4005fn concave_star_edges(
4006 solid: &BrepSolid,
4007 selected_edge_ids: &[u64],
4008 corner_edges: &[Vec<usize>],
4009 radius: f64,
4010 chamfer: bool,
4011) -> Vec<usize> {
4012 if chamfer || corner_edges.is_empty() || !concave_first_on() {
4013 return Vec::new();
4014 }
4015 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
4016 return Vec::new();
4017 };
4018 (0..selected_edge_ids.len())
4019 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
4020 .collect()
4021}
4022
4023#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4030enum CutterClass {
4031 Marched,
4032 Straight,
4033 Circular,
4034}
4035
4036fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
4037 match super::analyze::analyze_edge(solid, edge_id, radius) {
4038 Ok(cross) => match cross.path {
4039 super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
4040 super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
4041 },
4042 Err(_) => CutterClass::Marched,
4043 }
4044}
4045
4046fn mixed_star_class_ranks(
4068 solid: &BrepSolid,
4069 selected_edge_ids: &[u64],
4070 corner_edges: &[Vec<usize>],
4071 radius: f64,
4072 chamfer: bool,
4073) -> Vec<u8> {
4074 let mut ranks = vec![2u8; selected_edge_ids.len()];
4075 if chamfer || corner_edges.is_empty() || !class_order_on() {
4076 return ranks;
4077 }
4078 let classes: Vec<CutterClass> = selected_edge_ids
4079 .iter()
4080 .map(|&edge_id| cutter_class(solid, edge_id, radius))
4081 .collect();
4082 for adjacent in corner_edges {
4083 let Some(&head) = adjacent.first() else {
4084 continue;
4085 };
4086 if adjacent.iter().all(|&index| classes[index] == classes[head]) {
4087 continue;
4088 }
4089 for &index in adjacent {
4090 match classes[index] {
4091 CutterClass::Marched => ranks[index] = 1,
4092 CutterClass::Circular => ranks[index] = 3,
4093 CutterClass::Straight => {}
4094 }
4095 }
4096 }
4097 ranks
4098}
4099
4100pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
4107 let mut order: Vec<usize> = (0..count).collect();
4108 order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
4110 order
4111}
4112
4113fn class_order_on() -> bool {
4117 true
4118}
4119
4120
4121fn corners_concave_first(
4125 corners: Vec<Vec3>,
4126 corner_edges: Vec<Vec<usize>>,
4127 first: &[usize],
4128) -> (Vec<Vec3>, Vec<Vec<usize>>) {
4129 if first.is_empty() {
4130 return (corners, corner_edges);
4131 }
4132 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
4133 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
4134 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
4135 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
4136 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
4137 ordered.into_iter().unzip()
4138}
4139
4140fn concave_first_on() -> bool {
4143 true
4144}
4145
4146
4147pub fn fillet_edges_variable(
4155 solid: &BrepSolid,
4156 edge_points: &[Vec3],
4157 edge_names: Option<&[String]>,
4158 radii: &[(f64, f64)],
4159 chamfer: bool,
4160 name: Option<&str>,
4161) -> Result<BrepSolid, KernelRefusal> {
4162 let _operation = crate::blend::BlendOperation::enter();
4165 if edge_points.is_empty() {
4166 return Err(KernelRefusal::input(
4167 KernelStage::Collect,
4168 "variable_selection",
4169 "fillet_edges_variable: no edges selected",
4170 ));
4171 }
4172 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
4173 fillet_edges_variable_impl(
4174 solid,
4175 edge_points,
4176 edge_names,
4177 &per_edge_stops,
4178 chamfer,
4179 name,
4180 "fillet_edges_variable",
4181 true,
4182 )
4183}
4184
4185#[allow(clippy::too_many_arguments)]
4197fn fillet_edges_variable_impl(
4198 solid: &BrepSolid,
4199 edge_points: &[Vec3],
4200 edge_names: Option<&[String]>,
4201 per_edge_stops: &[Vec<(f64, f64)>],
4202 chamfer: bool,
4203 name: Option<&str>,
4204 entry: &str,
4205 allow_tapered_sequential: bool,
4206) -> Result<BrepSolid, KernelRefusal> {
4207 let bounds = crate::recentre::solid_bounds(solid);
4210 let translated = match crate::recentre::control_offset_at_entry() {
4211 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
4212 None => crate::recentre::guard_offset(bounds).map(|offset| {
4213 crate::recentre::profile_fired(entry, offset, bounds);
4214 (offset, crate::recentre::EntryKind::Guard)
4215 }),
4216 };
4217 if let Some((offset, kind)) = translated {
4218 let _entry = crate::recentre::TranslatedEntry::open(kind);
4219 let translate = |solid: &BrepSolid, offset: Vec3| {
4220 crate::recentre::translate_solid(solid, offset).map_err(|message| {
4221 KernelRefusal::internal(
4222 KernelStage::Collect,
4223 "translation_control",
4224 format!("{entry}: the translation control could not move the solid: {message}"),
4225 )
4226 })
4227 };
4228 let moved = translate(solid, offset)?;
4229 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
4230 let result = fillet_edges_variable_impl(
4231 &moved,
4232 &points,
4233 edge_names,
4234 per_edge_stops,
4235 chamfer,
4236 name,
4237 entry,
4238 allow_tapered_sequential,
4239 )?;
4240 return translate(&result, offset.scale(-1.0));
4241 }
4242 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
4243 if let Some(constant) = per_edge_stops
4247 .first()
4248 .and_then(|stops| stops.first())
4249 .map(|(_, radius)| *radius)
4250 {
4251 let uniform = per_edge_stops.iter().all(|stops| {
4252 stops
4253 .iter()
4254 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
4255 });
4256 if uniform && constant > 0.0 {
4257 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
4258 }
4259 }
4260 let max_radius = per_edge_stops
4261 .iter()
4262 .flat_map(|stops| stops.iter())
4263 .map(|(_, r)| r.abs())
4264 .fold(0.0_f64, f64::max);
4265
4266 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
4271 match fillet_variable_network(
4272 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
4273 )? {
4274 Ok(built) => return Ok(built),
4275 Err(refusal) if crate::blend::relief::relieving() => return Err(refusal),
4279 Err(_) => {}
4280 }
4281 }
4282
4283 let mut chain_corner = false;
4290 {
4291 use rustc_hash::FxHashSet as HashSet;
4292 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
4293 for (i, point) in edge_points.iter().enumerate() {
4294 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
4295 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
4296 if let (Ok(a), Ok(b)) =
4297 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
4298 {
4299 endpoints.push((a, i));
4300 endpoints.push((b, i));
4301 }
4302 }
4303 }
4304 }
4305 let mut used = vec![false; endpoints.len()];
4306 for i in 0..endpoints.len() {
4307 if used[i] {
4308 continue;
4309 }
4310 used[i] = true;
4311 let mut edges_here: HashSet<usize> = HashSet::default();
4312 edges_here.insert(endpoints[i].1);
4313 for j in (i + 1)..endpoints.len() {
4314 if used[j] {
4315 continue;
4316 }
4317 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
4318 used[j] = true;
4319 edges_here.insert(endpoints[j].1);
4320 }
4321 }
4322 if edges_here.len() == 2 {
4323 chain_corner = true;
4324 }
4325 }
4326 }
4327 let miter_operation = if chain_corner {
4328 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
4329 let mut any_convex = false;
4330 let mut any_concave = false;
4331 for point in edge_points {
4332 match resolve_edge_by_point(solid, *point)
4333 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
4334 {
4335 Ok(cross) if cross.convex => any_convex = true,
4336 Ok(_) => any_concave = true,
4337 Err(_) => {
4338 any_convex = true;
4339 any_concave = true;
4340 break;
4341 }
4342 }
4343 }
4344 match (any_convex, any_concave) {
4345 (true, false) => Some(crate::BooleanOperation::Intersect),
4346 (false, true) => Some(crate::BooleanOperation::Union),
4347 _ => None,
4348 }
4349 } else {
4350 None
4351 };
4352
4353 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
4360 let options = crate::BooleanOptions::default();
4361 let mut combined: Option<BrepSolid> = None;
4362 let mut failed = false;
4363 for (index, point) in edge_points.iter().enumerate() {
4364 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
4365 failed = true;
4366 break;
4367 };
4368 let edge_name = per_edge_name(edge_names, name, index);
4369 let Ok(blended) = crate::blend::blend_edge_variable(
4370 solid,
4371 edge_id,
4372 &per_edge_stops[index],
4373 chamfer,
4374 edge_name,
4375 ) else {
4376 failed = true;
4377 break;
4378 };
4379 let next = match combined.take() {
4380 None => blended,
4381 Some(previous) => {
4382 match crate::boolean_operation(&previous, &blended, operation, &options) {
4383 Ok(next) => next,
4384 Err(_) => {
4385 failed = true;
4386 break;
4387 }
4388 }
4389 }
4390 };
4391 combined = Some(next);
4392 }
4393 if failed {
4394 None
4395 } else {
4396 combined.filter(|s| s.validate().is_empty())
4397 }
4398 } else {
4399 None
4400 };
4401 let mut result = match miter_attempt {
4402 Some(mitered) => mitered,
4403 None => {
4404 let tapered = per_edge_stops.iter().any(|stops| {
4410 stops.first().is_some_and(|(_, first)| {
4411 stops
4412 .iter()
4413 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
4414 })
4415 });
4416 if chain_corner && tapered && !allow_tapered_sequential {
4417 return Err(KernelRefusal::unsupported(
4420 KernelStage::Sew,
4421 "tapered_chain_miter",
4422 format!(
4423 "{entry}: the tapered blends across the selected chain's shared corners \
4424 did not compose to a valid solid (the fitted blend boundaries could not \
4425 be mitered); fillet fewer edges per operation or reduce the taper"
4426 ),
4427 ));
4428 }
4429 let mut sequential = solid.clone();
4430 for (index, point) in edge_points.iter().enumerate() {
4431 let edge_id = resolve_edge_by_point(&sequential, *point)?;
4432 let edge_name = per_edge_name(edge_names, name, index);
4433 sequential = crate::blend::blend_edge_variable(
4434 &sequential,
4435 edge_id,
4436 &per_edge_stops[index],
4437 chamfer,
4438 edge_name,
4439 )?;
4440 }
4441 sequential
4442 }
4443 };
4444 heal_edge_vertex_gaps(&mut result, max_radius)?;
4447 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
4448 let issues = result.validate();
4453 if !issues.is_empty() {
4454 let (detail, class) = if allow_tapered_sequential {
4459 (
4460 "tapered blends meet at a shared chain vertex with \
4461 mismatched radii — the radius-transition corner patch is not implemented; \
4462 fillet the edges in separate operations or use matching stop radii",
4463 RefusalClass::UnsupportedGeometry {
4464 what: "taper_transition_patch".into(),
4465 },
4466 )
4467 } else {
4468 (
4469 "the composed radius-law blend produced invalid topology — the blends \
4470 across a shared chain corner failed to reassemble",
4471 RefusalClass::Internal {
4472 what: "law_chain_topology".into(),
4473 },
4474 )
4475 };
4476 return Err(KernelRefusal::new(
4477 class,
4478 KernelStage::Validate,
4479 format!(
4480 "{entry}: {detail} ({} validation issues, first: {})",
4481 issues.len(),
4482 issues
4483 .first()
4484 .map(|issue| issue.message.clone())
4485 .unwrap_or_default()
4486 ),
4487 ));
4488 }
4489 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
4494 crate::accept_sound(result, entry)
4495}
4496
4497#[allow(clippy::too_many_arguments)]
4502fn fillet_variable_network(
4503 solid: &BrepSolid,
4504 edge_points: &[Vec3],
4505 edge_names: Option<&[String]>,
4506 per_edge_stops: &[Vec<(f64, f64)>],
4507 chamfer: bool,
4508 name: Option<&str>,
4509 entry: &str,
4510 max_radius: f64,
4511) -> Result<Result<BrepSolid, KernelRefusal>, KernelRefusal> {
4512 let mut edge_ids = Vec::with_capacity(edge_points.len());
4513 for point in edge_points {
4514 edge_ids.push(resolve_edge_by_point(solid, *point)?);
4515 }
4516 let laws: Vec<crate::blend::StripeRadius> =
4517 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
4518 let network_names: Vec<Option<String>> = (0..edge_points.len())
4519 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
4520 .collect();
4521 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
4522 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
4523 let law_network = || {
4532 crate::blend::blend_star_network_laws(solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true)
4533 };
4534 let network_result = law_network().or_else(|first| {
4535 if crate::blend::is_pcurve_floor_refusal(&first) {
4536 crate::blend::with_pcurve_knot_retry(law_network).map_err(|_| first)
4537 } else {
4538 Err(first)
4539 }
4540 });
4541 let mut network = match network_result {
4542 Ok(network) => network,
4543 Err(refusal) => {
4544 if debug {
4545 eprintln!("law network refused: {refusal}");
4546 }
4547 return Ok(Err(refusal));
4548 }
4549 };
4550 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
4555 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
4556 let issues = network.validate();
4557 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
4558 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
4559 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
4560 crate::soundness::census::profile_operation(&network, &[solid], None, entry);
4561 match crate::accept_sound(network, entry) {
4562 Ok(sound) => return Ok(Ok(sound)),
4563 Err(refusal) => {
4564 if debug {
4565 eprintln!("law network result unsound: {refusal}");
4566 }
4567 return Ok(Err(refusal));
4568 }
4569 }
4570 }
4571 if debug {
4572 eprintln!(
4573 "law network result rejected: heal={healed:?} issues={issues:?} \
4574 interference={interference:?} bowtie={bowtie:?}"
4575 );
4576 }
4577 Ok(Err(KernelRefusal::unsupported(
4578 KernelStage::Validate,
4579 "law_network_rejected",
4580 format!(
4581 "{entry}: the tapered network's result was rejected (heal {}, {} validation issues, interference {}, loops {})",
4582 if healed.is_ok() { "ok" } else { "failed" },
4583 issues.len(),
4584 if interference.is_ok() { "ok" } else { "found" },
4585 if bowtie.is_ok() { "ok" } else { "found" },
4586 ),
4587 )))
4588}
4589
4590pub(crate) fn fillet_edges_variable_stops(
4591 solid: &BrepSolid,
4592 edge_points: &[Vec3],
4593 edge_names: Option<&[String]>,
4594 per_edge_stops: &[Vec<(f64, f64)>],
4595 chamfer: bool,
4596 name: Option<&str>,
4597) -> Result<BrepSolid, KernelRefusal> {
4598 let _operation = crate::blend::BlendOperation::enter();
4599 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
4600 return Err(KernelRefusal::input(
4601 KernelStage::Collect,
4602 "variable_selection",
4603 "fillet_edges_variable_stops: one stop list per selected edge is required",
4604 ));
4605 }
4606 fillet_edges_variable_impl(
4607 solid,
4608 edge_points,
4609 edge_names,
4610 per_edge_stops,
4611 chamfer,
4612 name,
4613 "fillet_edges_variable_stops",
4614 false,
4615 )
4616}
4617
4618struct ChainLink {
4622 input_index: usize,
4624 forward: bool,
4626 arc: Vec<(f64, f64)>,
4629 length: f64,
4631 abscissa: f64,
4634}
4635
4636fn edge_arc_table(
4647 curve: &NurbsCurve,
4648 t0: f64,
4649 t1: f64,
4650 tol: f64,
4651) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4652 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
4653 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4654 let mut table = Vec::with_capacity(n + 1);
4655 let mut cumulative = 0.0;
4656 let mut previous = evaluate(t0)?;
4657 table.push((0.0, 0.0));
4658 for j in 1..=n {
4659 let fraction = j as f64 / n as f64;
4660 let point = evaluate(t0 + (t1 - t0) * fraction)?;
4661 cumulative += point.sub(previous).length();
4662 previous = point;
4663 table.push((fraction, cumulative));
4664 }
4665 Ok((table, cumulative))
4666 };
4667 let mut n = 16usize;
4668 let (mut table, mut length) = build(n)?;
4669 while n < 4096 {
4670 n *= 2;
4671 let (next_table, next_length) = build(n)?;
4672 let converged = (next_length - length).abs() <= tol;
4673 table = next_table;
4674 length = next_length;
4675 if converged {
4676 break;
4677 }
4678 }
4679 Ok((table, length))
4680}
4681
4682fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
4685 let fraction = fraction.clamp(0.0, 1.0);
4686 let intervals = table.len() - 1;
4687 let scaled = fraction * intervals as f64;
4688 let index = (scaled.floor() as usize).min(intervals - 1);
4689 let local = scaled - index as f64;
4690 let (_, a) = table[index];
4691 let (_, b) = table[index + 1];
4692 a + (b - a) * local
4693}
4694
4695fn resolve_selected_chain(
4704 solid: &BrepSolid,
4705 edge_points: &[Vec3],
4706 entry: &str,
4707 tol: f64,
4708) -> Result<Vec<ChainLink>, KernelRefusal> {
4709 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
4712
4713 struct Resolved {
4714 edge_id: u64,
4715 start: Vec3,
4716 end: Vec3,
4717 arc: Vec<(f64, f64)>,
4718 length: f64,
4719 }
4720 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
4721 for point in edge_points {
4722 let edge_id = resolve_edge_by_point(solid, *point)?;
4723 if resolved.iter().any(|r| r.edge_id == edge_id) {
4724 return Err(KernelRefusal::input(
4725 KernelStage::Collect,
4726 "duplicate_edge",
4727 format!("{entry}: the same edge was selected more than once"),
4728 ));
4729 }
4730 let edge = solid
4731 .edges
4732 .iter()
4733 .find(|e| e.id == edge_id)
4734 .ok_or_else(|| {
4735 KernelRefusal::internal(
4736 KernelStage::Collect,
4737 "chain_resolved_edge",
4738 format!("{entry}: resolved edge vanished"),
4739 )
4740 })?;
4741 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
4742 if !(length > 0.0) {
4743 return Err(KernelRefusal::input(
4744 KernelStage::Collect,
4745 "zero_length_edge",
4746 format!("{entry}: selected edge has zero length"),
4747 ));
4748 }
4749 resolved.push(Resolved {
4750 edge_id,
4751 start: edge
4752 .curve
4753 .evaluate(edge.t0)
4754 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
4755 end: edge
4756 .curve
4757 .evaluate(edge.t1)
4758 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
4759 arc,
4760 length,
4761 });
4762 }
4763 let n = resolved.len();
4764 if n == 1 {
4765 let only = resolved.remove(0);
4766 return Ok(vec![ChainLink {
4767 input_index: 0,
4768 forward: true,
4769 arc: only.arc,
4770 length: only.length,
4771 abscissa: 0.0,
4772 }]);
4773 }
4774
4775 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
4777 for (i, r) in resolved.iter().enumerate() {
4778 for (point, is_start) in [(r.start, true), (r.end, false)] {
4779 match clusters
4780 .iter_mut()
4781 .find(|(anchor, _)| anchor.sub(point).length() < band)
4782 {
4783 Some((_, members)) => members.push((i, is_start)),
4784 None => clusters.push((point, vec![(i, is_start)])),
4785 }
4786 }
4787 }
4788 if clusters.iter().any(|(_, members)| members.len() > 2) {
4789 return Err(KernelRefusal::input(
4790 KernelStage::Collect,
4791 "chain_branching",
4792 format!(
4793 "{entry}: selected edges must form one open chain \
4794 (a vertex is shared by three or more selected edges)"
4795 ),
4796 ));
4797 }
4798 let free: Vec<usize> = clusters
4799 .iter()
4800 .enumerate()
4801 .filter(|(_, (_, members))| members.len() == 1)
4802 .map(|(c, _)| c)
4803 .collect();
4804 if free.len() != 2 {
4805 return Err(KernelRefusal::input(
4806 KernelStage::Collect,
4807 "chain_open",
4808 format!(
4809 "{entry}: selected edges must form one OPEN chain \
4810 (closed rings and disconnected selections are not supported)"
4811 ),
4812 ));
4813 }
4814 let start_cluster = *free
4816 .iter()
4817 .min_by_key(|&&c| clusters[c].1[0].0)
4818 .expect("two free ends");
4819
4820 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
4822 let mut visited = vec![false; n];
4823 let mut abscissa = 0.0_f64;
4824 let mut cluster = start_cluster;
4825 for _ in 0..n {
4826 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
4827 .1
4828 .iter()
4829 .find(|(edge_index, _)| !visited[*edge_index])
4830 else {
4831 return Err(KernelRefusal::input(
4832 KernelStage::Collect,
4833 "chain_walk",
4834 format!(
4835 "{entry}: selected edges are not connected into one chain"
4836 ),
4837 ));
4838 };
4839 visited[edge_index] = true;
4840 let r = &resolved[edge_index];
4841 links.push(ChainLink {
4842 input_index: edge_index,
4843 forward: entered_at_start,
4844 arc: r.arc.clone(),
4845 length: r.length,
4846 abscissa,
4847 });
4848 abscissa += r.length;
4849 let exit_point = if entered_at_start { r.end } else { r.start };
4850 cluster = clusters
4851 .iter()
4852 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
4853 .ok_or_else(|| {
4854 KernelRefusal::internal(
4855 KernelStage::Collect,
4856 "chain_cluster",
4857 format!("{entry}: chain walk lost an endpoint cluster"),
4858 )
4859 })?;
4860 }
4861 if visited.iter().any(|v| !v) {
4862 return Err(KernelRefusal::input(
4863 KernelStage::Collect,
4864 "chain_connected",
4865 format!(
4866 "{entry}: selected edges are not connected into one chain"
4867 ),
4868 ));
4869 }
4870 Ok(links)
4871}
4872
4873fn law_stops_for_link(
4892 link: &ChainLink,
4893 law: &crate::law::RadiusLaw,
4894 scale: f64,
4895 tol: f64,
4896) -> Vec<(f64, f64)> {
4897 let radius_at_fraction = |fraction: f64| -> f64 {
4898 let arc = arc_length_at_fraction(&link.arc, fraction);
4899 let chain_s = if link.forward {
4900 link.abscissa + arc
4901 } else {
4902 link.abscissa + (link.length - arc)
4903 };
4904 law.radius_at(chain_s * scale)
4905 };
4906 let curvature = law
4909 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
4910 * (scale * link.length).powi(2);
4911 let base = if curvature * 0.125 <= tol {
4912 1usize
4913 } else {
4914 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
4915 };
4916 let mut stops: Vec<(f64, f64)> = (0..=base)
4917 .map(|j| {
4918 let fraction = j as f64 / base as f64;
4919 (fraction, radius_at_fraction(fraction))
4920 })
4921 .collect();
4922 let mut depth = 0usize;
4924 while depth < 8 {
4925 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
4926 let mut inserted = false;
4927 for pair in stops.windows(2) {
4928 refined.push(pair[0]);
4929 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
4930 let law_mid = radius_at_fraction(mid_fraction);
4931 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
4932 if (law_mid - linear_mid).abs() > tol {
4933 refined.push((mid_fraction, law_mid));
4934 inserted = true;
4935 }
4936 }
4937 refined.push(*stops.last().expect("at least two stops"));
4938 stops = refined;
4939 if !inserted {
4940 break;
4941 }
4942 depth += 1;
4943 }
4944 stops
4945}
4946
4947pub fn fillet_edges_variable_law(
4959 solid: &BrepSolid,
4960 edge_points: &[Vec3],
4961 edge_names: Option<&[String]>,
4962 law: &crate::law::RadiusLaw,
4963 chamfer: bool,
4964 name: Option<&str>,
4965) -> Result<BrepSolid, KernelRefusal> {
4966 let _operation = crate::blend::BlendOperation::enter();
4969 const ENTRY: &str = "fillet_edges_variable_law";
4970 if edge_points.is_empty() {
4971 return Err(KernelRefusal::input(
4972 KernelStage::Collect,
4973 "law_selection",
4974 format!("{ENTRY}: no edges selected"),
4975 ));
4976 }
4977 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4978 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4979 fillet_variable_law_on_chain(
4980 solid,
4981 edge_points,
4982 edge_names,
4983 &chain,
4984 law,
4985 chamfer,
4986 name,
4987 ENTRY,
4988 tolerances.intersection_fit,
4989 )
4990}
4991
4992pub fn fillet_edges_variable_vertex_radii(
4998 solid: &BrepSolid,
4999 edge_points: &[Vec3],
5000 edge_names: Option<&[String]>,
5001 vertex_radii: &[f64],
5002 chamfer: bool,
5003 name: Option<&str>,
5004) -> Result<BrepSolid, KernelRefusal> {
5005 let _operation = crate::blend::BlendOperation::enter();
5008 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
5009 if edge_points.is_empty() {
5010 return Err(KernelRefusal::input(
5011 KernelStage::Collect,
5012 "vertex_radii_selection",
5013 format!("{ENTRY}: no edges selected"),
5014 ));
5015 }
5016 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
5017 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
5018 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
5019 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
5020 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
5021 fillet_variable_law_on_chain(
5022 solid,
5023 edge_points,
5024 edge_names,
5025 &chain,
5026 &law,
5027 chamfer,
5028 name,
5029 ENTRY,
5030 tolerances.intersection_fit,
5031 )
5032}
5033
5034#[allow(clippy::too_many_arguments)]
5038fn fillet_variable_law_on_chain(
5039 solid: &BrepSolid,
5040 edge_points: &[Vec3],
5041 edge_names: Option<&[String]>,
5042 chain: &[ChainLink],
5043 law: &crate::law::RadiusLaw,
5044 chamfer: bool,
5045 name: Option<&str>,
5046 entry: &str,
5047 tol: f64,
5048) -> Result<BrepSolid, KernelRefusal> {
5049 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
5050 let scale = law.total_length() / chain_length;
5051 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
5052 for link in chain {
5053 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
5054 }
5055 fillet_edges_variable_impl(
5056 solid,
5057 edge_points,
5058 edge_names,
5059 &per_edge_stops,
5060 chamfer,
5061 name,
5062 entry,
5063 false,
5064 )
5065}
5066
5067pub fn chamfer_edges_asymmetric(
5072 solid: &BrepSolid,
5073 edge_points: &[Vec3],
5074 edge_names: Option<&[String]>,
5075 d1: f64,
5076 d2: f64,
5077 name: Option<&str>,
5078) -> Result<BrepSolid, KernelRefusal> {
5079 let _operation = crate::blend::BlendOperation::enter();
5082 if edge_points.is_empty() {
5083 return Err(KernelRefusal::input(
5084 KernelStage::Collect,
5085 "asymmetric_selection",
5086 "chamfer_edges_asymmetric: no edges selected",
5087 ));
5088 }
5089 let mut result = solid.clone();
5090 for (index, point) in edge_points.iter().enumerate() {
5094 let edge_id = resolve_edge_by_point(&result, *point)?;
5095 let edge_name = per_edge_name(edge_names, name, index);
5096 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
5097 }
5098 refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
5099 Ok(result)
5100}
5101
5102pub fn chamfer_edges_angle(
5106 solid: &BrepSolid,
5107 edge_points: &[Vec3],
5108 edge_names: Option<&[String]>,
5109 d1: f64,
5110 angle_rad: f64,
5111 name: Option<&str>,
5112) -> Result<BrepSolid, KernelRefusal> {
5113 let _operation = crate::blend::BlendOperation::enter();
5116 if edge_points.is_empty() {
5117 return Err(KernelRefusal::input(
5118 KernelStage::Collect,
5119 "angle_selection",
5120 "chamfer_edges_angle: no edges selected",
5121 ));
5122 }
5123 let mut result = solid.clone();
5124 for (index, point) in edge_points.iter().enumerate() {
5125 let edge_id = resolve_edge_by_point(&result, *point)?;
5126 let edge_name = per_edge_name(edge_names, name, index);
5127 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
5128 }
5129 refuse_invalid_result(&result, "chamfer_edges_angle")?;
5130 Ok(result)
5131}
5132
5133fn dump_loops_debug(label: &str, solid: &BrepSolid) {
5137 for shell in &solid.shells {
5138 for face in &shell.faces {
5139 for (index, loop_record) in face.loops.iter().enumerate() {
5140 let walk: Vec<String> = loop_record
5141 .coedges
5142 .iter()
5143 .map(|coedge| {
5144 let uv = coedge
5148 .pcurve
5149 .domain()
5150 .ok()
5151 .and_then(|[q0, q1]| {
5152 let a = coedge.pcurve.evaluate(q0).ok()?;
5153 let b = coedge.pcurve.evaluate(q1).ok()?;
5154 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
5155 })
5156 .unwrap_or_default();
5157 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
5158 Some(edge) => format!(
5159 "c{}:e{}{}({}->{}){uv}",
5160 coedge.id,
5161 edge.id,
5162 if coedge.forward { "+" } else { "-" },
5163 edge.start_vertex_id,
5164 edge.end_vertex_id
5165 ),
5166 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
5167 }
5168 })
5169 .collect();
5170 eprintln!(
5171 "{label} face {} ({:?}) loop {index}: {}",
5172 face.id,
5173 face.name,
5174 walk.join(" ")
5175 );
5176 }
5177 }
5178 }
5179}
5180
5181
5182