brep_kernel/blending/fillet/edges.rs
1use super::*;
2
3/// Constant-radius rolling-ball fillet of one edge (Golovanov §4.9 march,
4/// §6.9 surgery; the cutter only where the march refuses).
5pub fn fillet_edge(
6 solid: &BrepSolid,
7 edge_id: u64,
8 radius: f64,
9 name: Option<&str>,
10) -> Result<BrepSolid, KernelRefusal> {
11 // One blend operation: the reports of an earlier one, never consumed, are
12 // dropped as it starts (nested calls keep this operation's).
13 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
19/// `fillet_edge` with the cutter first: the input the corner-closure lanes
20/// (`round_convex_corner`, the mixed-convexity closures) were written
21/// against. Their tests build it directly; production reaches those lanes
22/// only through the group's sequential composition, which is cutter-first
23/// for the same reason.
24pub(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
36/// Equal-leg chamfer of one edge (Golovanov §6.11: the same construction
37/// with the arc's chord).
38pub fn chamfer_edge(
39 solid: &BrepSolid,
40 edge_id: u64,
41 distance: f64,
42 name: Option<&str>,
43) -> Result<BrepSolid, KernelRefusal> {
44 // One blend operation: the reports of an earlier one, never consumed, are
45 // dropped as it starts (nested calls keep this operation's).
46 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
52/// Build and apply the chamfer tool for one straight edge from an already-built
53/// cross-section profile (curve index 1 is the chamfer chord / blend wall).
54/// Shared by the two-distance and distance-angle asymmetric entries.
55fn apply_chamfer_offsets_profile(
56 solid: &BrepSolid,
57 cross: &EdgeCross,
58 profile: &[NurbsCurve],
59 name: Option<&str>,
60) -> Result<BrepSolid, KernelRefusal> {
61 let mut tool = match &cross.path {
62 EdgePath::Straight { direction, length } => {
63 extrude_profile_brep(profile, *direction, *length)?
64 }
65 EdgePath::Circular { .. } => {
66 return Err(KernelRefusal::unsupported(
67 KernelStage::Classify,
68 "asymmetric_curved_edge",
69 "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
70 in this slice (asymmetric chamfer on general/curved edges is out of scope)",
71 ));
72 }
73 };
74 // Side faces are emitted in input-curve order; the chamfer wall is the
75 // second profile curve (index 1). Every other face is the cutter's
76 // scaffold, tagged and settled exactly as the symmetric cutter's is.
77 let mut side_index = 0usize;
78 for shell in &mut tool.shells {
79 for face in &mut shell.faces {
80 if side_index == 1 {
81 if face.name.is_none() {
82 face.name = name.map(str::to_string);
83 }
84 } else {
85 face.name = Some(scaffold_tag(name));
86 }
87 side_index += 1;
88 }
89 }
90 apply_tool(solid, &tool, cross.convex).map(settle_cutter_scaffold)
91}
92
93/// Asymmetric (two-distance) chamfer of one STRAIGHT edge between two planar
94/// faces (Golovanov §6.11): setback `d1` along face 1 and `d2` along face 2 —
95/// the standard CAD "d1 × d2" bevel. General/curved edges are out of scope for
96/// this slice and return a clear error.
97pub fn chamfer_edge_asymmetric(
98 solid: &BrepSolid,
99 edge_id: u64,
100 d1: f64,
101 d2: f64,
102 name: Option<&str>,
103) -> Result<BrepSolid, KernelRefusal> {
104 // One blend operation: the reports of an earlier one, never consumed, are
105 // dropped as it starts (nested calls keep this operation's).
106 let _operation = crate::blend::BlendOperation::enter();
107 if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
108 return Err(KernelRefusal::input(
109 KernelStage::Collect,
110 "setback_distances",
111 "chamfer_edge_asymmetric: both setback distances must be positive",
112 ));
113 }
114 // `analyze_edge` only uses the radius to size the orientation probe step;
115 // the smaller setback keeps that probe inside both faces.
116 let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
117 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
118 apply_chamfer_offsets_profile(solid, &cross, &profile, name)
119}
120
121/// Distance-angle chamfer of one STRAIGHT edge between two planar faces
122/// (Golovanov §6.11): setback `d1` along face 1 and angle `angle_rad` between
123/// the chamfer face and face 1. `d2` is constructed geometrically in the
124/// cross-section plane (see `chamfer_angle_second_distance`), then the
125/// two-distance builder is applied.
126pub fn chamfer_edge_angle(
127 solid: &BrepSolid,
128 edge_id: u64,
129 d1: f64,
130 angle_rad: f64,
131 name: Option<&str>,
132) -> Result<BrepSolid, KernelRefusal> {
133 // One blend operation: the reports of an earlier one, never consumed, are
134 // dropped as it starts (nested calls keep this operation's).
135 let _operation = crate::blend::BlendOperation::enter();
136 if !(d1 > 0.0) || !d1.is_finite() {
137 return Err(KernelRefusal::input(
138 KernelStage::Collect,
139 "setback_d1",
140 "chamfer_edge_angle: setback distance d1 must be positive",
141 ));
142 }
143 let cross = analyze_edge(solid, edge_id, d1)?;
144 let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
145 let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
146 apply_chamfer_offsets_profile(solid, &cross, &profile, name)
147}
148
149/// Snap small trim/intersection gaps between blend edges and their vertices.
150/// The boolean endpoint welder preserves gaps already inside the validation
151/// band and limits repairs to `max(|radius| * 1e-3, 1e-4)`.
152pub(super) fn heal_edge_vertex_gaps(
153 solid: &mut BrepSolid,
154 radius: f64,
155) -> Result<(), KernelRefusal> {
156 let search = (radius.abs() * 1e-3).max(1e-7);
157 crate::boolean::commit_nearby_edge_endpoints(solid, search)
158}
159
160/// The fillet pipeline's exit acceptance: a solid with a validation ERROR is
161/// refused by name, never returned.
162///
163/// Warnings do not refuse; only the issues `validate()` itself calls errors.
164fn refuse_invalid_result(result: &BrepSolid, entry: &str) -> Result<(), KernelRefusal> {
165 let errors: Vec<_> = result.validate_memoised().into_iter().filter(|issue| issue.severity == "error").collect();
166 let Some(first) = errors.first() else {
167 return Ok(());
168 };
169 Err(KernelRefusal::new(
170 RefusalClass::InvalidResultTopology { issues: errors.len() as u32 },
171 KernelStage::Validate,
172 format!(
173 "{entry}: the blend built a solid that fails validation ({} errors, first: {}); \
174 refused rather than returned",
175 errors.len(),
176 first.message
177 ),
178 ))
179}
180
181/// Resolve a point within 1e-3 of a trimmed edge.
182fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
183 match crate::topology::nearest_edge(solid, point) {
184 Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
185 Some((_, distance)) => Err(KernelRefusal::input(
186 KernelStage::Collect,
187 "edge_point",
188 format!(
189 "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
190 ),
191 )),
192 None => Err(KernelRefusal::input(
193 KernelStage::Collect,
194 "no_edges",
195 "fillet_edges: solid has no edges",
196 )),
197 }
198}
199
200/// Lane R1 row 3b: a fillet operation opens the validation coedge memo
201/// (`CoedgeMemoScope`). On by default (promoted 2026-10-18 on the 98 + 144
202/// census); `BREP_FILLET_COEDGE_MEMO=0` (or `off`) opens none.
203pub(crate) fn fillet_coedge_memo_on() -> bool {
204 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
205 *ON.get_or_init(|| !matches!(std::env::var("BREP_FILLET_COEDGE_MEMO").as_deref(), Ok("0") | Ok("off")))
206}
207
208
209/// Fillet (or chamfer) a GROUP of edges as ONE operation, and — for fillets —
210/// round the convex "star" vertices where three or more of the selected edges
211/// meet (Golovanov §6.9.7). This is the whole multi-edge fillet in a single
212/// kernel call: the caller passes the object plus one 3D point on each edge,
213/// and the kernel orchestrates the filleting and corner blending against the
214/// full topology (so acute corners resolve coherently instead of being
215/// stitched edge-by-edge by the app). A corner the kernel cannot round (e.g.
216/// non-orthogonal beyond support, or a general no-common-ball star) is left as
217/// the edge fillets rather than failing the whole group.
218///
219/// When the WHOLE selection cannot be blended as one unit — a shared convex
220/// corner where a revolve axis/pole edge meets the adjacent cap edges can
221/// defeat the sequential corner surgery even though each edge and every proper
222/// SUBSET of the selection blends cleanly (the three fillets converge on the
223/// pole with no single end face across the corner) — a maximal-valid-SUBSET
224/// search blends the largest subset that works, dropping only the edges that
225/// cannot co-blend. That is a real outcome and [`fillet_edges_reported`]
226/// returns it, WITH the list of edges it could not blend.
227///
228/// This entry point does not. A solid holding eight of the ten walls that
229/// were asked for is not the answer to the question that was asked, and
230/// returning it as a plain `Ok` reports success for a different result; so an
231/// incomplete selection is an error here, naming every edge that was dropped
232/// and the group refusal behind them. A caller that genuinely wants the
233/// partial answer asks for it by name.
234///
235/// `edge_names` (when `Some`) is the per-edge blend-FACE name parallel to
236/// `edge_points` — each grown wall is named after ITS originating edge; `None`
237/// names every wall with the single base `name` (the legacy/test behavior,
238/// byte-identical to before). The whole `*_edges` family takes the same
239/// `edge_names` slot in the same position.
240pub fn fillet_edges(
241 solid: &BrepSolid,
242 edge_points: &[Vec3],
243 edge_names: Option<&[String]>,
244 radius: f64,
245 chamfer: bool,
246 name: Option<&str>,
247) -> Result<BrepSolid, KernelRefusal> {
248 // One blend operation: the reports of an earlier one, never consumed, are
249 // dropped as it starts (nested calls keep this operation's).
250 let _operation = crate::blend::BlendOperation::enter();
251 // Each operation pays for the first read of every body it validates
252 // (`BrepSolid::validate_memoised`, lane R1 row 3).
253 BrepSolid::clear_validate_memo();
254 // One coedge-check memo per operation, as a boolean opens one (lane R1
255 // row 3b, `BREP_FILLET_COEDGE_MEMO`): the surgery's, the group lane's
256 // and the exit's validations re-read the coedges the rims left unchanged.
257 let _coedge_memo = crate::topology::CoedgeMemoScope::open(fillet_coedge_memo_on() && crate::topology::coedge_memo_enabled());
258 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
259 // The search is capped at one dropped edge here (lane F row 3);
260 // `BREP_SUBSET_SEARCH_FULL=1` restores the full search.
261 let cap = std::env::var_os("BREP_SUBSET_SEARCH_FULL").is_none().then_some(1);
262 let _cap = SubsetDropCap::set(cap);
263 let (result, selection) =
264 fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name)?;
265 if selection.is_complete() {
266 return Ok(result);
267 }
268 // Every dropped edge BY NAME: the caller's own per-edge name when it gave
269 // one, and always its index and the point it was selected by.
270 let dropped: Vec<String> = selection
271 .rejected
272 .iter()
273 .map(|index| {
274 let point = edge_points[*index];
275 let named = edge_names
276 .and_then(|names| names.get(*index))
277 .map(|name| format!("`{name}` "))
278 .unwrap_or_default();
279 format!(
280 "{named}#{index} at ({:.6}, {:.6}, {:.6})",
281 point.x, point.y, point.z
282 )
283 })
284 .collect();
285 // The selection, as specified, has no single answer the kernel will
286 // stand behind: the group refusal behind the drop travels as TEXT in
287 // `BlendSelection::reason`, so this refusal carries its own class.
288 Err(KernelRefusal::ill_posed(
289 KernelStage::Select,
290 "incomplete_selection",
291 format!(
292 "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
293 rest of the selection and carry no wall in the result ({}). The group refusal was: \
294 {}. Blend them separately, or call the reported entry point to accept the partial \
295 selection deliberately.",
296 selection.applied.len(),
297 selection.requested,
298 selection.rejected.len(),
299 dropped.join(", "),
300 selection
301 .reason
302 .as_deref()
303 .unwrap_or("the whole group built but not every edge grew a wall"),
304 ),
305 ))
306}
307
308/// What a blend selection actually achieved.
309///
310/// The kernel can answer a group blend with fewer walls than were asked for —
311/// the maximal-valid-subset search drops the edges that cannot co-blend at a
312/// shared corner rather than failing outright. This says WHICH, so partial
313/// fulfilment is a statement instead of a silence.
314#[derive(Clone, Debug)]
315pub struct BlendSelection {
316 /// How many edges the caller selected.
317 pub requested: usize,
318 /// Indices into that selection that carry a blend wall in the result.
319 pub applied: Vec<usize>,
320 /// Indices that do not.
321 pub rejected: Vec<usize>,
322 /// Why the whole selection could not be built, when something was dropped.
323 pub reason: Option<String>,
324}
325
326impl BlendSelection {
327 /// Every requested edge carries a wall.
328 pub fn is_complete(&self) -> bool {
329 self.rejected.is_empty()
330 }
331}
332
333/// [`fillet_edges`] with the selection outcome reported rather than refused.
334///
335/// The result is the same solid `fillet_edges` would build; the difference is
336/// that an incomplete selection comes back as `Ok` with the misses named,
337/// which is the explicit partial-result contract. Every named refusal that
338/// applies to the WHOLE selection — mixed concavity, a radius the local wall
339/// cannot hold, a mixed-convexity corner — is still an `Err`: dropping edges
340/// cannot rescue those, so a partial answer would be a different defect.
341/// [`fillet_edges_reported`] with the solid and the edge points translated by
342/// `offset` for the operation and the result translated back. Refusal
343/// messages name the translated coordinates.
344pub fn fillet_edges_reported_at_offset(
345 solid: &BrepSolid,
346 edge_points: &[Vec3],
347 edge_names: Option<&[String]>,
348 radius: f64,
349 chamfer: bool,
350 name: Option<&str>,
351 offset: Vec3,
352) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
353 fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Control)
354}
355
356/// The translated run behind the control and the guard.
357#[allow(clippy::too_many_arguments)]
358fn fillet_translated(
359 solid: &BrepSolid,
360 edge_points: &[Vec3],
361 edge_names: Option<&[String]>,
362 radius: f64,
363 chamfer: bool,
364 name: Option<&str>,
365 offset: Vec3,
366 kind: crate::recentre::EntryKind,
367) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
368 let _entry = crate::recentre::TranslatedEntry::open(kind);
369 let translate = |solid: &BrepSolid, offset: Vec3| {
370 crate::recentre::translate_solid(solid, offset).map_err(|message| {
371 KernelRefusal::internal(
372 KernelStage::Collect,
373 "translation_control",
374 format!("fillet_edges: the translation control could not move the solid: {message}"),
375 )
376 })
377 };
378 let moved = translate(solid, offset)?;
379 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
380 let (result, selection) = fillet_edges_reported(&moved, &points, edge_names, radius, chamfer, name)?;
381 Ok((translate(&result, offset.scale(-1.0))?, selection))
382}
383
384pub fn fillet_edges_reported(
385 solid: &BrepSolid,
386 edge_points: &[Vec3],
387 edge_names: Option<&[String]>,
388 radius: f64,
389 chamfer: bool,
390 name: Option<&str>,
391) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
392 // The translation control (`BREP_TRANSLATION_CONTROL`, `brep/recentre.rs`):
393 // the outermost entry runs the fillet on a translated solid.
394 if let Some(offset) = crate::recentre::control_offset_at_entry() {
395 return fillet_edges_reported_at_offset(solid, edge_points, edge_names, radius, chamfer, name, offset);
396 }
397 // The recentre guard: a solid far from the origin in its own units
398 // is filleted at the origin.
399 let bounds = crate::recentre::solid_bounds(solid);
400 if let Some(offset) = crate::recentre::guard_offset(bounds) {
401 crate::recentre::profile_fired("fillet", offset, bounds);
402 return fillet_translated(solid, edge_points, edge_names, radius, chamfer, name, offset, crate::recentre::EntryKind::Guard);
403 }
404 // One blend operation: the reports of an earlier one, never consumed, are
405 // dropped as it starts (nested calls keep this operation's).
406 let _operation = crate::blend::BlendOperation::enter();
407 // Dropped before `_operation`, so the total is on the ledger when the
408 // outermost operation prints it.
409 let _total = crate::blend::BlendTimer::start("timing.total_ms");
410 if !(radius > 0.0) || !radius.is_finite() {
411 return Err(KernelRefusal::input(
412 KernelStage::Collect,
413 "radius",
414 "fillet_edges: radius must be positive",
415 ));
416 }
417 if edge_points.is_empty() {
418 return Err(KernelRefusal::input(
419 KernelStage::Collect,
420 "selection",
421 "fillet_edges: no edges selected",
422 ));
423 }
424 // The rolling ball must reach both supports of every SELECTED edge — see
425 // `check_support_extent`. Checked ONCE, here, against the solid the
426 // selection was made on: inside the group build the faces have already
427 // been eaten into by earlier blends of the same selection, and a fillet
428 // legitimately runs off that remainder (two rounds pinching on a shared
429 // face). Dropping edges cannot rescue an oversized radius either, so
430 // this runs before the subset search rather than inside it.
431 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
432 let mut selected_ids = Vec::with_capacity(edge_points.len());
433 let selection_checks = crate::blend::BlendTimer::start("timing.selection_checks_ms");
434 for point in edge_points {
435 let edge_id = resolve_edge_by_point(solid, *point)?;
436 check_mixed_concavity(solid, edge_id, entry)?;
437 // A lone fillet edge may take the clipped concave fill (lane F row 5).
438 if edge_points.len() == 1 && !chamfer {
439 check_support_extent_clipping(solid, edge_id, radius, entry)?;
440 } else {
441 check_support_extent(solid, edge_id, radius, entry)?;
442 }
443 selected_ids.push(edge_id);
444 }
445 selection_checks.stop();
446 // A corner where the selection mixes convexity — a convex edge dying into
447 // the concave edges it meets — has no rolling-ball closure at all: the
448 // blends run out against each other partway along, which is the vertex
449 // blend the stripe network does not construct yet. It is TERMINAL, and
450 // for the same reason as the two checks above it runs once, here: neither
451 // the cutter composition nor the subset search below can rescue it. The
452 // cutter "succeeds" on this shape by shredding the solid (a sliver end cap
453 // per corner, the carrier face split and renamed), which is worse than
454 // the named refusal — see the 2026-09-09 rib-spine report.
455 //
456 // Nor can PARTITIONING the selection at the corner rescue it, which is why
457 // this is still a refusal and not a split (measured 2026-09-13, the
458 // partition record): the two convexity-pure halves each build on the
459 // network, and composing them walls every selected edge and leaves every
460 // other face's area identical to twelve digits — but the convex wall comes
461 // out as the full untrimmed quarter-round prism (2·(1 − π/4)·r²·L to
462 // 2.2e-10, with NO rolled-corner term) because its contact rail runs off
463 // its own face at the TRI-TANGENT STATION and the surgery splices the
464 // overhang in rather than clipping it. The faces it runs along come back
465 // with self-crossing loops that `validate()`, `solid_self_intersections`,
466 // the connectivity report and the Euler check all pass — and that
467 // `check_loop_self_crossings` REFUSES since 9e50f6d55, so the split is not
468 // a route to ten walls either: blend the concave half first and the
469 // acceptance refuses every convex edge whose wall runs out into it — on
470 // the rib both spines, at that same station, so that feature fails
471 // outright; blend the convex half first and the maximal-valid-subset
472 // search drops one ring edge and returns NINE walls of ten. The
473 // construction that is missing is the runout itself: past that station the
474 // ball rolls on one input face and on the concave stripe's own surface, so
475 // the march needs a STRIPE as its second carrier.
476 //
477 // And more than that, measured 2026-09-13 (`blend/runout.rs`, which this
478 // refusal now reports): the KEPT mate runs out too, at the mirror-image
479 // station, so the FIRST carrier becomes the other concave stripe and the
480 // last stretch rolls on two stripes; that stretch ends in a POLE where the
481 // two stripes' rails on the face they share meet and the section shrinks
482 // to a point. Two carrier switches and a degenerate terminus, not a march
483 // with a stop.
484 if !chamfer {
485 // The class is BUILT where the runout composition applies — the ring
486 // first, then one wall per spine from pole to pole (`blend/runout.rs`,
487 // the 2026-09-30 record §5) — and refused by name, with the
488 // composition's own reason appended, where it does not.
489 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
490 let mut corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
491 corners.extend(crate::blend::runout::one_ended_runout_corners(solid, &selected_ids, radius));
492 corner_analysis.stop();
493 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
494 for corner in &corners {
495 eprintln!(
496 "mixed corner at vertex {} ({:.4}, {:.4}, {:.4}): convex {:?} concave {:?}",
497 corner.vertex, corner.point.x, corner.point.y, corner.point.z,
498 corner.convex.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>(),
499 corner.concave.iter().map(|index| selected_ids[*index]).collect::<Vec<_>>()
500 );
501 }
502 }
503 if !corners.is_empty() {
504 // BREP lane L: begin walls_missing_tie_all_stations_20261015
505 // Cleared on entry: only THIS composition's runout may set it.
506 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(false));
507 // BREP lane L: end walls_missing_tie_all_stations_20261015
508 let composed = rounded_boss_composition(
509 solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
510 ).or_else(|error| {
511 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
512 eprintln!("rounded boss composition refused: {error}");
513 }
514 runout_composition(
515 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
516 )
517 }).or_else(|error| {
518 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
519 eprintln!("runout composition refused: {error}");
520 }
521 // The open-pad composition (2026-10-17 face-ring report): one
522 // convex and one concave edge at each mixed corner over a
523 // planar face, the concave pads continued onto the convex
524 // walls. Declines by name on any other corner shape, so the
525 // runout's refusal stands where it stood.
526 open_pad_composition(
527 solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
528 ).map_err(|pad_error| {
529 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
530 eprintln!("open-pad composition refused: {pad_error}");
531 }
532 // The decline is carried in the refusal's CLASS (`what`),
533 // never in its text: 2026-10-18 the text test let the
534 // decline replace the runout's named refusal on the
535 // nine-pick wedge (three lib tests).
536 let declined = matches!(&pad_error.class, crate::RefusalClass::UnsupportedGeometry { what } if what == "open_pad_shape");
537 if declined { error } else { pad_error }
538 })
539 });
540 match composed {
541 Ok(result) => {
542 // BREP lane L: begin walls_missing_tie_all_stations_20261015
543 // The runout composition's walls are built and checked by
544 // construction; a station its OWN neighbouring wall consumes
545 // (a runout trimmed at an edge's end) is not a missing wall.
546 // Only on the NETWORK's ring: there every concave edge has its
547 // own wall by construction and a runout trims at most its
548 // ends; a ring the cutter fallback built keeps the strict
549 // first-station rule (the rounded boss path too).
550 let network_ring = RUNOUT_RING_IS_NETWORK.with(|flag| flag.replace(false));
551 // Integrator merge 2026-10-16 (lanes F + L): the flag above is consumed
552 // BEFORE lane F's exit acceptance can return early, so a refused result
553 // never leaves it set for the next call on this thread.
554 refuse_invalid_result(&result, entry)?;
555 let missing = walls_missing_ties(
556 solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry, network_ring,
557 )?;
558 // BREP lane L: end walls_missing_tie_all_stations_20261015
559 if !missing.is_empty() {
560 return Err(KernelRefusal::internal(
561 KernelStage::Validate,
562 "runout_walls_missing",
563 format!(
564 "{entry}: the runout composition built a solid with no wall on {}",
565 missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
566 ),
567 ));
568 }
569 let n = edge_points.len();
570 return Ok((
571 result,
572 BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
573 ));
574 }
575 Err(error) => {
576 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
577 eprintln!("runout composition refused: {error}");
578 }
579 check_mixed_corner_convexity(
580 solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
581 )?;
582 }
583 }
584 }
585 // A selection whose corner setbacks consume a whole edge — the exact
586 // degenerate radius, every face shrunk to a point — has no strip for
587 // the network to build and nothing the cutter or the subset search
588 // can rescue (the search answered the r = 10 cube with nine walls and
589 // a 5174 volume against the sphere's 4189). Terminal, by name, here.
590 let corner_analysis = crate::blend::BlendTimer::start("timing.corner_analysis_ms");
591 check_degenerate_corner_setbacks(
592 solid,
593 &selected_ids,
594 edge_points,
595 edge_names,
596 radius,
597 name,
598 )?;
599 corner_analysis.stop();
600 }
601
602 let n = edge_points.len();
603 let complete = |applied: Vec<usize>| BlendSelection {
604 requested: n,
605 applied,
606 rejected: Vec::new(),
607 reason: None,
608 };
609 // Every subset below is built against the SAME solid, and a selection of
610 // several components is composed one component at a time in input order,
611 // so two subsets that keep the same leading components rebuild the same
612 // intermediate solids. The memo keeps each component's result under the
613 // chain of components composed before it, which is everything that result
614 // depends on here (the solid, radius, chamfer and name are fixed for the
615 // call), so the search returns what it did without the memo, only without
616 // the rebuilding. On the 2026-09-26 cross-drilled sphere report four bored
617 // rims were re-blended ahead of the same failing component in each of the
618 // (up to 372) subsets tried: 13 min 25 s for one refusal, 34 s with it.
619 let mut memo = ComponentMemo::default();
620 let profile = std::env::var_os("BREP_PROFILE").is_some();
621 let group_started = web_time::Instant::now();
622 let group = fillet_edges_group_in(
623 solid,
624 edge_points,
625 edge_names,
626 radius,
627 chamfer,
628 name,
629 Some(&mut memo),
630 );
631 if profile {
632 eprintln!(
633 "blend.selection group edges={n} ok={} ms={:.3}",
634 group.is_ok(),
635 group_started.elapsed().as_secs_f64() * 1e3
636 );
637 }
638 let group_err = match group {
639 // A group solid that fails `validate()` is never returned: it becomes
640 // the group's refusal, so the subset search below (which validates
641 // every subset it builds) still gets its turn, and a selection with
642 // nothing to drop refuses by that name.
643 Ok(result) if refuse_invalid_result(&result, entry).is_err() => {
644 refuse_invalid_result(&result, entry).unwrap_err()
645 }
646 Ok(result) => {
647 // The group returned a solid, which is NOT the same as the group
648 // having blended every edge. `validate()` cannot tell the
649 // difference — it checks incidence, and a solid missing a wall is
650 // perfectly incident — so the walls are COUNTED.
651 let missing =
652 walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
653 if missing.is_empty() {
654 return Ok((result, complete((0..n).collect())));
655 }
656 let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
657 return Ok((
658 result,
659 BlendSelection {
660 requested: n,
661 applied,
662 rejected: missing,
663 reason: None,
664 },
665 ));
666 }
667 Err(error) => error,
668 };
669 // A wall that FOLDS THROUGH ITSELF is terminal for the whole selection, and
670 // for the reason the two checks above are: dropping edges cannot rescue it.
671 // The subset search would happily answer this document's two collars with
672 // the ONE that does not fold, and return a solid whose volume is a blend
673 // short — the same silent partial `both_collars_add_independently` exists
674 // to catch. Reported by name instead (`blend/fold.rs`).
675 // A miter past the sharp edge's far vertex needs support-face switching,
676 // not a smaller selection. Do not let the subset search mask the limit.
677 if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err) {
678 return Err(group_err);
679 }
680 // Fewer than two edges: nothing to drop, so the group error is final.
681 // Cap the combinatorial search so a large malformed selection cannot
682 // explode (the full group carries the common case; the search is a
683 // rare fallback).
684 if n < 2 || n > 12 {
685 return Err(group_err);
686 }
687 // Drop the fewest edges first (largest surviving subset), trying the
688 // drop-sets in lexicographic order so the result is deterministic.
689 // Take the first subset that blends to a valid solid IN WHICH EVERY KEPT
690 // EDGE GREW A WALL: a subset that itself silently drops one of its own
691 // edges is not the maximal subset, it is a smaller one wearing a larger
692 // one's label.
693 let max_drop = SUBSET_DROP_CAP.with(|cap| cap.get()).map_or(n - 1, |cap| cap.min(n - 1));
694 for drop in 1..=max_drop {
695 for dropped in index_combinations(n, drop) {
696 let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
697 let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
698 // Subset the per-edge blend-face names with the IDENTICAL
699 // drop-set so `kept_names[k]` still names `kept[k]`.
700 let kept_names: Option<Vec<String>> =
701 edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
702 crate::blend::record_count("count.subset.builds", 1);
703 let subset_started = web_time::Instant::now();
704 let subset = fillet_edges_group_in(
705 solid,
706 &points,
707 kept_names.as_deref(),
708 radius,
709 chamfer,
710 name,
711 Some(&mut memo),
712 );
713 if profile {
714 eprintln!(
715 "blend.selection subset dropped={dropped:?} ok={} ms={:.3}",
716 subset.is_ok(),
717 subset_started.elapsed().as_secs_f64() * 1e3
718 );
719 }
720 let result = match subset {
721 Ok(result) => result,
722 Err(error) => {
723 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
724 eprintln!("subset dropped={dropped:?} refused: {error}");
725 }
726 continue;
727 }
728 };
729 if !result.validate().is_empty() {
730 continue;
731 }
732 if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
733 continue;
734 }
735 return Ok((
736 result,
737 BlendSelection {
738 requested: n,
739 applied: keep,
740 rejected: dropped,
741 reason: Some(group_err.message),
742 },
743 ));
744 }
745 }
746 Err(group_err)
747}
748
749/// Lane R1: [`walls_missing`] reads a station's side off its nearest face
750/// before casting a ray. Off unless `BREP_WALLS_MISSING_NEAREST` is `1`/`on`
751/// until its census promotes it.
752pub(crate) fn walls_missing_nearest_on() -> bool {
753 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
754 *ON.get_or_init(|| matches!(std::env::var("BREP_WALLS_MISSING_NEAREST").as_deref(), Ok("1") | Ok("on")))
755}
756
757
758/// Which of `selected` (indices into the caller's own selection) carry NO blend
759/// wall in `result` — decided by the MATERIAL, not by a name.
760///
761/// A blended edge is an edge whose material changed: a convex blend removes
762/// the sliver under the edge, so a point of the original edge ends up OUTSIDE
763/// the answer; a concave one pads the crease, so it ends up INSIDE. An edge
764/// the selection dropped is still the sharp edge it was, and its points still
765/// lie ON the skin. That statement holds for every caller and every lane,
766/// which the previous attribution did not: it looked for one face per edge
767/// carrying the caller's per-edge name, so a caller passing `None` (the wasm
768/// ABI, `fillet_edges_json`) got NO count at all, and a caller passing names
769/// was told a wall was missing where the network had merged two coaxial walls
770/// into one face (the U-channel at half its width keeps one name for two
771/// edges) or collapsed the walls of a full-width star into its corner patch
772/// (`{base}:CORNER:…`, and no wall name at all). Both were measured
773/// 2026-09-26; the named path REFUSED those complete blends.
774///
775/// The classifier answers `On` inside a band, so the check states how far
776/// the blend of THIS size moves the edge at each probed station — `r·(1/cos(α/2)
777/// − 1)` for a fillet and `d·sin(α/2)` for a chamfer, `α` the angle between the
778/// two faces' outward normals there — and probes the station the blend moves
779/// MOST. An edge that is tangent at one end (the miter seam two rounds leave
780/// runs to 180° at its pole) is still attributed where it is sharp. Only when
781/// no station clears the band is the selection refused by name, rather than
782/// an unseeable move read as a missing wall.
783pub(super) fn walls_missing(
784 original: &BrepSolid,
785 result: &BrepSolid,
786 edge_points: &[Vec3],
787 selected: &[usize],
788 size: f64,
789 chamfer: bool,
790 entry: &str,
791) -> Result<Vec<usize>, KernelRefusal> {
792 walls_missing_ties(original, result, edge_points, selected, size, chamfer, entry, false)
793}
794
795// BREP lane L: begin walls_missing_tie_all_stations_20261015
796/// [`walls_missing`], with `tie_majority`: on a clearance TIE (a uniform
797/// dihedral, every station moving the same) the first station stands for
798/// all of them unless this is set, when a wall is missing only if MORE THAN
799/// HALF of the tied stations still lie on the skin (an unwalled edge whose
800/// two ends neighbouring walls consume — the wedge nine-pick's unselected
801/// side A ∩ wall — still reads missing; "every tied station" did not). Only the runout composition sets it: its
802/// own neighbouring wall may consume an edge's end (the rib nine-edge
803/// selection's trimmed runout over the wall ∩ roof pad's first tenth read
804/// as that pad having no wall), while elsewhere the first station is what
805/// catches a cutter composition's wrong corners (the one-ended channel with
806/// its runout switched off: stations ON at both ends of an edge whose middle
807/// is walled — a measured 1253.219 against its 1253.373 body) and a station
808/// reading alone cannot tell the two apart.
809#[allow(clippy::too_many_arguments)]
810pub(super) fn walls_missing_ties(
811 original: &BrepSolid,
812 result: &BrepSolid,
813 edge_points: &[Vec3],
814 selected: &[usize],
815 size: f64,
816 chamfer: bool,
817 entry: &str,
818 tie_majority: bool,
819) -> Result<Vec<usize>, KernelRefusal> {
820// BREP lane L: end walls_missing_tie_all_stations_20261015
821 use crate::{PointClass, SolidClassifier};
822 if selected.is_empty() {
823 return Ok(Vec::new());
824 }
825 let _timing = crate::blend::BlendTimer::start("timing.walls_missing_ms");
826 crate::classification::classify_profile_begin();
827 crate::classification::containment_profile_begin();
828 let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
829 // `classify` reads `On` within ten times its band; attribution needs the
830 // move to clear that by the same margin again.
831 let visible = band * 100.0;
832 let build_timing = crate::blend::BlendTimer::start("timing.walls_missing.classifier_ms");
833 let classifier = SolidClassifier::new(result, band)
834 .or_refuse(KernelStage::Validate, "wall_classifier")?;
835 // Lane R1 (`BREP_WALLS_MISSING_NEAREST=1`): a station's On-or-not is read
836 // off its nearest face first (`NearestFaceSide`, the classifier's own On
837 // band of ten `band`s, answering only where the read is rigorous); the
838 // ray answers whatever it declines.
839 let nearest = if walls_missing_nearest_on() {
840 Some(super::analyze::NearestFaceSide::new(result, band).or_refuse(KernelStage::Validate, "wall_nearest_face")?)
841 } else {
842 None
843 };
844 let reads_on = |point: Vec3| -> Result<bool, KernelRefusal> {
845 if let Some(class) = nearest.as_ref().and_then(|nearest| nearest.side(point)) {
846 crate::blend::record_count("count.walls_missing.nearest", 1);
847 return Ok(class == PointClass::On);
848 }
849 Ok(classifier.classify(point).or_refuse(KernelStage::Validate, "wall_classify")?.class == PointClass::On)
850 };
851 build_timing.stop();
852 const STATIONS: usize = 9;
853 let mut missing = Vec::new();
854 for &index in selected {
855 let point = edge_points[index];
856 let edge_id = resolve_edge_by_point(original, point)?;
857 let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
858 // The station the blend moves most, and how far.
859 let mut best: Option<(Vec3, f64)> = None;
860 // BREP lane L: begin walls_missing_tie_all_stations_20261015
861 let mut read: Vec<(Vec3, f64)> = Vec::with_capacity(STATIONS);
862 // BREP lane L: end walls_missing_tie_all_stations_20261015
863 for step in 1..=STATIONS {
864 let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
865 let Ok(station) = edge.curve.evaluate(t) else {
866 continue;
867 };
868 let clearance = match faces[..] {
869 [first, second] => {
870 let (Ok((_, a)), Ok((_, b))) = (
871 super::analyze::face_plane_normal(first, station),
872 super::analyze::face_plane_normal(second, station),
873 ) else {
874 continue;
875 };
876 let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
877 if chamfer {
878 size * half.sin()
879 } else {
880 size * (1.0 / half.cos().max(1e-12) - 1.0)
881 }
882 }
883 // No dihedral to read: probe anyway, unguarded.
884 _ => f64::INFINITY,
885 };
886 if best.map_or(true, |(_, known)| clearance > known) {
887 best = Some((station, clearance));
888 }
889 // BREP lane L: begin walls_missing_tie_all_stations_20261015
890 read.push((station, clearance));
891 // BREP lane L: end walls_missing_tie_all_stations_20261015
892 }
893 let Some((station, clearance)) = best else {
894 return Err(KernelRefusal::internal(
895 KernelStage::Validate,
896 "wall_attribution_stations",
897 format!(
898 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
899 station of it could be evaluated",
900 point.x, point.y, point.z
901 ),
902 ));
903 };
904 if !(clearance > visible) {
905 // The faces themselves put the move under the band: the input
906 // leaves the attribution undecidable, and it is refused rather
907 // than guessed.
908 return Err(KernelRefusal::ill_posed(
909 KernelStage::Validate,
910 "wall_attribution_band",
911 format!(
912 "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
913 its faces are so nearly tangent that a size-{size} blend moves the edge by at \
914 most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
915 selection is refused rather than reported on a guess",
916 point.x, point.y, point.z
917 ),
918 ));
919 }
920 let classify_timing = crate::blend::BlendTimer::start("timing.walls_missing.classify_ms");
921 // The question is On or not On, never In against Out: a station the
922 // certificate below puts farther than `visible` from every face of
923 // the result cannot read On by the classifier's band pass (a foot on
924 // a carrier within ten bands) nor by its ray pass (a hit within ten
925 // bands), so it is not asked. Anything the certificate declines is
926 // classified as before.
927 let certified = station_clear_of_skin(result, station, band * 10.0, visible);
928 let on = if certified {
929 crate::blend::record_count("count.walls_missing.certified", 1);
930 false
931 } else {
932 crate::blend::record_count("count.walls_missing.classified", 1);
933 reads_on(station)?
934 };
935 classify_timing.stop();
936 // BREP lane L: begin walls_missing_tie_all_stations_20261015
937 let on = on && (!tie_majority || {
938 // The most-moved station read On; on a tie, so must more than
939 // half of the tied stations (itself included).
940 let tolerance = 1e-12 * clearance.abs().max(1.0);
941 let (mut tied, mut on_skin) = (1usize, 1usize);
942 for (other, other_clearance) in read.iter().copied() {
943 if other.sub(station).length() == 0.0 || clearance - other_clearance > tolerance {
944 continue;
945 }
946 tied += 1;
947 if !station_clear_of_skin(result, other, band * 10.0, visible) && reads_on(other)? {
948 on_skin += 1;
949 }
950 }
951 2 * on_skin > tied
952 });
953 // BREP lane L: end walls_missing_tie_all_stations_20261015
954 if on {
955 missing.push(index);
956 }
957 }
958 crate::classification::classify_profile_report("blend.walls_missing");
959 crate::classification::containment_profile_report("blend.walls_missing");
960 Ok(missing)
961}
962
963
964
965/// Is `station` certainly farther than `margin` from every face of `result`,
966/// trims included, read without a classifier query? `true` only when every
967/// face is certified by one of three readings, each a lower bound on the
968/// distance from the station to that face's trimmed carrier:
969///
970/// - its control-point box, widened by `on_band`, does not hold the station
971/// (the carrier lies in the hull of positively weighted control points);
972/// - its carrier is a recognised revolution (`NurbsSurface::analytic`,
973/// matched to 1e-9 relative): a sphere or torus farther than `margin` in
974/// closed form; a cone, cylinder or straight-generatrix partial sweep
975/// whose PATCH is farther than `margin` by the larger of three lower
976/// bounds, the meridian distance to the generatrix line, the axial
977/// clearance from the generatrix's slab and the clearance from the
978/// sweep's wedge (a station on one box edge lies ON the infinite cylinder
979/// of each neighbouring wall, a radius from its centre line, and is a
980/// radius short of its slab);
981/// - its carrier is a recognised plane, the station's foot on it is
982/// `Outside` the trims (`parameter_point_in_face`, the kernel's own lane
983/// at the band the classifier would use), every loop pcurve is a straight
984/// two-point unit-weight chord (so its image under the affine chart is a
985/// straight 3-D segment, and the trim polygon is exact), and the station
986/// is farther than `margin` from every chord's segment: a foot outside the
987/// region is nearer to the region's boundary than to any of its points.
988///
989/// Any face none of the three reads (an unrecognised carrier, a plane the
990/// station lies on whose trims are curved, a foot inside or on the trims)
991/// declines the whole station to the classifier. The recogniser's 1e-9
992/// relative bar sits four orders below the `margin` the caller passes
993/// (a hundred model bands), which is what absorbs it.
994fn station_clear_of_skin(result: &BrepSolid, station: Vec3, on_band: f64, margin: f64) -> bool {
995 use crate::AnalyticSurface;
996 // The station in a revolution frame: (ρ, h) in the meridian plane and
997 // its azimuth θ in [0, 2π) from the frame's x axis.
998 let meridian = |frame: &crate::RevolutionFrame| -> (f64, f64, f64) {
999 let offset = station.sub(frame.origin);
1000 let axial = offset.dot(frame.axis);
1001 let radial = offset.sub(frame.axis.scale(axial));
1002 let mut azimuth = radial.dot(frame.y_axis).atan2(radial.dot(frame.x_axis));
1003 if azimuth < 0.0 {
1004 azimuth += std::f64::consts::TAU;
1005 }
1006 (radial.length(), axial, azimuth)
1007 };
1008 // A patch of a revolution lies inside the axial slab of its generatrix
1009 // and inside the wedge of its sweep, so the station's clearance from
1010 // either is a lower bound on its distance to the patch.
1011 let axial_clearance = |axial: f64, h0: f64, h1: f64| (h0.min(h1) - axial).max(axial - h0.max(h1)).max(0.0);
1012 let wedge_clearance = |radial: f64, azimuth: f64, sweep: f64| -> f64 {
1013 if sweep >= std::f64::consts::TAU || azimuth <= sweep {
1014 return 0.0;
1015 }
1016 // Outside the sweep: the angle to the nearer bounding meridian.
1017 let turn = (azimuth - sweep).min(std::f64::consts::TAU - azimuth);
1018 if turn >= std::f64::consts::FRAC_PI_2 { radial } else { radial * turn.sin() }
1019 };
1020 // Distance in the meridian plane from (ρ, h) to the LINE through two
1021 // generatrix points: a lower bound for a cone or cylinder's carrier.
1022 let line_distance = |radial: f64, axial: f64, (rho0, h0): (f64, f64), (rho1, h1): (f64, f64)| -> Option<f64> {
1023 let (du, dv) = (rho1 - rho0, h1 - h0);
1024 let length = (du * du + dv * dv).sqrt();
1025 (length > 0.0).then(|| ((radial - rho0) * dv - (axial - h0) * du).abs() / length)
1026 };
1027 let clear = |distance: f64| distance.is_finite() && distance > margin;
1028 for face in result.shells.iter().flat_map(|shell| &shell.faces) {
1029 let Ok(controls) = crate::Aabb::from_surface_controls(&face.surface) else { return false };
1030 if !controls.expanded(on_band).contains(station) {
1031 continue;
1032 }
1033 let certified = match face.surface.analytic() {
1034 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, height }) => {
1035 let (radial, axial, _) = meridian(frame);
1036 let Some(line) = line_distance(radial, axial, (*rho0, 0.0), (*rho1, *height)) else { return false };
1037 clear(line.max(axial_clearance(axial, 0.0, *height)))
1038 }
1039 Some(AnalyticSurface::Sphere { frame, radius }) => clear((station.sub(frame.origin).length() - radius).abs()),
1040 Some(AnalyticSurface::Torus { frame, major_radius, minor_radius }) => {
1041 let (radial, axial, _) = meridian(frame);
1042 let tube = radial - major_radius;
1043 clear(((tube * tube + axial * axial).sqrt() - minor_radius).abs())
1044 }
1045 Some(AnalyticSurface::Revolution { frame, sweep, generatrix, .. }) => {
1046 // A STRAIGHT generatrix in the frame's zero meridian (the
1047 // extruded-arc walls: a line parallel to the axis at the
1048 // radius): its two control points read as (ρ, h), and the
1049 // patch's distance is bounded below by the meridian line
1050 // distance, the axial clearance and the wedge clearance
1051 // together. A curved or off-meridian generatrix declines.
1052 let [a, b] = generatrix.control_points.as_slice() else { return false };
1053 if generatrix.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1054 return false;
1055 }
1056 let meridian_of = |point: Vec3| -> Option<(f64, f64)> {
1057 let offset = point.sub(frame.origin);
1058 let axial = offset.dot(frame.axis);
1059 let radial = offset.sub(frame.axis.scale(axial));
1060 let rho = radial.length();
1061 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);
1062 on_meridian.then_some((rho, axial))
1063 };
1064 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 {
1065 return false;
1066 };
1067 let (radial, axial, azimuth) = meridian(frame);
1068 let Some(line) = line_distance(radial, axial, p0, p1) else { return false };
1069 // The TRIMMED face's slab is narrower than the surface's:
1070 // a station on one box edge lies on the untrimmed surface
1071 // of each perpendicular wall (that wall's rail line runs
1072 // through the corner), a radius short of its trimmed face.
1073 let slab = trimmed_axial_slab(face, frame).unwrap_or((p0.1, p1.1));
1074 clear(line.max(axial_clearance(axial, slab.0, slab.1)).max(wedge_clearance(radial, azimuth, *sweep)))
1075 }
1076 Some(plane @ AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) => {
1077 let Ok(normal) = u_dir.cross(*v_dir).normalized() else { return false };
1078 if clear(station.sub(*origin).dot(normal).abs()) {
1079 true
1080 } else {
1081 planar_trims_clear_of(face, plane, station, on_band, margin)
1082 }
1083 }
1084 None => false,
1085 };
1086 if !certified {
1087 return false;
1088 }
1089 }
1090 true
1091}
1092
1093/// The axial range `(h_low, h_high)` of a face's TRIMMED region on a
1094/// revolution whose generatrix is straight: the region lies in the uv box
1095/// of its straight-chord loops (every pcurve a two-point unit-weight
1096/// chord), the surface is a tensor product in which the axial coordinate
1097/// `h = (S − origin)·axis` depends on one parameter only and affinely
1098/// (degree 1, two control points along it), so `h` over the box is the
1099/// range between its values at the box's corners. `None` when any of that
1100/// is not read: a curved chord, an `h` that moves along both parameters,
1101/// a degree or control count that is not affine, an evaluation that fails.
1102fn trimmed_axial_slab(face: &FaceRecord, frame: &crate::RevolutionFrame) -> Option<(f64, f64)> {
1103 let (mut u_low, mut u_high, mut v_low, mut v_high) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
1104 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1105 let pcurve = &coedge.pcurve;
1106 let [a, b] = pcurve.control_points.as_slice() else { return None };
1107 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1108 return None;
1109 }
1110 for control in [a, b] {
1111 u_low = u_low.min(control.x);
1112 u_high = u_high.max(control.x);
1113 v_low = v_low.min(control.y);
1114 v_high = v_high.max(control.y);
1115 }
1116 }
1117 if !(u_low <= u_high && v_low <= v_high) {
1118 return None;
1119 }
1120 let axial_at = |u: f64, v: f64| face.surface.evaluate(u, v).ok().map(|point| point.sub(frame.origin).dot(frame.axis));
1121 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)?];
1122 let scale = corners.iter().fold(1.0_f64, |scale, h| scale.max(h.abs()));
1123 let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * scale;
1124 let surface = &face.surface;
1125 // h constant along u (the generatrix runs along v), or along v.
1126 let affine_in_v = same(corners[0], corners[1]) && same(corners[2], corners[3])
1127 && surface.degree_v == 1 && surface.control_points.first().is_some_and(|row| row.len() == 2);
1128 let affine_in_u = same(corners[0], corners[2]) && same(corners[1], corners[3])
1129 && surface.degree_u == 1 && surface.control_points.len() == 2;
1130 if !(affine_in_v || affine_in_u) {
1131 return None;
1132 }
1133 let low = corners.iter().copied().fold(f64::INFINITY, f64::min);
1134 let high = corners.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1135 (low.is_finite() && high.is_finite()).then_some((low, high))
1136}
1137
1138/// The planar reading of [`station_clear_of_skin`]: the station's foot on
1139/// the plane lies `Outside` the face's trims and every straight loop chord's
1140/// 3-D segment is farther than `margin` from the station.
1141fn planar_trims_clear_of(
1142 face: &FaceRecord,
1143 plane: &crate::AnalyticSurface,
1144 station: Vec3,
1145 on_band: f64,
1146 margin: f64,
1147) -> bool {
1148 use crate::PolygonClass;
1149 let Some(foot) = plane.project(&face.surface, station) else { return false };
1150 let uv_band = crate::classification::surface_uv_band(&face.surface, foot.u, foot.v, on_band);
1151 match crate::parameter_point_in_face(face, crate::Vec2 { x: foot.u, y: foot.v }, uv_band) {
1152 Ok(PolygonClass::Outside) => {}
1153 _ => return false,
1154 }
1155 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1156 let pcurve = &coedge.pcurve;
1157 let [a, b] = pcurve.control_points.as_slice() else { return false };
1158 if pcurve.degree != 1 || a.w != 1.0 || b.w != 1.0 {
1159 return false;
1160 }
1161 let (Ok(start), Ok(end)) = (face.surface.evaluate(a.x, a.y), face.surface.evaluate(b.x, b.y)) else { return false };
1162 let chord = end.sub(start);
1163 let length_squared = chord.dot(chord);
1164 let offset = station.sub(start);
1165 let along = if length_squared > 0.0 { (offset.dot(chord) / length_squared).clamp(0.0, 1.0) } else { 0.0 };
1166 let distance = offset.sub(chord.scale(along)).length();
1167 if !(distance.is_finite() && distance > margin) {
1168 return false;
1169 }
1170 }
1171 true
1172}
1173
1174/// The blend-FACE name for the `i`-th selected edge: its per-edge name when the
1175/// caller supplied the parallel `edge_names` (feature path — each wall named
1176/// after its originating edge, `{fid}:BLEND:{edge}`), else the single base
1177/// `name` for every wall (the legacy/test path, byte-identical to before).
1178fn per_edge_name<'a>(
1179 edge_names: Option<&'a [String]>,
1180 base: Option<&'a str>,
1181 i: usize,
1182) -> Option<&'a str> {
1183 match edge_names {
1184 Some(names) => names.get(i).map(|value| value.as_str()),
1185 None => base,
1186 }
1187}
1188
1189/// The star-corner patch name: `{base}:CORNER:{sorted+join of adjacent edge
1190/// names}` when per-edge names were supplied (feature path), else the single
1191/// `base` (legacy/test path — the corner keeps the wall name, pre-change
1192/// behavior). `base` is `{fid}:BLEND` and each `edge_names[i]` is the composed
1193/// `{fid}:BLEND:{edge}`, so stripping the `{base}:` prefix recovers the bare
1194/// originating-edge name for the join. Unique per corner: two distinct corners
1195/// never share the same set of >=3 selected edges.
1196fn corner_face_name(
1197 edge_names: Option<&[String]>,
1198 base: Option<&str>,
1199 adjacent: &[usize],
1200) -> Option<String> {
1201 match (edge_names, base) {
1202 (Some(names), Some(base)) => {
1203 let prefix = format!("{base}:");
1204 let mut raws: Vec<&str> = adjacent
1205 .iter()
1206 .filter_map(|&i| names.get(i))
1207 .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
1208 .collect();
1209 raws.sort_unstable();
1210 raws.dedup();
1211 Some(format!("{base}:CORNER:{}", raws.join("+")))
1212 }
1213 _ => base.map(|value| value.to_string()),
1214 }
1215}
1216
1217/// Round a connected group of convex edges first, including its ordinary
1218/// convex corner patches, then blend the concave rim across a curved bridge
1219/// at each transverse mixed corner. The spine's carriers and the cap may be
1220/// planes or curved analytic faces alike: transversality is read from the
1221/// three normals at the corner, and the rim network marches the bridge on the
1222/// actual carriers. Without these bridges the rim stripes would remain
1223/// disconnected. Redispatch the remaining selection: other mixed corners may
1224/// need another convex group or a pole-to-pole runout.
1225/// Each step removes the rounded group from the pending selection.
1226#[allow(clippy::too_many_arguments)]
1227fn rounded_boss_composition(
1228 solid: &BrepSolid,
1229 selected_ids: &[u64],
1230 edge_points: &[Vec3],
1231 edge_names: Option<&[String]>,
1232 radius: f64,
1233 name: Option<&str>,
1234 corners: &[crate::blend::MixedCorner],
1235) -> Result<BrepSolid, KernelRefusal> {
1236 // The legacy API gives every wall the same base name (or none). Use
1237 // distinct temporary identities through the recursive composition so a
1238 // bridge can be attributed to its own convex wall. Restore that API's
1239 // naming contract after the whole selection has been built.
1240 if edge_names.is_none() {
1241 let prefix = "FILLET:COMPOSITION";
1242 let names: Vec<_> = selected_ids
1243 .iter()
1244 .map(|id| format!("{prefix}:{id}"))
1245 .collect();
1246 let mut result = rounded_boss_composition(
1247 solid,
1248 selected_ids,
1249 edge_points,
1250 Some(&names),
1251 radius,
1252 Some(prefix),
1253 corners,
1254 )?;
1255 for face in result.shells.iter_mut().flat_map(|shell| &mut shell.faces) {
1256 if face
1257 .name
1258 .as_deref()
1259 .is_some_and(|value| value.starts_with("FILLET:COMPOSITION:"))
1260 {
1261 face.name = name.map(str::to_owned);
1262 }
1263 }
1264 return Ok(result);
1265 }
1266 let decline = || {
1267 KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
1268 "the rounded boss composition requires a convex group meeting transverse mixed corners at one end of each spine")
1269 };
1270 if std::env::var("BREP_NO_NETWORK").is_ok() {
1271 return Err(decline());
1272 }
1273 // Select by topology id so reversing the requested edges preserves the
1274 // construction order. Two-ended spines are left for runout composition.
1275 let corner = corners
1276 .iter()
1277 .filter(|corner| {
1278 corner.convex.len() == 1
1279 && corner.concave.len() == 2
1280 && corners
1281 .iter()
1282 .filter(|other| other.convex.contains(&corner.convex[0]))
1283 .count()
1284 == 1
1285 })
1286 .min_by_key(|corner| selected_ids[corner.convex[0]])
1287 .ok_or_else(decline)?;
1288 let convexity =
1289 crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
1290 if !convexity[corner.convex[0]] {
1291 return Err(decline());
1292 }
1293 let spine_index = corner.convex[0];
1294 // Resolve once so a missing selected edge refuses before connectivity
1295 // traversal rather than panicking inside the adjacency predicate.
1296 let selected_edges: Vec<&EdgeRecord> = selected_ids
1297 .iter()
1298 .map(|id| {
1299 solid
1300 .edges
1301 .iter()
1302 .find(|edge| edge.id == *id)
1303 .ok_or_else(|| {
1304 KernelRefusal::internal(
1305 KernelStage::Collect,
1306 "rounded_boss_selected_edge",
1307 format!("rounded boss composition: selected edge {id} is missing"),
1308 )
1309 })
1310 })
1311 .collect::<Result<_, _>>()?;
1312 // Round the connected convex selection together. Its far corners need
1313 // their usual vertex patches before a concave rim can cross these walls.
1314 let mut convex_indices = vec![spine_index];
1315 loop {
1316 let next = (0..selected_ids.len()).find(|index| {
1317 convexity[*index]
1318 && !convex_indices.contains(index)
1319 && convex_indices.iter().any(|other| {
1320 let edge = selected_edges[*index];
1321 let mate = selected_edges[*other];
1322 [edge.start_vertex_id, edge.end_vertex_id]
1323 .iter()
1324 .any(|v| *v == mate.start_vertex_id || *v == mate.end_vertex_id)
1325 })
1326 });
1327 match next {
1328 Some(index) => convex_indices.push(index),
1329 None => break,
1330 }
1331 }
1332 convex_indices.sort_by_key(|index| selected_ids[*index]);
1333 let mixed: Vec<_> = corners
1334 .iter()
1335 .filter(|corner| {
1336 corner
1337 .convex
1338 .iter()
1339 .any(|index| convex_indices.contains(index))
1340 })
1341 .collect();
1342 if mixed.iter().any(|corner| {
1343 corner.convex.len() != 1
1344 || corner.concave.len() != 2
1345 || corners
1346 .iter()
1347 .filter(|other| other.convex.contains(&corner.convex[0]))
1348 .count()
1349 != 1
1350 }) {
1351 return Err(decline());
1352 }
1353 let uses = |face: &FaceRecord, edge: u64| {
1354 face.loops
1355 .iter()
1356 .flat_map(|rim| &rim.coedges)
1357 .any(|coedge| coedge.edge_id == edge)
1358 };
1359 let mut caps = Vec::new();
1360 for corner in &mixed {
1361 let spine_id = selected_ids[corner.convex[0]];
1362 let cap = solid
1363 .shells
1364 .iter()
1365 .flat_map(|shell| &shell.faces)
1366 .find(|face| {
1367 corner
1368 .concave
1369 .iter()
1370 .all(|index| uses(face, selected_ids[*index]))
1371 })
1372 .ok_or_else(decline)?;
1373 let faces: Vec<_> = solid
1374 .shells
1375 .iter()
1376 .flat_map(|shell| &shell.faces)
1377 .filter(|face| face.id == cap.id || uses(face, spine_id))
1378 .collect();
1379 // The normals at the corner: a plane's from its frame, any other
1380 // carrier's at the corner's own foot on it. The 2026-10-12 sprocket
1381 // report (`inbox_20261012_sprocket_fillet`) was the first curved CAP;
1382 // the 2026-10-13 reports on the same part put the SPINE on a cylinder
1383 // (the rim top's arc, rim wall ∩ rim top, dying into the spoke wall)
1384 // and were declined here before anything was tried, which handed the
1385 // whole selection to the runout lane and its refusal
1386 // (`inbox_20261013_sprocket_spoke_fillet`).
1387 let normals: Option<Vec<Vec3>> = faces
1388 .iter()
1389 .map(|face| match face.surface.analytic() {
1390 Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
1391 u_dir.cross(*v_dir).normalized().ok()
1392 }
1393 _ => {
1394 let uv = crate::project_point_to_surface(&face.surface, corner.point).ok()?;
1395 face.surface.normal(uv.u, uv.v).ok()
1396 }
1397 })
1398 .collect();
1399 let normals = normals.ok_or_else(decline)?;
1400 // The cap need not be perpendicular to the spine's two carriers.
1401 // An oblique or cylindrical cap cuts the rounded wall in a curved
1402 // bridge. The rim network marches that bridge on the actual carriers.
1403 // Test transversality at the corner; a cylindrical cap need not be
1404 // replaced by its tangent plane to build this composition.
1405 if normals.len() != 3
1406 || normals[0].cross(normals[1]).dot(normals[2]).abs() < 1e-6
1407 {
1408 return Err(decline());
1409 }
1410 caps.push(cap.id);
1411 }
1412 // Give unnamed walls distinct identities too: bridges are located by
1413 // their two carriers, never proximity to the old corner.
1414 let convex_names: Vec<String> = convex_indices
1415 .iter()
1416 .map(|index| {
1417 per_edge_name(edge_names, name, *index)
1418 .map(str::to_owned)
1419 .unwrap_or_else(|| format!("FILLET:ROUNDED_BOSS_SPINE:{index}"))
1420 })
1421 .collect();
1422 let convex_points: Vec<Vec3> = convex_indices
1423 .iter()
1424 .map(|index| edge_points[*index])
1425 .collect();
1426 let rounded = fillet_edges_group_in(
1427 solid,
1428 &convex_points,
1429 Some(&convex_names),
1430 radius,
1431 false,
1432 name,
1433 None,
1434 )?;
1435 let mut bridges = Vec::new();
1436 for (corner, cap) in mixed.iter().zip(&caps) {
1437 let wall_index = convex_indices
1438 .iter()
1439 .position(|index| *index == corner.convex[0])
1440 .ok_or_else(|| {
1441 KernelRefusal::internal(
1442 KernelStage::Classify,
1443 "rounded_boss_bridge_wall",
1444 format!(
1445 "rounded boss composition: no convex wall for corner {}",
1446 corner.vertex
1447 ),
1448 )
1449 })?;
1450 let candidates: Vec<_> = rounded
1451 .edges
1452 .iter()
1453 .filter(|edge| {
1454 let faces: Vec<_> = rounded
1455 .shells
1456 .iter()
1457 .flat_map(|shell| &shell.faces)
1458 .filter(|face| uses(face, edge.id))
1459 .collect();
1460 faces.iter().any(|face| face.id == *cap)
1461 && faces
1462 .iter()
1463 .any(|face| face.name.as_deref() == Some(&convex_names[wall_index]))
1464 })
1465 .collect();
1466 if candidates.len() != 1 {
1467 return Err(decline());
1468 }
1469 bridges.push(candidates[0]);
1470 }
1471 let rim_ids: Vec<u64> = selected_ids
1472 .iter()
1473 .enumerate()
1474 .filter(|(index, _)| !convex_indices.contains(index))
1475 .map(|(_, id)| *id)
1476 .collect();
1477 // Both ends must join requested rim edges: never extend a selection
1478 // into an unselected perimeter.
1479 if !bridges.iter().all(|bridge| {
1480 [bridge.start_vertex_id, bridge.end_vertex_id]
1481 .iter()
1482 .all(|vertex| {
1483 rounded.edges.iter().any(|edge| {
1484 rim_ids.contains(&edge.id)
1485 && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
1486 })
1487 })
1488 }) {
1489 return Err(decline());
1490 }
1491 let mut points = Vec::new();
1492 let mut names = Vec::new();
1493 for (index, id) in selected_ids.iter().enumerate() {
1494 if convex_indices.contains(&index) {
1495 continue;
1496 }
1497 let edge = rounded
1498 .edges
1499 .iter()
1500 .find(|edge| edge.id == *id)
1501 .ok_or_else(decline)?;
1502 points.push(
1503 edge.curve
1504 .evaluate(0.5 * (edge.t0 + edge.t1))
1505 .or_refuse(KernelStage::Collect, "rim_midpoint")?,
1506 );
1507 names.push(
1508 per_edge_name(edge_names, name, index)
1509 .unwrap_or("FILLET:RIM")
1510 .to_owned(),
1511 );
1512 }
1513 for (corner, bridge) in mixed.iter().zip(&bridges) {
1514 points.push(
1515 bridge
1516 .curve
1517 .evaluate(0.5 * (bridge.t0 + bridge.t1))
1518 .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
1519 );
1520 let mut adjacent = corner.concave.clone();
1521 adjacent.push(corner.convex[0]);
1522 names.push(
1523 corner_face_name(edge_names, name, &adjacent)
1524 .unwrap_or_else(|| format!("FILLET:BOSS_CORNER:{}", corner.vertex)),
1525 );
1526 }
1527 // A partial rim would leave an open mixed-corner construction. Require
1528 // every pending wall, including each bridge, before accepting composition.
1529 fillet_edges_reported(&rounded, &points, Some(&names), radius, false, name).and_then(
1530 |(result, selection)| {
1531 if selection.applied.len() != points.len() {
1532 Err(decline())
1533 } else {
1534 Ok(result)
1535 }
1536 },
1537 )
1538}
1539
1540/// A corner with two concave edges and a convex one requires composition.
1541///
1542/// Where selected edges meet, the rolling ball has to touch every face around
1543/// the vertex from ONE side. A convex edge running into the concave edges it
1544/// meets (a rib spine dying into the fillets at its own base) asks the ball to
1545/// sit under the shared face for one blend and over it for the other, so no
1546/// ball seats there and the corner has no closure: physically the convex blend
1547/// runs out against the concave beads partway along the edge, which is a vertex
1548/// blend the stripe network does not construct.
1549///
1550/// The re-entrant vertex of a notch — ONE concave edge, the rest convex — is
1551/// NOT this: the ball rolls round that single concave edge from one convex
1552/// blend to the next, and `round_concave_chain_corner` / `round_convex_corner`
1553/// close it with the horn-torus sector. Only its mirror is refused.
1554///
1555/// This is TERMINAL — deliberately not a fall-through to the cutter
1556/// composition. The cutter answers this shape with a watertight but shredded
1557/// solid: a sliver end cap at each unclosed corner and the carrier face split
1558/// so the original name lands on a fragment, which breaks every downstream
1559/// reference to it. A named refusal that says which edges to separate is
1560/// worth more than that solid. The runout composition (the 2026-09-30 record
1561/// §5): a selection whose convex edges die into concave ones at
1562/// mixed-convexity corners is built in two stages — the CONCAVE edges first,
1563/// through the ordinary group path (exactly the ring the user gets selecting
1564/// them alone), then one runout wall per convex spine on that ring result,
1565/// marched from pole to pole with its carriers switching onto the ring's own
1566/// pads. Accepted under the network's own checks.
1567///
1568/// The class built here: every convex edge of the selection ends on a mixed
1569/// corner at BOTH ends and shares no vertex with another convex edge, and
1570/// every other selected edge is concave. Anything else keeps the named
1571/// refusal.
1572#[allow(clippy::too_many_arguments)]
1573/// `BREP_BLEND_OPEN_PAD=0`/`off` is the escape hatch for [`open_pad_composition`].
1574fn open_pad_on() -> bool {
1575 !matches!(std::env::var("BREP_BLEND_OPEN_PAD").as_deref(), Ok("0") | Ok("off"))
1576}
1577
1578/// The OPEN-PAD composition (the 2026-10-17 "fillet takes way too long"
1579/// face-ring report): a selection whose every mixed corner is ONE convex edge
1580/// meeting ONE concave edge over the planar face they share, the third edge
1581/// at the corner either a tangent rail (the convex edge is a blend's own end
1582/// cut by the plane, the concave one the boss it was blended to: a zero-angle
1583/// sliver on the plane) or a sharp concave edge (the boss meeting the convex
1584/// edge's other mate). Neither the rounded-boss nor the runout composition
1585/// admits it (flush-joined convex chains, concave pads ending at the corners,
1586/// no concave ring).
1587///
1588/// Built in two stages on the stripe network, each an ordinary group build:
1589///
1590/// 1. Every CONVEX edge first. A convex chain's free end at a mixed corner
1591/// is the §6.9 transverse construction against the boss as the end face:
1592/// the wall is trimmed where its envelope enters the boss, the sliver tip
1593/// of the planar face goes, and the boss skin that lay under it is exposed
1594/// along the trim curve (no cliff: the trim runs from the wall's floor
1595/// rail to its rail on the other mate, which at a tangent-rail corner
1596/// lands on that rail where wall and boss are tangent).
1597/// 2. Every CONCAVE edge on the stage-1 body, EXTENDED by the trim curves it
1598/// now continues into G1 at each trimmed end (floor|boss into wall|boss
1599/// across the wall's floor rail, a tangent seam): a flush join with a seam
1600/// at the trim's start, and at the trim's far end a pole where the wall
1601/// is tangent to the boss, or a cap where the trim meets an unselected
1602/// sharp edge. The pad rides the wall past the floor rails' crossing.
1603///
1604/// The walls are named after their edges; a trim continuation is named
1605/// `{base}:RUNOUT:{pad}+{wall}` from the two bare edge names.
1606#[allow(clippy::too_many_arguments)]
1607fn open_pad_composition(
1608 solid: &BrepSolid,
1609 selected_ids: &[u64],
1610 edge_points: &[Vec3],
1611 edge_names: Option<&[String]>,
1612 radius: f64,
1613 name: Option<&str>,
1614 corners: &[crate::blend::MixedCorner],
1615 entry: &str,
1616) -> Result<BrepSolid, KernelRefusal> {
1617 let shape = |message: String| KernelRefusal::unsupported(KernelStage::Classify, "open_pad_shape", message);
1618 if !open_pad_on() {
1619 return Err(shape("the open-pad composition is switched off (BREP_BLEND_OPEN_PAD)".into()));
1620 }
1621 if std::env::var("BREP_NO_NETWORK").is_ok() {
1622 return Err(shape("the open-pad composition is a network construction and BREP_NO_NETWORK is set".into()));
1623 }
1624 if corners.is_empty() || corners.iter().any(|corner| corner.convex.len() != 1 || corner.concave.len() != 1) {
1625 return Err(shape("the open-pad composition is built for corners of one convex and one concave edge".into()));
1626 }
1627 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
1628 .ok_or_else(|| shape("the selection's convexity could not be read edge by edge".into()))?;
1629 let concave: Vec<usize> = (0..selected_ids.len()).filter(|index| !convexity[*index]).collect();
1630 let convex: Vec<usize> = (0..selected_ids.len()).filter(|index| convexity[*index]).collect();
1631 if concave.is_empty() || convex.is_empty() {
1632 return Err(shape("the open-pad composition needs convex and concave edges".into()));
1633 }
1634 // Every concave edge ends at mixed corners (an open pad), and the face
1635 // the two edges share at each corner is planar.
1636 let mates = crate::blend::selection_mates(solid, selected_ids, radius)
1637 .map_err(|error| shape(format!("the selection's mates could not be read: {error}")))?;
1638 for corner in corners {
1639 let (_, convex_faces) = &mates[corner.convex[0]];
1640 let (_, concave_faces) = &mates[corner.concave[0]];
1641 let Some(shared) = convex_faces.iter().find(|face| concave_faces.iter().any(|other| other.id == face.id)) else {
1642 return Err(shape(format!("the two edges at vertex {} share no face", corner.vertex)));
1643 };
1644 if !matches!(shared.surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) {
1645 return Err(shape(format!("the face {} the edges share at vertex {} is not planar", shared.id, corner.vertex)));
1646 }
1647 }
1648 for &index in &concave {
1649 let edge = mates[index].0;
1650 for vertex in [edge.start_vertex_id, edge.end_vertex_id] {
1651 if !corners.iter().any(|corner| corner.vertex == vertex && corner.concave[0] == index) {
1652 return Err(shape(format!(
1653 "concave {} does not end at a mixed corner at vertex {vertex}; the open-pad composition is built for pads that do",
1654 selection_label(index, edge_points, edge_names, name)
1655 )));
1656 }
1657 }
1658 }
1659 let base = name.unwrap_or("FILLET:OPEN_PAD");
1660 let bare = |index: usize| -> String {
1661 per_edge_name(edge_names, name, index)
1662 .map(|composed| composed.strip_prefix(&format!("{base}:")).unwrap_or(composed).to_string())
1663 .unwrap_or_else(|| selected_ids[index].to_string())
1664 };
1665 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
1666
1667 // ---- Stage 1: the convex edges, as the group they are. ----
1668 let convex_points: Vec<Vec3> = convex.iter().map(|index| edge_points[*index]).collect();
1669 let convex_names: Vec<String> = convex
1670 .iter()
1671 .map(|index| per_edge_name(edge_names, name, *index).map(str::to_owned).unwrap_or_else(|| format!("{base}:WALL:{}", selected_ids[*index])))
1672 .collect();
1673 // Stage 1's body is an intermediate: the bowtie and soundness scans run
1674 // once, on stage 2's body (and the caller's acceptance after it), not on
1675 // a body the pads are about to change (1.2 s of tessellation on the
1676 // reported ring).
1677 // Both stages run as the open-pad composition's network
1678 // (`with_open_pad_network`: carrier-boundary stations, the march
1679 // extended to a section end outside the overshoot); no other network
1680 // takes those, so every body the plain network built stays as it was.
1681 let stage1 = crate::blend::with_open_pad_network(|| {
1682 fillet_edges_group_with_corner_policy(solid, &convex_points, Some(&convex_names), radius, false, name, None, true, false)
1683 })
1684 .map_err(|error| error.with_message(|error| format!("open-pad composition, the convex walls: {error}")))?;
1685 if debug {
1686 eprintln!("open-pad composition: stage 1 built the {} convex walls", convex.len());
1687 }
1688
1689 // ---- Stage 2: each pad with its trim continuations, on the stage-1 body. ----
1690 let uses = |face: &FaceRecord, edge_id: u64| face.loops.iter().flat_map(|l| &l.coedges).any(|c| c.edge_id == edge_id);
1691 let mut points: Vec<Vec3> = Vec::new();
1692 let mut names: Vec<String> = Vec::new();
1693 for &index in &concave {
1694 let (edge, faces) = &mates[index];
1695 let boss_id = if matches!(faces[0].surface.analytic(), Some(crate::AnalyticSurface::Plane { .. })) { faces[1].id } else { faces[0].id };
1696 let pad = stage1
1697 .edges
1698 .iter()
1699 .find(|candidate| candidate.id == edge.id)
1700 .ok_or_else(|| KernelRefusal::internal(KernelStage::Classify, "open_pad_edge", format!(
1701 "open-pad composition: concave edge {} vanished from the stage-1 body", edge.id
1702 )))?;
1703 points.push(pad.curve.evaluate(0.5 * (pad.t0 + pad.t1)).or_refuse(KernelStage::Collect, "pad_midpoint")?);
1704 names.push(per_edge_name(edge_names, name, index).map(str::to_owned).unwrap_or_else(|| format!("{base}:PAD:{}", edge.id)));
1705 // The trim continuations: at each end of the (trimmed) pad, the edge
1706 // that borders the boss and a stage-1 wall.
1707 for vertex in [pad.start_vertex_id, pad.end_vertex_id] {
1708 let trims: Vec<&EdgeRecord> = stage1
1709 .edges
1710 .iter()
1711 .filter(|candidate| {
1712 candidate.id != pad.id
1713 && !candidate.degenerate
1714 && (candidate.start_vertex_id == vertex || candidate.end_vertex_id == vertex)
1715 })
1716 .filter(|candidate| {
1717 let faces: Vec<&FaceRecord> = stage1.shells.iter().flat_map(|shell| &shell.faces).filter(|face| uses(face, candidate.id)).collect();
1718 faces.iter().any(|face| face.id == boss_id)
1719 && faces.iter().any(|face| face.name.as_deref().is_some_and(|value| convex_names.iter().any(|wall| wall == value)))
1720 })
1721 .collect();
1722 match trims.as_slice() {
1723 [trim] => {
1724 let wall_name = stage1
1725 .shells
1726 .iter()
1727 .flat_map(|shell| &shell.faces)
1728 .filter(|face| uses(face, trim.id))
1729 .find_map(|face| face.name.clone().filter(|value| convex_names.contains(value)))
1730 .unwrap_or_default();
1731 let wall_bare = wall_name.strip_prefix(&format!("{base}:")).unwrap_or(&wall_name).to_string();
1732 points.push(trim.curve.evaluate(0.5 * (trim.t0 + trim.t1)).or_refuse(KernelStage::Collect, "trim_midpoint")?);
1733 names.push(format!("{base}:RUNOUT:{}+{wall_bare}", bare(index)));
1734 if debug {
1735 eprintln!("open-pad composition: pad {} continues into trim edge {} (wall {wall_name}) at vertex {vertex}", edge.id, trim.id);
1736 }
1737 }
1738 [] => {
1739 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
1740 "open-pad composition: at vertex {vertex} the concave {} meets no trim curve of a stage-1 wall on the boss",
1741 selection_label(index, edge_points, edge_names, name)
1742 )));
1743 }
1744 _ => {
1745 return Err(KernelRefusal::unsupported(KernelStage::Classify, "open_pad_trim", format!(
1746 "open-pad composition: at vertex {vertex} the concave {} meets {} trim curves",
1747 selection_label(index, edge_points, edge_names, name), trims.len()
1748 )));
1749 }
1750 }
1751 }
1752 }
1753 let result = crate::blend::with_open_pad_network(|| fillet_edges_group_in(&stage1, &points, Some(&names), radius, false, name, None))
1754 .map_err(|error| error.with_message(|error| format!("open-pad composition, the pads on the walls: {error}")))?;
1755 let _ = entry;
1756 Ok(result)
1757}
1758
1759fn runout_composition(
1760 solid: &BrepSolid,
1761 selected_ids: &[u64],
1762 edge_points: &[Vec3],
1763 edge_names: Option<&[String]>,
1764 radius: f64,
1765 name: Option<&str>,
1766 corners: &[crate::blend::MixedCorner],
1767 entry: &str,
1768) -> Result<BrepSolid, KernelRefusal> {
1769 if std::env::var("BREP_NO_NETWORK").is_ok() {
1770 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
1771 }
1772 let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
1773 .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
1774 let mut spine_ends = vec![0usize; selected_ids.len()];
1775 for corner in corners {
1776 if corner.convex.len() != 1 {
1777 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
1778 "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
1779 corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
1780 )));
1781 }
1782 spine_ends[corner.convex[0]] += 1;
1783 }
1784 let mut spines: Vec<usize> = Vec::new();
1785 let mut concave: Vec<usize> = Vec::new();
1786 for (index, ends) in spine_ends.iter().enumerate() {
1787 match (*ends, convexity[index]) {
1788 (2, true) => spines.push(index),
1789 (1, true) if crate::blend::runout::one_ended_runout_on() => spines.push(index),
1790 (0, false) => concave.push(index),
1791 (ends, convex) => {
1792 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
1793 "{} 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",
1794 selection_label(index, edge_points, edge_names, name),
1795 if convex { "convex" } else { "concave" },
1796 )));
1797 }
1798 }
1799 }
1800 if concave.is_empty() {
1801 return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
1802 }
1803 // Stage 1: the ring, exactly as the concave edges build on their own.
1804 let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
1805 let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
1806 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = None);
1807 let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false, true)
1808 .map_err(|error| {
1809 // The ring's network classification is the first-order reason
1810 // when the group fell through to the cutter and that refused as
1811 // well (the wedge nine-pick: the cutter says "dihedral angle too
1812 // degenerate" where the network named the inside corner at
1813 // (8, 1, 2)); an inside corner is reported by its own name.
1814 let network = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
1815 match network {
1816 Some(network) if crate::blend::is_reentrant_concave(&network) => network.with_message(|network| format!(
1817 "the concave ring did not build: {network} (the cutter fallback then refused: {error})"
1818 )),
1819 _ => error.with_message(|error| format!("the concave ring did not build: {error}")),
1820 }
1821 })?;
1822 // The ring is the NETWORK's ring. When the network declined it at an
1823 // inside corner and the cutter fallback built a body for the same edges
1824 // instead (the wedge nine-pick: the cutter leaves the concave star at
1825 // (10, 1, 2) as bare edge fillets, and the walls marched on that body
1826 // read "dihedral angle too degenerate"), the composition declines here,
1827 // by the network's own classification — the corner named, not the
1828 // march's reading of a body that is not the ring. Gated to that class:
1829 // any other fallback ring is composed exactly as before.
1830 let network_declined = LAST_NETWORK_REFUSAL.with(|last| last.borrow_mut().take());
1831 // BREP lane L: begin walls_missing_tie_all_stations_20261015
1832 let ring_is_network = network_declined.is_none();
1833 // BREP lane L: end walls_missing_tie_all_stations_20261015
1834 if let Some(network) = network_declined {
1835 if crate::blend::is_reentrant_concave(&network) {
1836 return Err(network.with_message(|network| format!(
1837 "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"
1838 )));
1839 }
1840 }
1841 // Stage 2: the runout walls on it.
1842 let spine_walls: Vec<(u64, Option<String>)> = spines
1843 .iter()
1844 .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
1845 .collect();
1846 let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
1847 let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
1848 heal_edge_vertex_gaps(&mut result, radius)?;
1849 crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
1850 let issues = result.validate();
1851 if !issues.is_empty() {
1852 return Err(KernelRefusal::new(
1853 RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
1854 KernelStage::Validate,
1855 format!("the runout walls do not validate: {issues:?}"),
1856 ));
1857 }
1858 check_blend_interference(&ring, &result, &spine_ids, entry)?;
1859 check_loop_self_crossings(&result, entry)?;
1860 check_blend_wall_loops(&result, entry)?;
1861 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
1862 let accepted = crate::accept_sound(result, entry)?;
1863 // BREP lane L: begin walls_missing_tie_all_stations_20261015
1864 RUNOUT_RING_IS_NETWORK.with(|flag| flag.set(ring_is_network));
1865 // BREP lane L: end walls_missing_tie_all_stations_20261015
1866 Ok(accepted)
1867}
1868
1869// BREP lane L: begin walls_missing_tie_all_stations_20261015
1870thread_local! {
1871 /// Set by a runout composition that SUCCEEDED on the network's ring (no
1872 /// network refusal recorded while its ring was built), read and cleared
1873 /// by its acceptance: only there does a tie take the majority.
1874 static RUNOUT_RING_IS_NETWORK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1875}
1876// BREP lane L: end walls_missing_tie_all_stations_20261015
1877
1878fn check_mixed_corner_convexity(
1879 solid: &BrepSolid,
1880 edge_ids: &[u64],
1881 edge_points: &[Vec3],
1882 edge_names: Option<&[String]>,
1883 radius: f64,
1884 name: Option<&str>,
1885 composition_error: Option<&KernelRefusal>,
1886) -> Result<(), KernelRefusal> {
1887 match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
1888 Some(corner) => {
1889 // The refusal carries the RUNOUT's own numbers when they can be
1890 // measured — the station, the carrier pair, the stop and the pole
1891 // — so it says which construction is missing and where, not only
1892 // that the corner has none. A measurement that fails leaves the
1893 // refusal exactly as it was: the corner is refused either way.
1894 let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
1895 Ok(plan) => Some(plan),
1896 Err(error) => {
1897 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1898 eprintln!("runout not measured: {error}");
1899 }
1900 None
1901 }
1902 };
1903 // "this kernel does not build that runout here": a named deferral,
1904 // carrying why the composition declined this selection.
1905 let mut message = mixed_corner_message(
1906 &corner,
1907 edge_points,
1908 edge_names,
1909 name,
1910 measured.as_ref(),
1911 );
1912 if let Some(error) = composition_error {
1913 message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
1914 }
1915 Err(KernelRefusal::unsupported(
1916 KernelStage::Classify,
1917 "mixed_corner_convexity",
1918 message,
1919 ))
1920 }
1921 None => Ok(()),
1922 }
1923}
1924
1925/// Refuse a selection one of whose edges keeps NO blend strip between the
1926/// corner balls at its two ends (see `degenerate_corner_setbacks`).
1927fn check_degenerate_corner_setbacks(
1928 solid: &BrepSolid,
1929 edge_ids: &[u64],
1930 edge_points: &[Vec3],
1931 edge_names: Option<&[String]>,
1932 radius: f64,
1933 name: Option<&str>,
1934) -> Result<(), KernelRefusal> {
1935 let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
1936 return Ok(());
1937 };
1938 let [from, to] = setback.stops;
1939 let how = if setback.crossed {
1940 format!(
1941 "run {:.3e} PAST each other along it (the radius is beyond the limit)",
1942 setback.strip
1943 )
1944 } else {
1945 format!(
1946 "meet there ({:.3e} apart against a bar of {:.3e})",
1947 setback.strip, setback.bar
1948 )
1949 };
1950 // "The radius is at or past the degenerate limit … reduce it": a
1951 // parameter the geometry refuses, read at `Classify`.
1952 Err(KernelRefusal::input(
1953 KernelStage::Classify,
1954 "degenerate_corner_setbacks",
1955 format!(
1956 "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
1957 balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
1958 {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
1959 on are consumed whole. The radius is at or past the degenerate limit for this \
1960 selection; reduce it.",
1961 selection_label(setback.edge, edge_points, edge_names, name),
1962 setback.face_id,
1963 from.x,
1964 from.y,
1965 from.z,
1966 to.x,
1967 to.y,
1968 to.z,
1969 ),
1970 ))
1971}
1972
1973/// How a refusal names the `index`-th selected edge: its originating edge
1974/// name when the feature layer supplied one (with the feature's base prefix
1975/// stripped), else the picked point.
1976fn selection_label(
1977 index: usize,
1978 edge_points: &[Vec3],
1979 edge_names: Option<&[String]>,
1980 name: Option<&str>,
1981) -> String {
1982 let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
1983 Some(base) => composed
1984 .strip_prefix(&format!("{base}:"))
1985 .unwrap_or(composed)
1986 .to_string(),
1987 None => composed.clone(),
1988 });
1989 named.unwrap_or_else(|| match edge_points.get(index) {
1990 Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
1991 None => format!("selection #{}", index + 1),
1992 })
1993}
1994
1995/// The refusal text for [`check_mixed_corner_convexity`], naming the edges the
1996/// way the user selected them (their originating edge names when the feature
1997/// layer supplied them, else the picked point).
1998fn mixed_corner_message(
1999 corner: &crate::blend::MixedCorner,
2000 edge_points: &[Vec3],
2001 edge_names: Option<&[String]>,
2002 name: Option<&str>,
2003 measured: Option<&crate::blend::RunoutPlan>,
2004) -> String {
2005 let label = |index: usize| selection_label(index, edge_points, edge_names, name);
2006 let list = |indices: &[usize]| -> String {
2007 let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
2008 match parts.split_last() {
2009 None => "none".to_string(),
2010 Some((last, [])) => last.clone(),
2011 Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
2012 }
2013 };
2014 let mut message = format!(
2015 "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
2016 {} meets concave {} there. One rolling ball cannot touch the face they share from both \
2017 sides at once, so that corner has no closure: the convex blend runs out against the \
2018 concave ones partway along the edge, and this kernel does not build that runout yet. \
2019 Blending the concave edges in one fillet and the convex ones in another does not reach \
2020 a wall on every one of them either: the convex wall comes out at FULL length in either \
2021 order, and its contact leaves the faces it runs along where their own loops would then \
2022 cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
2023 whose wall runs out into them; blend the convex ones first and the subset search drops \
2024 the concave edges it cannot compose instead, leaving those unfilleted.",
2025 corner.point.x,
2026 corner.point.y,
2027 corner.point.z,
2028 list(&corner.convex),
2029 list(&corner.concave),
2030 );
2031 // The runout, measured: where the ball's carriers switch, what they switch
2032 // to, and where the last stretch degenerates. These are the numbers the
2033 // construction owes, so the refusal states them rather than leaving the
2034 // reader to derive them (`blend/runout.rs`).
2035 if let Some(plan) = measured {
2036 message.push_str(&format!(
2037 " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
2038 the face its two concave neighbours share — at s = {:.6} from the corner, centred \
2039 ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
2040 (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
2041 STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
2042 stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
2043 kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
2044 strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
2045 the bar a march is refused at is a full span.",
2046 plan.convex_edge,
2047 plan.shared_face,
2048 plan.station.s,
2049 plan.station.center.x,
2050 plan.station.center.y,
2051 plan.station.center.z,
2052 plan.replaced_mate,
2053 plan.second_carrier,
2054 plan.station.stripe_residual,
2055 plan.second_carrier,
2056 plan.marched,
2057 plan.fit_residual,
2058 plan.carrier_excursion,
2059 ));
2060 match (&plan.stop, plan.first_carrier) {
2061 (Some(stop), Some(first)) => message.push_str(&format!(
2062 " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
2063 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
2064 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
2065 stop.s, plan.kept_mate, first, stop.stripe_residual,
2066 )),
2067 _ => message.push_str(&format!(
2068 " Where the kept mate (face {}) runs out was not measurable on this selection.",
2069 plan.kept_mate
2070 )),
2071 }
2072 if let Some(pole) = plan.pole {
2073 message.push_str(&format!(
2074 " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
2075 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
2076 section shrinks to a point. Two carrier switches and a degenerate terminus is \
2077 more than a stop station on a stripe, and none of it is composed yet.",
2078 pole.x, pole.y, pole.z, plan.shared_face,
2079 ));
2080 }
2081 }
2082 message
2083}
2084
2085/// All ways to choose `k` distinct indices from `0..n`, in lexicographic order.
2086fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
2087 let mut out = Vec::new();
2088 if k == 0 || k > n {
2089 return out;
2090 }
2091 let mut idx: Vec<usize> = (0..k).collect();
2092 loop {
2093 out.push(idx.clone());
2094 // Advance to the next combination (like counting with carry).
2095 let mut i = k;
2096 loop {
2097 if i == 0 {
2098 return out;
2099 }
2100 i -= 1;
2101 if idx[i] != i + n - k {
2102 break;
2103 }
2104 }
2105 idx[i] += 1;
2106 for j in (i + 1)..k {
2107 idx[j] = idx[j - 1] + 1;
2108 }
2109 }
2110}
2111
2112thread_local! {
2113 /// Running count (per thread) of group builds that reached the CUTTER
2114 /// COMPOSITION — every network lane above it refused or was rejected. A
2115 /// zero delta across a call is the witness that the network answered it;
2116 /// a face count or a volume cannot say which lane built a solid, since the
2117 /// cutter can build the same one. Test-observable; not part of any result.
2118 pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
2119 const { std::cell::Cell::new(0) };
2120}
2121
2122/// Blend the WHOLE selection as one group (the single-shot multi-edge fillet).
2123/// Errors if any selected edge or the shared-corner surgery cannot compose;
2124/// `fillet_edges` wraps this with a maximal-valid-subset fallback.
2125/// [`fillet_edges_group_unchecked`] held to the ONE soundness question every
2126/// other acceptance in this file passes: no face of the result may have a trim
2127/// loop that crosses itself ([`check_loop_self_crossings`]).
2128///
2129/// It sits here, at the group's single exit, rather than beside each `Ok`
2130/// inside it, because the group has four of them — the closed rim, the stripe
2131/// network, the per-component composition and the cutter composition — and the
2132/// cutter's is the one no other check guards at all. The network and the rim
2133/// ALSO ask it inside, where a bowtie falls through to the next lane instead of
2134/// failing the group; here there is no next lane, so it refuses.
2135///
2136/// The maximal-valid-subset search in [`fillet_edges_reported`] calls this
2137/// entry, so a subset that blends to a bowtie is not a valid subset either.
2138fn fillet_edges_group(
2139 solid: &BrepSolid,
2140 edge_points: &[Vec3],
2141 edge_names: Option<&[String]>,
2142 radius: f64,
2143 chamfer: bool,
2144 name: Option<&str>,
2145) -> Result<BrepSolid, KernelRefusal> {
2146 fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
2147}
2148
2149thread_local! {
2150 /// The most edges the maximal-valid-subset search may drop on this
2151 /// thread; `None` searches every subset. Set by [`fillet_edges`] for its
2152 /// own call (lane F row 3, 2026-10-15): that entry refuses any partial
2153 /// selection, so the search there only words the refusal, and a search
2154 /// past one dropped edge cost lCount_125 117 s of booleans (112 subsets)
2155 /// to refuse what the drop-one level refuses in 11 s.
2156 static SUBSET_DROP_CAP: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
2157}
2158
2159/// Restores the previous [`SUBSET_DROP_CAP`] when the strict entry returns.
2160struct SubsetDropCap(Option<usize>);
2161
2162impl SubsetDropCap {
2163 fn set(cap: Option<usize>) -> Self {
2164 Self(SUBSET_DROP_CAP.with(|current| current.replace(cap)))
2165 }
2166}
2167
2168impl Drop for SubsetDropCap {
2169 fn drop(&mut self) {
2170 SUBSET_DROP_CAP.with(|current| current.set(self.0));
2171 }
2172}
2173
2174thread_local! {
2175 /// Running count (per thread) of [`fillet_edges_group`] builds, nested
2176 /// component builds included. Test-observable; not part of any result.
2177 pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2178 /// The network's last refusal in a group build on this thread, read
2179 /// back by the runout composition when its ring stage fails.
2180 static LAST_NETWORK_REFUSAL: std::cell::RefCell<Option<KernelRefusal>> = const { std::cell::RefCell::new(None) };
2181}
2182
2183/// One component of a selection: its edge points (as bits, so the key is
2184/// exact) and its per-edge wall names.
2185type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
2186
2187/// One step of the cutter composition's sequential build: the edge's
2188/// selection point (as bits), its wall name, the cutter's end pads (as bits)
2189/// and the composition lane (0 = general first, 1 = cutter first). The pads
2190/// and the lane are part of the key because both are read off the WHOLE
2191/// selection (its corners), so the same edge can take a different cutter in
2192/// a subset that drops a corner's third edge.
2193type SequentialKey = ([u64; 3], Option<String>, [u64; 2], u8);
2194
2195/// Component results of one [`fillet_edges_reported`] call, keyed by the chain
2196/// of components composed up to and including the one built. Valid only for
2197/// the solid, radius, chamfer flag and name of that call.
2198///
2199/// `sequential` is the same memo one level down, for a single connected
2200/// component: the evolving body of the cutter composition after each edge
2201/// blended, keyed by the chain of steps composed up to and including it. The
2202/// subset search's one-drop subsets of a connected selection mostly open
2203/// with the same edges in the same order, so without it every such subset
2204/// rebuilt the same cutter booleans before reaching the edge that refuses.
2205/// On the 2026-10-17 "fillet takes way too long" report (a 7-edge face ring
2206/// whose second edge refuses after the first edge's 6-8 s cutter boolean)
2207/// that boolean ran six times for one refusal: for the group and for the
2208/// five subsets that keep the first edge.
2209///
2210/// A one-drop subset of a connected ring can split it into two components
2211/// (the ring minus an edge that is not an end of a path), and the component
2212/// chains then differ from subset to subset while the cutter composition's
2213/// first steps do not; so the sequential memo is keyed by the chain of
2214/// components composed BEFORE the body being built (`prefix`, empty for a
2215/// single-component selection) together with the steps, and the component
2216/// builds carry the memo down instead of `None`. A FIRST component's steps
2217/// key under the empty prefix like a single-component selection's, which
2218/// is sound because both start from the same body: the component build
2219/// re-heals the selection's already healed input, and `heal_operands`
2220/// leaves a clean input byte-identical (its contract, stated at the heal
2221/// above; the cross-partition hits on the reported ring are its evidence).
2222#[derive(Default)]
2223struct ComponentMemo {
2224 built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
2225 /// The components composed before the build in progress, from the
2226 /// outermost selection down (set around each component build).
2227 prefix: Vec<ComponentKey>,
2228 sequential: std::collections::HashMap<
2229 (Vec<ComponentKey>, Vec<SequentialKey>),
2230 Result<BrepSolid, KernelRefusal>,
2231 >,
2232}
2233
2234/// [`fillet_edges_group`], with the subset search's component memo when
2235/// `memo` is `Some`. The search passes it on its own solid, and the component
2236/// builds pass it on down with `ComponentMemo::prefix` naming the components
2237/// composed before them (see `ComponentMemo::sequential`).
2238fn fillet_edges_group_in(
2239 solid: &BrepSolid,
2240 edge_points: &[Vec3],
2241 edge_names: Option<&[String]>,
2242 radius: f64,
2243 chamfer: bool,
2244 name: Option<&str>,
2245 memo: Option<&mut ComponentMemo>,
2246) -> Result<BrepSolid, KernelRefusal> {
2247 fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true, true)
2248}
2249
2250fn fillet_edges_group_with_corner_policy(
2251 solid: &BrepSolid,
2252 edge_points: &[Vec3],
2253 edge_names: Option<&[String]>,
2254 radius: f64,
2255 chamfer: bool,
2256 name: Option<&str>,
2257 memo: Option<&mut ComponentMemo>,
2258 wrap_concave: bool,
2259 accept: bool,
2260) -> Result<BrepSolid, KernelRefusal> {
2261 GROUP_BUILDS.with(|count| count.set(count.get() + 1));
2262 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2263 let GroupBody { body: result, bowtie_clean } =
2264 fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
2265 if !accept {
2266 return Ok(result);
2267 }
2268 // A body the network or the closed-rim lane already scanned clean and
2269 // handed up untouched is not scanned again (the scan is a pure function
2270 // of the body, 2026-10-12). The composite road and the cutter road hand
2271 // up bodies no lane scanned, and are scanned here.
2272 if bowtie_clean {
2273 crate::blend::record_count("count.bowtie.lane_scan_reused", 1);
2274 } else {
2275 check_loop_self_crossings(&result, entry)?;
2276 check_blend_wall_loops(&result, entry)?;
2277 }
2278 let soundness = crate::blend::BlendTimer::start("timing.soundness_ms");
2279 // The BODY's own soundness, beside the loop's. A blend wall is a FITTED
2280 // surface and a fit can carry two parameters of one wall to one point in
2281 // space — measured on `inbox-20260902-fillet-two-torus-box-edges`, whose
2282 // wall `F8:BLEND:Box_NZ|P.T5_Side[0]` does exactly that while `validate()`,
2283 // connectivity, Euler parity, the loop scan and the face-versus-face scan
2284 // are all silent. Two walls crossing each other is the same class and is
2285 // repaired where it can be. Neither is visible to a volume oracle, so a
2286 // result carrying one reports success for a different shape.
2287 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
2288 let accepted = crate::accept_sound(result, entry);
2289 soundness.stop();
2290 accepted
2291}
2292
2293/// What the unchecked group lane hands up: the body, and whether a lane's
2294/// own acceptance already ran the bowtie scan on exactly this body and
2295/// found it clean (the network and closed-rim exits return the scanned
2296/// body unchanged; the composite and cutter exits do not scan).
2297struct GroupBody {
2298 body: BrepSolid,
2299 bowtie_clean: bool,
2300}
2301
2302fn fillet_edges_group_unchecked(
2303 solid: &BrepSolid,
2304 edge_points: &[Vec3],
2305 edge_names: Option<&[String]>,
2306 radius: f64,
2307 chamfer: bool,
2308 name: Option<&str>,
2309 mut memo: Option<&mut ComponentMemo>,
2310 wrap_concave: bool,
2311) -> Result<GroupBody, KernelRefusal> {
2312 use rustc_hash::FxHashSet as HashSet;
2313
2314 // Fuse-first operand heal (Lever A) before the multi-edge surgery: snap the
2315 // input's near-coincident / off-plane vertices (e.g. a revolve pole apex
2316 // sitting a few microns off the axis) to exact and re-anchor incident
2317 // edges. The selected edges are resolved geometrically below, so a
2318 // sub-heal_tol vertex move never changes which edges are picked; a clean
2319 // input is left byte-identical.
2320 let heal = crate::blend::BlendTimer::start("timing.heal_operands_ms");
2321 let mut healed_input = solid.clone();
2322 let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
2323 crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
2324 let solid = &healed_input;
2325 heal.stop();
2326 let group_analysis = crate::blend::BlendTimer::start("timing.group_analysis_ms");
2327
2328 // 1. Detect convex corners from the ORIGINAL solid: a point that is an
2329 // endpoint of >=3 of the selected edges (a cube/prism-style vertex).
2330 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2331 // The extent each selected edge has BEFORE any blend trims it, so the
2332 // sequential build can run every cutter through the shared corners.
2333 let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
2334 let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
2335 for (i, point) in edge_points.iter().enumerate() {
2336 let edge_id = resolve_edge_by_point(solid, *point)?;
2337 let edge = solid
2338 .edges
2339 .iter()
2340 .find(|e| e.id == edge_id)
2341 .ok_or_else(|| {
2342 KernelRefusal::internal(
2343 KernelStage::Collect,
2344 "resolved_edge",
2345 "fillet_edges: resolved edge vanished",
2346 )
2347 })?;
2348 let (start, end) = (
2349 edge.curve
2350 .evaluate(edge.t0)
2351 .or_refuse(KernelStage::Collect, "edge_start")?,
2352 edge.curve
2353 .evaluate(edge.t1)
2354 .or_refuse(KernelStage::Collect, "edge_end")?,
2355 );
2356 endpoints.push((start, i));
2357 endpoints.push((end, i));
2358 original_extents.push((start, end));
2359 selected_edge_ids.push(edge_id);
2360 }
2361 let mut corners: Vec<Vec3> = Vec::new();
2362 // The selected-edge INPUT INDICES meeting at each star corner (parallel to
2363 // `corners`), sorted — used to name the corner patch after its adjacent
2364 // edges (`{fid}:BLEND:CORNER:{e_a}+{e_b}+…`), UNIQUE per corner because no
2365 // two distinct corners share the same set of >=3 selected edges.
2366 let mut corner_edges: Vec<Vec<usize>> = Vec::new();
2367 let mut chain_corner_count = 0usize;
2368 let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
2369 let mut used = vec![false; endpoints.len()];
2370 for i in 0..endpoints.len() {
2371 if used[i] {
2372 continue;
2373 }
2374 used[i] = true;
2375 let mut edges_here: HashSet<usize> = HashSet::default();
2376 edges_here.insert(endpoints[i].1);
2377 for j in (i + 1)..endpoints.len() {
2378 if used[j] {
2379 continue;
2380 }
2381 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
2382 used[j] = true;
2383 edges_here.insert(endpoints[j].1);
2384 }
2385 }
2386 if edges_here.len() >= 3 {
2387 corners.push(endpoints[i].0);
2388 let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
2389 adjacent.sort_unstable();
2390 corner_edges.push(adjacent);
2391 } else if edges_here.len() == 2 {
2392 chain_corner_count += 1;
2393 let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
2394 adjacent.sort_unstable();
2395 chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
2396 }
2397 }
2398
2399 // A FACE selection may contain several disconnected boundary components
2400 // (the common example is an outer perimeter plus a circular hole rim).
2401 // Miter composition is only meaningful inside one connected edge graph.
2402 // Feeding every component into the same INTERSECT/UNION combines multiple
2403 // independently filleted copies of otherwise untouched support faces; the
2404 // boolean then imprints those coincident copies and can leave redundant
2405 // seams (the reported through-hole cylinder split into three faces).
2406 //
2407 // Partition by shared original endpoints and process components in input
2408 // order. Each recursive call sees one connected component, so it follows
2409 // the existing miter/sequential path without recursion cycling. Distinct
2410 // components are then composed sequentially on the evolving solid.
2411 let mut component_of = vec![usize::MAX; edge_points.len()];
2412 let mut components: Vec<Vec<usize>> = Vec::new();
2413 for seed in 0..edge_points.len() {
2414 if component_of[seed] != usize::MAX {
2415 continue;
2416 }
2417 let component_index = components.len();
2418 component_of[seed] = component_index;
2419 let mut component = vec![seed];
2420 let mut cursor = 0;
2421 while cursor < component.len() {
2422 let current = component[cursor];
2423 cursor += 1;
2424 for candidate in 0..edge_points.len() {
2425 if component_of[candidate] != usize::MAX {
2426 continue;
2427 }
2428 let connected = [original_extents[current].0, original_extents[current].1]
2429 .into_iter()
2430 .any(|a| {
2431 [original_extents[candidate].0, original_extents[candidate].1]
2432 .into_iter()
2433 .any(|b| a.sub(b).length() < 1e-6)
2434 });
2435 if connected {
2436 component_of[candidate] = component_index;
2437 component.push(candidate);
2438 }
2439 }
2440 }
2441 component.sort_unstable();
2442 components.push(component);
2443 }
2444 group_analysis.stop();
2445 if components.len() > 1 {
2446 let mut separated = solid.clone();
2447 // The chain continues the memo's prefix, so a component composed
2448 // inside another's build keys under the whole path to it.
2449 let mut chain: Vec<ComponentKey> = memo.as_deref().map(|memo| memo.prefix.clone()).unwrap_or_default();
2450 chain.reserve(components.len());
2451 for component in components {
2452 let points = component
2453 .iter()
2454 .map(|index| edge_points[*index])
2455 .collect::<Vec<_>>();
2456 let names = edge_names.map(|all| {
2457 component
2458 .iter()
2459 .map(|index| all[*index].clone())
2460 .collect::<Vec<_>>()
2461 });
2462 let accept_component = !crate::blend::accept_once_on();
2463 let Some(memo) = memo.as_deref_mut() else {
2464 separated = fillet_edges_group_with_corner_policy(
2465 &separated,
2466 &points,
2467 names.as_deref(),
2468 radius,
2469 chamfer,
2470 name,
2471 None,
2472 wrap_concave,
2473 accept_component,
2474 )?;
2475 continue;
2476 };
2477 let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
2478 chain.push((bits.collect(), names.clone()));
2479 let built = match memo.built.get(&chain) {
2480 Some(built) => built.clone(),
2481 None => {
2482 // The component builds on the body the chain before it
2483 // composed: its own cutter steps key under that chain.
2484 let outer_prefix = std::mem::replace(&mut memo.prefix, chain[..chain.len() - 1].to_vec());
2485 let built = fillet_edges_group_with_corner_policy(
2486 &separated,
2487 &points,
2488 names.as_deref(),
2489 radius,
2490 chamfer,
2491 name,
2492 Some(&mut *memo),
2493 wrap_concave,
2494 accept_component,
2495 );
2496 memo.prefix = outer_prefix;
2497 memo.built.insert(chain.clone(), built.clone());
2498 built
2499 }
2500 };
2501 separated = built?;
2502 }
2503 return Ok(GroupBody { body: separated, bowtie_clean: false });
2504 }
2505
2506 // 2a. The stripe network (blend/network.rs) is the lane for EVERY
2507 // selection: each stripe is marched against THIS solid, each shared
2508 // vertex is solved before anything is cut — a star closed by a patch
2509 // of the corner ball, a two-edge corner by the seam between the two
2510 // blends, a re-entrant corner by a horn torus, a tangent pair by a
2511 // flush join, an unselected tangent continuation by a cap — and
2512 // nothing is subtracted, so no cutter can overshoot into a neighbour
2513 // and no leftover cap has to be identified afterwards. It refuses
2514 // BY NAME on what it does not yet construct (mixed-convexity corners,
2515 // no-common-ball stars, pinched edges), and those fall through to the
2516 // cutter composition below.
2517 //
2518 // A CLOSED edge is one of those refusals and it has an answer the
2519 // network is simply not the lane for: a closed rim has no corner to
2520 // share, so `blend_star_network` says "edge N is closed and has no
2521 // corner to share" and the composition took it. `blend_closed_edge`
2522 // builds exactly that case and the single-edge ladder in `tool.rs`
2523 // has always tried it first — but this function reached only the
2524 // network, so a closed rim inside a GROUP went to a cutter that the
2525 // same rim selected on its own never saw. A bored hole's rim splits
2526 // out as its own one-edge component (see the component partition
2527 // above), so that was every hole rim chamfered as part of a face
2528 // selection. Try it here too, under the same acceptance the network
2529 // result gets, and under the same `BREP_NO_NETWORK` A/B.
2530 if std::env::var("BREP_NO_NETWORK").is_err() {
2531 if selected_edge_ids.len() == 1 {
2532 let edge_id = selected_edge_ids[0];
2533 let closed = solid
2534 .edges
2535 .iter()
2536 .find(|edge| edge.id == edge_id)
2537 .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
2538 if closed {
2539 let rim_name = per_edge_name(edge_names, name, 0);
2540 match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
2541 Ok(mut rim) => {
2542 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2543 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
2544 let healed = heal_edge_vertex_gaps(&mut rim, radius);
2545 heal.stop();
2546 // A trim the surgery ran outside its planar carrier's
2547 // chart is a wrong body the three checks below all pass
2548 // (`crate::blend::fit_planar_charts_to_trims`), so the
2549 // carrier is widened here, after the heal has settled
2550 // the edges the trims are rebuilt from and before the
2551 // bowtie scan reads them. Its refusals are TERMINAL:
2552 // the composition is not a second opinion on a body
2553 // whose trims left their carrier.
2554 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
2555 crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
2556 charts.stop();
2557 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
2558 let issues = rim.validate_memoised();
2559 validate.stop();
2560 let interference =
2561 check_blend_interference(solid, &rim, &selected_edge_ids, entry);
2562 let bowtie = check_loop_self_crossings(&rim, entry).and_then(|()| check_blend_wall_loops(&rim, entry));
2563 if healed.is_ok()
2564 && issues.is_empty()
2565 && interference.is_ok()
2566 && bowtie.is_ok()
2567 {
2568 return Ok(GroupBody { body: rim, bowtie_clean: true });
2569 }
2570 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2571 eprintln!(
2572 "closed-rim result rejected: heal={healed:?} issues={issues:?} \
2573 interference={interference:?} bowtie={bowtie:?}"
2574 );
2575 }
2576 }
2577 Err(refusal) => {
2578 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2579 eprintln!("closed-rim refused: {refusal}");
2580 }
2581 }
2582 }
2583 }
2584 }
2585 let network_names: Vec<Option<String>> = (0..edge_points.len())
2586 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
2587 .collect();
2588 let network_corner_name =
2589 |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
2590 // BREP lane L: begin lcount229_knot_aware_group_network_20261016 A
2591 // support pcurve off the floor ends the group's network here, and
2592 // the ladder would go on to the cutter composition: re-run the
2593 // network ONCE with the knot-aware support fit; a re-run that also
2594 // fails keeps the first refusal.
2595 let group_network = || {
2596 crate::blend::blend_star_network_with_corner_policy(
2597 solid,
2598 &selected_edge_ids,
2599 radius,
2600 chamfer,
2601 &network_names,
2602 &network_corner_name,
2603 wrap_concave,
2604 )
2605 };
2606 let network_result = group_network().or_else(|first| {
2607 if crate::blend::is_pcurve_floor_refusal(&first) {
2608 crate::blend::with_pcurve_knot_retry(group_network).map_err(|_| first)
2609 } else {
2610 Err(first)
2611 }
2612 });
2613 // BREP lane L: end lcount229_knot_aware_group_network_20261016
2614 match network_result {
2615 Ok(mut network) => {
2616 // Fail-safe like the rest of the ladder: a heal or validation
2617 // problem in the network result falls through to the cutter,
2618 // it does not fail the group. So does an UNTRIMMED result: the
2619 // network re-trims only each stripe's two mates, and
2620 // `check_blend_interference` is what notices a third face
2621 // crossing the swept volume -- `validate` cannot, because a
2622 // self-intersecting solid is still a watertight one.
2623 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2624 let heal = crate::blend::BlendTimer::start("timing.heal_vertex_gaps_ms");
2625 let healed = heal_edge_vertex_gaps(&mut network, radius);
2626 heal.stop();
2627 // See the closed-rim lane above: widen a planar carrier the
2628 // surgery overran, or refuse — terminally — rather than fall
2629 // through to a composition that cannot answer that question.
2630 let charts = crate::blend::BlendTimer::start("timing.planar_charts_ms");
2631 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
2632 charts.stop();
2633 let validate = crate::blend::BlendTimer::start("timing.validate_ms");
2634 let issues = network.validate_memoised();
2635 validate.stop();
2636 let interference =
2637 check_blend_interference(solid, &network, &selected_edge_ids, entry);
2638 // And the fourth question, which the other three all pass: does
2639 // any face of the result have a loop that crosses ITSELF? A
2640 // wall whose rail ran off its own face comes back as a bowtie,
2641 // and a bowtie is watertight, incident and connected.
2642 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
2643 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
2644 return Ok(GroupBody { body: network, bowtie_clean: true });
2645 }
2646 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2647 eprintln!(
2648 "network result rejected: heal={healed:?} issues={issues:?} \
2649 interference={interference:?} bowtie={bowtie:?}"
2650 );
2651 dump_loops_debug("INPUT", solid);
2652 dump_loops_debug("RESULT", &network);
2653 }
2654 }
2655 Err(refusal) => {
2656 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2657 eprintln!("network refused: {refusal}");
2658 }
2659 // The runout composition reads the ring's own classification
2660 // back when the ring stage falls through to the cutter and
2661 // that refuses too (`LAST_NETWORK_REFUSAL`).
2662 LAST_NETWORK_REFUSAL.with(|last| *last.borrow_mut() = Some(refusal.clone()));
2663 // The fail-safe above is for a refusal that says "not this
2664 // lane". A marched fit that left its carriers does not say
2665 // that, and the cutter's answer to the same selection is not a
2666 // second opinion on it: on the notched cap's stuck connector
2667 // the composition reads 1724.506048919, +1.81e-2 against the
2668 // closed form, and it VALIDATES. A refusal that falls through
2669 // to a validating wrong solid is not a refusal.
2670 //
2671 // Only that one, not all of `is_terminal_blend_refusal`. The
2672 // single-edge tool also reads the WALL FOLD as terminal, and
2673 // making the group build agree costs
2674 // `inbox_20260902_corner_torus_collar_fillet::each_collar_has_its_own_fold_threshold`
2675 // a collar that the cutter composes today (measured
2676 // 2026-09-16: refuses at fold factor 1.035935 on a radius-2.1
2677 // wall). Whether a group selection should refuse where a
2678 // single edge does is a decision with a test standing on the
2679 // current answer, and it is not this slice's.
2680 //
2681 // A blend stopping within a sliver of its face's width that
2682 // cannot be snapped onto the far edge without opening the shell
2683 // is terminal for the same reason: the SIZE is the question
2684 // (`blend::CONSUMED_SNAP_UNSOUND`), and the composition's tool
2685 // cut a sliver short of an edge leaves the sliver no lane keeps.
2686 //
2687 // And a FULL-WIDTH corner the network cannot collapse soundly
2688 // (`blend::RAIL_COLLAPSE_UNSUPPORTED`,
2689 // `blend::FULL_WIDTH_COLLAPSE_UNSOUND`): the composition's answer
2690 // to a full-width corner was measured wrong — a 5F/8E/5V miter
2691 // 8.0e-4 off its closed form at 1e-5 short of the width, and the
2692 // picture-frame corner at a full-width chamfer star.
2693 //
2694 // And a trim that left its planar carrier's chart
2695 // (`blend::PLANAR_CHART_EDGE_OFF_PLANE`,
2696 // `blend::PLANAR_CHART_WIDEN_UNSOUND`): the clamped body the
2697 // composition would answer with is the defect being refused.
2698 if crate::blend::is_marched_fit_off_carriers(&refusal)
2699 || crate::blend::is_consumed_snap(&refusal)
2700 || crate::blend::is_rail_collapse(&refusal)
2701 || crate::blend::is_full_width_collapse(&refusal)
2702 || crate::blend::is_planar_chart_refusal(&refusal)
2703 || crate::blend::is_miter_support_overrun(&refusal)
2704 {
2705 return Err(refusal);
2706 }
2707 }
2708 }
2709 }
2710
2711 CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
2712 crate::blend::record_count("count.cutter_fallback", 1);
2713 let _cutter = crate::blend::BlendTimer::start("timing.cutter_ms");
2714 // 2. Build the blends.
2715 //
2716 // CHAIN corners (§6.9.6 — exactly TWO selected edges share a vertex):
2717 // the sequential build truncates the later blend where it runs into the
2718 // earlier one and closes it with a flat bulkhead across the fillet
2719 // channel — a hard step, not a transition. For selections containing
2720 // chain corners, blend each edge FULL-LENGTH on the ORIGINAL solid and
2721 // INTERSECT the per-edge results instead: the removal volumes union, so
2722 // adjacent blends run through the shared corner and trim each other
2723 // along their intersection curve — the standard MITER corner, tangent
2724 // to the shared face at the seam's tangency end. Non-adjacent edges
2725 // are unaffected (their removals are disjoint, intersection ≡
2726 // sequential).
2727 //
2728 // Selections without chain corners keep the sequential build unchanged
2729 // (star corners are rounded in step 3 against exactly the sequential
2730 // geometry round_convex_corner was built for).
2731 // CONVEXITY GUARD: a convex blend REMOVES material (fillet = orig −
2732 // cut), a concave blend ADDS it (orig + pad). Full-length blends
2733 // combine as orig − ∪cuts + ∪pads, so the per-edge results compose by
2734 // INTERSECTION when every edge is convex and by UNION when every edge
2735 // is concave; a mixed selection has no single composition and falls
2736 // back to the sequential build.
2737 // Fillet stars keep the sequential topology required by their sphere
2738 // patches. Chamfer stars have no subsequent corner patch: compose their
2739 // full-length removals too, so all bevel planes meet at the corner.
2740 let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
2741 && (chamfer || corners.is_empty())
2742 {
2743 let mut any_convex = false;
2744 let mut any_concave = false;
2745 for point in edge_points {
2746 // Convexity is a DIHEDRAL property, so it is read from the general
2747 // scan (`scan_dihedral`) — the same one `check_mixed_concavity`
2748 // has already run over every selected edge. `analyze_edge` used
2749 // to answer here and it ALSO refuses an edge the EXACT CUTTER
2750 // cannot build: a straight edge whose mates are not both planes,
2751 // a circular one whose mates do not share its axis. Reading that
2752 // refusal as "unknown convexity" dropped the §6.9.6 miter for
2753 // every selection touching a curved carrier — the groove rim of a
2754 // bored box, say — and the sequential build then left a bulkhead
2755 // face standing in the shared corner (the 2026-09-10 "chamfer
2756 // produced a spurious face" report).
2757 match resolve_edge_by_point(solid, *point)
2758 .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
2759 {
2760 // A mixed edge has no single composition either; it takes the
2761 // `_ => None` arm below through both flags.
2762 Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
2763 any_convex |= profile.any_convex;
2764 any_concave |= profile.any_concave;
2765 }
2766 // Unknown edge class: let the sequential path produce its own
2767 // (more specific) error or result.
2768 _ => {
2769 any_convex = true;
2770 any_concave = true;
2771 break;
2772 }
2773 }
2774 }
2775 match (any_convex, any_concave) {
2776 (true, false) => Some(crate::BooleanOperation::Intersect),
2777 (false, true) => Some(crate::BooleanOperation::Union),
2778 _ => None,
2779 }
2780 } else {
2781 None
2782 };
2783
2784 // Fillet/chamfer each edge in turn, resolving its point on the evolving
2785 // solid (ids shift as earlier fillets rewrite topology; the midpoint of an
2786 // edge is untouched by the corner surgery of the others). This is the
2787 // baseline build used directly for non-miter selections AND as the
2788 // fallback when the miter composition below cannot reassemble.
2789 // The composition's per-edge lane: cutter-first ONLY when a corner
2790 // closure will run afterwards and read cutter-shaped topology (a star or
2791 // a chain corner). A selection with no shared vertex — a lone closed
2792 // rim, a lone chamfer, disconnected edges — has no such closure, so each
2793 // edge gets the march first there too.
2794 let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
2795 Lane::GeneralFirst
2796 } else {
2797 Lane::CutterFirst
2798 };
2799 // The order the sequential build blends the selection in: the input
2800 // order, except that a CONCAVE edge ending at a star corner goes before
2801 // every convex edge (`sequential_build_order`). The §6.9.7 torus-sector
2802 // closure of a re-entrant vertex whose concave edge is selected too
2803 // (`round_mixed_concave_corner`) reads the concave blend's tangency
2804 // vertices off the convex cap cutters, so it exists only when the concave
2805 // edge was cut FIRST; a user who picks the keyway's rim and then its
2806 // inner corners (the 2026-10-13 sprocket report) got flat caps at both
2807 // corners from the input order, and the closure's own closed form from
2808 // this one. Every other selection keeps its input order, byte for byte.
2809 let concave_at_star = concave_star_edges(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
2810 // And at a star corner whose edges the ladder answers with DIFFERENT
2811 // cutters, the marched edges go before the exact ones and the revolved
2812 // cutters last (`mixed_star_class_ranks`): the 2026-10-13 sprocket
2813 // report whose slit walls were selected as faces.
2814 let class_ranks = mixed_star_class_ranks(solid, &selected_edge_ids, &corner_edges, radius, chamfer);
2815 let sequential_order = sequential_build_order(edge_points.len(), &concave_at_star, &class_ranks);
2816 // And the corners close in the same precedence: a star with a concave
2817 // edge (the torus sector) before the convex stars, which on the report's
2818 // keyway share a 3.8-long rim edge with it — the convex star's surgery
2819 // read an open edge when it ran first (measured 2026-10-13), and closes
2820 // cleanly after the sector has trimmed that rim.
2821 let (corners, corner_edges) = corners_concave_first(corners, corner_edges, &concave_at_star);
2822 let mut build_sequential = || -> Result<BrepSolid, KernelRefusal> {
2823 let mut sequential = solid.clone();
2824 // The prefix chain of this build, for the subset search's memo (see
2825 // `ComponentMemo::sequential`); unused without one.
2826 let mut chain: Vec<SequentialKey> = Vec::with_capacity(sequential_order.len());
2827 for &index in &sequential_order {
2828 let point = &edge_points[index];
2829 let edge_id = resolve_edge_by_point(&sequential, *point)?;
2830 // Earlier cutters in this loop TRIM the edges that share a corner
2831 // with them; extend this cutter back over what they took so the
2832 // two removal volumes union through the corner (§6.9.6 miter)
2833 // instead of leaving a wedge of material standing behind a flush
2834 // end cap. Untouched edges get a zero pad and the historical
2835 // flush cutter.
2836 let ends = tool_ends_to_original_extent(
2837 &sequential,
2838 edge_id,
2839 radius,
2840 original_extents[index],
2841 if chamfer { &[] } else { &corners },
2842 );
2843 // This edge's blend wall carries the name of THIS input edge
2844 // (`edge_names[index]`); a smooth chain that engulfs several edges
2845 // is named after the FIRST such input edge processed here (input
2846 // order), the chain's representative.
2847 let edge_name = per_edge_name(edge_names, name, index);
2848 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2849 eprintln!(
2850 "sequential build: selection #{index} {} is edge {edge_id} on the evolving body (ends {ends:?}, {composition_lane:?})",
2851 selection_label(index, edge_points, edge_names, name)
2852 );
2853 }
2854 // Every step is a deterministic function of the body it starts
2855 // from and these four inputs, and the body is itself the chain of
2856 // the steps before it: a chain the search already composed on
2857 // this solid is answered from the memo, refusals included.
2858 chain.push((
2859 [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()],
2860 edge_name.map(str::to_string),
2861 ends.key_bits(),
2862 u8::from(composition_lane == Lane::CutterFirst),
2863 ));
2864 if let Some(memo) = memo.as_deref_mut() {
2865 let key = (memo.prefix.clone(), chain.clone());
2866 let hit = memo.sequential.get(&key);
2867 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
2868 eprintln!(
2869 "sequential memo: {} components before, step {} {} ({} chains stored)",
2870 memo.prefix.len(),
2871 chain.len(),
2872 if hit.is_some() { "HIT" } else { "miss" },
2873 memo.sequential.len(),
2874 );
2875 }
2876 if let Some(built) = hit {
2877 crate::blend::record_count("count.sequential.memo_hits", 1);
2878 sequential = built.clone()?;
2879 continue;
2880 }
2881 }
2882 crate::blend::record_count("count.sequential.steps_built", 1);
2883 let built = fillet_or_chamfer(
2884 &sequential,
2885 edge_id,
2886 radius,
2887 chamfer,
2888 edge_name,
2889 ends,
2890 composition_lane,
2891 );
2892 if let Some(memo) = memo.as_deref_mut() {
2893 memo.sequential.insert((memo.prefix.clone(), chain.clone()), built.clone());
2894 }
2895 sequential = built?;
2896 }
2897 Ok(sequential)
2898 };
2899
2900 let mut result = if let Some(operation) = miter_operation {
2901 // The per-edge results still carry their cutters' standing scaffold,
2902 // which the group settles once its corners are closed. A scaffold face
2903 // merged here would hand its tag to whatever it merged with.
2904 let options = crate::BooleanOptions {
2905 keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
2906 ..crate::BooleanOptions::default()
2907 };
2908 let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
2909 for (index, point) in edge_points.iter().enumerate() {
2910 let edge_id = resolve_edge_by_point(solid, *point)?;
2911 let edge_name = per_edge_name(edge_names, name, index);
2912 let blended = {
2913 let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
2914 check_mixed_concavity(solid, edge_id, entry)?;
2915 check_support_extent(solid, edge_id, radius, entry)?;
2916 fillet_or_chamfer(
2917 solid,
2918 edge_id,
2919 radius,
2920 chamfer,
2921 edge_name,
2922 ToolEnds::default(),
2923 Lane::CutterFirst,
2924 )?
2925 };
2926 combined = match combined {
2927 Err(e) => Err(e),
2928 Ok(None) => Ok(Some(blended)),
2929 Ok(Some(previous)) => {
2930 crate::boolean_operation(&previous, &blended, operation, &options)
2931 .map(Some)
2932 .map_err(|error| {
2933 // The boolean's own class survives the wrap.
2934 error.with_message(|error| {
2935 format!("fillet_edges: chain-corner miter composition failed: {error}")
2936 })
2937 })
2938 }
2939 };
2940 if combined.is_err() {
2941 break;
2942 }
2943 }
2944 // A §6.9.6 miter (per-edge blends intersected/unioned through the
2945 // shared chain corners) can fail to reassemble on faces whose
2946 // fragmented boundary does not close — e.g. a planar cap whose ENTIRE
2947 // perimeter is selected, where fragment_face reports an "incomplete
2948 // run". Rather than let `fillet_edges` silently DROP a selected edge
2949 // to recover a valid subset (the reported defect: not every edge of
2950 // the face gets a fillet), fall back to the sequential build, which
2951 // blends EVERY selected edge (chain corners get a flat bulkhead
2952 // instead of a miter). Only if that also fails to produce a valid
2953 // solid do we surface the miter error so the caller's
2954 // maximal-valid-subset search can still run.
2955 match combined {
2956 Ok(Some(mitered)) => mitered,
2957 Ok(None) => {
2958 return Err(KernelRefusal::input(
2959 KernelStage::Collect,
2960 "miter_selection",
2961 "fillet_edges: no edges selected",
2962 ))
2963 }
2964 Err(miter_err) => match build_sequential() {
2965 Ok(seq) if seq.validate().is_empty() => seq,
2966 _ => return Err(miter_err),
2967 },
2968 }
2969 } else {
2970 build_sequential()?
2971 };
2972
2973 // A re-entrant vertex of a selected face perimeter has two selected,
2974 // convex cap-wall edges and one UNSELECTED concave wall-wall edge. The
2975 // two cutter volumes only touch there, so the boolean miter leaves a
2976 // triangular planar end-cap instead of carrying the rolling ball around
2977 // the corner. Close that exact orthogonal class with its horn-torus
2978 // sector (major radius = minor radius = fillet radius).
2979 // BREP lane L: begin keyway_floor_open_mixed_corner_20261014
2980 let mut open_mixed_corners: Vec<(Vec3, [u64; 2], Option<String>)> = Vec::new();
2981 // BREP lane L: end keyway_floor_open_mixed_corner_20261014
2982 if !chamfer {
2983 for (corner, adjacent) in &chain_corners {
2984 let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
2985 if selected_edge_ids.contains(&edge.id) {
2986 return false;
2987 }
2988 let Ok(a) = edge.curve.evaluate(edge.t0) else {
2989 return false;
2990 };
2991 let Ok(b) = edge.curve.evaluate(edge.t1) else {
2992 return false;
2993 };
2994 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
2995 && analyze_edge(solid, edge.id, radius)
2996 .map(|cross| !cross.convex)
2997 .unwrap_or(false)
2998 });
2999 // BREP lane L: begin keyway_floor_open_mixed_corner_20261014
3000 // The OPEN mixed corner (the 2026-10-13 sprocket's keyway floor):
3001 // one selected convex rim, one selected concave vertical and an
3002 // UNSELECTED convex third edge. The rim's cutter ran through the
3003 // concave pad and left a scaffold square and a misplaced
3004 // bulkhead there; the closure is the one-ended runout's body —
3005 // the §6.9.7 torus sector to C5's stop, ended by its cliff
3006 // (`blend/corner/mixed_open.rs`). It reads the SETTLED body (the
3007 // pad's flush end merged into the cap), so the corner is noted
3008 // here and closed after `settle_cutter_scaffold` below.
3009 if !has_concave_unselected_edge {
3010 let convexity = |edge_id: u64| analyze_edge(solid, edge_id, radius).map(|cross| cross.convex).ok();
3011 let selected_pair = [convexity(selected_edge_ids[adjacent[0]]), convexity(selected_edge_ids[adjacent[1]])];
3012 let one_concave = matches!(selected_pair, [Some(true), Some(false)] | [Some(false), Some(true)]);
3013 let has_convex_unselected_edge = solid.edges.iter().any(|edge| {
3014 if selected_edge_ids.contains(&edge.id) {
3015 return false;
3016 }
3017 let (Ok(a), Ok(b)) = (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1)) else {
3018 return false;
3019 };
3020 (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
3021 && analyze_edge(solid, edge.id, radius).map(|cross| cross.convex).unwrap_or(false)
3022 });
3023 if one_concave && has_convex_unselected_edge && crate::blend::open_mixed_corner_on() {
3024 open_mixed_corners.push((
3025 *corner,
3026 [selected_edge_ids[adjacent[0]], selected_edge_ids[adjacent[1]]],
3027 corner_face_name(edge_names, name, adjacent),
3028 ));
3029 }
3030 continue;
3031 }
3032 // BREP lane L: end keyway_floor_open_mixed_corner_20261014
3033 let corner_name = corner_face_name(edge_names, name, adjacent);
3034 if let Ok(rounded) = crate::blend::round_concave_chain_corner(
3035 &result,
3036 solid,
3037 *corner,
3038 [
3039 selected_edge_ids[adjacent[0]],
3040 selected_edge_ids[adjacent[1]],
3041 ],
3042 radius,
3043 corner_name.as_deref(),
3044 ) {
3045 result = rounded;
3046 }
3047 }
3048 }
3049
3050 // 3. Round the convex corners (fillets only — chamfers keep sharp
3051 // vertices). A corner that cannot be rounded is left as the edge
3052 // fillets so the group still succeeds. Each corner patch is named after
3053 // the selected edges meeting there (`{fid}:BLEND:CORNER:{e_a}+…`) so no
3054 // two corners collide and the patch stays under the `{fid}:BLEND` prefix.
3055 if !chamfer {
3056 for (ci, corner) in corners.iter().enumerate() {
3057 let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
3058 match crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref()) {
3059 Ok(rounded) => result = rounded,
3060 Err(error) => {
3061 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3062 eprintln!("corner at {corner:?} left as the edge fillets: {error}");
3063 }
3064 }
3065 }
3066 }
3067 }
3068
3069 // Heal any residual vertex/edge gaps introduced by the corner-rounding
3070 // surgery (the per-edge results are already healed inside fillet_or_chamfer).
3071 heal_edge_vertex_gaps(&mut result, radius)?;
3072 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3073 // BREP lane L: begin keyway_floor_open_mixed_corner_20261014
3074 let mut body = settle_cutter_scaffold(result);
3075 for (corner, pair, corner_name) in &open_mixed_corners {
3076 match crate::blend::round_mixed_concave_open_corner(&body, solid, *corner, *pair, radius, corner_name.as_deref()) {
3077 Ok(rounded) => body = rounded,
3078 Err(error) => {
3079 if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
3080 eprintln!("open mixed corner at {corner:?} left as the composition built it: {error}");
3081 }
3082 }
3083 }
3084 }
3085 Ok(GroupBody { body, bowtie_clean: false })
3086 // BREP lane L: end keyway_floor_open_mixed_corner_20261014
3087}
3088
3089/// The selected edges (indices into the selection) that are CONCAVE and end
3090/// at a star corner (`corner_edges`: three or more selected edges at one
3091/// vertex), in input order. Empty for a chamfer, a selection with no star
3092/// corner, one whose star corners carry no concave edge, and one whose
3093/// convexity cannot be read — the cases the composition builds in input
3094/// order, exactly as before.
3095fn concave_star_edges(
3096 solid: &BrepSolid,
3097 selected_edge_ids: &[u64],
3098 corner_edges: &[Vec<usize>],
3099 radius: f64,
3100 chamfer: bool,
3101) -> Vec<usize> {
3102 if chamfer || corner_edges.is_empty() || !concave_first_on() {
3103 return Vec::new();
3104 }
3105 let Some(convexity) = crate::blend::selection_convexity(solid, selected_edge_ids, radius) else {
3106 return Vec::new();
3107 };
3108 (0..selected_edge_ids.len())
3109 .filter(|&index| !convexity[index] && corner_edges.iter().any(|adjacent| adjacent.contains(&index)))
3110 .collect()
3111}
3112
3113/// The cutter the sequential build's single-edge ladder answers a selected
3114/// edge with, read on the ORIGINAL solid: an exact extrusion (a straight
3115/// edge between planes), an exact revolution (a circular edge on
3116/// straight-meridian carriers about its own axis) or, for every other edge,
3117/// the general march (`fillet_or_chamfer_exact` refuses it, so the march
3118/// builds it with direct surgery on the evolving body).
3119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3120enum CutterClass {
3121 Marched,
3122 Straight,
3123 Circular,
3124}
3125
3126fn cutter_class(solid: &BrepSolid, edge_id: u64, radius: f64) -> CutterClass {
3127 match super::analyze::analyze_edge(solid, edge_id, radius) {
3128 Ok(cross) => match cross.path {
3129 super::analyze::EdgePath::Straight { .. } => CutterClass::Straight,
3130 super::analyze::EdgePath::Circular { .. } => CutterClass::Circular,
3131 },
3132 Err(_) => CutterClass::Marched,
3133 }
3134}
3135
3136/// The sequential build's rank of every selected edge, from the cutter
3137/// classes meeting at the selection's star corners: `1` for a marched edge
3138/// and `3` for a revolved cutter at a star whose edges MIX classes, `2` for
3139/// everything else (a star of one class, a chain corner, a lone edge).
3140/// Within a rank the input order holds ([`sequential_build_order`]).
3141///
3142/// At such a corner the input order fails in two ways, both measured on the
3143/// 2026-10-13 sprocket report whose slit walls were selected as faces
3144/// (`inbox_20261013_sprocket_keyway_slit_fillet`): a revolved cutter leaves
3145/// a TORUS tangent to the hub's top plane along its rail, and the next
3146/// straight cutter's scaffold face lies in that very plane and ends on that
3147/// rail, so the boolean meets a tangent plane/torus contact along a 0.148
3148/// arc that the imprint traces but the assembly cannot close (`unsupported
3149/// tangent-curve surface intersection`, open edges, a 0.067 curve/vertex
3150/// gap); and the slit's vertical, a straight edge on the hub CYLINDER that
3151/// only the march builds, finds "no single end face across the corner" once
3152/// its star mates have been cut. The marched edge cut first sees the pristine
3153/// corner, and a straight cutter cut before the revolved one meets the
3154/// scaffold plane of a CYLINDER tangent along a line — the ordinary planar
3155/// star's own geometry, which the arrangement has always closed. Every
3156/// selection without a mixed-class star keeps its order, byte for byte.
3157fn mixed_star_class_ranks(
3158 solid: &BrepSolid,
3159 selected_edge_ids: &[u64],
3160 corner_edges: &[Vec<usize>],
3161 radius: f64,
3162 chamfer: bool,
3163) -> Vec<u8> {
3164 let mut ranks = vec![2u8; selected_edge_ids.len()];
3165 if chamfer || corner_edges.is_empty() || !class_order_on() {
3166 return ranks;
3167 }
3168 let classes: Vec<CutterClass> = selected_edge_ids
3169 .iter()
3170 .map(|&edge_id| cutter_class(solid, edge_id, radius))
3171 .collect();
3172 for adjacent in corner_edges {
3173 let Some(&head) = adjacent.first() else {
3174 continue;
3175 };
3176 if adjacent.iter().all(|&index| classes[index] == classes[head]) {
3177 continue;
3178 }
3179 for &index in adjacent {
3180 match classes[index] {
3181 CutterClass::Marched => ranks[index] = 1,
3182 CutterClass::Circular => ranks[index] = 3,
3183 CutterClass::Straight => {}
3184 }
3185 }
3186 }
3187 ranks
3188}
3189
3190/// The order the cutter composition's sequential build blends a selection
3191/// in: the input order, except that the edges in `first` (a star corner's
3192/// concave edges, [`concave_star_edges`]) are moved ahead of every other
3193/// edge and the rest follow by `ranks` ([`mixed_star_class_ranks`], lower
3194/// first), keeping the input order among the moved edges and within each
3195/// rank. With `first` empty and every rank equal this is the input order.
3196pub(super) fn sequential_build_order(count: usize, first: &[usize], ranks: &[u8]) -> Vec<usize> {
3197 let mut order: Vec<usize> = (0..count).collect();
3198 // A stable sort: ties keep the input order.
3199 order.sort_by_key(|&index| if first.contains(&index) { 0 } else { ranks.get(index).copied().unwrap_or(2).max(1) });
3200 order
3201}
3202
3203/// Whether the sequential build ranks a mixed-class star's edges by cutter
3204/// class ([`mixed_star_class_ranks`]); always, outside a test that forces it
3205/// off.
3206fn class_order_on() -> bool {
3207 true
3208}
3209
3210
3211/// `corners` and their `corner_edges` (parallel), with every corner that one
3212/// of the `first` edges ends at moved ahead of the rest, both runs in their
3213/// detection order. With `first` empty both come back as they were.
3214fn corners_concave_first(
3215 corners: Vec<Vec3>,
3216 corner_edges: Vec<Vec<usize>>,
3217 first: &[usize],
3218) -> (Vec<Vec3>, Vec<Vec<usize>>) {
3219 if first.is_empty() {
3220 return (corners, corner_edges);
3221 }
3222 let mut ordered: Vec<(Vec3, Vec<usize>)> = Vec::with_capacity(corners.len());
3223 let pairs: Vec<(Vec3, Vec<usize>)> = corners.into_iter().zip(corner_edges).collect();
3224 let with_concave = |adjacent: &Vec<usize>| adjacent.iter().any(|index| first.contains(index));
3225 ordered.extend(pairs.iter().filter(|(_, adjacent)| with_concave(adjacent)).cloned());
3226 ordered.extend(pairs.iter().filter(|(_, adjacent)| !with_concave(adjacent)).cloned());
3227 ordered.into_iter().unzip()
3228}
3229
3230/// Whether the sequential build moves a star corner's concave edge first
3231/// ([`sequential_build_order`]); always, outside a test that forces it off.
3232fn concave_first_on() -> bool {
3233 true
3234}
3235
3236
3237/// Variable-radius fillet/chamfer of a GROUP of edges (§4.9.5), the app entry
3238/// for tapered blends: each selected edge (resolved by a point on it) is
3239/// blended with the SAME radius profile `radii` — a list of (edge-fraction,
3240/// radius) stops in [0,1] — applied along that edge's own parameterization.
3241/// Edges are blended independently (no shared-vertex corner rounding; a
3242/// variable-radius star has no single tangent ball), so this is the tapered
3243/// counterpart of `fillet_edges` for the constant case.
3244pub fn fillet_edges_variable(
3245 solid: &BrepSolid,
3246 edge_points: &[Vec3],
3247 edge_names: Option<&[String]>,
3248 radii: &[(f64, f64)],
3249 chamfer: bool,
3250 name: Option<&str>,
3251) -> Result<BrepSolid, KernelRefusal> {
3252 // One blend operation: the reports of an earlier one, never consumed, are
3253 // dropped as it starts (nested calls keep this operation's).
3254 let _operation = crate::blend::BlendOperation::enter();
3255 if edge_points.is_empty() {
3256 return Err(KernelRefusal::input(
3257 KernelStage::Collect,
3258 "variable_selection",
3259 "fillet_edges_variable: no edges selected",
3260 ));
3261 }
3262 let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
3263 fillet_edges_variable_impl(
3264 solid,
3265 edge_points,
3266 edge_names,
3267 &per_edge_stops,
3268 chamfer,
3269 name,
3270 "fillet_edges_variable",
3271 true,
3272 )
3273}
3274
3275/// The shared variable-radius group core: each selected edge `i` is blended
3276/// with ITS OWN stop list `per_edge_stops[i]` (the legacy entry replicates one
3277/// list; the law entries sample a chain-abscissa [`crate::law::RadiusLaw`] per
3278/// edge). `allow_tapered_sequential` keeps the legacy entry's sequential
3279/// fallback for tapered chains (whose end state is the honest
3280/// "mismatched radii" validation gate); the law entries pass `false` because
3281/// their stop fractions are computed against the ORIGINAL edges — the
3282/// sequential build re-resolves edges on the evolving solid whose shared
3283/// corners are already TRIMMED by earlier blends, which would silently distort
3284/// the law's abscissa mapping (constant stops are immune, so they may still
3285/// fall back).
3286#[allow(clippy::too_many_arguments)]
3287fn fillet_edges_variable_impl(
3288 solid: &BrepSolid,
3289 edge_points: &[Vec3],
3290 edge_names: Option<&[String]>,
3291 per_edge_stops: &[Vec<(f64, f64)>],
3292 chamfer: bool,
3293 name: Option<&str>,
3294 entry: &str,
3295 allow_tapered_sequential: bool,
3296) -> Result<BrepSolid, KernelRefusal> {
3297 // The translation control (`BREP_TRANSLATION_CONTROL`, `brep/recentre.rs`)
3298 // and the recentre guard, as `fillet_edges_reported` applies them.
3299 let bounds = crate::recentre::solid_bounds(solid);
3300 let translated = match crate::recentre::control_offset_at_entry() {
3301 Some(offset) => Some((offset, crate::recentre::EntryKind::Control)),
3302 None => crate::recentre::guard_offset(bounds).map(|offset| {
3303 crate::recentre::profile_fired(entry, offset, bounds);
3304 (offset, crate::recentre::EntryKind::Guard)
3305 }),
3306 };
3307 if let Some((offset, kind)) = translated {
3308 let _entry = crate::recentre::TranslatedEntry::open(kind);
3309 let translate = |solid: &BrepSolid, offset: Vec3| {
3310 crate::recentre::translate_solid(solid, offset).map_err(|message| {
3311 KernelRefusal::internal(
3312 KernelStage::Collect,
3313 "translation_control",
3314 format!("{entry}: the translation control could not move the solid: {message}"),
3315 )
3316 })
3317 };
3318 let moved = translate(solid, offset)?;
3319 let points: Vec<Vec3> = edge_points.iter().map(|point| point.add(offset)).collect();
3320 let result = fillet_edges_variable_impl(
3321 &moved,
3322 &points,
3323 edge_names,
3324 per_edge_stops,
3325 chamfer,
3326 name,
3327 entry,
3328 allow_tapered_sequential,
3329 )?;
3330 return translate(&result, offset.scale(-1.0));
3331 }
3332 debug_assert_eq!(per_edge_stops.len(), edge_points.len());
3333 // A law that is one constant everywhere IS the constant-radius fillet:
3334 // hand it to the constant group, whose corners are constructed (the
3335 // stripe network) rather than composed by boolean.
3336 if let Some(constant) = per_edge_stops
3337 .first()
3338 .and_then(|stops| stops.first())
3339 .map(|(_, radius)| *radius)
3340 {
3341 let uniform = per_edge_stops.iter().all(|stops| {
3342 stops
3343 .iter()
3344 .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
3345 });
3346 if uniform && constant > 0.0 {
3347 return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
3348 }
3349 }
3350 let max_radius = per_edge_stops
3351 .iter()
3352 .flat_map(|stops| stops.iter())
3353 .map(|(_, r)| r.abs())
3354 .fold(0.0_f64, f64::max);
3355
3356 // Tried first behind its switch; whatever the network does not build (an
3357 // unequal-radius corner, a flush join or re-entrant vertex at a varying
3358 // radius, a result its acceptance rejects) falls through to the
3359 // composition below exactly as before.
3360 if crate::blend::taper_network_on() && edge_points.len() >= 2 {
3361 if let Some(built) = fillet_variable_network(
3362 solid, edge_points, edge_names, per_edge_stops, chamfer, name, entry, max_radius,
3363 )? {
3364 return Ok(built);
3365 }
3366 }
3367
3368 // Chain corners miter exactly like the constant-radius group (§6.9.6):
3369 // per-edge blends on the ORIGINAL solid composed by boolean — Intersect
3370 // when every edge is convex, Union when every edge is concave. Mixed or
3371 // unclassifiable selections keep the sequential build. Variable blends
3372 // never round star vertices, so unlike the constant group there is no
3373 // sequential-only star path to protect.
3374 let mut chain_corner = false;
3375 {
3376 use rustc_hash::FxHashSet as HashSet;
3377 let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
3378 for (i, point) in edge_points.iter().enumerate() {
3379 if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
3380 if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
3381 if let (Ok(a), Ok(b)) =
3382 (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
3383 {
3384 endpoints.push((a, i));
3385 endpoints.push((b, i));
3386 }
3387 }
3388 }
3389 }
3390 let mut used = vec![false; endpoints.len()];
3391 for i in 0..endpoints.len() {
3392 if used[i] {
3393 continue;
3394 }
3395 used[i] = true;
3396 let mut edges_here: HashSet<usize> = HashSet::default();
3397 edges_here.insert(endpoints[i].1);
3398 for j in (i + 1)..endpoints.len() {
3399 if used[j] {
3400 continue;
3401 }
3402 if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
3403 used[j] = true;
3404 edges_here.insert(endpoints[j].1);
3405 }
3406 }
3407 if edges_here.len() == 2 {
3408 chain_corner = true;
3409 }
3410 }
3411 }
3412 let miter_operation = if chain_corner {
3413 let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
3414 let mut any_convex = false;
3415 let mut any_concave = false;
3416 for point in edge_points {
3417 match resolve_edge_by_point(solid, *point)
3418 .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
3419 {
3420 Ok(cross) if cross.convex => any_convex = true,
3421 Ok(_) => any_concave = true,
3422 Err(_) => {
3423 any_convex = true;
3424 any_concave = true;
3425 break;
3426 }
3427 }
3428 }
3429 match (any_convex, any_concave) {
3430 (true, false) => Some(crate::BooleanOperation::Intersect),
3431 (false, true) => Some(crate::BooleanOperation::Union),
3432 _ => None,
3433 }
3434 } else {
3435 None
3436 };
3437
3438 // Try the miter first; the variable blend's FITTED boundary curves are
3439 // only ~1e-3 accurate at blend-blend tangencies (unlike the exact
3440 // constant-radius cylinders), so the composition can fail — fall back to
3441 // the sequential build then, which is never worse than the pre-miter
3442 // behavior. Tightening the taper surface's endpoint fitting is the
3443 // documented follow-up that would make the miter stick.
3444 let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
3445 let options = crate::BooleanOptions::default();
3446 let mut combined: Option<BrepSolid> = None;
3447 let mut failed = false;
3448 for (index, point) in edge_points.iter().enumerate() {
3449 let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
3450 failed = true;
3451 break;
3452 };
3453 let edge_name = per_edge_name(edge_names, name, index);
3454 let Ok(blended) = crate::blend::blend_edge_variable(
3455 solid,
3456 edge_id,
3457 &per_edge_stops[index],
3458 chamfer,
3459 edge_name,
3460 ) else {
3461 failed = true;
3462 break;
3463 };
3464 let next = match combined.take() {
3465 None => blended,
3466 Some(previous) => {
3467 match crate::boolean_operation(&previous, &blended, operation, &options) {
3468 Ok(next) => next,
3469 Err(_) => {
3470 failed = true;
3471 break;
3472 }
3473 }
3474 }
3475 };
3476 combined = Some(next);
3477 }
3478 if failed {
3479 None
3480 } else {
3481 combined.filter(|s| s.validate().is_empty())
3482 }
3483 } else {
3484 None
3485 };
3486 let mut result = match miter_attempt {
3487 Some(mitered) => mitered,
3488 None => {
3489 // Any stop list that is NOT radius-uniform (the same 1e-12
3490 // relative criterion `blend_edge_variable` uses for its exact
3491 // constant-radius degeneration) makes the sequential build
3492 // abscissa-distorting on trimmed chain edges; law entries refuse
3493 // instead of silently shifting the law.
3494 let tapered = per_edge_stops.iter().any(|stops| {
3495 stops.first().is_some_and(|(_, first)| {
3496 stops
3497 .iter()
3498 .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
3499 })
3500 });
3501 if chain_corner && tapered && !allow_tapered_sequential {
3502 // The transition patch that would carry a taper through the
3503 // corner is not built: a named deferral.
3504 return Err(KernelRefusal::unsupported(
3505 KernelStage::Sew,
3506 "tapered_chain_miter",
3507 format!(
3508 "{entry}: the tapered blends across the selected chain's shared corners \
3509 did not compose to a valid solid (the fitted blend boundaries could not \
3510 be mitered); fillet fewer edges per operation or reduce the taper"
3511 ),
3512 ));
3513 }
3514 let mut sequential = solid.clone();
3515 for (index, point) in edge_points.iter().enumerate() {
3516 let edge_id = resolve_edge_by_point(&sequential, *point)?;
3517 let edge_name = per_edge_name(edge_names, name, index);
3518 sequential = crate::blend::blend_edge_variable(
3519 &sequential,
3520 edge_id,
3521 &per_edge_stops[index],
3522 chamfer,
3523 edge_name,
3524 )?;
3525 }
3526 sequential
3527 }
3528 };
3529 // Heal §6.9 surgery so re-trimmed edges meet their vertices exactly; scale
3530 // the heal bound by the largest radius stop in the taper profile.
3531 heal_edge_vertex_gaps(&mut result, max_radius)?;
3532 crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
3533 // Final honesty gate: a genuinely TAPERED chain (different radii at the
3534 // shared corner) has mismatched trim stations there — the blends cannot
3535 // meet without a transition patch (not implemented), and the sequential
3536 // surgery silently left broken topology before this gate existed.
3537 let issues = result.validate();
3538 if !issues.is_empty() {
3539 // Two verdicts share the text's shape: the legacy entry names the
3540 // transition patch it does not build (a deferral); the law entries,
3541 // whose taper is continuous through the corner, report the
3542 // composition failing its own validation (a check that tripped).
3543 let (detail, class) = if allow_tapered_sequential {
3544 (
3545 "tapered blends meet at a shared chain vertex with \
3546 mismatched radii — the radius-transition corner patch is not implemented; \
3547 fillet the edges in separate operations or use matching stop radii",
3548 RefusalClass::UnsupportedGeometry {
3549 what: "taper_transition_patch".into(),
3550 },
3551 )
3552 } else {
3553 (
3554 "the composed radius-law blend produced invalid topology — the blends \
3555 across a shared chain corner failed to reassemble",
3556 RefusalClass::Internal {
3557 what: "law_chain_topology".into(),
3558 },
3559 )
3560 };
3561 return Err(KernelRefusal::new(
3562 class,
3563 KernelStage::Validate,
3564 format!(
3565 "{entry}: {detail} ({} validation issues, first: {})",
3566 issues.len(),
3567 issues
3568 .first()
3569 .map(|issue| issue.message.clone())
3570 .unwrap_or_default()
3571 ),
3572 ));
3573 }
3574 // The variable-radius lane's own soundness acceptance, the same one the
3575 // constant-radius group exit takes. `validate()` above is an INCIDENCE
3576 // test: a tapered wall that passes through a neighbour, or through itself,
3577 // satisfies every check it makes and still measures a volume.
3578 crate::soundness::census::profile_operation(&result, &[solid], None, entry);
3579 crate::accept_sound(result, entry)
3580}
3581
3582/// `Ok(None)` is "not this lane" — the network refused, or its result
3583/// failed the same acceptance the constant group holds the network to —
3584/// and the caller composes as before; `Err` is a refusal nothing below
3585/// can answer.
3586#[allow(clippy::too_many_arguments)]
3587fn fillet_variable_network(
3588 solid: &BrepSolid,
3589 edge_points: &[Vec3],
3590 edge_names: Option<&[String]>,
3591 per_edge_stops: &[Vec<(f64, f64)>],
3592 chamfer: bool,
3593 name: Option<&str>,
3594 entry: &str,
3595 max_radius: f64,
3596) -> Result<Option<BrepSolid>, KernelRefusal> {
3597 let mut edge_ids = Vec::with_capacity(edge_points.len());
3598 for point in edge_points {
3599 edge_ids.push(resolve_edge_by_point(solid, *point)?);
3600 }
3601 let laws: Vec<crate::blend::StripeRadius> =
3602 per_edge_stops.iter().map(|stops| crate::blend::StripeRadius::from_stops(stops)).collect();
3603 let network_names: Vec<Option<String>> = (0..edge_points.len())
3604 .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
3605 .collect();
3606 let network_corner_name = |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
3607 let debug = std::env::var("BREP_DEBUG_NETWORK").is_ok();
3608 let mut network = match crate::blend::blend_star_network_laws(
3609 solid, &edge_ids, &laws, chamfer, &network_names, &network_corner_name, true,
3610 ) {
3611 Ok(network) => network,
3612 Err(refusal) => {
3613 if debug {
3614 eprintln!("law network refused: {refusal}");
3615 }
3616 return Ok(None);
3617 }
3618 };
3619 // The constant group's acceptance of a network result, in its order:
3620 // the vertex-gap heal, the planar charts, validation, the interference
3621 // classifier and the bowtie scan; then the soundness acceptance the
3622 // variable lane's exit takes.
3623 let healed = heal_edge_vertex_gaps(&mut network, max_radius);
3624 crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
3625 let issues = network.validate();
3626 let interference = check_blend_interference(solid, &network, &edge_ids, entry);
3627 let bowtie = check_loop_self_crossings(&network, entry).and_then(|()| check_blend_wall_loops(&network, entry));
3628 if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
3629 crate::soundness::census::profile_operation(&network, &[solid], None, entry);
3630 match crate::accept_sound(network, entry) {
3631 Ok(sound) => return Ok(Some(sound)),
3632 Err(refusal) => {
3633 if debug {
3634 eprintln!("law network result unsound: {refusal}");
3635 }
3636 return Ok(None);
3637 }
3638 }
3639 }
3640 if debug {
3641 eprintln!(
3642 "law network result rejected: heal={healed:?} issues={issues:?} \
3643 interference={interference:?} bowtie={bowtie:?}"
3644 );
3645 }
3646 Ok(None)
3647}
3648
3649pub(crate) fn fillet_edges_variable_stops(
3650 solid: &BrepSolid,
3651 edge_points: &[Vec3],
3652 edge_names: Option<&[String]>,
3653 per_edge_stops: &[Vec<(f64, f64)>],
3654 chamfer: bool,
3655 name: Option<&str>,
3656) -> Result<BrepSolid, KernelRefusal> {
3657 let _operation = crate::blend::BlendOperation::enter();
3658 if edge_points.is_empty() || per_edge_stops.len() != edge_points.len() {
3659 return Err(KernelRefusal::input(
3660 KernelStage::Collect,
3661 "variable_selection",
3662 "fillet_edges_variable_stops: one stop list per selected edge is required",
3663 ));
3664 }
3665 fillet_edges_variable_impl(
3666 solid,
3667 edge_points,
3668 edge_names,
3669 per_edge_stops,
3670 chamfer,
3671 name,
3672 "fillet_edges_variable_stops",
3673 false,
3674 )
3675}
3676
3677/// One selected edge placed on the ordered chain, with its arc-length
3678/// parameterization (the caller-side mapping from chain abscissa to the
3679/// `blend_edge_variable` per-edge parameter-fraction stop seam).
3680struct ChainLink {
3681 /// Position of this edge in the caller's `edge_points` selection.
3682 input_index: usize,
3683 /// True when the edge's own t0→t1 parameter direction runs WITH the chain.
3684 forward: bool,
3685 /// Cumulative chord-length table from the edge's t0 end:
3686 /// `(parameter fraction, arc length)`, uniformly spaced in fraction.
3687 arc: Vec<(f64, f64)>,
3688 /// Total arc length of the edge.
3689 length: f64,
3690 /// Chain abscissa at the link's ENTRY vertex (the end reached first when
3691 /// walking the chain from its start).
3692 abscissa: f64,
3693}
3694
3695/// Cumulative chord-length table of one edge, `(parameter fraction, arc
3696/// length)` at uniform fractions. The sample count doubles until two
3697/// successive total-length estimates agree within `tol` (chord length
3698/// converges O(N⁻²) for smooth curves, so the agreement of the N and 2N
3699/// estimates bounds the remaining error at the same order); straight edges
3700/// converge on the first doubling. The 16-sample start resolves any
3701/// single-span arc of up to half a turn to sub-percent before refinement; the
3702/// 4096 cap (8 doublings) guards adversarial curves — beyond it the table is
3703/// two orders denser than the blend march's station grid, so finer chords
3704/// cannot move any station's sampled radius meaningfully.
3705fn edge_arc_table(
3706 curve: &NurbsCurve,
3707 t0: f64,
3708 t1: f64,
3709 tol: f64,
3710) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
3711 let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
3712 let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
3713 let mut table = Vec::with_capacity(n + 1);
3714 let mut cumulative = 0.0;
3715 let mut previous = evaluate(t0)?;
3716 table.push((0.0, 0.0));
3717 for j in 1..=n {
3718 let fraction = j as f64 / n as f64;
3719 let point = evaluate(t0 + (t1 - t0) * fraction)?;
3720 cumulative += point.sub(previous).length();
3721 previous = point;
3722 table.push((fraction, cumulative));
3723 }
3724 Ok((table, cumulative))
3725 };
3726 let mut n = 16usize;
3727 let (mut table, mut length) = build(n)?;
3728 while n < 4096 {
3729 n *= 2;
3730 let (next_table, next_length) = build(n)?;
3731 let converged = (next_length - length).abs() <= tol;
3732 table = next_table;
3733 length = next_length;
3734 if converged {
3735 break;
3736 }
3737 }
3738 Ok((table, length))
3739}
3740
3741/// Arc length from the edge's t0 end at `fraction` of its parameter span,
3742/// linearly interpolated in the uniform chord table.
3743fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
3744 let fraction = fraction.clamp(0.0, 1.0);
3745 let intervals = table.len() - 1;
3746 let scaled = fraction * intervals as f64;
3747 let index = (scaled.floor() as usize).min(intervals - 1);
3748 let local = scaled - index as f64;
3749 let (_, a) = table[index];
3750 let (_, b) = table[index + 1];
3751 a + (b - a) * local
3752}
3753
3754/// Resolve the selected edges and order them into ONE OPEN CHAIN with
3755/// cumulative arc-length abscissas. The chain starts at the free endpoint
3756/// belonging to the EARLIEST-selected end edge (so users get the natural
3757/// "first pick carries the law start" orientation); a single selected edge is
3758/// its own chain oriented t0→t1 (which also admits a closed edge — the law's
3759/// end radii must then match, enforced downstream by `blend_edge_variable`).
3760/// Branching (a vertex shared by 3+ selected edges), closed rings of several
3761/// edges, and disconnected selections refuse with named errors.
3762fn resolve_selected_chain(
3763 solid: &BrepSolid,
3764 edge_points: &[Vec3],
3765 entry: &str,
3766 tol: f64,
3767) -> Result<Vec<ChainLink>, KernelRefusal> {
3768 // Endpoint identity uses the kernel-wide COINCIDENCE_DISTANCE_FLOOR — the
3769 // same band the constant-radius group's corner detector applies.
3770 let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
3771
3772 struct Resolved {
3773 edge_id: u64,
3774 start: Vec3,
3775 end: Vec3,
3776 arc: Vec<(f64, f64)>,
3777 length: f64,
3778 }
3779 let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
3780 for point in edge_points {
3781 let edge_id = resolve_edge_by_point(solid, *point)?;
3782 if resolved.iter().any(|r| r.edge_id == edge_id) {
3783 return Err(KernelRefusal::input(
3784 KernelStage::Collect,
3785 "duplicate_edge",
3786 format!("{entry}: the same edge was selected more than once"),
3787 ));
3788 }
3789 let edge = solid
3790 .edges
3791 .iter()
3792 .find(|e| e.id == edge_id)
3793 .ok_or_else(|| {
3794 KernelRefusal::internal(
3795 KernelStage::Collect,
3796 "chain_resolved_edge",
3797 format!("{entry}: resolved edge vanished"),
3798 )
3799 })?;
3800 let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
3801 if !(length > 0.0) {
3802 return Err(KernelRefusal::input(
3803 KernelStage::Collect,
3804 "zero_length_edge",
3805 format!("{entry}: selected edge has zero length"),
3806 ));
3807 }
3808 resolved.push(Resolved {
3809 edge_id,
3810 start: edge
3811 .curve
3812 .evaluate(edge.t0)
3813 .or_refuse(KernelStage::Collect, "chain_edge_start")?,
3814 end: edge
3815 .curve
3816 .evaluate(edge.t1)
3817 .or_refuse(KernelStage::Collect, "chain_edge_end")?,
3818 arc,
3819 length,
3820 });
3821 }
3822 let n = resolved.len();
3823 if n == 1 {
3824 let only = resolved.remove(0);
3825 return Ok(vec![ChainLink {
3826 input_index: 0,
3827 forward: true,
3828 arc: only.arc,
3829 length: only.length,
3830 abscissa: 0.0,
3831 }]);
3832 }
3833
3834 // Cluster the 2n endpoints; each entry is (edge index, is_start_end).
3835 let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
3836 for (i, r) in resolved.iter().enumerate() {
3837 for (point, is_start) in [(r.start, true), (r.end, false)] {
3838 match clusters
3839 .iter_mut()
3840 .find(|(anchor, _)| anchor.sub(point).length() < band)
3841 {
3842 Some((_, members)) => members.push((i, is_start)),
3843 None => clusters.push((point, vec![(i, is_start)])),
3844 }
3845 }
3846 }
3847 if clusters.iter().any(|(_, members)| members.len() > 2) {
3848 return Err(KernelRefusal::input(
3849 KernelStage::Collect,
3850 "chain_branching",
3851 format!(
3852 "{entry}: selected edges must form one open chain \
3853 (a vertex is shared by three or more selected edges)"
3854 ),
3855 ));
3856 }
3857 let free: Vec<usize> = clusters
3858 .iter()
3859 .enumerate()
3860 .filter(|(_, (_, members))| members.len() == 1)
3861 .map(|(c, _)| c)
3862 .collect();
3863 if free.len() != 2 {
3864 return Err(KernelRefusal::input(
3865 KernelStage::Collect,
3866 "chain_open",
3867 format!(
3868 "{entry}: selected edges must form one OPEN chain \
3869 (closed rings and disconnected selections are not supported)"
3870 ),
3871 ));
3872 }
3873 // Start at the free end whose edge appears EARLIEST in the selection.
3874 let start_cluster = *free
3875 .iter()
3876 .min_by_key(|&&c| clusters[c].1[0].0)
3877 .expect("two free ends");
3878
3879 // Walk the chain.
3880 let mut links: Vec<ChainLink> = Vec::with_capacity(n);
3881 let mut visited = vec![false; n];
3882 let mut abscissa = 0.0_f64;
3883 let mut cluster = start_cluster;
3884 for _ in 0..n {
3885 let Some(&(edge_index, entered_at_start)) = clusters[cluster]
3886 .1
3887 .iter()
3888 .find(|(edge_index, _)| !visited[*edge_index])
3889 else {
3890 return Err(KernelRefusal::input(
3891 KernelStage::Collect,
3892 "chain_walk",
3893 format!(
3894 "{entry}: selected edges are not connected into one chain"
3895 ),
3896 ));
3897 };
3898 visited[edge_index] = true;
3899 let r = &resolved[edge_index];
3900 links.push(ChainLink {
3901 input_index: edge_index,
3902 forward: entered_at_start,
3903 arc: r.arc.clone(),
3904 length: r.length,
3905 abscissa,
3906 });
3907 abscissa += r.length;
3908 let exit_point = if entered_at_start { r.end } else { r.start };
3909 cluster = clusters
3910 .iter()
3911 .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
3912 .ok_or_else(|| {
3913 KernelRefusal::internal(
3914 KernelStage::Collect,
3915 "chain_cluster",
3916 format!("{entry}: chain walk lost an endpoint cluster"),
3917 )
3918 })?;
3919 }
3920 if visited.iter().any(|v| !v) {
3921 return Err(KernelRefusal::input(
3922 KernelStage::Collect,
3923 "chain_connected",
3924 format!(
3925 "{entry}: selected edges are not connected into one chain"
3926 ),
3927 ));
3928 }
3929 Ok(links)
3930}
3931
3932/// Sample the law into one edge's `(parameter fraction, radius)` stop list —
3933/// the caller-side bridge from chain abscissa into the `radius_at` closure
3934/// seam that `blend_edge_variable` builds over its stops.
3935///
3936/// `scale` maps chain abscissa into the law's own abscissa units
3937/// (`law.total_length() / chain_length` — proportional, so a law built from
3938/// the measured chain lengths maps 1:1). The base stop count derives from
3939/// the law's curvature: piecewise-linear sampling of a C1, piecewise-C2
3940/// function over step `h` errs at most `h²·max|r''|/8`, so
3941/// `h = sqrt(8·tol/max|r''|)` holds the sampling error under the SSI fit
3942/// tolerance the variable lane is built to. That bound is exact for
3943/// arc-length-linear (straight) edges; curved edges bend the
3944/// parameter→abscissa map, so each interval is additionally midpoint-checked
3945/// against the law and bisected on violation (up to 8 halvings — a 4⁸ ≈ 6·10⁴
3946/// error reduction, decisive for any C1 law). The base count is capped at
3947/// 256 intervals: 4× the blend march's 64-station density
3948/// (blend/stations.rs), beyond which denser stops cannot move any station's
3949/// sampled radius by more than the fit tolerance.
3950fn law_stops_for_link(
3951 link: &ChainLink,
3952 law: &crate::law::RadiusLaw,
3953 scale: f64,
3954 tol: f64,
3955) -> Vec<(f64, f64)> {
3956 let radius_at_fraction = |fraction: f64| -> f64 {
3957 let arc = arc_length_at_fraction(&link.arc, fraction);
3958 let chain_s = if link.forward {
3959 link.abscissa + arc
3960 } else {
3961 link.abscissa + (link.length - arc)
3962 };
3963 law.radius_at(chain_s * scale)
3964 };
3965 // Curvature of the law in edge-fraction units: d²r/df² ≤
3966 // max|r''|·(scale·length)² for the arc-length-linear map.
3967 let curvature = law
3968 .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
3969 * (scale * link.length).powi(2);
3970 let base = if curvature * 0.125 <= tol {
3971 1usize
3972 } else {
3973 ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
3974 };
3975 let mut stops: Vec<(f64, f64)> = (0..=base)
3976 .map(|j| {
3977 let fraction = j as f64 / base as f64;
3978 (fraction, radius_at_fraction(fraction))
3979 })
3980 .collect();
3981 // Midpoint refinement for curved parameter→abscissa maps.
3982 let mut depth = 0usize;
3983 while depth < 8 {
3984 let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
3985 let mut inserted = false;
3986 for pair in stops.windows(2) {
3987 refined.push(pair[0]);
3988 let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
3989 let law_mid = radius_at_fraction(mid_fraction);
3990 let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
3991 if (law_mid - linear_mid).abs() > tol {
3992 refined.push((mid_fraction, law_mid));
3993 inserted = true;
3994 }
3995 }
3996 refined.push(*stops.last().expect("at least two stops"));
3997 stops = refined;
3998 if !inserted {
3999 break;
4000 }
4001 depth += 1;
4002 }
4003 stops
4004}
4005
4006/// Fillet (or chamfer) a chain of edges under a composable radius law
4007/// evaluated on the chain's cumulative arc-length abscissa (the OCCT
4008/// `Law_Composite` model; see [`crate::law::RadiusLaw`]). The selected edges
4009/// must form ONE OPEN CHAIN (or be a single edge); the chain starts at the
4010/// free end of the earliest-selected end edge, and the law's abscissa maps
4011/// proportionally onto the chain's measured arc length (a law built with the
4012/// chain's own lengths — e.g. [`crate::law::RadiusLaw::from_vertex_radii`] —
4013/// maps 1:1). Endpoint radii are met exactly; radii at shared chain vertices
4014/// match by the law's continuity, so the per-edge blends miter through the
4015/// corners; a chain whose blends cannot be mitered REFUSES rather than
4016/// distorting the law through the sequential rebuild.
4017pub fn fillet_edges_variable_law(
4018 solid: &BrepSolid,
4019 edge_points: &[Vec3],
4020 edge_names: Option<&[String]>,
4021 law: &crate::law::RadiusLaw,
4022 chamfer: bool,
4023 name: Option<&str>,
4024) -> Result<BrepSolid, KernelRefusal> {
4025 // One blend operation: the reports of an earlier one, never consumed, are
4026 // dropped as it starts (nested calls keep this operation's).
4027 let _operation = crate::blend::BlendOperation::enter();
4028 const ENTRY: &str = "fillet_edges_variable_law";
4029 if edge_points.is_empty() {
4030 return Err(KernelRefusal::input(
4031 KernelStage::Collect,
4032 "law_selection",
4033 format!("{ENTRY}: no edges selected"),
4034 ));
4035 }
4036 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4037 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4038 fillet_variable_law_on_chain(
4039 solid,
4040 edge_points,
4041 edge_names,
4042 &chain,
4043 law,
4044 chamfer,
4045 name,
4046 ENTRY,
4047 tolerances.intersection_fit,
4048 )
4049}
4050
4051/// The natural per-vertex user model: radius `vertex_radii[i]` at chain
4052/// vertex `i` (in CHAIN order, starting at the free end of the
4053/// earliest-selected edge), smoothly interpolated along the chain
4054/// (monotone C1 — every vertex radius met exactly, no overshoot). Requires
4055/// exactly one radius per chain vertex (`edges + 1`).
4056pub fn fillet_edges_variable_vertex_radii(
4057 solid: &BrepSolid,
4058 edge_points: &[Vec3],
4059 edge_names: Option<&[String]>,
4060 vertex_radii: &[f64],
4061 chamfer: bool,
4062 name: Option<&str>,
4063) -> Result<BrepSolid, KernelRefusal> {
4064 // One blend operation: the reports of an earlier one, never consumed, are
4065 // dropped as it starts (nested calls keep this operation's).
4066 let _operation = crate::blend::BlendOperation::enter();
4067 const ENTRY: &str = "fillet_edges_variable_vertex_radii";
4068 if edge_points.is_empty() {
4069 return Err(KernelRefusal::input(
4070 KernelStage::Collect,
4071 "vertex_radii_selection",
4072 format!("{ENTRY}: no edges selected"),
4073 ));
4074 }
4075 let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
4076 let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
4077 let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
4078 let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
4079 .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
4080 fillet_variable_law_on_chain(
4081 solid,
4082 edge_points,
4083 edge_names,
4084 &chain,
4085 &law,
4086 chamfer,
4087 name,
4088 ENTRY,
4089 tolerances.intersection_fit,
4090 )
4091}
4092
4093/// Shared law-entry tail: sample per-edge stop lists from the law over the
4094/// resolved chain and run the variable group core (miter-or-refuse: no
4095/// sequential fallback for tapered chains — see `fillet_edges_variable_impl`).
4096#[allow(clippy::too_many_arguments)]
4097fn fillet_variable_law_on_chain(
4098 solid: &BrepSolid,
4099 edge_points: &[Vec3],
4100 edge_names: Option<&[String]>,
4101 chain: &[ChainLink],
4102 law: &crate::law::RadiusLaw,
4103 chamfer: bool,
4104 name: Option<&str>,
4105 entry: &str,
4106 tol: f64,
4107) -> Result<BrepSolid, KernelRefusal> {
4108 let chain_length: f64 = chain.iter().map(|link| link.length).sum();
4109 let scale = law.total_length() / chain_length;
4110 let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
4111 for link in chain {
4112 per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
4113 }
4114 fillet_edges_variable_impl(
4115 solid,
4116 edge_points,
4117 edge_names,
4118 &per_edge_stops,
4119 chamfer,
4120 name,
4121 entry,
4122 false,
4123 )
4124}
4125
4126/// Asymmetric (two-distance) chamfer of a GROUP of edges, the app entry: each
4127/// selected edge (resolved by a point on it) gets a `d1 × d2` bevel (§6.11).
4128/// Edges are chamfered independently — asymmetric chamfers keep sharp vertices,
4129/// so there is no shared-corner blending.
4130pub fn chamfer_edges_asymmetric(
4131 solid: &BrepSolid,
4132 edge_points: &[Vec3],
4133 edge_names: Option<&[String]>,
4134 d1: f64,
4135 d2: f64,
4136 name: Option<&str>,
4137) -> Result<BrepSolid, KernelRefusal> {
4138 // One blend operation: the reports of an earlier one, never consumed, are
4139 // dropped as it starts (nested calls keep this operation's).
4140 let _operation = crate::blend::BlendOperation::enter();
4141 if edge_points.is_empty() {
4142 return Err(KernelRefusal::input(
4143 KernelStage::Collect,
4144 "asymmetric_selection",
4145 "chamfer_edges_asymmetric: no edges selected",
4146 ));
4147 }
4148 let mut result = solid.clone();
4149 // `edge_names[index]` is keyed by INPUT position; the loop resolves each
4150 // point on the EVOLVING `result`, but enumerates the input points in order,
4151 // so the index alignment holds.
4152 for (index, point) in edge_points.iter().enumerate() {
4153 let edge_id = resolve_edge_by_point(&result, *point)?;
4154 let edge_name = per_edge_name(edge_names, name, index);
4155 result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
4156 }
4157 refuse_invalid_result(&result, "chamfer_edges_asymmetric")?;
4158 Ok(result)
4159}
4160
4161/// Distance-angle chamfer of a GROUP of edges, the app entry: each selected
4162/// edge (resolved by a point on it) gets a setback `d1` on face 1 and a chamfer
4163/// face at `angle_rad` from face 1 (§6.11); `d2` is constructed per edge.
4164pub fn chamfer_edges_angle(
4165 solid: &BrepSolid,
4166 edge_points: &[Vec3],
4167 edge_names: Option<&[String]>,
4168 d1: f64,
4169 angle_rad: f64,
4170 name: Option<&str>,
4171) -> Result<BrepSolid, KernelRefusal> {
4172 // One blend operation: the reports of an earlier one, never consumed, are
4173 // dropped as it starts (nested calls keep this operation's).
4174 let _operation = crate::blend::BlendOperation::enter();
4175 if edge_points.is_empty() {
4176 return Err(KernelRefusal::input(
4177 KernelStage::Collect,
4178 "angle_selection",
4179 "chamfer_edges_angle: no edges selected",
4180 ));
4181 }
4182 let mut result = solid.clone();
4183 for (index, point) in edge_points.iter().enumerate() {
4184 let edge_id = resolve_edge_by_point(&result, *point)?;
4185 let edge_name = per_edge_name(edge_names, name, index);
4186 result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
4187 }
4188 refuse_invalid_result(&result, "chamfer_edges_angle")?;
4189 Ok(result)
4190}
4191
4192/// Debug aid (`BREP_DEBUG_NETWORK`): every face loop as its coedge walk, with
4193/// each edge's stored vertices, so a loop the surgery left open can be read
4194/// against the input it was built from.
4195fn dump_loops_debug(label: &str, solid: &BrepSolid) {
4196 for shell in &solid.shells {
4197 for face in &shell.faces {
4198 for (index, loop_record) in face.loops.iter().enumerate() {
4199 let walk: Vec<String> = loop_record
4200 .coedges
4201 .iter()
4202 .map(|coedge| {
4203 // The pcurve's walk endpoints in the face's (u, v), so a
4204 // loop that closes in space but not in parameter space
4205 // (a periodic carrier's seam) is visible in the dump.
4206 let uv = coedge
4207 .pcurve
4208 .domain()
4209 .ok()
4210 .and_then(|[q0, q1]| {
4211 let a = coedge.pcurve.evaluate(q0).ok()?;
4212 let b = coedge.pcurve.evaluate(q1).ok()?;
4213 Some(format!("uv[({:.4},{:.4})->({:.4},{:.4})]", a.x, a.y, b.x, b.y))
4214 })
4215 .unwrap_or_default();
4216 match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
4217 Some(edge) => format!(
4218 "c{}:e{}{}({}->{}){uv}",
4219 coedge.id,
4220 edge.id,
4221 if coedge.forward { "+" } else { "-" },
4222 edge.start_vertex_id,
4223 edge.end_vertex_id
4224 ),
4225 None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
4226 }
4227 })
4228 .collect();
4229 eprintln!(
4230 "{label} face {} ({:?}) loop {index}: {}",
4231 face.id,
4232 face.name,
4233 walk.join(" ")
4234 );
4235 }
4236 }
4237 }
4238}
4239
4240