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