Skip to main content

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