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 { .. } if chamfer_asym_curved_on() => {
78 super::tool::build_exact_tool(cross, profile, 0.0, ToolEnds::default(), name)?
79 }
80 EdgePath::Circular { .. } => {
81 return Err(KernelRefusal::unsupported(
82 KernelStage::Classify,
83 "asymmetric_curved_edge",
84 "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
85 in this slice (asymmetric chamfer on general/curved edges is out of scope)",
86 ));
87 }
88 };
89 let mut side_index = 0usize;
93 for shell in &mut tool.shells {
94 for face in &mut shell.faces {
95 if side_index == 1 {
96 if face.name.is_none() {
97 face.name = name.map(str::to_string);
98 }
99 } else {
100 face.name = Some(scaffold_tag(name));
101 }
102 side_index += 1;
103 }
104 }
105 apply_tool(solid, &tool, cross.convex).map(settle_cutter_scaffold)
106}
107
108pub(crate) fn chamfer_asym_curved_on() -> bool {
112 switch!("BREP_CHAMFER_ASYM_CURVED", true)
113}
114
115pub fn chamfer_edge_asymmetric(
120 solid: &BrepSolid,
121 edge_id: u64,
122 d1: f64,
123 d2: f64,
124 name: Option<&str>,
125) -> Result<BrepSolid, KernelRefusal> {
126 let _operation = crate::blend::BlendOperation::enter();
129 if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
130 return Err(KernelRefusal::input(
131 KernelStage::Collect,
132 "setback_distances",
133 "chamfer_edge_asymmetric: both setback distances must be positive",
134 ));
135 }
136 let cross = match analyze_edge(solid, edge_id, d1.min(d2)) {
139 Ok(cross) => cross,
140 Err(refusal) if chamfer_asym_curved_on() => {
141 let faces = setback_faces(solid, edge_id).map_err(|_| refusal)?;
142 let law = crate::blend::setback::SetbackLaw::Distances { faces, distances: [d1, d2] };
143 return chamfer_by_setback_law(solid, edge_id, law, name, "chamfer_edge_asymmetric", false);
144 }
145 Err(refusal) => return Err(refusal),
146 };
147 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
148 apply_chamfer_offsets_profile(solid, &cross, &profile, name, edge_id)
149}
150
151fn setback_faces(solid: &BrepSolid, edge_id: u64) -> Result<[u64; 2], KernelRefusal> {
154 let (_, faces) = super::analyze::edge_and_faces(solid, edge_id)?;
155 match faces.as_slice() {
156 [first, second] if first.id != second.id => Ok([first.id, second.id]),
157 _ => Err(KernelRefusal::input(
158 KernelStage::Collect,
159 "edge_faces",
160 format!("asymmetric chamfer: edge {edge_id} does not join two faces"),
161 )),
162 }
163}
164
165fn selected_chain(
169 solid: &BrepSolid,
170 edge_id: u64,
171 edge_points: &[Vec3],
172 done: &[bool],
173) -> Result<Option<Vec<usize>>, KernelRefusal> {
174 let Ok((members, _closed)) = crate::blend::smooth_chain_members(solid, edge_id) else {
175 return Ok(None);
176 };
177 if members.len() < 2 {
178 return Ok(None);
179 }
180 let mut positions = Vec::new();
181 for member in &members {
182 let position = edge_points.iter().enumerate().position(|(index, point)| {
183 !done[index] && resolve_edge_by_point(solid, *point).is_ok_and(|id| id == *member)
184 });
185 match position {
186 Some(position) => positions.push(position),
187 None => return Ok(None),
188 }
189 }
190 Ok(Some(positions))
191}
192
193fn chamfer_by_setback_law(
200 solid: &BrepSolid,
201 edge_id: u64,
202 law: crate::blend::setback::SetbackLaw,
203 name: Option<&str>,
204 entry: &str,
205 walk_chain: bool,
206) -> Result<BrepSolid, KernelRefusal> {
207 let edge = solid
208 .edges
209 .iter()
210 .find(|edge| edge.id == edge_id)
211 .ok_or_else(|| KernelRefusal::input(KernelStage::Collect, "edge", format!("{entry}: edge {edge_id} not found")))?;
212 let closed = edge.start_vertex_id == edge.end_vertex_id;
213 let mut healed = solid.clone();
215 let policy = crate::KernelTolerances::for_solid(&healed, 1e-7);
216 crate::heal::heal_operands(&mut healed, &policy)?;
217 let reach = law.reach();
218 let mut result = crate::blend::setback::with_setback_law(law, || {
219 if walk_chain {
220 crate::blend::blend_smooth_chain(&healed, edge_id, reach, true, name)
221 } else if closed {
222 crate::blend::blend_closed_edge(&healed, edge_id, reach, true, name)
223 } else {
224 crate::blend::blend_smooth_chain_if_closed(&healed, edge_id, reach, true, name).or_else(|refusal| {
229 let lone = matches!(&refusal.class, RefusalClass::Internal { what } if what == "chain_single_segment");
230 if lone {
231 crate::blend::blend_open_edge(&healed, edge_id, reach, true, name)
232 } else {
233 Err(refusal)
234 }
235 })
236 }
237 })?;
238 heal_edge_vertex_gaps(&mut result, reach)?;
239 crate::blend::fit_planar_charts_to_trims(&healed, &mut result)?;
240 let result = settle_cutter_scaffold(result);
241 refuse_invalid_result(&result, entry)?;
242 super::analyze::check_blend_interference(&healed, &result, &[edge_id], "chamfer")?;
243 super::analyze::check_blend_wall_loops(&result, "chamfer")?;
244 Ok(result)
245}
246
247pub fn chamfer_edge_angle(
253 solid: &BrepSolid,
254 edge_id: u64,
255 d1: f64,
256 angle_rad: f64,
257 name: Option<&str>,
258) -> Result<BrepSolid, KernelRefusal> {
259 let _operation = crate::blend::BlendOperation::enter();
262 if !(d1 > 0.0) || !d1.is_finite() {
263 return Err(KernelRefusal::input(
264 KernelStage::Collect,
265 "setback_d1",
266 "chamfer_edge_angle: setback distance d1 must be positive",
267 ));
268 }
269 let cross = match analyze_edge(solid, edge_id, d1) {
270 Ok(cross) => cross,
271 Err(refusal) if chamfer_asym_curved_on() => {
272 let [first, _] = setback_faces(solid, edge_id).map_err(|_| refusal)?;
273 if !(angle_rad > 1e-9) || !(angle_rad < std::f64::consts::PI) {
274 return Err(KernelRefusal::input(
275 KernelStage::Collect,
276 "chamfer_angle",
277 format!("chamfer_edge_angle: angle {angle_rad} must be positive"),
278 ));
279 }
280 let law = crate::blend::setback::SetbackLaw::Angle { first, distance: d1, angle: angle_rad };
281 return chamfer_by_setback_law(solid, edge_id, law, name, "chamfer_edge_angle", false);
282 }
283 Err(refusal) => return Err(refusal),
284 };
285 let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
286 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
287 apply_chamfer_offsets_profile(solid, &cross, &profile, name, edge_id)
288}
289
290pub(super) fn heal_edge_vertex_gaps(
294 solid: &mut BrepSolid,
295 radius: f64,
296) -> Result<(), KernelRefusal> {
297 let search = (radius.abs() * 1e-3).max(1e-7);
298 crate::boolean::commit_nearby_edge_endpoints(solid, search)
299}
300
301fn refuse_invalid_result(result: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
306 let errors: Vec<_> = result.validate_memoised().into_iter().filter(|issue| issue.severity == "error").collect();
307 let Some(first) = errors.first() else {
308 return Ok(());
309 };
310 Err(KernelRefusal::new(
311 RefusalClass::InvalidResultTopology { issues: errors.len() as u32 },
312 KernelStage::Validate,
313 format!(
314 "{entry}: the blend built a solid that fails validation ({} errors, first: {}); \
315 refused rather than returned",
316 errors.len(),
317 first.message
318 ),
319 ))
320}
321
322fn pick_skips_seam_on() -> bool {
332 switch!("BREP_FILLET_PICK_SKIPS_SEAM", true)
333}
334
335fn is_seam(solid: &BrepSolid, edge_id: u64) -> bool {
337 let users = solid
338 .shells
339 .iter()
340 .flat_map(|shell| &shell.faces)
341 .filter(|face| face.loops.iter().flat_map(|loop_record| &loop_record.coedges).any(|coedge| coedge.edge_id == edge_id))
342 .count();
343 users == 1
344}
345
346pub(super) fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
348 let nearest = crate::topology::nearest_edge(solid, point);
349 if pick_skips_seam_on() {
350 if let Some((edge_id, distance)) = nearest {
351 if distance <= 1e-3 && is_seam(solid, edge_id) {
352 let tie = distance + 1e-9 * (1.0 + point.length());
353 let blendable = solid.edges.iter().filter(|edge| edge.id != edge_id && !edge.degenerate).find(|edge| {
354 crate::project_point_to_curve(&edge.curve, point).ok().is_some_and(|projection| {
355 let at = projection.u.clamp(edge.t0.min(edge.t1), edge.t0.max(edge.t1));
356 edge.curve.evaluate(at).is_ok_and(|sample| sample.sub(point).length() <= tie)
357 }) && !is_seam(solid, edge.id)
358 });
359 if let Some(edge) = blendable {
360 return Ok(edge.id);
361 }
362 }
363 }
364 }
365 match nearest {
366 Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
367 Some((_, distance)) => Err(KernelRefusal::input(
368 KernelStage::Collect,
369 "edge_point",
370 format!(
371 "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
372 ),
373 )),
374 None => Err(KernelRefusal::input(
375 KernelStage::Collect,
376 "no_edges",
377 "fillet_edges: solid has no edges",
378 )),
379 }
380}
381
382pub(crate) fn fillet_coedge_memo_on() -> bool {
386 switch!("BREP_FILLET_COEDGE_MEMO", true)
387}
388
389
390pub fn fillet_edges(
422 solid: &BrepSolid,
423 edge_points: &[Vec3],
424 edge_names: Option<&[String]>,
425 radius: f64,
426 chamfer: bool,
427 name: Option<&str>,
428) -> Result<BrepSolid, KernelRefusal> {
429 let prepared = if crate::blend::creased_rim::creased_rim_split_on() {
436 crate::blend::creased_rim::split_creased_rims(solid, edge_points, edge_names, &resolve_edge_by_point)?
437 } else {
438 None
439 };
440 match prepared {
441 Some(prepared) => {
442 let names = prepared.names.as_deref();
443 let built = fillet_edges_prepared(&prepared.solid, &prepared.points, names, radius, chamfer, name)?;
444 Ok(prepared.rejoin(built))
445 }
446 None => {
447 if chamfer && super::chamfer_star::chamfer_mixed_star_on() {
453 let build = |body: &BrepSolid, points: &[Vec3], names: Option<&[String]>| {
454 fillet_edges_prepared(body, points, names, radius, true, name)
455 };
456 if let Some(built) = super::chamfer_star::chamfer_mixed_stars(solid, edge_points, edge_names, &build) {
457 return Ok(built);
458 }
459 }
460 let ordinary = fillet_edges_prepared(solid, edge_points, edge_names, radius, chamfer, name);
461 let answered = match ordinary {
469 Err(refusal)
470 if crate::blend::split_rim::split_rim_merge_on()
471 && matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what } if what == super::analyze::TANGENT_ROLLOVER_ONLY_WHAT) =>
472 {
473 match crate::blend::split_rim::merge_split_rims(solid, edge_points, edge_names, &resolve_edge_by_point) {
474 Some(merged) => fillet_edges_prepared(&merged.solid, &merged.points, merged.names.as_deref(), radius, chamfer, name)
479 .map_err(|merged_refusal| {
480 refusal.with_message(|message| {
481 format!("{message} The rim merged into one closed edge refused too: {}", merged_refusal.message)
482 })
483 }),
484 None => Err(refusal),
485 }
486 }
487 other => other,
488 };
489 match answered {
496 Err(refusal) if solid.kind == crate::BodyKind::Sheet && crate::blend::sheet_ends::sheet_free_end_on() => {
497 let edge_ids = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<Vec<_>, _>>();
498 let Ok(edge_ids) = edge_ids else { return Err(refusal) };
499 let Some(closed) = crate::blend::sheet_ends::close_free_ends(solid, &edge_ids) else { return Err(refusal) };
500 let count = crate::blend::sheet_ends::temporary_faces(&closed);
501 match fillet_edges_prepared(&closed, edge_points, edge_names, radius, chamfer, name) {
502 Ok(built) => crate::blend::sheet_ends::open_free_ends(built, count).ok_or(refusal),
503 Err(closed_refusal) => Err(refusal.with_message(|message| {
504 format!("{message} The sheet with its free ends closed refused too: {}", closed_refusal.message)
505 })),
506 }
507 }
508 other => other,
509 }
510 }
511 }
512}
513
514fn fillet_edges_prepared(
515 solid: &BrepSolid,
516 edge_points: &[Vec3],
517 edge_names: Option<&[String]>,
518 radius: f64,
519 chamfer: bool,
520 name: Option<&str>,
521) -> Result<BrepSolid, KernelRefusal> {
522 let _operation = crate::blend::BlendOperation::enter();
525 BrepSolid::clear_validate_memo();
528 let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
532 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
533 let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
536 let _cap = SubsetDropCap::set(cap);
537 let (result, selection) = match fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name) {
538 Err(fold) if !chamfer && crate::blend::is_wall_fold(&fold) && crate::blend::relief::radius_relief_on() => {
541 let misfit = crate::blend::relief::is_misfit_fold(&fold);
542 let relieved = relieve_fold(solid, edge_points, edge_names, radius, name, fold);
543 if relieved.is_err() && misfit {
547 let _off = crate::blend::relief::MisfitOff::enter();
548 return fillet_edges(solid, edge_points, edge_names, radius, chamfer, name);
549 }
550 return relieved;
551 }
552 reported => reported?,
553 };
554 if selection.is_complete() {
555 return Ok(result);
556 }
557 let dropped: Vec<String> = selection
560 .rejected
561 .iter()
562 .map(|index| {
563 let point = edge_points[*index];
564 let named = edge_names
565 .and_then(|names| names.get(*index))
566 .map(|name| format!("`{name}` "))
567 .unwrap_or_default();
568 format!(
569 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
570 point.x, point.y, point.z
571 )
572 })
573 .collect();
574 Err(KernelRefusal::ill_posed(
578 KernelStage::Select,
579 "incomplete_selection",
580 format!(
581 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
582 rest of the selection and carry no wall in the result ({}). The group refusal was: \
583 {}. Blend them separately, or call the reported entry point to accept the partial \
584 selection deliberately.",
585 selection.applied.len(),
586 selection.requested,
587 selection.rejected.len(),
588 dropped.join(", "),
589 selection
590 .reason
591 .as_deref()
592 .unwrap_or("the whole group built but not every edge grew a wall"),
593 ),
594 ))
595}
596
597fn relieve_fold(
605 solid: &BrepSolid,
606 edge_points: &[Vec3],
607 edge_names: Option<&[String]>,
608 radius: f64,
609 name: Option<&str>,
610 fold: KernelRefusal,
611) -> Result<BrepSolid, KernelRefusal> {
612 let _relieving = crate::blend::relief::Relieving::enter();
613 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
614 let selected: Vec<u64> = edge_points.iter().map(|point| resolve_edge_by_point(solid, *point)).collect::<Result<_, _>>()?;
615 let joined = crate::blend::relief::join_continuations(solid, &selected);
619 let solid = joined.as_ref().unwrap_or(solid);
620 let mut ids: Vec<u64> = Vec::with_capacity(edge_points.len());
621 let mut points: Vec<Vec3> = Vec::with_capacity(edge_points.len());
622 let mut names: Vec<String> = Vec::with_capacity(edge_points.len());
623 for (index, point) in edge_points.iter().enumerate() {
624 let id = resolve_edge_by_point(solid, *point)?;
625 if !ids.contains(&id) {
626 ids.push(id);
627 points.push(*point);
628 if let Some(name) = edge_names.and_then(|names| names.get(index)) {
629 names.push(name.clone());
630 }
631 }
632 }
633 let (edge_points, edge_names) = (&points[..], edge_names.map(|_| &names[..]));
634 if debug && joined.is_some() {
635 eprintln!("radius relief: joined continuation edges, {} selected edges become {}", selected.len(), ids.len());
636 }
637 let started = web_time::Instant::now();
638 let measured = match crate::blend::relief::measure_ceilings(solid, &ids, radius) {
639 Ok(measured) => measured,
640 Err(refusal) => return Err(fold.with_message(|message| format!("{message}; the relief could not measure the edges: {refusal}"))),
641 };
642 if debug {
643 eprintln!("radius relief: ceilings measured in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
644 }
645 let largest = measured.iter().flatten().map(|(_, ceiling)| *ceiling).fold(0.0_f64, f64::max);
650 if largest < radius {
651 return Err(fold.with_message(|message| {
652 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}")
653 }));
654 }
655 let mut last: Option<KernelRefusal> = None;
656 for attempt in 0..crate::blend::relief::ATTEMPTS {
657 let plan = match crate::blend::relief::plan_relief(solid, &ids, radius, &measured, attempt) {
658 Ok(Some(plan)) => plan,
659 Ok(None) => break,
662 Err(refusal) => {
663 last = Some(refusal);
664 break;
665 }
666 };
667 let mark = crate::blend::blend_notes_mark();
668 match build_relief_plan(solid, edge_points, edge_names, &ids, radius, name, &plan) {
669 Ok(body) => {
670 for zone in &plan.zones {
671 let near: Vec<String> = zone
672 .edges
673 .iter()
674 .filter_map(|index| solid.edges.iter().find(|edge| edge.id == ids[*index]))
675 .map(|edge| edge.name.clone().unwrap_or_else(|| format!("edge #{}", edge.id)))
676 .map(|label| format!("`{label}`"))
677 .collect();
678 let (low, high) = zone
683 .edges
684 .iter()
685 .flat_map(|index| plan.stops[*index].iter().map(|(_, r)| *r))
686 .fold((f64::INFINITY, 0.0_f64), |(low, high), r| (low.min(r), high.max(r)));
687 let note = if high < radius * (1.0 - 1e-9) {
688 format!(
689 "radius reduced over the whole of {}: {low:.2} to {high:.2}, so r {radius} is reached nowhere \
690 on them (where they turn tightest the largest ball that fits is {:.2})",
691 near.join(", "),
692 zone.ceiling,
693 )
694 } else {
695 format!(
696 "radius reduced to {:.2} locally near {}: r {radius} does not fit there (the largest ball that does is {:.2})",
697 zone.radius,
698 near.join(", "),
699 zone.ceiling,
700 )
701 };
702 crate::blend::record_blend_note(note);
703 }
704 return Ok(body);
705 }
706 Err(refusal) => {
707 crate::blend::blend_notes_truncate(mark);
708 if debug {
709 eprintln!("radius relief attempt {attempt} refused at {:.1} ms: {refusal}", started.elapsed().as_secs_f64() * 1e3);
710 }
711 last = Some(refusal);
712 }
713 }
714 }
715 Err(match last {
716 Some(last) => fold.with_message(|message| format!("{message}; a locally reduced radius did not build either: {last}")),
717 None => fold,
718 })
719}
720
721fn build_relief_plan(
723 solid: &BrepSolid,
724 edge_points: &[Vec3],
725 edge_names: Option<&[String]>,
726 ids: &[u64],
727 radius: f64,
728 name: Option<&str>,
729 plan: &crate::blend::relief::ReliefPlan,
730) -> Result<BrepSolid, KernelRefusal> {
731 let count = ids.len();
733 let mut group: Vec<usize> = (0..count).collect();
734 let find = |group: &mut Vec<usize>, mut index: usize| {
735 while group[index] != index {
736 group[index] = group[group[index]];
737 index = group[index];
738 }
739 index
740 };
741 let vertices: Vec<[u64; 2]> = ids
742 .iter()
743 .map(|id| {
744 solid.edges.iter().find(|edge| edge.id == *id).map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).unwrap_or([u64::MAX; 2])
745 })
746 .collect();
747 for a in 0..count {
748 for b in a + 1..count {
749 if vertices[a].iter().any(|vertex| *vertex != u64::MAX && vertices[b].contains(vertex)) {
750 let (root_a, root_b) = (find(&mut group, a), find(&mut group, b));
751 group[root_a] = root_b;
752 }
753 }
754 }
755 let reduced: Vec<bool> = plan.stops.iter().map(|stops| stops.iter().any(|(_, r)| *r < radius)).collect();
756 let relieved_group: Vec<bool> = (0..count)
757 .map(|index| {
758 let root = find(&mut group, index);
759 (0..count).any(|other| reduced[other] && find(&mut group, other) == root)
760 })
761 .collect();
762 let pick = |want: bool| -> (Vec<Vec3>, Option<Vec<String>>, Vec<Vec<(f64, f64)>>) {
763 let indices: Vec<usize> = (0..count).filter(|index| relieved_group[*index] == want).collect();
764 (
765 indices.iter().map(|index| edge_points[*index]).collect(),
766 edge_names.map(|names| indices.iter().filter_map(|index| names.get(*index).cloned()).collect()),
767 indices.iter().map(|index| plan.stops[*index].clone()).collect(),
768 )
769 };
770 let (constant_points, constant_names, _) = pick(false);
771 let (relieved_points, relieved_names, relieved_stops) = pick(true);
772 let mut body = solid.clone();
773 if !constant_points.is_empty() {
774 body = fillet_edges(&body, &constant_points, constant_names.as_deref(), radius, false, name)?;
775 }
776 let built = fillet_edges_variable_impl(
777 &body,
778 &relieved_points,
779 relieved_names.as_deref(),
780 &relieved_stops,
781 false,
782 name,
783 "fillet_edges",
784 false,
785 )?;
786 let issues = built.validate();
787 if !issues.is_empty() {
788 return Err(KernelRefusal::internal(
789 KernelStage::Validate,
790 "relief_validate",
791 format!("radius relief: the relieved fillet does not validate: {issues:?}"),
792 ));
793 }
794 Ok(built)
795}
796
797#[derive(Clone, Debug)]
804pub struct BlendSelection {
805 pub requested: usize,
807 pub applied: Vec<usize>,
809 pub rejected: Vec<usize>,
811 pub reason: Option<String>,
813}
814
815impl BlendSelection {
816 pub fn is_complete(&self) -> bool {
818 self.rejected.is_empty()
819 }
820}
821
822pub fn fillet_edges_reported_at_offset(
834 solid: &BrepSolid,
835 edge_points: &[Vec3],
836 edge_names: Option<&[String]>,
837 radius: f64,
838 chamfer: bool,
839 name: Option<&str>,
840 offset: Vec3,
841) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
842 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
843}
844
845#[allow(clippy::too_many_arguments)]
847fn fillet_translated(
848 solid: &BrepSolid,
849 edge_points: &[Vec3],
850 edge_names: Option<&[String]>,
851 radius: f64,
852 chamfer: bool,
853 name: Option<&str>,
854 offset: Vec3,
855 kind: crate::recentre::EntryKind,
856) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
857 let _entry = crate::recentre::TranslatedEntry::open(kind);
858 let translate = |solid: &BrepSolid, offset: Vec3| {
859 crate::recentre::translate_solid(solid, offset).map_err(|message| {
860 KernelRefusal::internal(
861 KernelStage::Collect,
862 "translation_control",
863 format!("fillet_edges: the translation control could not move the solid: {message}"),
864 )
865 })
866 };
867 let moved = translate(solid, offset)?;
868 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
869 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
870 Ok((translate(&result, offset.scale(-1.0))?, selection))
871}
872
873pub fn fillet_edges_reported(
874 solid: &BrepSolid,
875 edge_points: &[Vec3],
876 edge_names: Option<&[String]>,
877 radius: f64,
878 chamfer: bool,
879 name: Option<&str>,
880) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
881 if let Some(offset) = crate::recentre::control_offset_at_entry() {
884 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
885 }
886 let bounds = crate::recentre::solid_bounds(solid);
889 if let Some(offset) = crate::recentre::guard_offset(bounds) {
890 crate::recentre::profile_fired("fillet", offset, bounds);
891 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
892 }
893 let _operation = crate::blend::BlendOperation::enter();
896 let _total = crate::blend::BlendTimer::start("timing.total_ms");
899 if !(radius > 0.0) || !radius.is_finite() {
900 return Err(KernelRefusal::input(
901 KernelStage::Collect,
902 "radius",
903 "fillet_edges: radius must be positive",
904 ));
905 }
906 if edge_points.is_empty() {
907 return Err(KernelRefusal::input(
908 KernelStage::Collect,
909 "selection",
910 "fillet_edges: no edges selected",
911 ));
912 }
913 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
921 let mut selected_ids = Vec::with_capacity(edge_points.len());
922 if crate::blend::blend_operation_outermost() {
923 super::analyze::clear_rollover_only();
924 }
925 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
926 let _boundaries = super::analyze::SupportBoundaryScope::open();
927 for point in edge_points {
928 let resolve = crate::blend::BlendTimer::start("timing.selection_checks.resolve_ms");
929 let edge_id = resolve_edge_by_point(solid, *point)?;
930 resolve.stop();
931 let concavity = crate::blend::BlendTimer::start("timing.selection_checks.concavity_ms");
932 check_mixed_concavity(solid, edge_id, entry)?;
933 concavity.stop();
934 let _extent = crate::blend::BlendTimer::start("timing.selection_checks.extent_ms");
935 if edge_points.len() == 1 && !chamfer {
937 check_support_extent_clipping(solid, edge_id, radius, entry)?;
938 } else {
939 check_support_extent(solid, edge_id, radius, entry)?;
940 }
941 selected_ids.push(edge_id);
942 }
943 drop(_boundaries);
944 selection_checks.stop();
945 if !chamfer {
984 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
989 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
990 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
991 corner_analysis.stop();
992 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
993 for corner in &corners {
994 eprintln!(
995 "mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
996 corner.vertex, corner.point.x, corner.point.y, corner.point.z,
997 corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
998 corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
999 );
1000 }
1001 }
1002 if !corners.is_empty() {
1003 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
1006 let composed = rounded_boss_composition(
1008 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
1009 ).or_else(|error| {
1010 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1011 eprintln!("rounded boss composition refused: {error}");
1012 }
1013 runout_composition(
1014 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
1015 )
1016 }).or_else(|error| {
1017 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1018 eprintln!("runout composition refused: {error}");
1019 }
1020 open_pad_composition(
1026 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
1027 ).map_err(|pad_error| {
1028 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1029 eprintln!("open-pad composition refused: {pad_error}");
1030 }
1031 let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
1036 if declined { error } else { pad_error }
1037 })
1038 }).or_else(|error| {
1039 cusp_composition(solid, &selected_ids, edge_points, edge_names, radius, name, &corners).map_err(|cusp_error| {
1044 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1045 eprintln!("cusp composition refused: {cusp_error}");
1046 }
1047 error
1048 })
1049 });
1050 match composed {
1051 Ok(result) => {
1052 let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
1061 refuse_invalid_result(&result, entry)?;
1065 let missing = walls_missing_ties(
1066 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
1067 )?;
1068 if !missing.is_empty() {
1070 return Err(KernelRefusal::internal(
1071 KernelStage::Validate,
1072 "runout_walls_missing",
1073 format!(
1074 "{entry}: the composition built a solid with no wall on {}",
1075 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
1076 ),
1077 ));
1078 }
1079 let n = edge_points.len();
1080 return Ok((
1081 result,
1082 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
1083 ));
1084 }
1085 Err(error) => {
1086 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1087 eprintln!("runout composition refused: {error}");
1088 }
1089 check_mixed_corner_convexity(
1090 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
1091 )?;
1092 }
1093 }
1094 }
1095 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
1101 check_degenerate_corner_setbacks(
1102 solid,
1103 &selected_ids,
1104 edge_points,
1105 edge_names,
1106 radius,
1107 name,
1108 )?;
1109 corner_analysis.stop();
1110 }
1111
1112 let n = edge_points.len();
1113 let complete = |applied: Vec<usize>| BlendSelection {
1114 requested: n,
1115 applied,
1116 rejected: Vec::new(),
1117 reason: None,
1118 };
1119 let mut memo = ComponentMemo::default();
1130 let profile = crate::profile::profile_enabled();
1131 let group_started = web_time::Instant::now();
1132 let group = fillet_edges_group_in(
1133 solid,
1134 edge_points,
1135 edge_names,
1136 radius,
1137 chamfer,
1138 name,
1139 Some(&mut memo),
1140 );
1141 if profile {
1142 profile_line!(
1143 "blend.selection group edges={n} ok={} ms={:.3}",
1144 group.is_ok(),
1145 group_started.elapsed().as_secs_f64() * 1e3
1146 );
1147 }
1148 let group_err = match group {
1149 Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
1154 refuse_invalid_result(&result, entry).unwrap_err()
1155 }
1156 Ok(result) => {
1157 let missing =
1162 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
1163 if missing.is_empty() {
1164 return Ok((result, complete((0..n).collect())));
1165 }
1166 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
1167 return Ok((
1168 result,
1169 BlendSelection {
1170 requested: n,
1171 applied,
1172 rejected: missing,
1173 reason: None,
1174 },
1175 ));
1176 }
1177 Err(error) => error,
1178 };
1179 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err)
1189 || crate::blend::is_tangential_transverse_end(&group_err)
1190 || matches!(&group_err.class, RefusalClass::UnsupportedGeometry { what } if what == "canal_footprint_bound")
1191 || crate::blend::is_ball_does_not_fit(&group_err)
1192 {
1193 return Err(group_err);
1194 }
1195 if n < 2 || n > 12 {
1200 return Err(group_err);
1201 }
1202 let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
1209 for drop in 1..=max_drop {
1210 for dropped in index_combinations(n, drop) {
1211 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
1212 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
1213 let kept_names: Option<Vec<String>> =
1216 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
1217 crate::blend::record_count("count.subset.builds", 1);
1218 let subset_started = web_time::Instant::now();
1219 let subset = fillet_edges_group_in(
1220 solid,
1221 &points,
1222 kept_names.as_deref(),
1223 radius,
1224 chamfer,
1225 name,
1226 Some(&mut memo),
1227 );
1228 if profile {
1229 profile_line!(
1230 "blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
1231 subset.is_ok(),
1232 subset_started.elapsed().as_secs_f64() * 1e3
1233 );
1234 }
1235 let result = match subset {
1236 Ok(result) => result,
1237 Err(error) => {
1238 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1239 eprintln!("subset dropped={dropped:?} refused: {error}");
1240 }
1241 continue;
1242 }
1243 };
1244 if !result.validate().is_empty() {
1245 continue;
1246 }
1247 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
1248 continue;
1249 }
1250 return Ok((
1251 result,
1252 BlendSelection {
1253 requested: n,
1254 applied: keep,
1255 rejected: dropped,
1256 reason: Some(group_err.message),
1257 },
1258 ));
1259 }
1260 }
1261 Err(group_err)
1262}
1263
1264pub(crate) fn walls_missing_nearest_on() -> bool {
1269 switch!("BREP_WALLS_MISSING_NEAREST", true)
1270}
1271
1272
1273pub(super) fn walls_missing(
1299 original: &BrepSolid,
1300 result: &BrepSolid,
1301 edge_points: &[Vec3],
1302 selected: &[usize],
1303 size: f64,
1304 chamfer: bool,
1305 entry: &str,
1306) -> Result<Vec<usize>, KernelRefusal> {
1307 walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
1308}
1309
1310#[allow(clippy::too_many_arguments)]
1325pub(super) fn walls_missing_ties(
1326 original: &BrepSolid,
1327 result: &BrepSolid,
1328 edge_points: &[Vec3],
1329 selected: &[usize],
1330 size: f64,
1331 chamfer: bool,
1332 entry: &str,
1333 tie_majority: bool,
1334) -> Result<Vec<usize>, KernelRefusal> {
1335use crate::{PointClass, SolidClassifier};
1337 if selected.is_empty() {
1338 return Ok(Vec::new());
1339 }
1340 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
1341 crate::classification::classify_profile_begin();
1342 crate::classification::containment_profile_begin();
1343 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
1344 let visible = band * 100.0;
1347 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
1348 let classifier = SolidClassifier::new(result, band)
1349 .or_refuse(KernelStage::Validate, "wall_classifier")?;
1350 let nearest = if walls_missing_nearest_on() {
1355 Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
1356 } else {
1357 None
1358 };
1359 let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
1360 if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
1361 crate::blend::record_count("count.walls_missing.nearest", 1);
1362 return Ok(class == PointClass::On);
1363 }
1364 Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
1365 };
1366 build_timing.stop();
1367 const STATIONS: usize = 9;
1368 let mut missing = Vec::new();
1369 for &index in selected {
1370 let point = edge_points[index];
1371 let edge_id = resolve_edge_by_point(original, point)?;
1372 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
1373 let mut best: Option<(Vec3, f64)> = None;
1375 let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
1377 for step in 1..=STATIONS {
1379 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
1380 let Ok(station) = edge.curve.evaluate(t) else {
1381 continue;
1382 };
1383 let clearance = match faces[..] {
1384 [first, second] => {
1385 let (Ok((_, a)), Ok((_, b))) = (
1386 super::analyze::face_plane_normal(first, station),
1387 super::analyze::face_plane_normal(second, station),
1388 ) else {
1389 continue;
1390 };
1391 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
1392 if chamfer {
1393 size * half.sin()
1394 } else {
1395 size * (1.0 / half.cos().max(1e-12) - 1.0)
1396 }
1397 }
1398 _ => f64::INFINITY,
1400 };
1401 if best.map_or(true, |(_, known)| clearance > known) {
1402 best = Some((station, clearance));
1403 }
1404 read.push((station, clearance));
1406 }
1408 let Some((station, clearance)) = best else {
1409 return Err(KernelRefusal::internal(
1410 KernelStage::Validate,
1411 "wall_attribution_stations",
1412 format!(
1413 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
1414 station of it could be evaluated",
1415 point.x, point.y, point.z
1416 ),
1417 ));
1418 };
1419 if !(clearance > visible) {
1420 return Err(KernelRefusal::ill_posed(
1424 KernelStage::Validate,
1425 "wall_attribution_band",
1426 format!(
1427 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
1428 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
1429 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
1430 selection is refused rather than reported on a guess",
1431 point.x, point.y, point.z
1432 ),
1433 ));
1434 }
1435 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
1436 let certified = station_clear_of_skin(result, station, band * 10.0, visible)
1443 || station_clear_of_trimmed_skin(result, station, band * 10.0, visible);
1444 let on = if certified {
1445 crate::blend::record_count("count.walls_missing.certified", 1);
1446 false
1447 } else {
1448 crate::blend::record_count("count.walls_missing.classified", 1);
1449 reads_on(station)?
1450 };
1451 classify_timing.stop();
1452 let on = on && (!tie_majority || {
1454 let tolerance = 1e-12 * clearance.abs().max(1.0);
1457 let (mut tied, mut on_skin) = (1usize, 1usize);
1458 for (other, other_clearance) in read.iter().copied() {
1459 if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
1460 continue;
1461 }
1462 tied += 1;
1463 if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
1464 on_skin += 1;
1465 }
1466 }
1467 2 * on_skin > tied
1468 });
1469 if on {
1471 missing.push(index);
1472 }
1473 }
1474 crate::classification::classify_profile_report("blend.walls_missing");
1475 crate::classification::containment_profile_report("blend.walls_missing");
1476 Ok(missing)
1477}
1478
1479
1480
1481fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
1511 use crate::AnalyticSurface;
1512 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
1515 let offset = station.sub(frame.origin);
1516 let axial = offset.dot(frame.axis);
1517 let radial = offset.sub(frame.axis.scale(axial));
1518 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
1519 if azimuth < 0.0 {
1520 azimuth += std::f64::consts::TAU;
1521 }
1522 (radial.length(), axial, azimuth)
1523 };
1524 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
1528 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
1529 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
1530 return 0.0;
1531 }
1532 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
1534 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
1535 };
1536 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
1539 let (du, dv) = (rho1 - rho0, h1 - h0);
1540 let length = (du * du + dv * dv).sqrt();
1541 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
1542 };
1543 let clear = |distance: f64| distance.is_finite() && distance > margin;
1544 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1545 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
1546 if !controls.expanded(on_band).contains(station) {
1547 continue;
1548 }
1549 let certified = match face.surface.analytic() {
1550 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
1551 let (radial, axial, _) = meridian(frame);
1552 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
1553 clear(line.max(axial_clearance(axial, 0.0, *height)))
1554 }
1555 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
1556 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
1557 let (radial, axial, _) = meridian(frame);
1558 let tube = radial - major_radius;
1559 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
1560 }
1561 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
1562 let [a, b] = generatrix.control_points.as_slice() else { return false };
1569 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1570 return false;
1571 }
1572 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
1573 let offset = point.sub(frame.origin);
1574 let axial = offset.dot(frame.axis);
1575 let radial = offset.sub(frame.axis.scale(axial));
1576 let rho = radial.length();
1577 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);
1578 on_meridian.then_some((rho, axial))
1579 };
1580 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 {
1581 return false;
1582 };
1583 let (radial, axial, azimuth) = meridian(frame);
1584 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
1585 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
1590 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
1591 }
1592 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
1593 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
1594 if clear(station.sub(*origin).dot(normal).abs()) {
1595 true
1596 } else {
1597 planar_trims_clear_of(face, plane, station, on_band, margin)
1598 }
1599 }
1600 None => false,
1601 };
1602 if !certified {
1603 return false;
1604 }
1605 }
1606 true
1607}
1608
1609fn station_clear_of_trimmed_skin(result: &BrepSolid, point: Vec3, on_band: f64, margin: f64) -> bool {
1615 let _timer = crate::blend::BlendTimer::start("timing.walls_missing.trimmed_bound_ms");
1616 if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1617 || !margin.is_finite() || !(margin > on_band && on_band > 0.0)
1618 { return false; }
1619 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1620 let Ok(bounds) = crate::Aabb::from_surface_controls(&face.surface) else { return false; };
1621 if !bounds.expanded(margin).contains(point) { continue; }
1622 if face.surface.analytic().is_none() {
1623 if general_carrier_clear(&face.surface, point, margin) { continue; }
1624 return false;
1625 }
1626 match face.surface.analytic() {
1632 Some(crate::AnalyticSurface::Plane { .. }) => {},
1633 Some(crate::AnalyticSurface::Sphere { radius, .. }) if *radius > 4.0 * margin => {},
1634 Some(crate::AnalyticSurface::Torus { major_radius, minor_radius, .. })
1635 if *minor_radius > 4.0 * margin && *major_radius > *minor_radius + 4.0 * margin => {},
1636 _ => return false,
1637 }
1638 let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else { return false; };
1639 if !projection.distance.is_finite() { return false; }
1640 if projection.distance > margin { continue; }
1641 let band = crate::classification::surface_uv_band(&face.surface, projection.u, projection.v, on_band);
1642 let Ok(inside) = crate::parameter_point_in_face(face, crate::Vec2 { x: projection.u, y: projection.v }, band) else { return false; };
1643 if inside != crate::PolygonClass::Outside { return false; }
1644 let mut edges = 0;
1645 for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
1646 let Some(edge) = result.edges.iter().find(|edge| edge.id == coedge.edge_id) else { return false; };
1647 let Some(piece) = super::analyze::edge_piece(&edge.curve, edge.t0.min(edge.t1), edge.t0.max(edge.t1)) else { return false; };
1648 let Ok(foot) = crate::project_point_to_curve(&piece, point) else { return false; };
1649 if !foot.distance.is_finite() || foot.distance <= margin { return false; }
1650 edges += 1;
1651 }
1652 if edges == 0 { return false; }
1653 }
1654 true
1655}
1656
1657fn general_carrier_clear(surface: &crate::NurbsSurface, point: Vec3, margin: f64) -> bool {
1663 use crate::Vec4;
1664 let rows = surface.control_points.len();
1665 let columns = surface.control_points.first().map_or(0, Vec::len);
1666 if rows == 0 || columns == 0 || rows.saturating_mul(columns) > 4096
1667 || surface.degree_u > 16 || surface.degree_v > 16 || surface.control_points.iter().any(|row| row.len() != columns)
1668 || !margin.is_finite() || margin <= 0.0
1669 || !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite()
1670 { return false; }
1671 let mut scale = 1.0_f64;
1672 for control in surface.control_points.iter().flatten() {
1673 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1674 let Ok(p) = control.point() else { return false; };
1675 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1676 scale = scale.max(p.x.abs()).max(p.y.abs()).max(p.z.abs());
1677 }
1678 let roundoff = 256.0 * f64::EPSILON * scale * (surface.degree_u + 1) as f64 * (surface.degree_v + 1) as f64;
1681 if roundoff >= margin * 0.01 { return false; }
1682 let zero = Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
1683 let blend = |a: Vec4, b: Vec4, alpha: f64| a.scale(alpha).add(b.scale(1.0 - alpha));
1684 let mut fibres = Vec::with_capacity(columns);
1685 for column in 0..columns {
1686 let controls: Vec<_> = surface.control_points.iter().map(|row| row[column]).collect();
1687 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_u, &controls, surface.degree_u, zero, blend) else { return false; };
1688 fibres.push(spans);
1689 }
1690 let mut budget = 256usize;
1691 for u_span in 0..fibres[0].len() {
1692 let mut across = Vec::with_capacity(surface.degree_u + 1);
1693 for row in 0..=surface.degree_u {
1694 let controls: Vec<_> = fibres.iter().map(|fibre| fibre[u_span][row]).collect();
1695 let Some(spans) = crate::curve::bezier_decompose_raw(&surface.knots_v, &controls, surface.degree_v, zero, blend) else { return false; };
1696 across.push(spans);
1697 }
1698 for v_span in 0..across[0].len() {
1699 let net = across.iter().map(|row| row[v_span].clone()).collect();
1700 if !bezier_box_clear(net, point, margin + roundoff, 0, &mut budget) { return false; }
1701 }
1702 }
1703 true
1704}
1705
1706fn bezier_box_clear(net: Vec<Vec<crate::Vec4>>, point: Vec3, margin: f64, depth: usize, budget: &mut usize) -> bool {
1707 if *budget == 0 { return false; }
1708 *budget -= 1;
1709 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1710 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1711 for control in net.iter().flatten() {
1712 if !(control.w > 0.0) || !control.w.is_finite() { return false; }
1713 let Ok(p) = control.point() else { return false; };
1714 if !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() { return false; }
1715 low.x = low.x.min(p.x); low.y = low.y.min(p.y); low.z = low.z.min(p.z);
1716 high.x = high.x.max(p.x); high.y = high.y.max(p.y); high.z = high.z.max(p.z);
1717 }
1718 if point.x < low.x - margin || point.x > high.x + margin
1720 || point.y < low.y - margin || point.y > high.y + margin
1721 || point.z < low.z - margin || point.z > high.z + margin
1722 { return true; }
1723 if depth == 10 { return false; }
1724 let rows = net.len();
1725 let columns = net[0].len();
1726 let mut left = net.clone();
1727 let mut right = net.clone();
1728 if depth % 2 == 0 {
1731 for column in 0..columns {
1732 let mut work: Vec<_> = net.iter().map(|row| row[column]).collect();
1733 for level in 1..rows {
1734 for i in 0..rows - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1735 left[level][column] = work[0];
1736 right[rows - level - 1][column] = work[rows - level - 1];
1737 }
1738 }
1739 } else {
1740 for row in 0..rows {
1741 let mut work = net[row].clone();
1742 for level in 1..columns {
1743 for i in 0..columns - level { work[i] = work[i].scale(0.5).add(work[i + 1].scale(0.5)); }
1744 left[row][level] = work[0];
1745 right[row][columns - level - 1] = work[columns - level - 1];
1746 }
1747 }
1748 }
1749 bezier_box_clear(left, point, margin, depth + 1, budget)
1750 && bezier_box_clear(right, point, margin, depth + 1, budget)
1751}
1752
1753fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
1763 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
1764 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1765 let pcurve = &coedge.pcurve;
1766 let [a, b] = pcurve.control_points.as_slice() else { return None };
1767 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1768 return None;
1769 }
1770 for control in [a, b] {
1771 u_low = u_low.min(control.x);
1772 u_high = u_high.max(control.x);
1773 v_low = v_low.min(control.y);
1774 v_high = v_high.max(control.y);
1775 }
1776 }
1777 if !(u_low <= u_high && v_low <= v_high) {
1778 return None;
1779 }
1780 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
1781 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)?];
1782 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
1783 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
1784 let surface = &face.surface;
1785 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
1787 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
1788 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
1789 && surface.degree_u == 1 && surface.control_points.len() == 2;
1790 if !(affine_in_v || affine_in_u) {
1791 return None;
1792 }
1793 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
1794 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1795 (low.is_finite() && high.is_finite()).then_some((low, high))
1796}
1797
1798fn planar_trims_clear_of(
1802 face: &FaceRecord,
1803 plane: &crate::AnalyticSurface,
1804 station: Vec3,
1805 on_band: f64,
1806 margin: f64,
1807) -> bool {
1808 use crate::PolygonClass;
1809 let Some(foot) = plane.project(&face.surface, station) else { return false };
1810 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
1811 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
1812 Ok(PolygonClass::Outside) => {}
1813 _ => return false,
1814 }
1815 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1816 let pcurve = &coedge.pcurve;
1817 let [a, b] = pcurve.control_points.as_slice() else { return false };
1818 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1819 return false;
1820 }
1821 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
1822 let chord = end.sub(start);
1823 let length_squared = chord.dot(chord);
1824 let offset = station.sub(start);
1825 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
1826 let distance = offset.sub(chord.scale(along)).length();
1827 if !(distance.is_finite() && distance > margin) {
1828 return false;
1829 }
1830 }
1831 true
1832}
1833
1834fn per_edge_name<'a>(
1839 edge_names: Option<&'a [String]>,
1840 base: Option<&'a str>,
1841 i: usize,
1842) -> Option<&'a str> {
1843 match edge_names {
1844 Some(names) => names.get(i).map(|value| value.as_str()),
1845 None => base,
1846 }
1847}
1848
1849fn corner_face_name(
1857 edge_names: Option<&[String]>,
1858 base: Option<&str>,
1859 adjacent: &[usize],
1860) -> Option<String> {
1861 match (edge_names, base) {
1862 (Some(names), Some(base)) => {
1863 let prefix = format!("{base}:");
1864 let mut raws: Vec<&str> = adjacent
1865 .iter()
1866 .filter_map(|&i| names.get(i))
1867 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
1868 .collect();
1869 raws.sort_unstable();
1870 raws.dedup();
1871 Some(format!("{base}:CORNER:{}", raws.join("+")))
1872 }
1873 _ => base.map(|value| value.to_string()),
1874 }
1875}
1876
1877#[allow(clippy::too_many_arguments)]
1887fn rounded_boss_composition(
1888 solid: &BrepSolid,
1889 selected_ids: &[u64],
1890 edge_points: &[Vec3],
1891 edge_names: Option<&[String]>,
1892 radius: f64,
1893 name: Option<&str>,
1894 corners: &[crate::blend::MixedCorner],
1895) -> Result<BrepSolid, KernelRefusal> {
1896 if edge_names.is_none() {
1901 let prefix = "FILLET:COMPOSITION";
1902 let names: Vec<_> = selected_ids
1903 .iter()
1904 .map(|id| format!("{prefix}:{id}"))
1905 .collect();
1906 let mut result = rounded_boss_composition(
1907 solid,
1908 selected_ids,
1909 edge_points,
1910 Some(&names),
1911 radius,
1912 Some(prefix),
1913 corners,
1914 )?;
1915 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1916 if face
1917 .name
1918 .as_deref()
1919 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1920 {
1921 face.name = name.map(str::to_owned);
1922 }
1923 }
1924 return Ok(result);
1925 }
1926 let decline = || {
1927 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1928 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1929 };
1930 if std::env::var("BREP_NO_NETWORK").is_ok() {
1931 return Err(decline());
1932 }
1933 let corner = corners
1936 .iter()
1937 .filter(|corner| {
1938 corner.convex.len() == 1
1939 && corner.concave.len() == 2
1940 && corners
1941 .iter()
1942 .filter(|other| other.convex.contains(&corner.convex[0]))
1943 .count()
1944 == 1
1945 })
1946 .min_by_key(|corner| selected_ids[corner.convex[0]])
1947 .ok_or_else(decline)?;
1948 let convexity =
1949 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1950 if !convexity[corner.convex[0]] {
1951 return Err(decline());
1952 }
1953 let spine_index = corner.convex[0];
1954 let selected_edges: Vec<&EdgeRecord> = selected_ids
1957 .iter()
1958 .map(|id| {
1959 solid
1960 .edges
1961 .iter()
1962 .find(|edge| edge.id == *id)
1963 .ok_or_else(|| {
1964 KernelRefusal::internal(
1965 KernelStage::Collect,
1966 "rounded_boss_selected_edge",
1967 format!("rounded boss composition: selected edge {id} is missing"),
1968 )
1969 })
1970 })
1971 .collect::<Result<_, _>>()?;
1972 let mut rounded_indices = vec![spine_index];
1975 loop {
1976 let next = (0..selected_ids.len()).find(|index| {
1977 convexity[*index]
1978 && !rounded_indices.contains(index)
1979 && rounded_indices.iter().any(|other| {
1980 let edge = selected_edges[*index];
1981 let mate = selected_edges[*other];
1982 [edge.start_vertex_id, edge.end_vertex_id]
1983 .iter()
1984 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1985 })
1986 });
1987 match next {
1988 Some(index) => rounded_indices.push(index),
1989 None => break,
1990 }
1991 }
1992 loop {
2001 let next = (0..selected_ids.len()).find(|&index| {
2002 if convexity[index] || rounded_indices.contains(&index) {
2003 return false;
2004 }
2005 let edge = selected_edges[index];
2006 let mut has_star = false;
2007 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
2008 let neighbours: Vec<_> = selected_edges.iter().enumerate()
2009 .filter(|(other, mate)| *other != index
2010 && (mate.start_vertex_id == vertex || mate.end_vertex_id == vertex))
2011 .map(|(other, _)| other)
2012 .collect();
2013 if neighbours.iter().any(|other| !rounded_indices.contains(other)) {
2014 return false;
2015 }
2016 has_star |= neighbours.len() >= 2;
2017 }
2018 has_star
2019 });
2020 match next {
2021 Some(index) => rounded_indices.push(index),
2022 None => break,
2023 }
2024 }
2025 rounded_indices.sort_by_key(|index| selected_ids[*index]);
2026 let mixed: Vec<_> = corners
2027 .iter()
2028 .filter(|corner| {
2029 corner
2030 .convex
2031 .iter()
2032 .any(|index| rounded_indices.contains(index))
2033 })
2034 .collect();
2035 if mixed.iter().any(|corner| {
2036 corner.convex.len() != 1
2037 || corner.concave.len() != 2
2038 || corners
2039 .iter()
2040 .filter(|other| other.convex.contains(&corner.convex[0]))
2041 .count()
2042 != 1
2043 }) {
2044 return Err(decline());
2045 }
2046 let uses = |face: &FaceRecord, edge: u64| {
2047 face.loops
2048 .iter()
2049 .flat_map(|rim| &rim.coedges)
2050 .any(|coedge| coedge.edge_id == edge)
2051 };
2052 let mut caps = Vec::new();
2053 for corner in &mixed {
2054 let spine_id = selected_ids[corner.convex[0]];
2055 let cap = solid
2056 .shells
2057 .iter()
2058 .flat_map(|shell| &shell.faces)
2059 .find(|face| {
2060 corner
2061 .concave
2062 .iter()
2063 .all(|index| uses(face, selected_ids[*index]))
2064 })
2065 .ok_or_else(decline)?;
2066 let faces: Vec<_> = solid
2067 .shells
2068 .iter()
2069 .flat_map(|shell| &shell.faces)
2070 .filter(|face| face.id == cap.id || uses(face, spine_id))
2071 .collect();
2072 let normals: Option<Vec<Vec3>> = faces
2081 .iter()
2082 .map(|face| match face.surface.analytic() {
2083 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
2084 u_dir.cross(*v_dir).normalized().ok()
2085 }
2086 _ => {
2087 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
2088 face.surface.normal(uv.u, uv.v).ok()
2089 }
2090 })
2091 .collect();
2092 let normals = normals.ok_or_else(decline)?;
2093 if normals.len() != 3
2099 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
2100 {
2101 return Err(decline());
2102 }
2103 caps.push(cap.id);
2104 }
2105 let rounded_names: Vec<String> = rounded_indices
2108 .iter()
2109 .map(|index| {
2110 per_edge_name(edge_names, name, *index)
2111 .map(str::to_owned)
2112 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
2113 })
2114 .collect();
2115 let rounded_points: Vec<Vec3> = rounded_indices
2116 .iter()
2117 .map(|index| edge_points[*index])
2118 .collect();
2119 let rounded = fillet_edges_group_in(
2120 solid,
2121 &rounded_points,
2122 Some(&rounded_names),
2123 radius,
2124 false,
2125 name,
2126 None,
2127 )?;
2128 let mut bridges = Vec::new();
2129 for (corner, cap) in mixed.iter().zip(&caps) {
2130 let wall_index = rounded_indices
2131 .iter()
2132 .position(|index| *index == corner.convex[0])
2133 .ok_or_else(|| {
2134 KernelRefusal::internal(
2135 KernelStage::Classify,
2136 "rounded_boss_bridge_wall",
2137 format!(
2138 "rounded boss composition: no convex wall for corner {}",
2139 corner.vertex
2140 ),
2141 )
2142 })?;
2143 let candidates: Vec<_> = rounded
2144 .edges
2145 .iter()
2146 .filter(|edge| {
2147 let faces: Vec<_> = rounded
2148 .shells
2149 .iter()
2150 .flat_map(|shell| &shell.faces)
2151 .filter(|face| uses(face, edge.id))
2152 .collect();
2153 faces.iter().any(|face| face.id == *cap)
2154 && faces
2155 .iter()
2156 .any(|face| face.name.as_deref() == Some(&rounded_names[wall_index]))
2157 })
2158 .collect();
2159 if candidates.len() != 1 {
2160 return Err(decline());
2161 }
2162 bridges.push(candidates[0]);
2163 }
2164 let rim_ids: Vec<u64> = selected_ids
2165 .iter()
2166 .enumerate()
2167 .filter(|(index, _)| !rounded_indices.contains(index))
2168 .map(|(_, id)| *id)
2169 .collect();
2170 if !bridges.iter().all(|bridge| {
2173 [bridge.start_vertex_id, bridge.end_vertex_id]
2174 .iter()
2175 .all(|vertex| {
2176 rounded.edges.iter().any(|edge| {
2177 rim_ids.contains(&edge.id)
2178 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
2179 })
2180 })
2181 }) {
2182 return Err(decline());
2183 }
2184 let mut points = Vec::new();
2185 let mut names = Vec::new();
2186 for (index, id) in selected_ids.iter().enumerate() {
2187 if rounded_indices.contains(&index) {
2188 continue;
2189 }
2190 let edge = rounded
2191 .edges
2192 .iter()
2193 .find(|edge| edge.id == *id)
2194 .ok_or_else(decline)?;
2195 points.push(
2196 edge.curve
2197 .evaluate(0.5 * (edge.t0 + edge.t1))
2198 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
2199 );
2200 names.push(
2201 per_edge_name(edge_names, name, index)
2202 .unwrap_or("FILLET:RIM")
2203 .to_owned(),
2204 );
2205 }
2206 for (corner, bridge) in mixed.iter().zip(&bridges) {
2207 points.push(
2208 bridge
2209 .curve
2210 .evaluate(0.5 * (bridge.t0 + bridge.t1))
2211 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
2212 );
2213 let mut adjacent = corner.concave.clone();
2214 adjacent.push(corner.convex[0]);
2215 names.push(
2216 corner_face_name(edge_names, name, &adjacent)
2217 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
2218 );
2219 }
2220 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
2223 |(result, selection)| {
2224 if selection.applied.len() != points.len() {
2225 Err(decline())
2226 } else {
2227 Ok(result)
2228 }
2229 },
2230 )
2231}
2232
2233#[allow(clippy::too_many_arguments)]
2266fn open_pad_on() -> bool {
2268 switch!(live "BREP_BLEND_OPEN_PAD", true)
2269}
2270
2271#[allow(clippy::too_many_arguments)]
2300fn open_pad_composition(
2301 solid: &BrepSolid,
2302 selected_ids: &[u64],
2303 edge_points: &[Vec3],
2304 edge_names: Option<&[String]>,
2305 radius: f64,
2306 name: Option<&str>,
2307 corners: &[crate::blend::MixedCorner],
2308 entry: &str,
2309) -> Result<BrepSolid, KernelRefusal> {
2310 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
2311 if !open_pad_on() {
2312 return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
2313 }
2314 if std::env::var("BREP_NO_NETWORK").is_ok() {
2315 return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
2316 }
2317 if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
2318 return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
2319 }
2320 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2321 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2322 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2323 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2324 if concave.is_empty() || convex.is_empty() {
2325 return Err(shape("the open-pad composition needs convex and concave edges".into()));
2326 }
2327 let mates = crate::blend::selection_mates(solid, selected_ids, radius)
2330 .map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
2331 for corner in corners {
2332 let (_, convex_faces) = &mates[corner.convex[0]];
2333 let (_, concave_faces) = &mates[corner.concave[0]];
2334 let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
2335 return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
2336 };
2337 if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
2338 return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
2339 }
2340 }
2341 for &index in &concave {
2342 let edge = mates[index].0;
2343 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
2344 if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
2345 return Err(shape(format!(
2346 "concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
2347 selection_label(index, edge_points, edge_names, name)
2348 )));
2349 }
2350 }
2351 }
2352 let base = name.unwrap_or("FILLET:OPEN_PAD");
2353 let bare = |index: usize| -> String {
2354 per_edge_name(edge_names, name, index)
2355 .map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
2356 .unwrap_or_else(|| selected_ids[index].to_string())
2357 };
2358 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2359
2360 let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
2362 let convex_names: Vec<String> = convex
2363 .iter()
2364 .map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
2365 .collect();
2366 let stage1 = crate::blend::with_open_pad_network(|| {
2375 fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
2376 })
2377 .map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
2378 if debug {
2379 eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
2380 }
2381
2382 let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
2384 let mut points: Vec<Vec3> = Vec::new();
2385 let mut names: Vec<String> = Vec::new();
2386 for &index in &concave {
2387 let (edge, faces) = &mates[index];
2388 let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
2389 let pad = stage1
2390 .edges
2391 .iter()
2392 .find(|candidate| candidate.id == edge.id)
2393 .ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
2394 "open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
2395 )))?;
2396 points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
2397 names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
2398 for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
2401 let trims: Vec<&EdgeRecord> = stage1
2402 .edges
2403 .iter()
2404 .filter(|candidate| {
2405 candidate.id != pad.id
2406 && !candidate.degenerate
2407 && (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
2408 })
2409 .filter(|candidate| {
2410 let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
2411 faces.iter().any(|face| face.id == boss_id)
2412 && faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
2413 })
2414 .collect();
2415 match trims.as_slice() {
2416 [trim] => {
2417 let wall_name = stage1
2418 .shells
2419 .iter()
2420 .flat_map(|shell| &shell.faces)
2421 .filter(|face| uses(face, trim.id))
2422 .find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
2423 .unwrap_or_default();
2424 let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
2425 points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
2426 names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
2427 if debug {
2428 eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
2429 }
2430 }
2431 [] => {
2432 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2433 "open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
2434 selection_label(index, edge_points, edge_names, name)
2435 )));
2436 }
2437 _ => {
2438 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
2439 "open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
2440 selection_label(index, edge_points, edge_names, name), trims.len()
2441 )));
2442 }
2443 }
2444 }
2445 }
2446 let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
2447 .map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
2448 let _ = entry;
2449 Ok(result)
2450}
2451
2452fn cusp_composition(
2472 solid: &BrepSolid,
2473 selected_ids: &[u64],
2474 edge_points: &[Vec3],
2475 edge_names: Option<&[String]>,
2476 radius: f64,
2477 name: Option<&str>,
2478 corners: &[crate::blend::MixedCorner],
2479) -> Result<BrepSolid, KernelRefusal> {
2480 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "cusp_composition_shape", message);
2481 if std::env::var("BREP_NO_NETWORK").is_ok() {
2482 return Err(shape("the cusp composition is a network construction and BREP_NO_NETWORK is set".into()));
2483 }
2484 let edge_of = |index: usize| solid.edges.iter().find(|edge| edge.id == selected_ids[index]);
2485 let away = |index: usize, vertex: u64| -> Option<Vec3> {
2487 let edge = edge_of(index)?;
2488 let at_start = edge.start_vertex_id == vertex;
2489 let tangent = edge.curve.unit_tangent(if at_start { edge.t0 } else { edge.t1 }, edge.t0, edge.t1).ok()?;
2490 Some(if at_start { tangent } else { tangent.scale(-1.0) })
2491 };
2492 if corners.is_empty() {
2493 return Err(shape("no mixed corners".into()));
2494 }
2495 for corner in corners {
2496 let cusped = corner.convex.iter().all(|&convex| {
2497 corner.concave.iter().any(|&concave| {
2498 matches!((away(convex, corner.vertex), away(concave, corner.vertex)), (Some(a), Some(b)) if a.dot(b) >= 1.0 - 1e-6)
2499 })
2500 });
2501 if !cusped || corner.convex.is_empty() || corner.concave.is_empty() {
2502 return Err(shape(format!("the mixed corner at vertex {} is not a cusp", corner.vertex)));
2503 }
2504 }
2505 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2506 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
2507 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
2508 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
2509 if concave.is_empty() || convex.is_empty() {
2510 return Err(shape("the cusp composition needs convex and concave edges".into()));
2511 }
2512 let names_of = |indices: &[usize]| -> Option<Vec<String>> { edge_names.map(|names| indices.iter().map(|index| names[*index].clone()).collect()) };
2513 let points_of = |indices: &[usize]| -> Vec<Vec3> { indices.iter().map(|index| edge_points[*index]).collect() };
2514 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
2515 let stage1 = fillet_edges_group_in(solid, &points_of(&concave), names_of(&concave).as_deref(), radius, false, name, None)
2516 .map_err(|error| error.with_message(|error| format!("cusp composition, the concave rounds: {error}")))?;
2517 if debug {
2518 eprintln!("cusp composition: stage 1 built the {} concave rounds", concave.len());
2519 }
2520 for &index in &convex {
2523 resolve_edge_by_point(&stage1, edge_points[index])
2524 .map_err(|error| error.with_message(|error| format!("cusp composition: a convex edge is not on the stage-1 body: {error}")))?;
2525 }
2526 let result = fillet_edges_group_in(&stage1, &points_of(&convex), names_of(&convex).as_deref(), radius, false, name, None)
2527 .map_err(|error| error.with_message(|error| format!("cusp composition, the convex rounds: {error}")))?;
2528 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(true));
2534 Ok(result)
2535}
2536
2537fn runout_composition(
2538 solid: &BrepSolid,
2539 selected_ids: &[u64],
2540 edge_points: &[Vec3],
2541 edge_names: Option<&[String]>,
2542 radius: f64,
2543 name: Option<&str>,
2544 corners: &[crate::blend::MixedCorner],
2545 entry: &str,
2546) -> Result<BrepSolid, KernelRefusal> {
2547 if std::env::var("BREP_NO_NETWORK").is_ok() {
2548 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
2549 }
2550 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
2551 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
2552 let mut spine_ends = vec![0usize; selected_ids.len()];
2553 for corner in corners {
2554 if corner.convex.len() != 1 {
2555 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
2556 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
2557 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
2558 )));
2559 }
2560 spine_ends[corner.convex[0]] += 1;
2561 }
2562 let mut spines: Vec<usize> = Vec::new();
2563 let mut concave: Vec<usize> = Vec::new();
2564 for (index, ends) in spine_ends.iter().enumerate() {
2565 match (*ends, convexity[index]) {
2566 (2, true) => spines.push(index),
2567 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
2568 (0, false) => concave.push(index),
2569 (ends, convex) => {
2570 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
2571 "{} 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",
2572 selection_label(index, edge_points, edge_names, name),
2573 if convex { "convex" } else { "concave" },
2574 )));
2575 }
2576 }
2577 }
2578 if concave.is_empty() {
2579 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
2580 }
2581 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
2583 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
2584 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
2585 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
2586 .map_err(|error| {
2587 let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2593 match network {
2594 Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
2595 "the concave ring did not build: {network} (the cutter fallback then refused: {error})"
2596 )),
2597 _ => error.with_message(|error| format!("the concave ring did not build: {error}")),
2598 }
2599 })?;
2600 let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
2609 let ring_is_network = network_declined.is_none();
2611 if let Some(network) = network_declined {
2613 if crate::blend::is_reentrant_concave(&network) {
2614 return Err(network.with_message(|network| format!(
2615 "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"
2616 )));
2617 }
2618 }
2619 let spine_walls: Vec<(u64, Option<String>)> = spines
2621 .iter()
2622 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
2623 .collect();
2624 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
2625 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
2626 heal_edge_vertex_gaps(&mut result, radius)?;
2627 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
2628 let issues = result.validate();
2629 if !issues.is_empty() {
2630 return Err(KernelRefusal::new(
2631 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
2632 KernelStage::Validate,
2633 format!("the runout walls do not validate: {issues:?}"),
2634 ));
2635 }
2636 check_blend_interference(&ring, &result, &spine_ids, entry)?;
2637 check_loop_self_crossings(&result, entry)?;
2638 check_blend_wall_loops(&result, entry)?;
2639 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2640 let accepted = crate::accept_sound(result, entry)?;
2641 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
2643 Ok(accepted)
2645}
2646
2647thread_local! {
2649 static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2654}
2655fn check_mixed_corner_convexity(
2658 solid: &BrepSolid,
2659 edge_ids: &[u64],
2660 edge_points: &[Vec3],
2661 edge_names: Option<&[String]>,
2662 radius: f64,
2663 name: Option<&str>,
2664 composition_error: Option<&KernelRefusal>,
2665) -> Result<(), KernelRefusal> {
2666 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
2667 Some(corner) => {
2668 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
2674 Ok(plan) => Some(plan),
2675 Err(error) => {
2676 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2677 eprintln!("runout not measured: {error}");
2678 }
2679 None
2680 }
2681 };
2682 let mut message = mixed_corner_message(
2685 &corner,
2686 edge_points,
2687 edge_names,
2688 name,
2689 measured.as_ref(),
2690 );
2691 if let Some(error) = composition_error {
2692 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
2693 }
2694 Err(KernelRefusal::unsupported(
2695 KernelStage::Classify,
2696 "mixed_corner_convexity",
2697 message,
2698 ))
2699 }
2700 None => Ok(()),
2701 }
2702}
2703
2704fn check_degenerate_corner_setbacks(
2707 solid: &BrepSolid,
2708 edge_ids: &[u64],
2709 edge_points: &[Vec3],
2710 edge_names: Option<&[String]>,
2711 radius: f64,
2712 name: Option<&str>,
2713) -> Result<(), KernelRefusal> {
2714 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
2715 return Ok(());
2716 };
2717 let [from, to] = setback.stops;
2718 let how = if setback.crossed {
2719 format!(
2720 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
2721 setback.strip
2722 )
2723 } else {
2724 format!(
2725 "meet there ({:.3e} apart against a bar of {:.3e})",
2726 setback.strip, setback.bar
2727 )
2728 };
2729 Err(KernelRefusal::input(
2732 KernelStage::Classify,
2733 "degenerate_corner_setbacks",
2734 format!(
2735 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
2736 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
2737 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
2738 on are consumed whole. The radius is at or past the degenerate limit for this \
2739 selection; reduce it.",
2740 selection_label(setback.edge, edge_points, edge_names, name),
2741 setback.face_id,
2742 from.x,
2743 from.y,
2744 from.z,
2745 to.x,
2746 to.y,
2747 to.z,
2748 ),
2749 ))
2750}
2751
2752fn selection_label(
2756 index: usize,
2757 edge_points: &[Vec3],
2758 edge_names: Option<&[String]>,
2759 name: Option<&str>,
2760) -> String {
2761 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
2762 Some(base) => composed
2763 .strip_prefix(&format!("{base}:"))
2764 .unwrap_or(composed)
2765 .to_string(),
2766 None => composed.clone(),
2767 });
2768 named.unwrap_or_else(|| match edge_points.get(index) {
2769 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
2770 None => format!("selection #{}", index + 1),
2771 })
2772}
2773
2774fn mixed_corner_message(
2778 corner: &crate::blend::MixedCorner,
2779 edge_points: &[Vec3],
2780 edge_names: Option<&[String]>,
2781 name: Option<&str>,
2782 measured: Option<&crate::blend::RunoutPlan>,
2783) -> String {
2784 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
2785 let list = |indices: &[usize]| -> String {
2786 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
2787 match parts.split_last() {
2788 None => "none".to_string(),
2789 Some((last, [])) => last.clone(),
2790 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
2791 }
2792 };
2793 let mut message = format!(
2794 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
2795 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
2796 sides at once, so that corner has no closure: the convex blend runs out against the \
2797 concave ones partway along the edge, and this kernel does not build that runout yet. \
2798 Blending the concave edges in one fillet and the convex ones in another does not reach \
2799 a wall on every one of them either: the convex wall comes out at FULL length in either \
2800 order, and its contact leaves the faces it runs along where their own loops would then \
2801 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
2802 whose wall runs out into them; blend the convex ones first and the subset search drops \
2803 the concave edges it cannot compose instead, leaving those unfilleted.",
2804 corner.point.x,
2805 corner.point.y,
2806 corner.point.z,
2807 list(&corner.convex),
2808 list(&corner.concave),
2809 );
2810 if let Some(plan) = measured {
2815 message.push_str(&format!(
2816 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
2817 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
2818 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
2819 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
2820 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
2821 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
2822 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
2823 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
2824 the bar a march is refused at is a full span.",
2825 plan.convex_edge,
2826 plan.shared_face,
2827 plan.station.s,
2828 plan.station.center.x,
2829 plan.station.center.y,
2830 plan.station.center.z,
2831 plan.replaced_mate,
2832 plan.second_carrier,
2833 plan.station.stripe_residual,
2834 plan.second_carrier,
2835 plan.marched,
2836 plan.fit_residual,
2837 plan.carrier_excursion,
2838 ));
2839 match (&plan.stop, plan.first_carrier) {
2840 (Some(stop), Some(first)) => message.push_str(&format!(
2841 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
2842 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
2843 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
2844 stop.s, plan.kept_mate, first, stop.stripe_residual,
2845 )),
2846 _ => message.push_str(&format!(
2847 " Where the kept mate (face {}) runs out was not measurable on this selection.",
2848 plan.kept_mate
2849 )),
2850 }
2851 if let Some(pole) = plan.pole {
2852 message.push_str(&format!(
2853 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
2854 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
2855 section shrinks to a point. Two carrier switches and a degenerate terminus is \
2856 more than a stop station on a stripe, and none of it is composed yet.",
2857 pole.x, pole.y, pole.z, plan.shared_face,
2858 ));
2859 }
2860 }
2861 message
2862}
2863
2864fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
2866 let mut out = Vec::new();
2867 if k == 0 || k > n {
2868 return out;
2869 }
2870 let mut idx: Vec<usize> = (0..k).collect();
2871 loop {
2872 out.push(idx.clone());
2873 let mut i = k;
2875 loop {
2876 if i == 0 {
2877 return out;
2878 }
2879 i -= 1;
2880 if idx[i] != i + n - k {
2881 break;
2882 }
2883 }
2884 idx[i] += 1;
2885 for j in (i + 1)..k {
2886 idx[j] = idx[j - 1] + 1;
2887 }
2888 }
2889}
2890
2891thread_local! {
2892 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
2898 const { std::cell::Cell::new(0) };
2899}
2900
2901fn fillet_edges_group(
2918 solid: &BrepSolid,
2919 edge_points: &[Vec3],
2920 edge_names: Option<&[String]>,
2921 radius: f64,
2922 chamfer: bool,
2923 name: Option<&str>,
2924) -> Result<BrepSolid, KernelRefusal> {
2925 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
2926}
2927
2928thread_local! {
2929 static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
2936}
2937
2938struct SubsetDropCap(Option<usize>);
2940
2941impl SubsetDropCap {
2942 fn set(cap: Option<usize>) -> Self {
2943 Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
2944 }
2945}
2946
2947impl Drop for SubsetDropCap {
2948 fn drop(&mut self) {
2949 SUBSET_DROP_CAP.with(|current| current.set(self.0));
2950 }
2951}
2952
2953thread_local! {
2954 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2957 static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2960 static GROUP_NETWORK_FOLD: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2964}
2965
2966type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
2969
2970type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
2977
2978#[derive(Default)]
3006struct ComponentMemo {
3007 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
3008 prefix: Vec<ComponentKey>,
3011 sequential: std::collections::HashMap<
3012 (Vec<ComponentKey>, Vec<SequentialKey>),
3013 Result<BrepSolid, KernelRefusal>,
3014 >,
3015}
3016
3017fn fillet_edges_group_in(
3022 solid: &BrepSolid,
3023 edge_points: &[Vec3],
3024 edge_names: Option<&[String]>,
3025 radius: f64,
3026 chamfer: bool,
3027 name: Option<&str>,
3028 memo: Option<&mut ComponentMemo>,
3029) -> Result<BrepSolid, KernelRefusal> {
3030 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
3031}
3032
3033fn fillet_edges_group_with_corner_policy(
3034 solid: &BrepSolid,
3035 edge_points: &[Vec3],
3036 edge_names: Option<&[String]>,
3037 radius: f64,
3038 chamfer: bool,
3039 name: Option<&str>,
3040 memo: Option<&mut ComponentMemo>,
3041 wrap_concave: bool,
3042 accept: bool,
3043) -> Result<BrepSolid, KernelRefusal> {
3044 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
3045 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3046 let GroupBody { body: result, bowtie_clean } =
3047 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
3048 if !accept {
3049 return Ok(result);
3050 }
3051 if bowtie_clean {
3056 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
3057 } else {
3058 check_loop_self_crossings(&result, entry)?;
3059 check_blend_wall_loops(&result, entry)?;
3060 }
3061 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
3062 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
3071 let accepted = crate::accept_sound(result, entry);
3072 soundness.stop();
3073 accepted
3074}
3075
3076struct GroupBody {
3081 body: BrepSolid,
3082 bowtie_clean: bool,
3083}
3084
3085fn fillet_edges_group_unchecked(
3092 solid: &BrepSolid,
3093 edge_points: &[Vec3],
3094 edge_names: Option<&[String]>,
3095 radius: f64,
3096 chamfer: bool,
3097 name: Option<&str>,
3098 memo: Option<&mut ComponentMemo>,
3099 wrap_concave: bool,
3100) -> Result<GroupBody, KernelRefusal> {
3101 let outer = GROUP_NETWORK_FOLD.with(|fold| fold.borrow_mut().take());
3102 let built = fillet_edges_group_lanes(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave);
3103 let fold = GROUP_NETWORK_FOLD.with(|fold| std::mem::replace(&mut *fold.borrow_mut(), outer));
3104 match (built, fold) {
3105 (Err(error), Some(fold)) if !crate::blend::is_wall_fold(&error) => Err(fold),
3106 (built, _) => built,
3107 }
3108}
3109
3110fn fillet_edges_group_lanes(
3111 solid: &BrepSolid,
3112 edge_points: &[Vec3],
3113 edge_names: Option<&[String]>,
3114 radius: f64,
3115 chamfer: bool,
3116 name: Option<&str>,
3117 mut memo: Option<&mut ComponentMemo>,
3118 wrap_concave: bool,
3119) -> Result<GroupBody, KernelRefusal> {
3120 use rustc_hash::FxHashSet as HashSet;
3121
3122 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
3129 let mut healed_input = solid.clone();
3130 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
3131 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
3132 let solid = &healed_input;
3133 heal.stop();
3134 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
3135
3136 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
3139 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
3142 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
3143 for (i, point) in edge_points.iter().enumerate() {
3144 let edge_id = resolve_edge_by_point(solid, *point)?;
3145 let edge = solid
3146 .edges
3147 .iter()
3148 .find(|e| e.id == edge_id)
3149 .ok_or_else(|| {
3150 KernelRefusal::internal(
3151 KernelStage::Collect,
3152 "resolved_edge",
3153 "fillet_edges: resolved edge vanished",
3154 )
3155 })?;
3156 let (start, end) = (
3157 edge.curve
3158 .evaluate(edge.t0)
3159 .or_refuse(KernelStage::Collect, "edge_start")?,
3160 edge.curve
3161 .evaluate(edge.t1)
3162 .or_refuse(KernelStage::Collect, "edge_end")?,
3163 );
3164 endpoints.push((start, i));
3165 endpoints.push((end, i));
3166 original_extents.push((start, end));
3167 selected_edge_ids.push(edge_id);
3168 }
3169 let mut corners: Vec<Vec3> = Vec::new();
3170 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
3175 let mut chain_corner_count = 0usize;
3176 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
3177 let mut used = vec![false; endpoints.len()];
3178 for i in 0..endpoints.len() {
3179 if used[i] {
3180 continue;
3181 }
3182 used[i] = true;
3183 let mut edges_here: HashSet<usize> = HashSet::default();
3184 edges_here.insert(endpoints[i].1);
3185 for j in (i + 1)..endpoints.len() {
3186 if used[j] {
3187 continue;
3188 }
3189 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
3190 used[j] = true;
3191 edges_here.insert(endpoints[j].1);
3192 }
3193 }
3194 if edges_here.len() >= 3 {
3195 corners.push(endpoints[i].0);
3196 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
3197 adjacent.sort_unstable();
3198 corner_edges.push(adjacent);
3199 } else if edges_here.len() == 2 {
3200 chain_corner_count += 1;
3201 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
3202 adjacent.sort_unstable();
3203 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
3204 }
3205 }
3206
3207 let mut component_of = vec![usize::MAX; edge_points.len()];
3220 let mut components: Vec<Vec<usize>> = Vec::new();
3221 for seed in 0..edge_points.len() {
3222 if component_of[seed] != usize::MAX {
3223 continue;
3224 }
3225 let component_index = components.len();
3226 component_of[seed] = component_index;
3227 let mut component = vec![seed];
3228 let mut cursor = 0;
3229 while cursor < component.len() {
3230 let current = component[cursor];
3231 cursor += 1;
3232 for candidate in 0..edge_points.len() {
3233 if component_of[candidate] != usize::MAX {
3234 continue;
3235 }
3236 let connected = [original_extents[current].0, original_extents[current].1]
3237 .into_iter()
3238 .any(|a| {
3239 [original_extents[candidate].0, original_extents[candidate].1]
3240 .into_iter()
3241 .any(|b| a.sub(b).length() < 1e-6)
3242 });
3243 if connected {
3244 component_of[candidate] = component_index;
3245 component.push(candidate);
3246 }
3247 }
3248 }
3249 component.sort_unstable();
3250 components.push(component);
3251 }
3252 group_analysis.stop();
3253 if components.len() > 1 {
3254 let mut separated = solid.clone();
3255 let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
3258 chain.reserve(components.len());
3259 for component in components {
3260 let points = component
3261 .iter()
3262 .map(|index| edge_points[*index])
3263 .collect::<Vec<_>>();
3264 let names = edge_names.map(|all| {
3265 component
3266 .iter()
3267 .map(|index| all[*index].clone())
3268 .collect::<Vec<_>>()
3269 });
3270 let accept_component = !crate::blend::accept_once_on();
3271 let Some(memo) = memo.as_deref_mut() else {
3272 separated = fillet_edges_group_with_corner_policy(
3273 &separated,
3274 &points,
3275 names.as_deref(),
3276 radius,
3277 chamfer,
3278 name,
3279 None,
3280 wrap_concave,
3281 accept_component,
3282 )?;
3283 continue;
3284 };
3285 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
3286 chain.push((bits.collect(), names.clone()));
3287 let built = match memo.built.get(&chain) {
3288 Some(built) => built.clone(),
3289 None => {
3290 let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
3293 let built = fillet_edges_group_with_corner_policy(
3294 &separated,
3295 &points,
3296 names.as_deref(),
3297 radius,
3298 chamfer,
3299 name,
3300 Some(&mut *memo),
3301 wrap_concave,
3302 accept_component,
3303 );
3304 memo.prefix = outer_prefix;
3305 memo.built.insert(chain.clone(), built.clone());
3306 built
3307 }
3308 };
3309 separated = built?;
3310 }
3311 return Ok(GroupBody { body: separated, bowtie_clean: false });
3312 }
3313
3314 if std::env::var("BREP_NO_NETWORK").is_err() {
3339 if selected_edge_ids.len() == 1 {
3340 let edge_id = selected_edge_ids[0];
3341 let closed = solid
3342 .edges
3343 .iter()
3344 .find(|edge| edge.id == edge_id)
3345 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
3346 if closed {
3347 let rim_name = per_edge_name(edge_names, name, 0);
3348 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
3349 Ok(mut rim) => {
3350 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3351 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3352 let healed = heal_edge_vertex_gaps(&mut rim, radius);
3353 heal.stop();
3354 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3363 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
3364 charts.stop();
3365 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3366 let issues = rim.validate_memoised();
3367 validate.stop();
3368 let interference =
3369 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
3370 let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
3371 if healed.is_ok()
3372 && issues.is_empty()
3373 && interference.is_ok()
3374 && bowtie.is_ok()
3375 {
3376 return Ok(GroupBody { body: rim, bowtie_clean: true });
3377 }
3378 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3379 eprintln!(
3380 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
3381 interference={interference:?} bowtie={bowtie:?}"
3382 );
3383 }
3384 }
3385 Err(refusal) => {
3386 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3387 eprintln!("closed-rim refused: {refusal}");
3388 }
3389 }
3390 }
3391 }
3392 }
3393 let network_names: Vec<Option<String>> = (0..edge_points.len())
3394 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
3395 .collect();
3396 let network_corner_name =
3397 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
3398 let group_network = || {
3404 crate::blend::blend_star_network_with_corner_policy(
3405 solid,
3406 &selected_edge_ids,
3407 radius,
3408 chamfer,
3409 &network_names,
3410 &network_corner_name,
3411 wrap_concave,
3412 )
3413 };
3414 let network_result = group_network().or_else(|first| {
3415 if crate::blend::is_pcurve_floor_refusal(&first) {
3416 crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
3417 } else {
3418 Err(first)
3419 }
3420 });
3421 let accept_network = |mut network: BrepSolid, built_radius: f64| -> Result<Option<GroupBody>, KernelRefusal> {
3425 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3433 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
3434 let healed = heal_edge_vertex_gaps(&mut network, built_radius);
3435 heal.stop();
3436 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
3440 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
3441 charts.stop();
3442 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
3443 let issues = network.validate_memoised();
3444 validate.stop();
3445 let interference =
3446 check_blend_interference(solid, &network, &selected_edge_ids, entry);
3447 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
3452 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
3453 return Ok(Some(GroupBody { body: network, bowtie_clean: true }));
3454 }
3455 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3456 eprintln!(
3457 "network result rejected: heal={healed:?} issues={issues:?} \
3458 interference={interference:?} bowtie={bowtie:?}"
3459 );
3460 dump_loops_debug("INPUT", solid);
3461 dump_loops_debug("RESULT", &network);
3462 }
3463 Ok(None)
3464 };
3465 match network_result {
3466 Ok(network) => {
3467 if let Some(body) = accept_network(network, radius)? {
3468 return Ok(body);
3469 }
3470 }
3471 Err(refusal) => {
3472 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3473 eprintln!("network refused: {refusal}");
3474 }
3475 let pinched_taper = matches!(&refusal.class, crate::RefusalClass::UnsupportedGeometry { what }
3486 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT);
3487 if !chamfer && pinched_taper && crate::blend::relief::radius_relief_on() {
3488 let labels: Vec<String> =
3489 (0..edge_points.len()).map(|index| selection_label(index, edge_points, edge_names, name)).collect();
3490 for step in 1..=10u32 {
3491 let reduced = radius * (1.0 - 0.05 * f64::from(step));
3492 let built = crate::blend::blend_star_network_with_corner_policy(
3493 solid, &selected_edge_ids, reduced, chamfer, &network_names, &network_corner_name, wrap_concave,
3494 );
3495 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3496 eprintln!(
3497 "taper pinch ladder: r {reduced:.4} -> {}",
3498 match &built {
3499 Ok(_) => "built".to_string(),
3500 Err(error) => format!("refused: {error}"),
3501 }
3502 );
3503 }
3504 match built {
3505 Ok(network) => {
3506 let Some(body) = accept_network(network, reduced)? else { break };
3507 crate::blend::record_blend_note(format!(
3508 "radius reduced over the whole of {} to {reduced:.4}: at r {radius} the round tapering to \
3509 its tip reaches the full radius only inside the corner setback at its other end, so \
3510 r {radius} is reached nowhere",
3511 labels.join(", ")
3512 ));
3513 return Ok(body);
3514 }
3515 Err(again) if matches!(&again.class, crate::RefusalClass::UnsupportedGeometry { what }
3516 if what == crate::blend::STRIPE_PINCH_TAPER_WHAT) => continue,
3517 Err(_) => break,
3518 }
3519 }
3520 }
3521 if matches!(&refusal.class, RefusalClass::UnsupportedGeometry { what }
3525 if what == "canal_footprint_bound") || crate::blend::is_ball_does_not_fit(&refusal) {
3526 return Err(refusal);
3527 }
3528 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
3532 if crate::blend::is_wall_fold(&refusal) {
3533 GROUP_NETWORK_FOLD.with(|fold| *fold.borrow_mut() = Some(refusal.clone()));
3534 }
3535 if !chamfer && !crate::blend::is_wall_fold(&refusal) && crate::blend::relief::radius_relief_on() {
3541 let started = web_time::Instant::now();
3542 let misfit = crate::blend::relief::first_misfit(solid, &selected_edge_ids, radius);
3543 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3544 eprintln!("radius relief: misfit probe {misfit:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);
3545 }
3546 if let Some((edge_id, least)) = misfit {
3547 return Err(crate::blend::relief::misfit_fold(edge_id, least, radius, &refusal));
3548 }
3549 }
3550 if crate::blend::is_rail_fold(&refusal)
3591 || crate::blend::is_marched_fit_off_carriers(&refusal)
3592 || crate::blend::is_consumed_snap(&refusal)
3593 || crate::blend::is_rail_collapse(&refusal)
3594 || crate::blend::is_full_width_collapse(&refusal)
3595 || crate::blend::is_planar_chart_refusal(&refusal)
3596 || crate::blend::is_miter_support_overrun(&refusal)
3597 || crate::blend::is_tangential_transverse_end(&refusal)
3598 {
3599 return Err(refusal);
3600 }
3601 }
3602 }
3603 }
3604
3605 if let Some(refusal) = super::analyze::rollover_only_refusal(&selected_edge_ids) {
3611 return Err(refusal);
3612 }
3613 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
3614 crate::blend::record_count("count.cutter_fallback", 1);
3615 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
3616 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
3643 && (chamfer || corners.is_empty())
3644 {
3645 let mut any_convex = false;
3646 let mut any_concave = false;
3647 for point in edge_points {
3648 match resolve_edge_by_point(solid, *point)
3660 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
3661 {
3662 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
3665 any_convex |= profile.any_convex;
3666 any_concave |= profile.any_concave;
3667 }
3668 _ => {
3671 any_convex = true;
3672 any_concave = true;
3673 break;
3674 }
3675 }
3676 }
3677 match (any_convex, any_concave) {
3678 (true, false) => Some(crate::BooleanOperation::Intersect),
3679 (false, true) => Some(crate::BooleanOperation::Union),
3680 _ => None,
3681 }
3682 } else {
3683 None
3684 };
3685
3686 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
3697 Lane::GeneralFirst
3698 } else {
3699 Lane::CutterFirst
3700 };
3701 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3712 let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
3717 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
3718 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
3724 let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
3725 let mut sequential = solid.clone();
3726 let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
3729 for &index in &sequential_order {
3730 let point = &edge_points[index];
3731 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3732 let ends = tool_ends_to_original_extent(
3739 &sequential,
3740 edge_id,
3741 radius,
3742 original_extents[index],
3743 if chamfer { &[] } else { &corners },
3744 );
3745 let edge_name = per_edge_name(edge_names, name, index);
3750 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3751 eprintln!(
3752 "sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
3753 selection_label(index, edge_points, edge_names, name)
3754 );
3755 }
3756 chain.push((
3761 [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
3762 edge_name.map(str::to_string),
3763 ends.key_bits(),
3764 u8::from(composition_lane == Lane::CutterFirst),
3765 ));
3766 if let Some(memo) = memo.as_deref_mut() {
3767 let key = (memo.prefix.clone(), chain.clone());
3768 let hit = memo.sequential.get(&key);
3769 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3770 eprintln!(
3771 "sequential memo: {} components before, step {} {} ({} chains stored)",
3772 memo.prefix.len(),
3773 chain.len(),
3774 if hit.is_some() { "HIT" } else { "miss" },
3775 memo.sequential.len(),
3776 );
3777 }
3778 if let Some(built) = hit {
3779 crate::blend::record_count("count.sequential.memo_hits", 1);
3780 sequential = built.clone()?;
3781 continue;
3782 }
3783 }
3784 crate::blend::record_count("count.sequential.steps_built", 1);
3785 let built = fillet_or_chamfer(
3786 &sequential,
3787 edge_id,
3788 radius,
3789 chamfer,
3790 edge_name,
3791 ends,
3792 composition_lane,
3793 );
3794 if let Some(memo) = memo.as_deref_mut() {
3795 memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
3796 }
3797 sequential = built?;
3798 }
3799 Ok(sequential)
3800 };
3801
3802 let mut result = if let Some(operation) = miter_operation {
3803 let options = crate::BooleanOptions {
3807 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
3808 ..crate::BooleanOptions::default()
3809 };
3810 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
3811 let units = miter_tangent_units(solid, &selected_edge_ids, &chain_corners, edge_points.len());
3815 for unit in &units {
3816 let index = unit[0];
3817 let point = &edge_points[index];
3818 let blended = if unit.len() > 1 {
3819 let points = unit.iter().map(|&member| edge_points[member]).collect::<Vec<_>>();
3820 let names = edge_names.map(|all| unit.iter().map(|&member| all[member].clone()).collect::<Vec<_>>());
3821 fillet_edges_group_with_corner_policy(
3822 solid,
3823 &points,
3824 names.as_deref(),
3825 radius,
3826 chamfer,
3827 name,
3828 None,
3829 wrap_concave,
3830 !crate::blend::accept_once_on(),
3831 )?
3832 } else {
3833 let edge_id = resolve_edge_by_point(solid, *point)?;
3834 let edge_name = per_edge_name(edge_names, name, index);
3835 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
3836 check_mixed_concavity(solid, edge_id, entry)?;
3837 check_support_extent(solid, edge_id, radius, entry)?;
3838 fillet_or_chamfer(
3839 solid,
3840 edge_id,
3841 radius,
3842 chamfer,
3843 edge_name,
3844 ToolEnds::default(),
3845 Lane::CutterFirst,
3846 )?
3847 };
3848 combined = match combined {
3849 Err(e) => Err(e),
3850 Ok(None) => Ok(Some(blended)),
3851 Ok(Some(previous)) => {
3852 crate::boolean_operation(&previous, &blended, operation, &options)
3853 .map(Some)
3854 .map_err(|error| {
3855 error.with_message(|error| {
3857 format!("fillet_edges: chain-corner miter composition failed: {error}")
3858 })
3859 })
3860 }
3861 };
3862 if combined.is_err() {
3863 break;
3864 }
3865 }
3866 match combined {
3878 Ok(Some(mut mitered)) => {
3879 drop_slit_shells(&mut mitered);
3880 mitered
3881 }
3882 Ok(None) => {
3883 return Err(KernelRefusal::input(
3884 KernelStage::Collect,
3885 "miter_selection",
3886 "fillet_edges: no edges selected",
3887 ))
3888 }
3889 Err(miter_err) => match build_sequential() {
3890 Ok(seq) if seq.validate().is_empty() => seq,
3891 _ => return Err(miter_err),
3892 },
3893 }
3894 } else {
3895 build_sequential()?
3896 };
3897
3898 let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
3906 if !chamfer {
3908 for (corner, adjacent) in &chain_corners {
3909 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
3910 if selected_edge_ids.contains(&edge.id) {
3911 return false;
3912 }
3913 let Ok(a) = edge.curve.evaluate(edge.t0) else {
3914 return false;
3915 };
3916 let Ok(b) = edge.curve.evaluate(edge.t1) else {
3917 return false;
3918 };
3919 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3920 && analyze_edge(solid, edge.id, radius)
3921 .map(|cross| !cross.convex)
3922 .unwrap_or(false)
3923 });
3924 if !has_concave_unselected_edge {
3935 let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
3936 let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
3937 let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
3938 let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
3939 if selected_edge_ids.contains(&edge.id) {
3940 return false;
3941 }
3942 let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
3943 return false;
3944 };
3945 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3946 && analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
3947 });
3948 if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
3949 open_mixed_corners.push((
3950 *corner,
3951 [selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
3952 corner_face_name(edge_names, name, adjacent),
3953 ));
3954 }
3955 continue;
3956 }
3957 let corner_name = corner_face_name(edge_names, name, adjacent);
3959 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
3960 &result,
3961 solid,
3962 *corner,
3963 [
3964 selected_edge_ids[adjacent[0]],
3965 selected_edge_ids[adjacent[1]],
3966 ],
3967 radius,
3968 corner_name.as_deref(),
3969 ) {
3970 result = rounded;
3971 }
3972 }
3973 }
3974
3975 if !chamfer {
3981 for (ci, corner) in corners.iter().enumerate() {
3982 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
3983 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
3984 Ok(rounded) => result = rounded,
3985 Err(error) => {
3986 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3987 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
3988 }
3989 }
3990 }
3991 }
3992 }
3993
3994 heal_edge_vertex_gaps(&mut result, radius)?;
3997 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3998 let mut body = settle_cutter_scaffold(result);
4000 for (corner, pair, corner_name) in &open_mixed_corners {
4001 match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
4002 Ok(rounded) => body = rounded,
4003 Err(error) => {
4004 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
4005 eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
4006 }
4007 }
4008 }
4009 }
4010 Ok(GroupBody { body, bowtie_clean: false })
4011 }
4013
4014fn drop_slit_shells_on() -> bool {
4023 switch!(live "BREP_FILLET_MITER_DROP_SLIT_SHELLS", true)
4024}
4025
4026fn drop_slit_shells(body: &mut BrepSolid) -> usize {
4037 if !drop_slit_shells_on() || body.shells.len() < 2 {
4038 return 0;
4039 }
4040 let mut users: std::collections::HashMap<u64, std::collections::HashSet<usize>> = std::collections::HashMap::new();
4041 for (index, shell) in body.shells.iter().enumerate() {
4042 for coedge in shell.faces.iter().flat_map(|face| &face.loops).flat_map(|lp| &lp.coedges) {
4043 users.entry(coedge.edge_id).or_default().insert(index);
4044 }
4045 }
4046 let slit_face = |face: &crate::topology::FaceRecord| {
4047 face.loops.iter().all(|lp| {
4048 let mut runs: std::collections::HashMap<u64, (usize, usize)> = std::collections::HashMap::new();
4049 for coedge in &lp.coedges {
4050 let entry = runs.entry(coedge.edge_id).or_default();
4051 if coedge.forward {
4052 entry.0 += 1;
4053 } else {
4054 entry.1 += 1;
4055 }
4056 }
4057 !runs.is_empty() && runs.values().all(|&(forward, back)| forward == 1 && back == 1)
4058 })
4059 };
4060 let mut index = 0usize;
4061 let mut dropped = 0usize;
4062 body.shells.retain(|shell| {
4063 let this = index;
4064 index += 1;
4065 let slit = this > 0
4066 && !shell.faces.is_empty()
4067 && shell.faces.iter().all(|face| slit_face(face))
4068 && shell
4069 .faces
4070 .iter()
4071 .flat_map(|face| &face.loops)
4072 .flat_map(|lp| &lp.coedges)
4073 .all(|coedge| users.get(&coedge.edge_id).is_some_and(|set| set.len() == 1));
4074 dropped += usize::from(slit);
4075 !slit
4076 });
4077 if dropped == 0 {
4078 return 0;
4079 }
4080 let used_edges: std::collections::HashSet<u64> = body
4081 .shells
4082 .iter()
4083 .flat_map(|shell| &shell.faces)
4084 .flat_map(|face| &face.loops)
4085 .flat_map(|lp| &lp.coedges)
4086 .map(|coedge| coedge.edge_id)
4087 .collect();
4088 body.edges.retain(|edge| used_edges.contains(&edge.id));
4089 let used_vertices: std::collections::HashSet<u64> =
4090 body.edges.iter().flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id]).collect();
4091 body.vertices.retain(|vertex| used_vertices.contains(&vertex.id));
4092 dropped
4093}
4094
4095fn miter_tangent_units_on() -> bool {
4098 switch!(live "BREP_FILLET_MITER_TANGENT_UNITS", true)
4099}
4100
4101fn miter_tangent_units(
4123 solid: &BrepSolid,
4124 selected_edge_ids: &[u64],
4125 chain_corners: &[(Vec3, [usize; 2])],
4126 count: usize,
4127) -> Vec<Vec<usize>> {
4128 let singles = || (0..count).map(|index| vec![index]).collect::<Vec<_>>();
4129 if !miter_tangent_units_on() || selected_edge_ids.len() != count {
4130 return singles();
4131 }
4132 let leaving = |edge_id: u64, corner: Vec3| -> Option<Vec3> {
4134 let edge = solid.edges.iter().find(|edge| edge.id == edge_id)?;
4135 if edge.degenerate {
4136 return None;
4137 }
4138 let start = edge.curve.evaluate(edge.t0).ok()?;
4139 let end = edge.curve.evaluate(edge.t1).ok()?;
4140 let at_start = start.sub(corner).length() <= end.sub(corner).length();
4141 let t = if at_start { edge.t0 } else { edge.t1 };
4142 let tangent = edge.curve.unit_tangent(t, edge.t0, edge.t1).ok()?;
4143 Some(if at_start { tangent } else { tangent.scale(-1.0) })
4144 };
4145 let mut parent: Vec<usize> = (0..count).collect();
4146 fn root(parent: &mut [usize], mut index: usize) -> usize {
4147 while parent[index] != index {
4148 parent[index] = parent[parent[index]];
4149 index = parent[index];
4150 }
4151 index
4152 }
4153 for (corner, [a, b]) in chain_corners {
4154 let (Some(ta), Some(tb)) = (leaving(selected_edge_ids[*a], *corner), leaving(selected_edge_ids[*b], *corner)) else {
4155 continue;
4156 };
4157 if ta.dot(tb) <= -(1.0 - 1e-6) {
4159 let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
4160 if ra != rb {
4161 parent[ra.max(rb)] = ra.min(rb);
4162 }
4163 }
4164 }
4165 let mut units: Vec<Vec<usize>> = Vec::new();
4166 let mut unit_of_root: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
4167 for index in 0..count {
4168 let r = root(&mut parent, index);
4169 let slot = *unit_of_root.entry(r).or_insert_with(|| {
4170 units.push(Vec::new());
4171 units.len() - 1
4172 });
4173 units[slot].push(index);
4174 }
4175 if units.len() <= 1 || units.iter().all(|unit| unit.len() == 1) {
4176 return singles();
4177 }
4178 units
4179}
4180
4181fn concave_star_edges(
4182 solid: &BrepSolid,
4183 selected_edge_ids: &[u64],
4184 corner_edges: &[Vec<usize>],
4185 radius: f64,
4186 chamfer: bool,
4187) -> Vec<usize> {
4188 if chamfer || corner_edges.is_empty() || !concave_first_on() {
4189 return Vec::new();
4190 }
4191 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
4192 return Vec::new();
4193 };
4194 (0..selected_edge_ids.len())
4195 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
4196 .collect()
4197}
4198
4199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4206enum CutterClass {
4207 Marched,
4208 Straight,
4209 Circular,
4210}
4211
4212fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
4213 match super::analyze::analyze_edge(solid, edge_id, radius) {
4214 Ok(cross) => match cross.path {
4215 super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
4216 super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
4217 },
4218 Err(_) => CutterClass::Marched,
4219 }
4220}
4221
4222fn mixed_star_class_ranks(
4244 solid: &BrepSolid,
4245 selected_edge_ids: &[u64],
4246 corner_edges: &[Vec<usize>],
4247 radius: f64,
4248 chamfer: bool,
4249) -> Vec<u8> {
4250 let mut ranks = vec![2u8; selected_edge_ids.len()];
4251 if chamfer || corner_edges.is_empty() || !class_order_on() {
4252 return ranks;
4253 }
4254 let classes: Vec<CutterClass> = selected_edge_ids
4255 .iter()
4256 .map(|&edge_id| cutter_class(solid, edge_id, radius))
4257 .collect();
4258 for adjacent in corner_edges {
4259 let Some(&head) = adjacent.first() else {
4260 continue;
4261 };
4262 if adjacent.iter().all(|&index| classes[index] == classes[head]) {
4263 continue;
4264 }
4265 for &index in adjacent {
4266 match classes[index] {
4267 CutterClass::Marched => ranks[index] = 1,
4268 CutterClass::Circular => ranks[index] = 3,
4269 CutterClass::Straight => {}
4270 }
4271 }
4272 }
4273 ranks
4274}
4275
4276pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
4283 let mut order: Vec<usize> = (0..count).collect();
4284 order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
4286 order
4287}
4288
4289fn class_order_on() -> bool {
4293 true
4294}
4295
4296
4297fn corners_concave_first(
4301 corners: Vec<Vec3>,
4302 corner_edges: Vec<Vec<usize>>,
4303 first: &[usize],
4304) -> (Vec<Vec3>, Vec<Vec<usize>>) {
4305 if first.is_empty() {
4306 return (corners, corner_edges);
4307 }
4308 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
4309 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
4310 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
4311 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
4312 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
4313 ordered.into_iter().unzip()
4314}
4315
4316fn concave_first_on() -> bool {
4319 true
4320}
4321
4322
4323pub fn fillet_edges_variable(
4331 solid: &BrepSolid,
4332 edge_points: &[Vec3],
4333 edge_names: Option<&[String]>,
4334 radii: &[(f64, f64)],
4335 chamfer: bool,
4336 name: Option<&str>,
4337) -> Result<BrepSolid, KernelRefusal> {
4338 let _operation = crate::blend::BlendOperation::enter();
4341 if edge_points.is_empty() {
4342 return Err(KernelRefusal::input(
4343 KernelStage::Collect,
4344 "variable_selection",
4345 "fillet_edges_variable: no edges selected",
4346 ));
4347 }
4348 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
4349 fillet_edges_variable_impl(
4350 solid,
4351 edge_points,
4352 edge_names,
4353 &per_edge_stops,
4354 chamfer,
4355 name,
4356 "fillet_edges_variable",
4357 true,
4358 )
4359}
4360
4361#[allow(clippy::too_many_arguments)]
4373fn fillet_edges_variable_impl(
4374 solid: &BrepSolid,
4375 edge_points: &[Vec3],
4376 edge_names: Option<&[String]>,
4377 per_edge_stops: &[Vec<(f64, f64)>],
4378 chamfer: bool,
4379 name: Option<&str>,
4380 entry: &str,
4381 allow_tapered_sequential: bool,
4382) -> Result<BrepSolid, KernelRefusal> {
4383 let bounds = crate::recentre::solid_bounds(solid);
4386 let translated = match crate::recentre::control_offset_at_entry() {
4387 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
4388 None => crate::recentre::guard_offset(bounds).map(|offset| {
4389 crate::recentre::profile_fired(entry, offset, bounds);
4390 (offset, crate::recentre::EntryKind::Guard)
4391 }),
4392 };
4393 if let Some((offset, kind)) = translated {
4394 let _entry = crate::recentre::TranslatedEntry::open(kind);
4395 let translate = |solid: &BrepSolid, offset: Vec3| {
4396 crate::recentre::translate_solid(solid, offset).map_err(|message| {
4397 KernelRefusal::internal(
4398 KernelStage::Collect,
4399 "translation_control",
4400 format!("{entry}: the translation control could not move the solid: {message}"),
4401 )
4402 })
4403 };
4404 let moved = translate(solid, offset)?;
4405 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
4406 let result = fillet_edges_variable_impl(
4407 &moved,
4408 &points,
4409 edge_names,
4410 per_edge_stops,
4411 chamfer,
4412 name,
4413 entry,
4414 allow_tapered_sequential,
4415 )?;
4416 return translate(&result, offset.scale(-1.0));
4417 }
4418 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
4419 if let Some(constant) = per_edge_stops
4423 .first()
4424 .and_then(|stops| stops.first())
4425 .map(|(_, radius)| *radius)
4426 {
4427 let uniform = per_edge_stops.iter().all(|stops| {
4428 stops
4429 .iter()
4430 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
4431 });
4432 if uniform && constant > 0.0 {
4433 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
4434 }
4435 }
4436 let max_radius = per_edge_stops
4437 .iter()
4438 .flat_map(|stops| stops.iter())
4439 .map(|(_, r)| r.abs())
4440 .fold(0.0_f64, f64::max);
4441
4442 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
4447 match fillet_variable_network(
4448 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
4449 )? {
4450 Ok(built) => return Ok(built),
4451 Err(refusal) if crate::blend::relief::relieving() => return Err(refusal),
4455 Err(_) => {}
4456 }
4457 }
4458
4459 let mut chain_corner = false;
4466 {
4467 use rustc_hash::FxHashSet as HashSet;
4468 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
4469 for (i, point) in edge_points.iter().enumerate() {
4470 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
4471 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
4472 if let (Ok(a), Ok(b)) =
4473 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
4474 {
4475 endpoints.push((a, i));
4476 endpoints.push((b, i));
4477 }
4478 }
4479 }
4480 }
4481 let mut used = vec![false; endpoints.len()];
4482 for i in 0..endpoints.len() {
4483 if used[i] {
4484 continue;
4485 }
4486 used[i] = true;
4487 let mut edges_here: HashSet<usize> = HashSet::default();
4488 edges_here.insert(endpoints[i].1);
4489 for j in (i + 1)..endpoints.len() {
4490 if used[j] {
4491 continue;
4492 }
4493 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
4494 used[j] = true;
4495 edges_here.insert(endpoints[j].1);
4496 }
4497 }
4498 if edges_here.len() == 2 {
4499 chain_corner = true;
4500 }
4501 }
4502 }
4503 let miter_operation = if chain_corner {
4504 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
4505 let mut any_convex = false;
4506 let mut any_concave = false;
4507 for point in edge_points {
4508 match resolve_edge_by_point(solid, *point)
4509 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
4510 {
4511 Ok(cross) if cross.convex => any_convex = true,
4512 Ok(_) => any_concave = true,
4513 Err(_) => {
4514 any_convex = true;
4515 any_concave = true;
4516 break;
4517 }
4518 }
4519 }
4520 match (any_convex, any_concave) {
4521 (true, false) => Some(crate::BooleanOperation::Intersect),
4522 (false, true) => Some(crate::BooleanOperation::Union),
4523 _ => None,
4524 }
4525 } else {
4526 None
4527 };
4528
4529 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
4536 let options = crate::BooleanOptions::default();
4537 let mut combined: Option<BrepSolid> = None;
4538 let mut failed = false;
4539 for (index, point) in edge_points.iter().enumerate() {
4540 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
4541 failed = true;
4542 break;
4543 };
4544 let edge_name = per_edge_name(edge_names, name, index);
4545 let Ok(blended) = crate::blend::blend_edge_variable(
4546 solid,
4547 edge_id,
4548 &per_edge_stops[index],
4549 chamfer,
4550 edge_name,
4551 ) else {
4552 failed = true;
4553 break;
4554 };
4555 let next = match combined.take() {
4556 None => blended,
4557 Some(previous) => {
4558 match crate::boolean_operation(&previous, &blended, operation, &options) {
4559 Ok(next) => next,
4560 Err(_) => {
4561 failed = true;
4562 break;
4563 }
4564 }
4565 }
4566 };
4567 combined = Some(next);
4568 }
4569 if failed {
4570 None
4571 } else {
4572 combined.filter(|s| s.validate().is_empty())
4573 }
4574 } else {
4575 None
4576 };
4577 let mut result = match miter_attempt {
4578 Some(mitered) => mitered,
4579 None => {
4580 let tapered = per_edge_stops.iter().any(|stops| {
4586 stops.first().is_some_and(|(_, first)| {
4587 stops
4588 .iter()
4589 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
4590 })
4591 });
4592 if chain_corner && tapered && !allow_tapered_sequential {
4593 return Err(KernelRefusal::unsupported(
4596 KernelStage::Sew,
4597 "tapered_chain_miter",
4598 format!(
4599 "{entry}: the tapered blends across the selected chain's shared corners \
4600 did not compose to a valid solid (the fitted blend boundaries could not \
4601 be mitered); fillet fewer edges per operation or reduce the taper"
4602 ),
4603 ));
4604 }
4605 let mut sequential = solid.clone();
4606 for (index, point) in edge_points.iter().enumerate() {
4607 let edge_id = resolve_edge_by_point(&sequential, *point)?;
4608 let edge_name = per_edge_name(edge_names, name, index);
4609 sequential = crate::blend::blend_edge_variable(
4610 &sequential,
4611 edge_id,
4612 &per_edge_stops[index],
4613 chamfer,
4614 edge_name,
4615 )?;
4616 }
4617 sequential
4618 }
4619 };
4620 heal_edge_vertex_gaps(&mut result, max_radius)?;
4623 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
4624 let issues = result.validate();
4629 if !issues.is_empty() {
4630 let (detail, class) = if allow_tapered_sequential {
4635 (
4636 "tapered blends meet at a shared chain vertex with \
4637 mismatched radii — the radius-transition corner patch is not implemented; \
4638 fillet the edges in separate operations or use matching stop radii",
4639 RefusalClass::UnsupportedGeometry {
4640 what: "taper_transition_patch".into(),
4641 },
4642 )
4643 } else {
4644 (
4645 "the composed radius-law blend produced invalid topology — the blends \
4646 across a shared chain corner failed to reassemble",
4647 RefusalClass::Internal {
4648 what: "law_chain_topology".into(),
4649 },
4650 )
4651 };
4652 return Err(KernelRefusal::new(
4653 class,
4654 KernelStage::Validate,
4655 format!(
4656 "{entry}: {detail} ({} validation issues, first: {})",
4657 issues.len(),
4658 issues
4659 .first()
4660 .map(|issue| issue.message.clone())
4661 .unwrap_or_default()
4662 ),
4663 ));
4664 }
4665 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
4670 crate::accept_sound(result, entry)
4671}
4672
4673#[allow(clippy::too_many_arguments)]
4678fn fillet_variable_network(
4679 solid: &BrepSolid,
4680 edge_points: &[Vec3],
4681 edge_names: Option<&[String]>,
4682 per_edge_stops: &[Vec<(f64, f64)>],
4683 chamfer: bool,
4684 name: Option<&str>,
4685 entry: &str,
4686 max_radius: f64,
4687) -> Result<Result<BrepSolid, KernelRefusal>, KernelRefusal> {
4688 let mut edge_ids = Vec::with_capacity(edge_points.len());
4689 for point in edge_points {
4690 edge_ids.push(resolve_edge_by_point(solid, *point)?);
4691 }
4692 let laws: Vec<crate::blend::StripeRadius> =
4693 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
4694 let network_names: Vec<Option<String>> = (0..edge_points.len())
4695 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
4696 .collect();
4697 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
4698 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
4699 let law_network = || {
4708 crate::blend::blend_star_network_laws(solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true)
4709 };
4710 let network_result = law_network().or_else(|first| {
4711 if crate::blend::is_pcurve_floor_refusal(&first) {
4712 crate::blend::with_pcurve_knot_retry(law_network).map_err(|_| first)
4713 } else {
4714 Err(first)
4715 }
4716 });
4717 let mut network = match network_result {
4718 Ok(network) => network,
4719 Err(refusal) => {
4720 if debug {
4721 eprintln!("law network refused: {refusal}");
4722 }
4723 return Ok(Err(refusal));
4724 }
4725 };
4726 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
4731 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
4732 let issues = network.validate();
4733 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
4734 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
4735 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
4736 crate::soundness::census::profile_operation(&network, &[solid], None, entry);
4737 match crate::accept_sound(network, entry) {
4738 Ok(sound) => return Ok(Ok(sound)),
4739 Err(refusal) => {
4740 if debug {
4741 eprintln!("law network result unsound: {refusal}");
4742 }
4743 return Ok(Err(refusal));
4744 }
4745 }
4746 }
4747 if debug {
4748 eprintln!(
4749 "law network result rejected: heal={healed:?} issues={issues:?} \
4750 interference={interference:?} bowtie={bowtie:?}"
4751 );
4752 }
4753 Ok(Err(KernelRefusal::unsupported(
4754 KernelStage::Validate,
4755 "law_network_rejected",
4756 format!(
4757 "{entry}: the tapered network's result was rejected (heal {}, {} validation issues, interference {}, loops {})",
4758 if healed.is_ok() { "ok" } else { "failed" },
4759 issues.len(),
4760 if interference.is_ok() { "ok" } else { "found" },
4761 if bowtie.is_ok() { "ok" } else { "found" },
4762 ),
4763 )))
4764}
4765
4766pub(crate) fn fillet_edges_variable_stops(
4767 solid: &BrepSolid,
4768 edge_points: &[Vec3],
4769 edge_names: Option<&[String]>,
4770 per_edge_stops: &[Vec<(f64, f64)>],
4771 chamfer: bool,
4772 name: Option<&str>,
4773) -> Result<BrepSolid, KernelRefusal> {
4774 let _operation = crate::blend::BlendOperation::enter();
4775 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
4776 return Err(KernelRefusal::input(
4777 KernelStage::Collect,
4778 "variable_selection",
4779 "fillet_edges_variable_stops: one stop list per selected edge is required",
4780 ));
4781 }
4782 fillet_edges_variable_impl(
4783 solid,
4784 edge_points,
4785 edge_names,
4786 per_edge_stops,
4787 chamfer,
4788 name,
4789 "fillet_edges_variable_stops",
4790 false,
4791 )
4792}
4793
4794struct ChainLink {
4798 input_index: usize,
4800 forward: bool,
4802 arc: Vec<(f64, f64)>,
4805 length: f64,
4807 abscissa: f64,
4810}
4811
4812fn edge_arc_table(
4823 curve: &NurbsCurve,
4824 t0: f64,
4825 t1: f64,
4826 tol: f64,
4827) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4828 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
4829 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
4830 let mut table = Vec::with_capacity(n + 1);
4831 let mut cumulative = 0.0;
4832 let mut previous = evaluate(t0)?;
4833 table.push((0.0, 0.0));
4834 for j in 1..=n {
4835 let fraction = j as f64 / n as f64;
4836 let point = evaluate(t0 + (t1 - t0) * fraction)?;
4837 cumulative += point.sub(previous).length();
4838 previous = point;
4839 table.push((fraction, cumulative));
4840 }
4841 Ok((table, cumulative))
4842 };
4843 let mut n = 16usize;
4844 let (mut table, mut length) = build(n)?;
4845 while n < 4096 {
4846 n *= 2;
4847 let (next_table, next_length) = build(n)?;
4848 let converged = (next_length - length).abs() <= tol;
4849 table = next_table;
4850 length = next_length;
4851 if converged {
4852 break;
4853 }
4854 }
4855 Ok((table, length))
4856}
4857
4858fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
4861 let fraction = fraction.clamp(0.0, 1.0);
4862 let intervals = table.len() - 1;
4863 let scaled = fraction * intervals as f64;
4864 let index = (scaled.floor() as usize).min(intervals - 1);
4865 let local = scaled - index as f64;
4866 let (_, a) = table[index];
4867 let (_, b) = table[index + 1];
4868 a + (b - a) * local
4869}
4870
4871fn resolve_selected_chain(
4880 solid: &BrepSolid,
4881 edge_points: &[Vec3],
4882 entry: &str,
4883 tol: f64,
4884) -> Result<Vec<ChainLink>, KernelRefusal> {
4885 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
4888
4889 struct Resolved {
4890 edge_id: u64,
4891 start: Vec3,
4892 end: Vec3,
4893 arc: Vec<(f64, f64)>,
4894 length: f64,
4895 }
4896 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
4897 for point in edge_points {
4898 let edge_id = resolve_edge_by_point(solid, *point)?;
4899 if resolved.iter().any(|r| r.edge_id == edge_id) {
4900 return Err(KernelRefusal::input(
4901 KernelStage::Collect,
4902 "duplicate_edge",
4903 format!("{entry}: the same edge was selected more than once"),
4904 ));
4905 }
4906 let edge = solid
4907 .edges
4908 .iter()
4909 .find(|e| e.id == edge_id)
4910 .ok_or_else(|| {
4911 KernelRefusal::internal(
4912 KernelStage::Collect,
4913 "chain_resolved_edge",
4914 format!("{entry}: resolved edge vanished"),
4915 )
4916 })?;
4917 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
4918 if !(length > 0.0) {
4919 return Err(KernelRefusal::input(
4920 KernelStage::Collect,
4921 "zero_length_edge",
4922 format!("{entry}: selected edge has zero length"),
4923 ));
4924 }
4925 resolved.push(Resolved {
4926 edge_id,
4927 start: edge
4928 .curve
4929 .evaluate(edge.t0)
4930 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
4931 end: edge
4932 .curve
4933 .evaluate(edge.t1)
4934 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
4935 arc,
4936 length,
4937 });
4938 }
4939 let n = resolved.len();
4940 if n == 1 {
4941 let only = resolved.remove(0);
4942 return Ok(vec![ChainLink {
4943 input_index: 0,
4944 forward: true,
4945 arc: only.arc,
4946 length: only.length,
4947 abscissa: 0.0,
4948 }]);
4949 }
4950
4951 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
4953 for (i, r) in resolved.iter().enumerate() {
4954 for (point, is_start) in [(r.start, true), (r.end, false)] {
4955 match clusters
4956 .iter_mut()
4957 .find(|(anchor, _)| anchor.sub(point).length() < band)
4958 {
4959 Some((_, members)) => members.push((i, is_start)),
4960 None => clusters.push((point, vec![(i, is_start)])),
4961 }
4962 }
4963 }
4964 if clusters.iter().any(|(_, members)| members.len() > 2) {
4965 return Err(KernelRefusal::input(
4966 KernelStage::Collect,
4967 "chain_branching",
4968 format!(
4969 "{entry}: selected edges must form one open chain \
4970 (a vertex is shared by three or more selected edges)"
4971 ),
4972 ));
4973 }
4974 let free: Vec<usize> = clusters
4975 .iter()
4976 .enumerate()
4977 .filter(|(_, (_, members))| members.len() == 1)
4978 .map(|(c, _)| c)
4979 .collect();
4980 if free.len() != 2 {
4981 return Err(KernelRefusal::input(
4982 KernelStage::Collect,
4983 "chain_open",
4984 format!(
4985 "{entry}: selected edges must form one OPEN chain \
4986 (closed rings and disconnected selections are not supported)"
4987 ),
4988 ));
4989 }
4990 let start_cluster = *free
4992 .iter()
4993 .min_by_key(|&&c| clusters[c].1[0].0)
4994 .expect("two free ends");
4995
4996 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
4998 let mut visited = vec![false; n];
4999 let mut abscissa = 0.0_f64;
5000 let mut cluster = start_cluster;
5001 for _ in 0..n {
5002 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
5003 .1
5004 .iter()
5005 .find(|(edge_index, _)| !visited[*edge_index])
5006 else {
5007 return Err(KernelRefusal::input(
5008 KernelStage::Collect,
5009 "chain_walk",
5010 format!(
5011 "{entry}: selected edges are not connected into one chain"
5012 ),
5013 ));
5014 };
5015 visited[edge_index] = true;
5016 let r = &resolved[edge_index];
5017 links.push(ChainLink {
5018 input_index: edge_index,
5019 forward: entered_at_start,
5020 arc: r.arc.clone(),
5021 length: r.length,
5022 abscissa,
5023 });
5024 abscissa += r.length;
5025 let exit_point = if entered_at_start { r.end } else { r.start };
5026 cluster = clusters
5027 .iter()
5028 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
5029 .ok_or_else(|| {
5030 KernelRefusal::internal(
5031 KernelStage::Collect,
5032 "chain_cluster",
5033 format!("{entry}: chain walk lost an endpoint cluster"),
5034 )
5035 })?;
5036 }
5037 if visited.iter().any(|v| !v) {
5038 return Err(KernelRefusal::input(
5039 KernelStage::Collect,
5040 "chain_connected",
5041 format!(
5042 "{entry}: selected edges are not connected into one chain"
5043 ),
5044 ));
5045 }
5046 Ok(links)
5047}
5048
5049fn law_stops_for_link(
5068 link: &ChainLink,
5069 law: &crate::law::RadiusLaw,
5070 scale: f64,
5071 tol: f64,
5072) -> Vec<(f64, f64)> {
5073 let radius_at_fraction = |fraction: f64| -> f64 {
5074 let arc = arc_length_at_fraction(&link.arc, fraction);
5075 let chain_s = if link.forward {
5076 link.abscissa + arc
5077 } else {
5078 link.abscissa + (link.length - arc)
5079 };
5080 law.radius_at(chain_s * scale)
5081 };
5082 let curvature = law
5085 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
5086 * (scale * link.length).powi(2);
5087 let base = if curvature * 0.125 <= tol {
5088 1usize
5089 } else {
5090 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
5091 };
5092 let mut stops: Vec<(f64, f64)> = (0..=base)
5093 .map(|j| {
5094 let fraction = j as f64 / base as f64;
5095 (fraction, radius_at_fraction(fraction))
5096 })
5097 .collect();
5098 let mut depth = 0usize;
5100 while depth < 8 {
5101 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
5102 let mut inserted = false;
5103 for pair in stops.windows(2) {
5104 refined.push(pair[0]);
5105 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
5106 let law_mid = radius_at_fraction(mid_fraction);
5107 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
5108 if (law_mid - linear_mid).abs() > tol {
5109 refined.push((mid_fraction, law_mid));
5110 inserted = true;
5111 }
5112 }
5113 refined.push(*stops.last().expect("at least two stops"));
5114 stops = refined;
5115 if !inserted {
5116 break;
5117 }
5118 depth += 1;
5119 }
5120 stops
5121}
5122
5123pub fn fillet_edges_variable_law(
5135 solid: &BrepSolid,
5136 edge_points: &[Vec3],
5137 edge_names: Option<&[String]>,
5138 law: &crate::law::RadiusLaw,
5139 chamfer: bool,
5140 name: Option<&str>,
5141) -> Result<BrepSolid, KernelRefusal> {
5142 let _operation = crate::blend::BlendOperation::enter();
5145 const ENTRY: &str = "fillet_edges_variable_law";
5146 if edge_points.is_empty() {
5147 return Err(KernelRefusal::input(
5148 KernelStage::Collect,
5149 "law_selection",
5150 format!("{ENTRY}: no edges selected"),
5151 ));
5152 }
5153 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
5154 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
5155 fillet_variable_law_on_chain(
5156 solid,
5157 edge_points,
5158 edge_names,
5159 &chain,
5160 law,
5161 chamfer,
5162 name,
5163 ENTRY,
5164 tolerances.intersection_fit,
5165 )
5166}
5167
5168pub fn fillet_edges_variable_vertex_radii(
5174 solid: &BrepSolid,
5175 edge_points: &[Vec3],
5176 edge_names: Option<&[String]>,
5177 vertex_radii: &[f64],
5178 chamfer: bool,
5179 name: Option<&str>,
5180) -> Result<BrepSolid, KernelRefusal> {
5181 let _operation = crate::blend::BlendOperation::enter();
5184 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
5185 if edge_points.is_empty() {
5186 return Err(KernelRefusal::input(
5187 KernelStage::Collect,
5188 "vertex_radii_selection",
5189 format!("{ENTRY}: no edges selected"),
5190 ));
5191 }
5192 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
5193 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
5194 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
5195 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
5196 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
5197 fillet_variable_law_on_chain(
5198 solid,
5199 edge_points,
5200 edge_names,
5201 &chain,
5202 &law,
5203 chamfer,
5204 name,
5205 ENTRY,
5206 tolerances.intersection_fit,
5207 )
5208}
5209
5210#[allow(clippy::too_many_arguments)]
5214fn fillet_variable_law_on_chain(
5215 solid: &BrepSolid,
5216 edge_points: &[Vec3],
5217 edge_names: Option<&[String]>,
5218 chain: &[ChainLink],
5219 law: &crate::law::RadiusLaw,
5220 chamfer: bool,
5221 name: Option<&str>,
5222 entry: &str,
5223 tol: f64,
5224) -> Result<BrepSolid, KernelRefusal> {
5225 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
5226 let scale = law.total_length() / chain_length;
5227 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
5228 for link in chain {
5229 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
5230 }
5231 fillet_edges_variable_impl(
5232 solid,
5233 edge_points,
5234 edge_names,
5235 &per_edge_stops,
5236 chamfer,
5237 name,
5238 entry,
5239 false,
5240 )
5241}
5242
5243pub fn chamfer_edges_asymmetric(
5248 solid: &BrepSolid,
5249 edge_points: &[Vec3],
5250 edge_names: Option<&[String]>,
5251 d1: f64,
5252 d2: f64,
5253 name: Option<&str>,
5254) -> Result<BrepSolid, KernelRefusal> {
5255 let _operation = crate::blend::BlendOperation::enter();
5258 if edge_points.is_empty() {
5259 return Err(KernelRefusal::input(
5260 KernelStage::Collect,
5261 "asymmetric_selection",
5262 "chamfer_edges_asymmetric: no edges selected",
5263 ));
5264 }
5265 let mut result = solid.clone();
5266 let mut done = vec![false; edge_points.len()];
5267 for (index, point) in edge_points.iter().enumerate() {
5271 if done[index] {
5272 continue;
5273 }
5274 let edge_id = resolve_edge_by_point(&result, *point)?;
5275 let edge_name = per_edge_name(edge_names, name, index);
5276 if chamfer_asym_curved_on() {
5279 if let Some(members) = selected_chain(&result, edge_id, edge_points, &done)? {
5280 let faces = setback_faces(&result, edge_id)?;
5281 let law = crate::blend::setback::SetbackLaw::Distances { faces, distances: [d1, d2] };
5282 result = chamfer_by_setback_law(&result, edge_id, law, edge_name, "chamfer_edges_asymmetric", true)?;
5283 for member in members {
5284 done[member] = true;
5285 }
5286 continue;
5287 }
5288 }
5289 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
5290 done[index] = true;
5291 }
5292 refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
5293 Ok(result)
5294}
5295
5296pub fn chamfer_edges_angle(
5300 solid: &BrepSolid,
5301 edge_points: &[Vec3],
5302 edge_names: Option<&[String]>,
5303 d1: f64,
5304 angle_rad: f64,
5305 name: Option<&str>,
5306) -> Result<BrepSolid, KernelRefusal> {
5307 let _operation = crate::blend::BlendOperation::enter();
5310 if edge_points.is_empty() {
5311 return Err(KernelRefusal::input(
5312 KernelStage::Collect,
5313 "angle_selection",
5314 "chamfer_edges_angle: no edges selected",
5315 ));
5316 }
5317 let mut result = solid.clone();
5318 let mut done = vec![false; edge_points.len()];
5319 for (index, point) in edge_points.iter().enumerate() {
5320 if done[index] {
5321 continue;
5322 }
5323 let edge_id = resolve_edge_by_point(&result, *point)?;
5324 let edge_name = per_edge_name(edge_names, name, index);
5325 if chamfer_asym_curved_on() && d1 > 0.0 && d1.is_finite() && angle_rad > 1e-9 && angle_rad < std::f64::consts::PI {
5328 if let Some(members) = selected_chain(&result, edge_id, edge_points, &done)? {
5329 let [first, _] = setback_faces(&result, edge_id)?;
5330 let law = crate::blend::setback::SetbackLaw::Angle { first, distance: d1, angle: angle_rad };
5331 result = chamfer_by_setback_law(&result, edge_id, law, edge_name, "chamfer_edges_angle", true)?;
5332 for member in members {
5333 done[member] = true;
5334 }
5335 continue;
5336 }
5337 }
5338 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
5339 done[index] = true;
5340 }
5341 refuse_invalid_result(&result, "chamfer_edges_angle")?;
5342 Ok(result)
5343}
5344
5345fn dump_loops_debug(label: &str, solid: &BrepSolid) {
5349 for shell in &solid.shells {
5350 for face in &shell.faces {
5351 for (index, loop_record) in face.loops.iter().enumerate() {
5352 let walk: Vec<String> = loop_record
5353 .coedges
5354 .iter()
5355 .map(|coedge| {
5356 let uv = coedge
5360 .pcurve
5361 .domain()
5362 .ok()
5363 .and_then(|[q0, q1]| {
5364 let a = coedge.pcurve.evaluate(q0).ok()?;
5365 let b = coedge.pcurve.evaluate(q1).ok()?;
5366 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
5367 })
5368 .unwrap_or_default();
5369 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
5370 Some(edge) => format!(
5371 "c{}:e{}{}({}->{}){uv}",
5372 coedge.id,
5373 edge.id,
5374 if coedge.forward { "+" } else { "-" },
5375 edge.start_vertex_id,
5376 edge.end_vertex_id
5377 ),
5378 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
5379 }
5380 })
5381 .collect();
5382 eprintln!(
5383 "{label} face {} ({:?}) loop {index}: {}",
5384 face.id,
5385 face.name,
5386 walk.join(" ")
5387 );
5388 }
5389 }
5390 }
5391}
5392
5393
5394