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