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