Skip to main content

brep_kernel/blending/fillet/
edges.rs

1use super::*;
2
3/// Constant-radius rolling-ball fillet of one edge (Golovanov §4.9 march,
4/// §6.9 surgery; the cutter only where the march refuses).
5pub fn fillet_edge(
6    solid: &BrepSolid,
7    edge_id: u64,
8    radius: f64,
9    name: Option<&str>,
10) -> Result<BrepSolid, KernelRefusal> {
11    // One blend operation: the reports of an earlier one, never consumed, are
12    // dropped as it starts (nested calls keep this operation's).
13    let _operation = crate::blend::BlendOperation::enter();
14    check_mixed_concavity(solid, edge_id, "fillet_edge")?;
15    check_support_extent(solid, edge_id, radius, "fillet_edge")?;
16    fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::GeneralFirst)
17}
18
19/// `fillet_edge` with the cutter first: the input the corner-closure lanes
20/// (`round_convex_corner`, the mixed-convexity closures) were written
21/// against.  Their tests build it directly; production reaches those lanes
22/// only through the group's sequential composition, which is cutter-first
23/// for the same reason.
24pub(crate) fn fillet_edge_cutter(
25    solid: &BrepSolid,
26    edge_id: u64,
27    radius: f64,
28    name: Option<&str>,
29) -> Result<BrepSolid, KernelRefusal> {
30    check_mixed_concavity(solid, edge_id, "fillet_edge")?;
31    check_support_extent(solid, edge_id, radius, "fillet_edge")?;
32    fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::CutterFirst)
33        .map(settle_cutter_scaffold)
34}
35
36/// Equal-leg chamfer of one edge (Golovanov §6.11: the same construction
37/// with the arc's chord).
38pub fn chamfer_edge(
39    solid: &BrepSolid,
40    edge_id: u64,
41    distance: f64,
42    name: Option<&str>,
43) -> Result<BrepSolid, KernelRefusal> {
44    // One blend operation: the reports of an earlier one, never consumed, are
45    // dropped as it starts (nested calls keep this operation's).
46    let _operation = crate::blend::BlendOperation::enter();
47    check_mixed_concavity(solid, edge_id, "chamfer_edge")?;
48    check_support_extent(solid, edge_id, distance, "chamfer_edge")?;
49    fillet_or_chamfer(solid, edge_id, distance, true, name, ToolEnds::default(), Lane::GeneralFirst)
50}
51
52/// Build and apply the chamfer tool for one straight edge from an already-built
53/// cross-section profile (curve index 1 is the chamfer chord / blend wall).
54/// Shared by the two-distance and distance-angle asymmetric entries.
55fn apply_chamfer_offsets_profile(
56    solid: &BrepSolid,
57    cross: &EdgeCross,
58    profile: &[NurbsCurve],
59    name: Option<&str>,
60) -> Result<BrepSolid, KernelRefusal> {
61    let mut tool = match &cross.path {
62        EdgePath::Straight { direction, length } => {
63            extrude_profile_brep(profile, *direction, *length)?
64        }
65        EdgePath::Circular { .. } => {
66            return Err(KernelRefusal::unsupported(
67                KernelStage::Classify,
68                "asymmetric_curved_edge",
69                "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
70                 in this slice (asymmetric chamfer on general/curved edges is out of scope)",
71            ));
72        }
73    };
74    // Side faces are emitted in input-curve order; the chamfer wall is the
75    // second profile curve (index 1). Every other face is the cutter's
76    // scaffold, tagged and settled exactly as the symmetric cutter's is.
77    let mut side_index = 0usize;
78    for shell in &mut tool.shells {
79        for face in &mut shell.faces {
80            if side_index == 1 {
81                if face.name.is_none() {
82                    face.name = name.map(str::to_string);
83                }
84            } else {
85                face.name = Some(scaffold_tag(name));
86            }
87            side_index += 1;
88        }
89    }
90    apply_tool(solid, &tool, cross.convex).map(settle_cutter_scaffold)
91}
92
93/// Asymmetric (two-distance) chamfer of one STRAIGHT edge between two planar
94/// faces (Golovanov §6.11): setback `d1` along face 1 and `d2` along face 2 —
95/// the standard CAD "d1 × d2" bevel.  General/curved edges are out of scope for
96/// this slice and return a clear error.
97pub fn chamfer_edge_asymmetric(
98    solid: &BrepSolid,
99    edge_id: u64,
100    d1: f64,
101    d2: f64,
102    name: Option<&str>,
103) -> Result<BrepSolid, KernelRefusal> {
104    // One blend operation: the reports of an earlier one, never consumed, are
105    // dropped as it starts (nested calls keep this operation's).
106    let _operation = crate::blend::BlendOperation::enter();
107    if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
108        return Err(KernelRefusal::input(
109            KernelStage::Collect,
110            "setback_distances",
111            "chamfer_edge_asymmetric: both setback distances must be positive",
112        ));
113    }
114    // `analyze_edge` only uses the radius to size the orientation probe step;
115    // the smaller setback keeps that probe inside both faces.
116    let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
117    let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
118    apply_chamfer_offsets_profile(solid, &cross, &profile, name)
119}
120
121/// Distance-angle chamfer of one STRAIGHT edge between two planar faces
122/// (Golovanov §6.11): setback `d1` along face 1 and angle `angle_rad` between
123/// the chamfer face and face 1.  `d2` is constructed geometrically in the
124/// cross-section plane (see `chamfer_angle_second_distance`), then the
125/// two-distance builder is applied.
126pub fn chamfer_edge_angle(
127    solid: &BrepSolid,
128    edge_id: u64,
129    d1: f64,
130    angle_rad: f64,
131    name: Option<&str>,
132) -> Result<BrepSolid, KernelRefusal> {
133    // One blend operation: the reports of an earlier one, never consumed, are
134    // dropped as it starts (nested calls keep this operation's).
135    let _operation = crate::blend::BlendOperation::enter();
136    if !(d1 > 0.0) || !d1.is_finite() {
137        return Err(KernelRefusal::input(
138            KernelStage::Collect,
139            "setback_d1",
140            "chamfer_edge_angle: setback distance d1 must be positive",
141        ));
142    }
143    let cross = analyze_edge(solid, edge_id, d1)?;
144    let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
145    let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
146    apply_chamfer_offsets_profile(solid, &cross, &profile, name)
147}
148
149/// Snap small trim/intersection gaps between blend edges and their vertices.
150/// The boolean endpoint welder preserves gaps already inside the validation
151/// band and limits repairs to `max(|radius| * 1e-3, 1e-4)`.
152pub(super) fn heal_edge_vertex_gaps(
153    solid: &mut BrepSolid,
154    radius: f64,
155) -> Result<(), KernelRefusal> {
156    let search = (radius.abs() * 1e-3).max(1e-7);
157    crate::boolean::commit_nearby_edge_endpoints(solid, search)
158}
159
160/// Resolve a point within 1e-3 of a trimmed edge.
161fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
162    match crate::topology::nearest_edge(solid, point) {
163        Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
164        Some((_, distance)) => Err(KernelRefusal::input(
165            KernelStage::Collect,
166            "edge_point",
167            format!(
168                "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
169            ),
170        )),
171        None => Err(KernelRefusal::input(
172            KernelStage::Collect,
173            "no_edges",
174            "fillet_edges: solid has no edges",
175        )),
176    }
177}
178
179/// Fillet (or chamfer) a GROUP of edges as ONE operation, and — for fillets —
180/// round the convex "star" vertices where three or more of the selected edges
181/// meet (Golovanov §6.9.7).  This is the whole multi-edge fillet in a single
182/// kernel call: the caller passes the object plus one 3D point on each edge,
183/// and the kernel orchestrates the filleting and corner blending against the
184/// full topology (so acute corners resolve coherently instead of being
185/// stitched edge-by-edge by the app).  A corner the kernel cannot round (e.g.
186/// non-orthogonal beyond support, or a general no-common-ball star) is left as
187/// the edge fillets rather than failing the whole group.
188///
189/// When the WHOLE selection cannot be blended as one unit — a shared convex
190/// corner where a revolve axis/pole edge meets the adjacent cap edges can
191/// defeat the sequential corner surgery even though each edge and every proper
192/// SUBSET of the selection blends cleanly (the three fillets converge on the
193/// pole with no single end face across the corner) — a maximal-valid-SUBSET
194/// search blends the largest subset that works, dropping only the edges that
195/// cannot co-blend.  That is a real outcome and [`fillet_edges_reported`]
196/// returns it, WITH the list of edges it could not blend.
197///
198/// This entry point does not.  A solid holding eight of the ten walls that
199/// were asked for is not the answer to the question that was asked, and
200/// returning it as a plain `Ok` reports success for a different result; so an
201/// incomplete selection is an error here, naming every edge that was dropped
202/// and the group refusal behind them.  A caller that genuinely wants the
203/// partial answer asks for it by name.
204///
205/// `edge_names` (when `Some`) is the per-edge blend-FACE name parallel to
206/// `edge_points` — each grown wall is named after ITS originating edge; `None`
207/// names every wall with the single base `name` (the legacy/test behavior,
208/// byte-identical to before). The whole `*_edges` family takes the same
209/// `edge_names` slot in the same position.
210pub fn fillet_edges(
211    solid: &BrepSolid,
212    edge_points: &[Vec3],
213    edge_names: Option<&[String]>,
214    radius: f64,
215    chamfer: bool,
216    name: Option<&str>,
217) -> Result<BrepSolid, KernelRefusal> {
218    // One blend operation: the reports of an earlier one, never consumed, are
219    // dropped as it starts (nested calls keep this operation's).
220    let _operation = crate::blend::BlendOperation::enter();
221    let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
222    let (result, selection) =
223        fillet_edges_reported(solid, edge_points, edge_names, radius, chamfer, name)?;
224    if selection.is_complete() {
225        return Ok(result);
226    }
227    // Every dropped edge BY NAME: the caller's own per-edge name when it gave
228    // one, and always its index and the point it was selected by.
229    let dropped: Vec<String> = selection
230        .rejected
231        .iter()
232        .map(|index| {
233            let point = edge_points[*index];
234            let named = edge_names
235                .and_then(|names| names.get(*index))
236                .map(|name| format!("`{name}` "))
237                .unwrap_or_default();
238            format!(
239                "{named}#{index} at ({:.6}, {:.6}, {:.6})",
240                point.x, point.y, point.z
241            )
242        })
243        .collect();
244    // The selection, as specified, has no single answer the kernel will
245    // stand behind: the group refusal behind the drop travels as TEXT in
246    // `BlendSelection::reason`, so this refusal carries its own class.
247    Err(KernelRefusal::ill_posed(
248        KernelStage::Select,
249        "incomplete_selection",
250        format!(
251            "{entry}: {} of the {} selected edges were blended — {} could not co-blend with the \
252             rest of the selection and carry no wall in the result ({}). The group refusal was: \
253             {}. Blend them separately, or call the reported entry point to accept the partial \
254             selection deliberately.",
255            selection.applied.len(),
256            selection.requested,
257            selection.rejected.len(),
258            dropped.join(", "),
259            selection
260                .reason
261                .as_deref()
262                .unwrap_or("the whole group built but not every edge grew a wall"),
263        ),
264    ))
265}
266
267/// What a blend selection actually achieved.
268///
269/// The kernel can answer a group blend with fewer walls than were asked for —
270/// the maximal-valid-subset search drops the edges that cannot co-blend at a
271/// shared corner rather than failing outright. This says WHICH, so partial
272/// fulfilment is a statement instead of a silence.
273#[derive(Clone, Debug)]
274pub struct BlendSelection {
275    /// How many edges the caller selected.
276    pub requested: usize,
277    /// Indices into that selection that carry a blend wall in the result.
278    pub applied: Vec<usize>,
279    /// Indices that do not.
280    pub rejected: Vec<usize>,
281    /// Why the whole selection could not be built, when something was dropped.
282    pub reason: Option<String>,
283}
284
285impl BlendSelection {
286    /// Every requested edge carries a wall.
287    pub fn is_complete(&self) -> bool {
288        self.rejected.is_empty()
289    }
290}
291
292/// [`fillet_edges`] with the selection outcome reported rather than refused.
293///
294/// The result is the same solid `fillet_edges` would build; the difference is
295/// that an incomplete selection comes back as `Ok` with the misses named,
296/// which is the explicit partial-result contract.  Every named refusal that
297/// applies to the WHOLE selection — mixed concavity, a radius the local wall
298/// cannot hold, a mixed-convexity corner — is still an `Err`: dropping edges
299/// cannot rescue those, so a partial answer would be a different defect.
300pub fn fillet_edges_reported(
301    solid: &BrepSolid,
302    edge_points: &[Vec3],
303    edge_names: Option<&[String]>,
304    radius: f64,
305    chamfer: bool,
306    name: Option<&str>,
307) -> Result<(BrepSolid, BlendSelection), KernelRefusal> {
308    // One blend operation: the reports of an earlier one, never consumed, are
309    // dropped as it starts (nested calls keep this operation's).
310    let _operation = crate::blend::BlendOperation::enter();
311    if !(radius > 0.0) || !radius.is_finite() {
312        return Err(KernelRefusal::input(
313            KernelStage::Collect,
314            "radius",
315            "fillet_edges: radius must be positive",
316        ));
317    }
318    if edge_points.is_empty() {
319        return Err(KernelRefusal::input(
320            KernelStage::Collect,
321            "selection",
322            "fillet_edges: no edges selected",
323        ));
324    }
325    // The rolling ball must reach both supports of every SELECTED edge — see
326    // `check_support_extent`.  Checked ONCE, here, against the solid the
327    // selection was made on: inside the group build the faces have already
328    // been eaten into by earlier blends of the same selection, and a fillet
329    // legitimately runs off that remainder (two rounds pinching on a shared
330    // face).  Dropping edges cannot rescue an oversized radius either, so
331    // this runs before the subset search rather than inside it.
332    let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
333    let mut selected_ids = Vec::with_capacity(edge_points.len());
334    for point in edge_points {
335        let edge_id = resolve_edge_by_point(solid, *point)?;
336        check_mixed_concavity(solid, edge_id, entry)?;
337        check_support_extent(solid, edge_id, radius, entry)?;
338        selected_ids.push(edge_id);
339    }
340    // A corner where the selection mixes convexity — a convex edge dying into
341    // the concave edges it meets — has no rolling-ball closure at all: the
342    // blends run out against each other partway along, which is the vertex
343    // blend the stripe network does not construct yet.  It is TERMINAL, and
344    // for the same reason as the two checks above it runs once, here: neither
345    // the cutter composition nor the subset search below can rescue it.  The
346    // cutter "succeeds" on this shape by shredding the solid (a sliver end cap
347    // per corner, the carrier face split and renamed), which is worse than
348    // the named refusal — see the 2026-09-09 rib-spine report.
349    //
350    // Nor can PARTITIONING the selection at the corner rescue it, which is why
351    // this is still a refusal and not a split (measured 2026-09-13, the
352    // partition record): the two convexity-pure halves each build on the
353    // network, and composing them walls every selected edge and leaves every
354    // other face's area identical to twelve digits — but the convex wall comes
355    // out as the full untrimmed quarter-round prism (2·(1 − π/4)·r²·L to
356    // 2.2e-10, with NO rolled-corner term) because its contact rail runs off
357    // its own face at the TRI-TANGENT STATION and the surgery splices the
358    // overhang in rather than clipping it. The faces it runs along come back
359    // with self-crossing loops that `validate()`, `solid_self_intersections`,
360    // the connectivity report and the Euler check all pass — and that
361    // `check_loop_self_crossings` REFUSES since 9e50f6d55, so the split is not
362    // a route to ten walls either: blend the concave half first and the
363    // acceptance refuses every convex edge whose wall runs out into it — on
364    // the rib both spines, at that same station, so that feature fails
365    // outright; blend the convex half first and the maximal-valid-subset
366    // search drops one ring edge and returns NINE walls of ten. The
367    // construction that is missing is the runout itself: past that station the
368    // ball rolls on one input face and on the concave stripe's own surface, so
369    // the march needs a STRIPE as its second carrier.
370    //
371    // And more than that, measured 2026-09-13 (`blend/runout.rs`, which this
372    // refusal now reports): the KEPT mate runs out too, at the mirror-image
373    // station, so the FIRST carrier becomes the other concave stripe and the
374    // last stretch rolls on two stripes; that stretch ends in a POLE where the
375    // two stripes' rails on the face they share meet and the section shrinks
376    // to a point. Two carrier switches and a degenerate terminus, not a march
377    // with a stop.
378    if !chamfer {
379        // The class is BUILT where the runout composition applies — the ring
380        // first, then one wall per spine from pole to pole (`blend/runout.rs`,
381        // the 2026-09-30 record §5) — and refused by name, with the
382        // composition's own reason appended, where it does not.
383        let corners = crate::blend::mixed_convexity_corners(solid, &selected_ids, radius);
384        if !corners.is_empty() {
385            let composed = rounded_boss_composition(
386                solid, &selected_ids, edge_points, edge_names, radius, name, &corners,
387            ).or_else(|_| runout_composition(
388                solid, &selected_ids, edge_points, edge_names, radius, name, &corners, entry,
389            ));
390            match composed {
391                Ok(result) => {
392                    let missing = walls_missing(
393                        solid, &result, edge_points, &(0..edge_points.len()).collect::<Vec<_>>(), radius, chamfer, entry,
394                    )?;
395                    if !missing.is_empty() {
396                        return Err(KernelRefusal::internal(
397                            KernelStage::Validate,
398                            "runout_walls_missing",
399                            format!(
400                                "{entry}: the runout composition built a solid with no wall on {}",
401                                missing.iter().map(|index| selection_label(*index, edge_points, edge_names, name)).collect::<Vec<_>>().join(", ")
402                            ),
403                        ));
404                    }
405                    let n = edge_points.len();
406                    return Ok((
407                        result,
408                        BlendSelection { requested: n, applied: (0..n).collect(), rejected: Vec::new(), reason: None },
409                    ));
410                }
411                Err(error) => {
412                    if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
413                        eprintln!("runout composition refused: {error}");
414                    }
415                    check_mixed_corner_convexity(
416                        solid, &selected_ids, edge_points, edge_names, radius, name, Some(&error),
417                    )?;
418                }
419            }
420        }
421        // A selection whose corner setbacks consume a whole edge — the exact
422        // degenerate radius, every face shrunk to a point — has no strip for
423        // the network to build and nothing the cutter or the subset search
424        // can rescue (the search answered the r = 10 cube with nine walls and
425        // a 5174 volume against the sphere's 4189).  Terminal, by name, here.
426        check_degenerate_corner_setbacks(
427            solid,
428            &selected_ids,
429            edge_points,
430            edge_names,
431            radius,
432            name,
433        )?;
434    }
435
436    let n = edge_points.len();
437    let complete = |applied: Vec<usize>| BlendSelection {
438        requested: n,
439        applied,
440        rejected: Vec::new(),
441        reason: None,
442    };
443    // Every subset below is built against the SAME solid, and a selection of
444    // several components is composed one component at a time in input order,
445    // so two subsets that keep the same leading components rebuild the same
446    // intermediate solids. The memo keeps each component's result under the
447    // chain of components composed before it, which is everything that result
448    // depends on here (the solid, radius, chamfer and name are fixed for the
449    // call), so the search returns what it did without the memo, only without
450    // the rebuilding. On the 2026-09-26 cross-drilled sphere report four bored
451    // rims were re-blended ahead of the same failing component in each of the
452    // (up to 372) subsets tried: 13 min 25 s for one refusal, 34 s with it.
453    let mut memo = ComponentMemo::default();
454    let group_err = match fillet_edges_group_in(
455        solid,
456        edge_points,
457        edge_names,
458        radius,
459        chamfer,
460        name,
461        Some(&mut memo),
462    ) {
463        Ok(result) => {
464            // The group returned a solid, which is NOT the same as the group
465            // having blended every edge.  `validate()` cannot tell the
466            // difference — it checks incidence, and a solid missing a wall is
467            // perfectly incident — so the walls are COUNTED.
468            let missing =
469                walls_missing(solid, &result, edge_points, &(0..n).collect::<Vec<_>>(), radius, chamfer, entry)?;
470            if missing.is_empty() {
471                return Ok((result, complete((0..n).collect())));
472            }
473            let applied: Vec<usize> = (0..n).filter(|index| !missing.contains(index)).collect();
474            return Ok((
475                result,
476                BlendSelection {
477                    requested: n,
478                    applied,
479                    rejected: missing,
480                    reason: None,
481                },
482            ));
483        }
484        Err(error) => error,
485    };
486    // A wall that FOLDS THROUGH ITSELF is terminal for the whole selection, and
487    // for the reason the two checks above are: dropping edges cannot rescue it.
488    // The subset search would happily answer this document's two collars with
489    // the ONE that does not fold, and return a solid whose volume is a blend
490    // short — the same silent partial `both_collars_add_independently` exists
491    // to catch.  Reported by name instead (`blend/fold.rs`).
492    // A miter past the sharp edge's far vertex needs support-face switching,
493    // not a smaller selection. Do not let the subset search mask the limit.
494    if crate::blend::is_wall_fold(&group_err) || crate::blend::is_miter_support_overrun(&group_err) {
495        return Err(group_err);
496    }
497    // Fewer than two edges: nothing to drop, so the group error is final.
498    // Cap the combinatorial search so a large malformed selection cannot
499    // explode (the full group carries the common case; the search is a
500    // rare fallback).
501    if n < 2 || n > 12 {
502        return Err(group_err);
503    }
504    // Drop the fewest edges first (largest surviving subset), trying the
505    // drop-sets in lexicographic order so the result is deterministic.
506    // Take the first subset that blends to a valid solid IN WHICH EVERY KEPT
507    // EDGE GREW A WALL: a subset that itself silently drops one of its own
508    // edges is not the maximal subset, it is a smaller one wearing a larger
509    // one's label.
510    for drop in 1..n {
511        for dropped in index_combinations(n, drop) {
512            let keep: Vec<usize> = (0..n).filter(|i| !dropped.contains(i)).collect();
513            let points: Vec<Vec3> = keep.iter().map(|i| edge_points[*i]).collect();
514            // Subset the per-edge blend-face names with the IDENTICAL
515            // drop-set so `kept_names[k]` still names `kept[k]`.
516            let kept_names: Option<Vec<String>> =
517                edge_names.map(|names| keep.iter().map(|i| names[*i].clone()).collect());
518            let Ok(result) = fillet_edges_group_in(
519                solid,
520                &points,
521                kept_names.as_deref(),
522                radius,
523                chamfer,
524                name,
525                Some(&mut memo),
526            ) else {
527                continue;
528            };
529            if !result.validate().is_empty() {
530                continue;
531            }
532            if !walls_missing(solid, &result, edge_points, &keep, radius, chamfer, entry)?.is_empty() {
533                continue;
534            }
535            return Ok((
536                result,
537                BlendSelection {
538                    requested: n,
539                    applied: keep,
540                    rejected: dropped,
541                    reason: Some(group_err.message),
542                },
543            ));
544        }
545    }
546    Err(group_err)
547}
548
549/// Which of `selected` (indices into the caller's own selection) carry NO blend
550/// wall in `result` — decided by the MATERIAL, not by a name.
551///
552/// A blended edge is an edge whose material changed: a convex blend removes
553/// the sliver under the edge, so a point of the original edge ends up OUTSIDE
554/// the answer; a concave one pads the crease, so it ends up INSIDE. An edge
555/// the selection dropped is still the sharp edge it was, and its points still
556/// lie ON the skin. That statement holds for every caller and every lane,
557/// which the previous attribution did not: it looked for one face per edge
558/// carrying the caller's per-edge name, so a caller passing `None` (the wasm
559/// ABI, `fillet_edges_json`) got NO count at all, and a caller passing names
560/// was told a wall was missing where the network had merged two coaxial walls
561/// into one face (the U-channel at half its width keeps one name for two
562/// edges) or collapsed the walls of a full-width star into its corner patch
563/// (`{base}:CORNER:…`, and no wall name at all). Both were measured
564/// 2026-09-26; the named path REFUSED those complete blends.
565///
566/// The classifier answers `On` inside a band, so the check states how far
567/// the blend of THIS size moves the edge at each probed station — `r·(1/cos(α/2)
568/// − 1)` for a fillet and `d·sin(α/2)` for a chamfer, `α` the angle between the
569/// two faces' outward normals there — and probes the station the blend moves
570/// MOST. An edge that is tangent at one end (the miter seam two rounds leave
571/// runs to 180° at its pole) is still attributed where it is sharp. Only when
572/// no station clears the band is the selection refused by name, rather than
573/// an unseeable move read as a missing wall.
574pub(super) fn walls_missing(
575    original: &BrepSolid,
576    result: &BrepSolid,
577    edge_points: &[Vec3],
578    selected: &[usize],
579    size: f64,
580    chamfer: bool,
581    entry: &str,
582) -> Result<Vec<usize>, KernelRefusal> {
583    use crate::{PointClass, SolidClassifier};
584    if selected.is_empty() {
585        return Ok(Vec::new());
586    }
587    let band = crate::KernelTolerances::for_solid(result, 1e-7).model;
588    // `classify` reads `On` within ten times its band; attribution needs the
589    // move to clear that by the same margin again.
590    let visible = band * 100.0;
591    let classifier = SolidClassifier::new(result, band)
592        .or_refuse(KernelStage::Validate, "wall_classifier")?;
593    const STATIONS: usize = 9;
594    let mut missing = Vec::new();
595    for &index in selected {
596        let point = edge_points[index];
597        let edge_id = resolve_edge_by_point(original, point)?;
598        let (edge, faces) = super::analyze::edge_and_faces(original, edge_id)?;
599        // The station the blend moves most, and how far.
600        let mut best: Option<(Vec3, f64)> = None;
601        for step in 1..=STATIONS {
602            let t = edge.t0 + (edge.t1 - edge.t0) * step as f64 / (STATIONS + 1) as f64;
603            let Ok(station) = edge.curve.evaluate(t) else {
604                continue;
605            };
606            let clearance = match faces[..] {
607                [first, second] => {
608                    let (Ok((_, a)), Ok((_, b))) = (
609                        super::analyze::face_plane_normal(first, station),
610                        super::analyze::face_plane_normal(second, station),
611                    ) else {
612                        continue;
613                    };
614                    let half: f64 = 0.5 * a.dot(b).clamp(-1.0, 1.0).acos();
615                    if chamfer {
616                        size * half.sin()
617                    } else {
618                        size * (1.0 / half.cos().max(1e-12) - 1.0)
619                    }
620                }
621                // No dihedral to read: probe anyway, unguarded.
622                _ => f64::INFINITY,
623            };
624            if best.map_or(true, |(_, known)| clearance > known) {
625                best = Some((station, clearance));
626            }
627        }
628        let Some((station, clearance)) = best else {
629            return Err(KernelRefusal::internal(
630                KernelStage::Validate,
631                "wall_attribution_stations",
632                format!(
633                    "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — no \
634                     station of it could be evaluated",
635                    point.x, point.y, point.z
636                ),
637            ));
638        };
639        if !(clearance > visible) {
640            // The faces themselves put the move under the band: the input
641            // leaves the attribution undecidable, and it is refused rather
642            // than guessed.
643            return Err(KernelRefusal::ill_posed(
644                KernelStage::Validate,
645                "wall_attribution_band",
646                format!(
647                    "{entry}: cannot attribute a wall to edge #{index} at ({:.6}, {:.6}, {:.6}) — \
648                     its faces are so nearly tangent that a size-{size} blend moves the edge by at \
649                     most {clearance:.3e}, inside the {visible:.3e} the classifier can see; the \
650                     selection is refused rather than reported on a guess",
651                    point.x, point.y, point.z
652                ),
653            ));
654        }
655        if classifier
656            .classify(station)
657            .or_refuse(KernelStage::Validate, "wall_classify")?
658            .class
659            == PointClass::On
660        {
661            missing.push(index);
662        }
663    }
664    Ok(missing)
665}
666
667/// The blend-FACE name for the `i`-th selected edge: its per-edge name when the
668/// caller supplied the parallel `edge_names` (feature path — each wall named
669/// after its originating edge, `{fid}:BLEND:{edge}`), else the single base
670/// `name` for every wall (the legacy/test path, byte-identical to before).
671fn per_edge_name<'a>(
672    edge_names: Option<&'a [String]>,
673    base: Option<&'a str>,
674    i: usize,
675) -> Option<&'a str> {
676    match edge_names {
677        Some(names) => names.get(i).map(|value| value.as_str()),
678        None => base,
679    }
680}
681
682/// The star-corner patch name: `{base}:CORNER:{sorted+join of adjacent edge
683/// names}` when per-edge names were supplied (feature path), else the single
684/// `base` (legacy/test path — the corner keeps the wall name, pre-change
685/// behavior).  `base` is `{fid}:BLEND` and each `edge_names[i]` is the composed
686/// `{fid}:BLEND:{edge}`, so stripping the `{base}:` prefix recovers the bare
687/// originating-edge name for the join.  Unique per corner: two distinct corners
688/// never share the same set of >=3 selected edges.
689fn corner_face_name(
690    edge_names: Option<&[String]>,
691    base: Option<&str>,
692    adjacent: &[usize],
693) -> Option<String> {
694    match (edge_names, base) {
695        (Some(names), Some(base)) => {
696            let prefix = format!("{base}:");
697            let mut raws: Vec<&str> = adjacent
698                .iter()
699                .filter_map(|&i| names.get(i))
700                .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
701                .collect();
702            raws.sort_unstable();
703            raws.dedup();
704            Some(format!("{base}:CORNER:{}", raws.join("+")))
705        }
706        _ => base.map(|value| value.to_string()),
707    }
708}
709
710/// Round an isolated convex boss edge first, then blend the concave rim
711/// including its new circular bridge across the convex wall. Omitting that
712/// bridge leaves the two rim stripes disconnected at the mixed corner.
713#[allow(clippy::too_many_arguments)]
714fn rounded_boss_composition(
715    solid: &BrepSolid,
716    selected_ids: &[u64],
717    edge_points: &[Vec3],
718    edge_names: Option<&[String]>,
719    radius: f64,
720    name: Option<&str>,
721    corners: &[crate::blend::MixedCorner],
722) -> Result<BrepSolid, KernelRefusal> {
723    let decline = || {
724        KernelRefusal::unsupported(KernelStage::Classify, "rounded_boss_selection",
725        "the rounded boss composition requires one isolated convex edge and one orthogonal mixed corner")
726    };
727    if std::env::var("BREP_NO_NETWORK").is_ok() || corners.len() != 1 {
728        return Err(decline());
729    }
730    let corner = &corners[0];
731    if corner.convex.len() != 1 || corner.concave.len() != 2 {
732        return Err(decline());
733    }
734    let convexity =
735        crate::blend::selection_convexity(solid, selected_ids, radius).ok_or_else(decline)?;
736    if convexity.iter().filter(|convex| **convex).count() != 1 {
737        return Err(decline());
738    }
739    let spine_index = corner.convex[0];
740    let spine = solid
741        .edges
742        .iter()
743        .find(|edge| edge.id == selected_ids[spine_index])
744        .ok_or_else(decline)?;
745    let far = if spine.start_vertex_id == corner.vertex {
746        spine.end_vertex_id
747    } else {
748        spine.start_vertex_id
749    };
750    if solid.edges.iter().any(|edge| {
751        edge.id != spine.id
752            && selected_ids.contains(&edge.id)
753            && (edge.start_vertex_id == far || edge.end_vertex_id == far)
754    }) {
755        return Err(decline());
756    }
757    let uses = |face: &FaceRecord, edge: u64| {
758        face.loops
759            .iter()
760            .flat_map(|rim| &rim.coedges)
761            .any(|coedge| coedge.edge_id == edge)
762    };
763    let cap = solid
764        .shells
765        .iter()
766        .flat_map(|shell| &shell.faces)
767        .find(|face| {
768            corner
769                .concave
770                .iter()
771                .all(|index| uses(face, selected_ids[*index]))
772        })
773        .ok_or_else(decline)?;
774    let faces: Vec<&FaceRecord> = solid
775        .shells
776        .iter()
777        .flat_map(|shell| &shell.faces)
778        .filter(|face| face.id == cap.id || uses(face, spine.id))
779        .collect();
780    let normals: Option<Vec<Vec3>> = faces
781        .iter()
782        .map(|face| match face.surface.analytic() {
783            Some(crate::AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
784                u_dir.cross(*v_dir).normalized().ok()
785            }
786            _ => None,
787        })
788        .collect();
789    let normals = normals.ok_or_else(decline)?;
790    if normals.len() != 3
791        || (0..3).any(|a| (a + 1..3).any(|b| normals[a].dot(normals[b]).abs() > 1e-6))
792    {
793        return Err(decline());
794    }
795    // Name the wall even for unnamed callers so the bridge is identified
796    // from its carriers rather than its distance to the old vertex.
797    let spine_name = per_edge_name(edge_names, name, spine_index)
798        .unwrap_or("FILLET:ROUNDED_BOSS_SPINE")
799        .to_owned();
800    let rounded = fillet_edges_group_in(
801        solid,
802        &[edge_points[spine_index]],
803        Some(&[spine_name.clone()]),
804        radius,
805        false,
806        name,
807        None,
808    )?;
809    let bridges: Vec<&EdgeRecord> = rounded
810        .edges
811        .iter()
812        .filter(|edge| {
813            let faces: Vec<&FaceRecord> = rounded
814                .shells
815                .iter()
816                .flat_map(|shell| &shell.faces)
817                .filter(|face| uses(face, edge.id))
818                .collect();
819            faces.iter().any(|face| face.id == cap.id)
820                && faces
821                    .iter()
822                    .any(|face| face.name.as_deref() == Some(&spine_name))
823        })
824        .collect();
825    if bridges.len() != 1 {
826        return Err(decline());
827    }
828    let bridge = bridges[0];
829    let rim_ids: Vec<u64> = selected_ids
830        .iter()
831        .enumerate()
832        .filter(|(index, _)| *index != spine_index)
833        .map(|(_, id)| *id)
834        .collect();
835    // Both ends must join requested rim edges: never extend a selection
836    // into an unselected perimeter.
837    if ![bridge.start_vertex_id, bridge.end_vertex_id]
838        .iter()
839        .all(|vertex| {
840            rounded.edges.iter().any(|edge| {
841                rim_ids.contains(&edge.id)
842                    && (edge.start_vertex_id == *vertex || edge.end_vertex_id == *vertex)
843            })
844        })
845    {
846        return Err(decline());
847    }
848    let mut points = Vec::new();
849    let mut names = Vec::new();
850    for (index, id) in selected_ids.iter().enumerate() {
851        if index == spine_index {
852            continue;
853        }
854        let edge = rounded
855            .edges
856            .iter()
857            .find(|edge| edge.id == *id)
858            .ok_or_else(decline)?;
859        points.push(
860            edge.curve
861                .evaluate(0.5 * (edge.t0 + edge.t1))
862                .or_refuse(KernelStage::Collect, "rim_midpoint")?,
863        );
864        names.push(
865            per_edge_name(edge_names, name, index)
866                .unwrap_or("FILLET:RIM")
867                .to_owned(),
868        );
869    }
870    points.push(
871        bridge
872            .curve
873            .evaluate(0.5 * (bridge.t0 + bridge.t1))
874            .or_refuse(KernelStage::Collect, "bridge_midpoint")?,
875    );
876    let mut adjacent = corner.concave.clone();
877    adjacent.push(spine_index);
878    names.push(
879        corner_face_name(edge_names, name, &adjacent).unwrap_or("FILLET:BOSS_CORNER".to_owned()),
880    );
881    fillet_edges_group_in(&rounded, &points, Some(&names), radius, false, name, None)
882}
883
884/// A corner with two concave edges and a convex one requires composition.
885///
886/// Where selected edges meet, the rolling ball has to touch every face around
887/// the vertex from ONE side. A convex edge running into the concave edges it
888/// meets (a rib spine dying into the fillets at its own base) asks the ball to
889/// sit under the shared face for one blend and over it for the other, so no
890/// ball seats there and the corner has no closure: physically the convex blend
891/// runs out against the concave beads partway along the edge, which is a vertex
892/// blend the stripe network does not construct.
893///
894/// The re-entrant vertex of a notch — ONE concave edge, the rest convex — is
895/// NOT this: the ball rolls round that single concave edge from one convex
896/// blend to the next, and `round_concave_chain_corner` / `round_convex_corner`
897/// close it with the horn-torus sector.  Only its mirror is refused.
898///
899/// This is TERMINAL — deliberately not a fall-through to the cutter
900/// composition. The cutter answers this shape with a watertight but shredded
901/// solid: a sliver end cap at each unclosed corner and the carrier face split
902/// so the original name lands on a fragment, which breaks every downstream
903/// reference to it. A named refusal that says which edges to separate is
904/// worth more than that solid. The runout composition (the 2026-09-30 record
905/// §5): a selection whose convex edges die into concave ones at
906/// mixed-convexity corners is built in two stages — the CONCAVE edges first,
907/// through the ordinary group path (exactly the ring the user gets selecting
908/// them alone), then one runout wall per convex spine on that ring result,
909/// marched from pole to pole with its carriers switching onto the ring's own
910/// pads. Accepted under the network's own checks.
911///
912/// The class built here: every convex edge of the selection ends on a mixed
913/// corner at BOTH ends and shares no vertex with another convex edge, and
914/// every other selected edge is concave.  Anything else keeps the named
915/// refusal.
916#[allow(clippy::too_many_arguments)]
917fn runout_composition(
918    solid: &BrepSolid,
919    selected_ids: &[u64],
920    edge_points: &[Vec3],
921    edge_names: Option<&[String]>,
922    radius: f64,
923    name: Option<&str>,
924    corners: &[crate::blend::MixedCorner],
925    entry: &str,
926) -> Result<BrepSolid, KernelRefusal> {
927    if std::env::var("BREP_NO_NETWORK").is_ok() {
928        return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_no_network", "the runout composition is a network construction and BREP_NO_NETWORK is set"));
929    }
930    let convexity = crate::blend::selection_convexity(solid, selected_ids, radius)
931        .ok_or(KernelRefusal::unsupported(KernelStage::Classify, "runout_convexity", "the selection's convexity could not be read edge by edge"))?;
932    let mut spine_ends = vec![0usize; selected_ids.len()];
933    for corner in corners {
934        if corner.convex.len() != 1 {
935            return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_corner_shape", format!(
936                "the corner at ({:.3}, {:.3}, {:.3}) has {} convex edges; the runout is built for one convex edge dying into concave ones",
937                corner.point.x, corner.point.y, corner.point.z, corner.convex.len()
938            )));
939        }
940        spine_ends[corner.convex[0]] += 1;
941    }
942    let mut spines: Vec<usize> = Vec::new();
943    let mut concave: Vec<usize> = Vec::new();
944    for (index, ends) in spine_ends.iter().enumerate() {
945        match (*ends, convexity[index]) {
946            (2, true) => spines.push(index),
947            (0, false) => concave.push(index),
948            (ends, convex) => {
949                return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", format!(
950                    "{} 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",
951                    selection_label(index, edge_points, edge_names, name),
952                    if convex { "convex" } else { "concave" },
953                )));
954            }
955        }
956    }
957    if concave.is_empty() {
958        return Err(KernelRefusal::unsupported(KernelStage::Classify, "runout_selection_shape", "no concave edges to build the ring from"));
959    }
960    // Stage 1: the ring, exactly as the concave edges build on their own.
961    let ring_points: Vec<Vec3> = concave.iter().map(|index| edge_points[*index]).collect();
962    let ring_names: Option<Vec<String>> = edge_names.map(|names| concave.iter().map(|index| names[*index].clone()).collect());
963    let ring = fillet_edges_group_with_corner_policy(solid, &ring_points, ring_names.as_deref(), radius, false, name, None, false)
964        .map_err(|error| error.with_message(|error| format!("the concave ring did not build: {error}")))?;
965    // Stage 2: the runout walls on it.
966    let spine_walls: Vec<(u64, Option<String>)> = spines
967        .iter()
968        .map(|index| (selected_ids[*index], per_edge_name(edge_names, name, *index).map(str::to_string)))
969        .collect();
970    let spine_ids: Vec<u64> = spine_walls.iter().map(|(id, _)| *id).collect();
971    let mut result = crate::blend::build_runout_walls(&ring, &spine_walls, radius)?;
972    heal_edge_vertex_gaps(&mut result, radius)?;
973    crate::blend::fit_planar_charts_to_trims(&ring, &mut result)?;
974    let issues = result.validate();
975    if !issues.is_empty() {
976        return Err(KernelRefusal::new(
977            RefusalClass::InvalidResultTopology { issues: issues.len() as u32 },
978            KernelStage::Validate,
979            format!("the runout walls do not validate: {issues:?}"),
980        ));
981    }
982    check_blend_interference(&ring, &result, &spine_ids, entry)?;
983    check_loop_self_crossings(&result, entry)?;
984    crate::accept_sound(result, entry)
985}
986
987fn check_mixed_corner_convexity(
988    solid: &BrepSolid,
989    edge_ids: &[u64],
990    edge_points: &[Vec3],
991    edge_names: Option<&[String]>,
992    radius: f64,
993    name: Option<&str>,
994    composition_error: Option<&KernelRefusal>,
995) -> Result<(), KernelRefusal> {
996    match crate::blend::mixed_convexity_corner(solid, edge_ids, radius) {
997        Some(corner) => {
998            // The refusal carries the RUNOUT's own numbers when they can be
999            // measured — the station, the carrier pair, the stop and the pole
1000            // — so it says which construction is missing and where, not only
1001            // that the corner has none.  A measurement that fails leaves the
1002            // refusal exactly as it was: the corner is refused either way.
1003            let measured = match crate::blend::plan_runout(solid, edge_ids, radius, &corner) {
1004                Ok(plan) => Some(plan),
1005                Err(error) => {
1006                    if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1007                        eprintln!("runout not measured: {error}");
1008                    }
1009                    None
1010                }
1011            };
1012            // "this kernel does not build that runout here": a named deferral,
1013            // carrying why the composition declined this selection.
1014            let mut message = mixed_corner_message(
1015                &corner,
1016                edge_points,
1017                edge_names,
1018                name,
1019                measured.as_ref(),
1020            );
1021            if let Some(error) = composition_error {
1022                message.push_str(&format!(" The runout composition was tried on this selection and refused: {error}"));
1023            }
1024            Err(KernelRefusal::unsupported(
1025                KernelStage::Classify,
1026                "mixed_corner_convexity",
1027                message,
1028            ))
1029        }
1030        None => Ok(()),
1031    }
1032}
1033
1034/// Refuse a selection one of whose edges keeps NO blend strip between the
1035/// corner balls at its two ends (see `degenerate_corner_setbacks`).
1036fn check_degenerate_corner_setbacks(
1037    solid: &BrepSolid,
1038    edge_ids: &[u64],
1039    edge_points: &[Vec3],
1040    edge_names: Option<&[String]>,
1041    radius: f64,
1042    name: Option<&str>,
1043) -> Result<(), KernelRefusal> {
1044    let Some(setback) = crate::blend::degenerate_corner_setbacks(solid, edge_ids, radius) else {
1045        return Ok(());
1046    };
1047    let [from, to] = setback.stops;
1048    let how = if setback.crossed {
1049        format!(
1050            "run {:.3e} PAST each other along it (the radius is beyond the limit)",
1051            setback.strip
1052        )
1053    } else {
1054        format!(
1055            "meet there ({:.3e} apart against a bar of {:.3e})",
1056            setback.strip, setback.bar
1057        )
1058    };
1059    // "The radius is at or past the degenerate limit … reduce it": a
1060    // parameter the geometry refuses, read at `Classify`.
1061    Err(KernelRefusal::input(
1062        KernelStage::Classify,
1063        "degenerate_corner_setbacks",
1064        format!(
1065            "fillet_edges: at r = {radius} the corner setbacks consume the whole of {} — the rolling \
1066             balls seated at its two corners touch face {} at ({:.4}, {:.4}, {:.4}) and ({:.4}, \
1067             {:.4}, {:.4}) and {how}, so no blend strip is left between them and the faces it lies \
1068             on are consumed whole. The radius is at or past the degenerate limit for this \
1069             selection; reduce it.",
1070            selection_label(setback.edge, edge_points, edge_names, name),
1071            setback.face_id,
1072            from.x,
1073            from.y,
1074            from.z,
1075            to.x,
1076            to.y,
1077            to.z,
1078        ),
1079    ))
1080}
1081
1082/// How a refusal names the `index`-th selected edge: its originating edge
1083/// name when the feature layer supplied one (with the feature's base prefix
1084/// stripped), else the picked point.
1085fn selection_label(
1086    index: usize,
1087    edge_points: &[Vec3],
1088    edge_names: Option<&[String]>,
1089    name: Option<&str>,
1090) -> String {
1091    let named = edge_names.and_then(|names| names.get(index)).map(|composed| match name {
1092        Some(base) => composed
1093            .strip_prefix(&format!("{base}:"))
1094            .unwrap_or(composed)
1095            .to_string(),
1096        None => composed.clone(),
1097    });
1098    named.unwrap_or_else(|| match edge_points.get(index) {
1099        Some(point) => format!("the edge at ({:.3}, {:.3}, {:.3})", point.x, point.y, point.z),
1100        None => format!("selection #{}", index + 1),
1101    })
1102}
1103
1104/// The refusal text for [`check_mixed_corner_convexity`], naming the edges the
1105/// way the user selected them (their originating edge names when the feature
1106/// layer supplied them, else the picked point).
1107fn mixed_corner_message(
1108    corner: &crate::blend::MixedCorner,
1109    edge_points: &[Vec3],
1110    edge_names: Option<&[String]>,
1111    name: Option<&str>,
1112    measured: Option<&crate::blend::RunoutPlan>,
1113) -> String {
1114    let label = |index: usize| selection_label(index, edge_points, edge_names, name);
1115    let list = |indices: &[usize]| -> String {
1116        let parts: Vec<String> = indices.iter().map(|index| label(*index)).collect();
1117        match parts.split_last() {
1118            None => "none".to_string(),
1119            Some((last, [])) => last.clone(),
1120            Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
1121        }
1122    };
1123    let mut message = format!(
1124        "fillet_edges: the selection mixes convexity at the corner ({:.3}, {:.3}, {:.3}) — convex \
1125         {} meets concave {} there. One rolling ball cannot touch the face they share from both \
1126         sides at once, so that corner has no closure: the convex blend runs out against the \
1127         concave ones partway along the edge, and this kernel does not build that runout yet. \
1128         Blending the concave edges in one fillet and the convex ones in another does not reach \
1129         a wall on every one of them either: the convex wall comes out at FULL length in either \
1130         order, and its contact leaves the faces it runs along where their own loops would then \
1131         cross. Blend the concave edges first and the blend acceptance refuses each convex edge \
1132         whose wall runs out into them; blend the convex ones first and the subset search drops \
1133         the concave edges it cannot compose instead, leaving those unfilleted.",
1134        corner.point.x,
1135        corner.point.y,
1136        corner.point.z,
1137        list(&corner.convex),
1138        list(&corner.concave),
1139    );
1140    // The runout, measured: where the ball's carriers switch, what they switch
1141    // to, and where the last stretch degenerates.  These are the numbers the
1142    // construction owes, so the refusal states them rather than leaving the
1143    // reader to derive them (`blend/runout.rs`).
1144    if let Some(plan) = measured {
1145        message.push_str(&format!(
1146            " Measured on this selection: the convex edge {}'s ball becomes tangent to face {} — \
1147             the face its two concave neighbours share — at s = {:.6} from the corner, centred \
1148             ({:.6}, {:.6}, {:.6}), where its contact on face {} reaches edge {}'s own rail \
1149             (ball-to-stripe residual {:.3e}). Past that station the second carrier is edge {}'s \
1150             STRIPE SURFACE, not a face of the input, and the march holds on it for {} \
1151             stations that fit rows reproducing their own contacts to {:.3e}. On the way the \
1152             kept mate's contact walks {:.3e} of a span off that face's own patch — into the \
1153             strip the concave stripe's pad ADDS to it — which is the shape, not an escape: \
1154             the bar a march is refused at is a full span.",
1155            plan.convex_edge,
1156            plan.shared_face,
1157            plan.station.s,
1158            plan.station.center.x,
1159            plan.station.center.y,
1160            plan.station.center.z,
1161            plan.replaced_mate,
1162            plan.second_carrier,
1163            plan.station.stripe_residual,
1164            plan.second_carrier,
1165            plan.marched,
1166            plan.fit_residual,
1167            plan.carrier_excursion,
1168        ));
1169        match (&plan.stop, plan.first_carrier) {
1170            (Some(stop), Some(first)) => message.push_str(&format!(
1171                " It does not simply stop there: at s = {:.6} the KEPT mate (face {}) runs out too \
1172                 — the ball's contact on it reaches edge {}'s rail, residual {:.3e} — so the FIRST \
1173                 carrier becomes that stripe as well and the last stretch rolls on two stripes.",
1174                stop.s, plan.kept_mate, first, stop.stripe_residual,
1175            )),
1176            _ => message.push_str(&format!(
1177                " Where the kept mate (face {}) runs out was not measurable on this selection.",
1178                plan.kept_mate
1179            )),
1180        }
1181        if let Some(pole) = plan.pole {
1182            message.push_str(&format!(
1183                " That stretch ends at a POLE, ({:.6}, {:.6}, {:.6}), where the two concave \
1184                 stripes' rails on face {} meet and the ball's two contacts merge, so the blend \
1185                 section shrinks to a point. Two carrier switches and a degenerate terminus is \
1186                 more than a stop station on a stripe, and none of it is composed yet.",
1187                pole.x, pole.y, pole.z, plan.shared_face,
1188            ));
1189        }
1190    }
1191    message
1192}
1193
1194/// All ways to choose `k` distinct indices from `0..n`, in lexicographic order.
1195fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
1196    let mut out = Vec::new();
1197    if k == 0 || k > n {
1198        return out;
1199    }
1200    let mut idx: Vec<usize> = (0..k).collect();
1201    loop {
1202        out.push(idx.clone());
1203        // Advance to the next combination (like counting with carry).
1204        let mut i = k;
1205        loop {
1206            if i == 0 {
1207                return out;
1208            }
1209            i -= 1;
1210            if idx[i] != i + n - k {
1211                break;
1212            }
1213        }
1214        idx[i] += 1;
1215        for j in (i + 1)..k {
1216            idx[j] = idx[j - 1] + 1;
1217        }
1218    }
1219}
1220
1221thread_local! {
1222    /// Running count (per thread) of group builds that reached the CUTTER
1223    /// COMPOSITION — every network lane above it refused or was rejected.  A
1224    /// zero delta across a call is the witness that the network answered it;
1225    /// a face count or a volume cannot say which lane built a solid, since the
1226    /// cutter can build the same one.  Test-observable; not part of any result.
1227    pub(crate) static CUTTER_COMPOSITIONS: std::cell::Cell<u64> =
1228        const { std::cell::Cell::new(0) };
1229}
1230
1231/// Blend the WHOLE selection as one group (the single-shot multi-edge fillet).
1232/// Errors if any selected edge or the shared-corner surgery cannot compose;
1233/// `fillet_edges` wraps this with a maximal-valid-subset fallback.
1234/// [`fillet_edges_group_unchecked`] held to the ONE soundness question every
1235/// other acceptance in this file passes: no face of the result may have a trim
1236/// loop that crosses itself ([`check_loop_self_crossings`]).
1237///
1238/// It sits here, at the group's single exit, rather than beside each `Ok`
1239/// inside it, because the group has four of them — the closed rim, the stripe
1240/// network, the per-component composition and the cutter composition — and the
1241/// cutter's is the one no other check guards at all. The network and the rim
1242/// ALSO ask it inside, where a bowtie falls through to the next lane instead of
1243/// failing the group; here there is no next lane, so it refuses.
1244///
1245/// The maximal-valid-subset search in [`fillet_edges_reported`] calls this
1246/// entry, so a subset that blends to a bowtie is not a valid subset either.
1247fn fillet_edges_group(
1248    solid: &BrepSolid,
1249    edge_points: &[Vec3],
1250    edge_names: Option<&[String]>,
1251    radius: f64,
1252    chamfer: bool,
1253    name: Option<&str>,
1254) -> Result<BrepSolid, KernelRefusal> {
1255    fillet_edges_group_in(solid, edge_points, edge_names, radius, chamfer, name, None)
1256}
1257
1258thread_local! {
1259    /// Running count (per thread) of [`fillet_edges_group`] builds, nested
1260    /// component builds included. Test-observable; not part of any result.
1261    pub(crate) static GROUP_BUILDS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1262}
1263
1264/// One component of a selection: its edge points (as bits, so the key is
1265/// exact) and its per-edge wall names.
1266type ComponentKey = (Vec<[u64; 3]>, Option<Vec<String>>);
1267
1268/// Component results of one [`fillet_edges_reported`] call, keyed by the chain
1269/// of components composed up to and including the one built. Valid only for
1270/// the solid, radius, chamfer flag and name of that call.
1271#[derive(Default)]
1272struct ComponentMemo {
1273    built: std::collections::HashMap<Vec<ComponentKey>, Result<BrepSolid, KernelRefusal>>,
1274}
1275
1276/// [`fillet_edges_group`], with the subset search's component memo when
1277/// `memo` is `Some`. Only the call made on the search's own solid may pass it.
1278fn fillet_edges_group_in(
1279    solid: &BrepSolid,
1280    edge_points: &[Vec3],
1281    edge_names: Option<&[String]>,
1282    radius: f64,
1283    chamfer: bool,
1284    name: Option<&str>,
1285    memo: Option<&mut ComponentMemo>,
1286) -> Result<BrepSolid, KernelRefusal> {
1287    fillet_edges_group_with_corner_policy(solid, edge_points, edge_names, radius, chamfer, name, memo, true)
1288}
1289
1290fn fillet_edges_group_with_corner_policy(
1291    solid: &BrepSolid,
1292    edge_points: &[Vec3],
1293    edge_names: Option<&[String]>,
1294    radius: f64,
1295    chamfer: bool,
1296    name: Option<&str>,
1297    memo: Option<&mut ComponentMemo>,
1298    wrap_concave: bool,
1299) -> Result<BrepSolid, KernelRefusal> {
1300    GROUP_BUILDS.with(|count| count.set(count.get() + 1));
1301    let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
1302    let result =
1303        fillet_edges_group_unchecked(solid, edge_points, edge_names, radius, chamfer, name, memo, wrap_concave)?;
1304    check_loop_self_crossings(&result, entry)?;
1305    // The BODY's own soundness, beside the loop's. A blend wall is a FITTED
1306    // surface and a fit can carry two parameters of one wall to one point in
1307    // space — measured on `inbox-20260902-fillet-two-torus-box-edges`, whose
1308    // wall `F8:BLEND:Box_NZ|P.T5_Side[0]` does exactly that while `validate()`,
1309    // connectivity, Euler parity, the loop scan and the face-versus-face scan
1310    // are all silent. Two walls crossing each other is the same class and is
1311    // repaired where it can be. Neither is visible to a volume oracle, so a
1312    // result carrying one reports success for a different shape.
1313    crate::accept_sound(result, entry)
1314}
1315
1316fn fillet_edges_group_unchecked(
1317    solid: &BrepSolid,
1318    edge_points: &[Vec3],
1319    edge_names: Option<&[String]>,
1320    radius: f64,
1321    chamfer: bool,
1322    name: Option<&str>,
1323    mut memo: Option<&mut ComponentMemo>,
1324    wrap_concave: bool,
1325) -> Result<BrepSolid, KernelRefusal> {
1326    use rustc_hash::FxHashSet as HashSet;
1327
1328    // Fuse-first operand heal (Lever A) before the multi-edge surgery: snap the
1329    // input's near-coincident / off-plane vertices (e.g. a revolve pole apex
1330    // sitting a few microns off the axis) to exact and re-anchor incident
1331    // edges.  The selected edges are resolved geometrically below, so a
1332    // sub-heal_tol vertex move never changes which edges are picked; a clean
1333    // input is left byte-identical.
1334    let mut healed_input = solid.clone();
1335    let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
1336    crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
1337    let solid = &healed_input;
1338
1339    // 1. Detect convex corners from the ORIGINAL solid: a point that is an
1340    //    endpoint of >=3 of the selected edges (a cube/prism-style vertex).
1341    let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
1342    // The extent each selected edge has BEFORE any blend trims it, so the
1343    // sequential build can run every cutter through the shared corners.
1344    let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
1345    let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
1346    for (i, point) in edge_points.iter().enumerate() {
1347        let edge_id = resolve_edge_by_point(solid, *point)?;
1348        let edge = solid
1349            .edges
1350            .iter()
1351            .find(|e| e.id == edge_id)
1352            .ok_or_else(|| {
1353                KernelRefusal::internal(
1354                    KernelStage::Collect,
1355                    "resolved_edge",
1356                    "fillet_edges: resolved edge vanished",
1357                )
1358            })?;
1359        let (start, end) = (
1360            edge.curve
1361                .evaluate(edge.t0)
1362                .or_refuse(KernelStage::Collect, "edge_start")?,
1363            edge.curve
1364                .evaluate(edge.t1)
1365                .or_refuse(KernelStage::Collect, "edge_end")?,
1366        );
1367        endpoints.push((start, i));
1368        endpoints.push((end, i));
1369        original_extents.push((start, end));
1370        selected_edge_ids.push(edge_id);
1371    }
1372    let mut corners: Vec<Vec3> = Vec::new();
1373    // The selected-edge INPUT INDICES meeting at each star corner (parallel to
1374    // `corners`), sorted — used to name the corner patch after its adjacent
1375    // edges (`{fid}:BLEND:CORNER:{e_a}+{e_b}+…`), UNIQUE per corner because no
1376    // two distinct corners share the same set of >=3 selected edges.
1377    let mut corner_edges: Vec<Vec<usize>> = Vec::new();
1378    let mut chain_corner_count = 0usize;
1379    let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
1380    let mut used = vec![false; endpoints.len()];
1381    for i in 0..endpoints.len() {
1382        if used[i] {
1383            continue;
1384        }
1385        used[i] = true;
1386        let mut edges_here: HashSet<usize> = HashSet::default();
1387        edges_here.insert(endpoints[i].1);
1388        for j in (i + 1)..endpoints.len() {
1389            if used[j] {
1390                continue;
1391            }
1392            if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
1393                used[j] = true;
1394                edges_here.insert(endpoints[j].1);
1395            }
1396        }
1397        if edges_here.len() >= 3 {
1398            corners.push(endpoints[i].0);
1399            let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
1400            adjacent.sort_unstable();
1401            corner_edges.push(adjacent);
1402        } else if edges_here.len() == 2 {
1403            chain_corner_count += 1;
1404            let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
1405            adjacent.sort_unstable();
1406            chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
1407        }
1408    }
1409
1410    // A FACE selection may contain several disconnected boundary components
1411    // (the common example is an outer perimeter plus a circular hole rim).
1412    // Miter composition is only meaningful inside one connected edge graph.
1413    // Feeding every component into the same INTERSECT/UNION combines multiple
1414    // independently filleted copies of otherwise untouched support faces; the
1415    // boolean then imprints those coincident copies and can leave redundant
1416    // seams (the reported through-hole cylinder split into three faces).
1417    //
1418    // Partition by shared original endpoints and process components in input
1419    // order.  Each recursive call sees one connected component, so it follows
1420    // the existing miter/sequential path without recursion cycling.  Distinct
1421    // components are then composed sequentially on the evolving solid.
1422    let mut component_of = vec![usize::MAX; edge_points.len()];
1423    let mut components: Vec<Vec<usize>> = Vec::new();
1424    for seed in 0..edge_points.len() {
1425        if component_of[seed] != usize::MAX {
1426            continue;
1427        }
1428        let component_index = components.len();
1429        component_of[seed] = component_index;
1430        let mut component = vec![seed];
1431        let mut cursor = 0;
1432        while cursor < component.len() {
1433            let current = component[cursor];
1434            cursor += 1;
1435            for candidate in 0..edge_points.len() {
1436                if component_of[candidate] != usize::MAX {
1437                    continue;
1438                }
1439                let connected = [original_extents[current].0, original_extents[current].1]
1440                    .into_iter()
1441                    .any(|a| {
1442                        [original_extents[candidate].0, original_extents[candidate].1]
1443                            .into_iter()
1444                            .any(|b| a.sub(b).length() < 1e-6)
1445                    });
1446                if connected {
1447                    component_of[candidate] = component_index;
1448                    component.push(candidate);
1449                }
1450            }
1451        }
1452        component.sort_unstable();
1453        components.push(component);
1454    }
1455    if components.len() > 1 {
1456        let mut separated = solid.clone();
1457        let mut chain: Vec<ComponentKey> = Vec::with_capacity(components.len());
1458        for component in components {
1459            let points = component
1460                .iter()
1461                .map(|index| edge_points[*index])
1462                .collect::<Vec<_>>();
1463            let names = edge_names.map(|all| {
1464                component
1465                    .iter()
1466                    .map(|index| all[*index].clone())
1467                    .collect::<Vec<_>>()
1468            });
1469            let Some(memo) = memo.as_deref_mut() else {
1470                separated = fillet_edges_group_with_corner_policy(
1471                    &separated,
1472                    &points,
1473                    names.as_deref(),
1474                    radius,
1475                    chamfer,
1476                    name,
1477                    None,
1478                    wrap_concave,
1479                )?;
1480                continue;
1481            };
1482            let bits = points.iter().map(|p| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]);
1483            chain.push((bits.collect(), names.clone()));
1484            let built = match memo.built.get(&chain) {
1485                Some(built) => built.clone(),
1486                None => {
1487                    let built = fillet_edges_group_with_corner_policy(
1488                        &separated,
1489                        &points,
1490                        names.as_deref(),
1491                        radius,
1492                        chamfer,
1493                        name,
1494                        None,
1495                        wrap_concave,
1496                    );
1497                    memo.built.insert(chain.clone(), built.clone());
1498                    built
1499                }
1500            };
1501            separated = built?;
1502        }
1503        return Ok(separated);
1504    }
1505
1506    // 2a. The stripe network (blend/network.rs) is the lane for EVERY
1507    //     selection: each stripe is marched against THIS solid, each shared
1508    //     vertex is solved before anything is cut — a star closed by a patch
1509    //     of the corner ball, a two-edge corner by the seam between the two
1510    //     blends, a re-entrant corner by a horn torus, a tangent pair by a
1511    //     flush join, an unselected tangent continuation by a cap — and
1512    //     nothing is subtracted, so no cutter can overshoot into a neighbour
1513    //     and no leftover cap has to be identified afterwards.  It refuses
1514    //     BY NAME on what it does not yet construct (mixed-convexity corners,
1515    //     no-common-ball stars, pinched edges), and those fall through to the
1516    //     cutter composition below.
1517    //
1518    //     A CLOSED edge is one of those refusals and it has an answer the
1519    //     network is simply not the lane for: a closed rim has no corner to
1520    //     share, so `blend_star_network` says "edge N is closed and has no
1521    //     corner to share" and the composition took it.  `blend_closed_edge`
1522    //     builds exactly that case and the single-edge ladder in `tool.rs`
1523    //     has always tried it first — but this function reached only the
1524    //     network, so a closed rim inside a GROUP went to a cutter that the
1525    //     same rim selected on its own never saw.  A bored hole's rim splits
1526    //     out as its own one-edge component (see the component partition
1527    //     above), so that was every hole rim chamfered as part of a face
1528    //     selection.  Try it here too, under the same acceptance the network
1529    //     result gets, and under the same `BREP_NO_NETWORK` A/B.
1530    if std::env::var("BREP_NO_NETWORK").is_err() {
1531        if selected_edge_ids.len() == 1 {
1532            let edge_id = selected_edge_ids[0];
1533            let closed = solid
1534                .edges
1535                .iter()
1536                .find(|edge| edge.id == edge_id)
1537                .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
1538            if closed {
1539                let rim_name = per_edge_name(edge_names, name, 0);
1540                match crate::blend::blend_closed_edge(solid, edge_id, radius, chamfer, rim_name) {
1541                    Ok(mut rim) => {
1542                        let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
1543                        let healed = heal_edge_vertex_gaps(&mut rim, radius);
1544                        // A trim the surgery ran outside its planar carrier's
1545                        // chart is a wrong body the three checks below all pass
1546                        // (`crate::blend::fit_planar_charts_to_trims`), so the
1547                        // carrier is widened here, after the heal has settled
1548                        // the edges the trims are rebuilt from and before the
1549                        // bowtie scan reads them.  Its refusals are TERMINAL:
1550                        // the composition is not a second opinion on a body
1551                        // whose trims left their carrier.
1552                        crate::blend::fit_planar_charts_to_trims(solid, &mut rim)?;
1553                        let issues = rim.validate();
1554                        let interference =
1555                            check_blend_interference(solid, &rim, &selected_edge_ids, entry);
1556                        let bowtie = check_loop_self_crossings(&rim, entry);
1557                        if healed.is_ok()
1558                            && issues.is_empty()
1559                            && interference.is_ok()
1560                            && bowtie.is_ok()
1561                        {
1562                            return Ok(rim);
1563                        }
1564                        if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1565                            eprintln!(
1566                                "closed-rim result rejected: heal={healed:?} issues={issues:?} \
1567                                 interference={interference:?} bowtie={bowtie:?}"
1568                            );
1569                        }
1570                    }
1571                    Err(refusal) => {
1572                        if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1573                            eprintln!("closed-rim refused: {refusal}");
1574                        }
1575                    }
1576                }
1577            }
1578        }
1579        let network_names: Vec<Option<String>> = (0..edge_points.len())
1580            .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
1581            .collect();
1582        let network_corner_name =
1583            |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
1584        match crate::blend::blend_star_network_with_corner_policy(
1585            solid,
1586            &selected_edge_ids,
1587            radius,
1588            chamfer,
1589            &network_names,
1590            &network_corner_name,
1591            wrap_concave,
1592        ) {
1593            Ok(mut network) => {
1594                // Fail-safe like the rest of the ladder: a heal or validation
1595                // problem in the network result falls through to the cutter,
1596                // it does not fail the group.  So does an UNTRIMMED result: the
1597                // network re-trims only each stripe's two mates, and
1598                // `check_blend_interference` is what notices a third face
1599                // crossing the swept volume -- `validate` cannot, because a
1600                // self-intersecting solid is still a watertight one.
1601                let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
1602                let healed = heal_edge_vertex_gaps(&mut network, radius);
1603                // See the closed-rim lane above: widen a planar carrier the
1604                // surgery overran, or refuse — terminally — rather than fall
1605                // through to a composition that cannot answer that question.
1606                crate::blend::fit_planar_charts_to_trims(solid, &mut network)?;
1607                let issues = network.validate();
1608                let interference =
1609                    check_blend_interference(solid, &network, &selected_edge_ids, entry);
1610                // And the fourth question, which the other three all pass: does
1611                // any face of the result have a loop that crosses ITSELF? A
1612                // wall whose rail ran off its own face comes back as a bowtie,
1613                // and a bowtie is watertight, incident and connected.
1614                let bowtie = check_loop_self_crossings(&network, entry);
1615                if healed.is_ok() && issues.is_empty() && interference.is_ok() && bowtie.is_ok() {
1616                    return Ok(network);
1617                }
1618                if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1619                    eprintln!(
1620                        "network result rejected: heal={healed:?} issues={issues:?} \
1621                         interference={interference:?} bowtie={bowtie:?}"
1622                    );
1623                    dump_loops_debug("INPUT", solid);
1624                    dump_loops_debug("RESULT", &network);
1625                }
1626            }
1627            Err(refusal) => {
1628                if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
1629                    eprintln!("network refused: {refusal}");
1630                }
1631                // The fail-safe above is for a refusal that says "not this
1632                // lane".  A marched fit that left its carriers does not say
1633                // that, and the cutter's answer to the same selection is not a
1634                // second opinion on it: on the notched cap's stuck connector
1635                // the composition reads 1724.506048919, +1.81e-2 against the
1636                // closed form, and it VALIDATES.  A refusal that falls through
1637                // to a validating wrong solid is not a refusal.
1638                //
1639                // Only that one, not all of `is_terminal_blend_refusal`.  The
1640                // single-edge tool also reads the WALL FOLD as terminal, and
1641                // making the group build agree costs
1642                // `inbox_20260902_corner_torus_collar_fillet::each_collar_has_its_own_fold_threshold`
1643                // a collar that the cutter composes today (measured
1644                // 2026-09-16: refuses at fold factor 1.035935 on a radius-2.1
1645                // wall). Whether a group selection should refuse where a
1646                // single edge does is a decision with a test standing on the
1647                // current answer, and it is not this slice's.
1648                //
1649                // A blend stopping within a sliver of its face's width that
1650                // cannot be snapped onto the far edge without opening the shell
1651                // is terminal for the same reason: the SIZE is the question
1652                // (`blend::CONSUMED_SNAP_UNSOUND`), and the composition's tool
1653                // cut a sliver short of an edge leaves the sliver no lane keeps.
1654                //
1655                // And a FULL-WIDTH corner the network cannot collapse soundly
1656                // (`blend::RAIL_COLLAPSE_UNSUPPORTED`,
1657                // `blend::FULL_WIDTH_COLLAPSE_UNSOUND`): the composition's answer
1658                // to a full-width corner was measured wrong — a 5F/8E/5V miter
1659                // 8.0e-4 off its closed form at 1e-5 short of the width, and the
1660                // picture-frame corner at a full-width chamfer star.
1661                //
1662                // And a trim that left its planar carrier's chart
1663                // (`blend::PLANAR_CHART_EDGE_OFF_PLANE`,
1664                // `blend::PLANAR_CHART_WIDEN_UNSOUND`): the clamped body the
1665                // composition would answer with is the defect being refused.
1666                if crate::blend::is_marched_fit_off_carriers(&refusal)
1667                    || crate::blend::is_consumed_snap(&refusal)
1668                    || crate::blend::is_rail_collapse(&refusal)
1669                    || crate::blend::is_full_width_collapse(&refusal)
1670                    || crate::blend::is_planar_chart_refusal(&refusal)
1671                    || crate::blend::is_miter_support_overrun(&refusal)
1672                {
1673                    return Err(refusal);
1674                }
1675            }
1676        }
1677    }
1678
1679    CUTTER_COMPOSITIONS.with(|count| count.set(count.get() + 1));
1680    // 2. Build the blends.
1681    //
1682    //    CHAIN corners (§6.9.6 — exactly TWO selected edges share a vertex):
1683    //    the sequential build truncates the later blend where it runs into the
1684    //    earlier one and closes it with a flat bulkhead across the fillet
1685    //    channel — a hard step, not a transition.  For selections containing
1686    //    chain corners, blend each edge FULL-LENGTH on the ORIGINAL solid and
1687    //    INTERSECT the per-edge results instead: the removal volumes union, so
1688    //    adjacent blends run through the shared corner and trim each other
1689    //    along their intersection curve — the standard MITER corner, tangent
1690    //    to the shared face at the seam's tangency end.  Non-adjacent edges
1691    //    are unaffected (their removals are disjoint, intersection ≡
1692    //    sequential).
1693    //
1694    //    Selections without chain corners keep the sequential build unchanged
1695    //    (star corners are rounded in step 3 against exactly the sequential
1696    //    geometry round_convex_corner was built for).
1697    //    CONVEXITY GUARD: a convex blend REMOVES material (fillet = orig −
1698    //    cut), a concave blend ADDS it (orig + pad).  Full-length blends
1699    //    combine as orig − ∪cuts + ∪pads, so the per-edge results compose by
1700    //    INTERSECTION when every edge is convex and by UNION when every edge
1701    //    is concave; a mixed selection has no single composition and falls
1702    //    back to the sequential build.
1703    // Fillet stars keep the sequential topology required by their sphere
1704    // patches. Chamfer stars have no subsequent corner patch: compose their
1705    // full-length removals too, so all bevel planes meet at the corner.
1706    let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
1707        && (chamfer || corners.is_empty())
1708    {
1709        let mut any_convex = false;
1710        let mut any_concave = false;
1711        for point in edge_points {
1712            // Convexity is a DIHEDRAL property, so it is read from the general
1713            // scan (`scan_dihedral`) — the same one `check_mixed_concavity`
1714            // has already run over every selected edge.  `analyze_edge` used
1715            // to answer here and it ALSO refuses an edge the EXACT CUTTER
1716            // cannot build: a straight edge whose mates are not both planes,
1717            // a circular one whose mates do not share its axis.  Reading that
1718            // refusal as "unknown convexity" dropped the §6.9.6 miter for
1719            // every selection touching a curved carrier — the groove rim of a
1720            // bored box, say — and the sequential build then left a bulkhead
1721            // face standing in the shared corner (the 2026-09-10 "chamfer
1722            // produced a spurious face" report).
1723            match resolve_edge_by_point(solid, *point)
1724                .and_then(|edge_id| super::analyze::scan_dihedral(solid, edge_id))
1725            {
1726                // A mixed edge has no single composition either; it takes the
1727                // `_ => None` arm below through both flags.
1728                Ok(profile) if profile.samples > 0 && !profile.is_mixed() => {
1729                    any_convex |= profile.any_convex;
1730                    any_concave |= profile.any_concave;
1731                }
1732                // Unknown edge class: let the sequential path produce its own
1733                // (more specific) error or result.
1734                _ => {
1735                    any_convex = true;
1736                    any_concave = true;
1737                    break;
1738                }
1739            }
1740        }
1741        match (any_convex, any_concave) {
1742            (true, false) => Some(crate::BooleanOperation::Intersect),
1743            (false, true) => Some(crate::BooleanOperation::Union),
1744            _ => None,
1745        }
1746    } else {
1747        None
1748    };
1749
1750    // Fillet/chamfer each edge in turn, resolving its point on the evolving
1751    // solid (ids shift as earlier fillets rewrite topology; the midpoint of an
1752    // edge is untouched by the corner surgery of the others).  This is the
1753    // baseline build used directly for non-miter selections AND as the
1754    // fallback when the miter composition below cannot reassemble.
1755    // The composition's per-edge lane: cutter-first ONLY when a corner
1756    // closure will run afterwards and read cutter-shaped topology (a star or
1757    // a chain corner).  A selection with no shared vertex — a lone closed
1758    // rim, a lone chamfer, disconnected edges — has no such closure, so each
1759    // edge gets the march first there too.
1760    let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
1761        Lane::GeneralFirst
1762    } else {
1763        Lane::CutterFirst
1764    };
1765    let build_sequential = || -> Result<BrepSolid, KernelRefusal> {
1766        let mut sequential = solid.clone();
1767        for (index, point) in edge_points.iter().enumerate() {
1768            let edge_id = resolve_edge_by_point(&sequential, *point)?;
1769            // Earlier cutters in this loop TRIM the edges that share a corner
1770            // with them; extend this cutter back over what they took so the
1771            // two removal volumes union through the corner (§6.9.6 miter)
1772            // instead of leaving a wedge of material standing behind a flush
1773            // end cap.  Untouched edges get a zero pad and the historical
1774            // flush cutter.
1775            let ends = tool_ends_to_original_extent(
1776                &sequential,
1777                edge_id,
1778                radius,
1779                original_extents[index],
1780                if chamfer { &[] } else { &corners },
1781            );
1782            // This edge's blend wall carries the name of THIS input edge
1783            // (`edge_names[index]`); a smooth chain that engulfs several edges
1784            // is named after the FIRST such input edge processed here (input
1785            // order), the chain's representative.
1786            let edge_name = per_edge_name(edge_names, name, index);
1787            sequential = fillet_or_chamfer(
1788                &sequential,
1789                edge_id,
1790                radius,
1791                chamfer,
1792                edge_name,
1793                ends,
1794                composition_lane,
1795            )?;
1796        }
1797        Ok(sequential)
1798    };
1799
1800    let mut result = if let Some(operation) = miter_operation {
1801        // The per-edge results still carry their cutters' standing scaffold,
1802        // which the group settles once its corners are closed. A scaffold face
1803        // merged here would hand its tag to whatever it merged with.
1804        let options = crate::BooleanOptions {
1805            keep_unmerged_name_substrs: vec![CUTTER_SCAFFOLD_NAME.to_string()],
1806            ..crate::BooleanOptions::default()
1807        };
1808        let mut combined: Result<Option<BrepSolid>, KernelRefusal> = Ok(None);
1809        for (index, point) in edge_points.iter().enumerate() {
1810            let edge_id = resolve_edge_by_point(solid, *point)?;
1811            let edge_name = per_edge_name(edge_names, name, index);
1812            let blended = {
1813                let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
1814                check_mixed_concavity(solid, edge_id, entry)?;
1815                check_support_extent(solid, edge_id, radius, entry)?;
1816                fillet_or_chamfer(
1817                    solid,
1818                    edge_id,
1819                    radius,
1820                    chamfer,
1821                    edge_name,
1822                    ToolEnds::default(),
1823                    Lane::CutterFirst,
1824                )?
1825            };
1826            combined = match combined {
1827                Err(e) => Err(e),
1828                Ok(None) => Ok(Some(blended)),
1829                Ok(Some(previous)) => {
1830                    crate::boolean_operation(&previous, &blended, operation, &options)
1831                        .map(Some)
1832                        .map_err(|error| {
1833                            // The boolean's own class survives the wrap.
1834                            error.with_message(|error| {
1835                                format!("fillet_edges: chain-corner miter composition failed: {error}")
1836                            })
1837                        })
1838                }
1839            };
1840            if combined.is_err() {
1841                break;
1842            }
1843        }
1844        // A §6.9.6 miter (per-edge blends intersected/unioned through the
1845        // shared chain corners) can fail to reassemble on faces whose
1846        // fragmented boundary does not close — e.g. a planar cap whose ENTIRE
1847        // perimeter is selected, where fragment_face reports an "incomplete
1848        // run".  Rather than let `fillet_edges` silently DROP a selected edge
1849        // to recover a valid subset (the reported defect: not every edge of
1850        // the face gets a fillet), fall back to the sequential build, which
1851        // blends EVERY selected edge (chain corners get a flat bulkhead
1852        // instead of a miter).  Only if that also fails to produce a valid
1853        // solid do we surface the miter error so the caller's
1854        // maximal-valid-subset search can still run.
1855        match combined {
1856            Ok(Some(mitered)) => mitered,
1857            Ok(None) => {
1858                return Err(KernelRefusal::input(
1859                    KernelStage::Collect,
1860                    "miter_selection",
1861                    "fillet_edges: no edges selected",
1862                ))
1863            }
1864            Err(miter_err) => match build_sequential() {
1865                Ok(seq) if seq.validate().is_empty() => seq,
1866                _ => return Err(miter_err),
1867            },
1868        }
1869    } else {
1870        build_sequential()?
1871    };
1872
1873    // A re-entrant vertex of a selected face perimeter has two selected,
1874    // convex cap-wall edges and one UNSELECTED concave wall-wall edge.  The
1875    // two cutter volumes only touch there, so the boolean miter leaves a
1876    // triangular planar end-cap instead of carrying the rolling ball around
1877    // the corner.  Close that exact orthogonal class with its horn-torus
1878    // sector (major radius = minor radius = fillet radius).
1879    if !chamfer {
1880        for (corner, adjacent) in &chain_corners {
1881            let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
1882                if selected_edge_ids.contains(&edge.id) {
1883                    return false;
1884                }
1885                let Ok(a) = edge.curve.evaluate(edge.t0) else {
1886                    return false;
1887                };
1888                let Ok(b) = edge.curve.evaluate(edge.t1) else {
1889                    return false;
1890                };
1891                (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
1892                    && analyze_edge(solid, edge.id, radius)
1893                        .map(|cross| !cross.convex)
1894                        .unwrap_or(false)
1895            });
1896            if !has_concave_unselected_edge {
1897                continue;
1898            }
1899            let corner_name = corner_face_name(edge_names, name, adjacent);
1900            if let Ok(rounded) = crate::blend::round_concave_chain_corner(
1901                &result,
1902                solid,
1903                *corner,
1904                [
1905                    selected_edge_ids[adjacent[0]],
1906                    selected_edge_ids[adjacent[1]],
1907                ],
1908                radius,
1909                corner_name.as_deref(),
1910            ) {
1911                result = rounded;
1912            }
1913        }
1914    }
1915
1916    // 3. Round the convex corners (fillets only — chamfers keep sharp
1917    //    vertices).  A corner that cannot be rounded is left as the edge
1918    //    fillets so the group still succeeds.  Each corner patch is named after
1919    //    the selected edges meeting there (`{fid}:BLEND:CORNER:{e_a}+…`) so no
1920    //    two corners collide and the patch stays under the `{fid}:BLEND` prefix.
1921    if !chamfer {
1922        for (ci, corner) in corners.iter().enumerate() {
1923            let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
1924            if let Ok(rounded) =
1925                crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref())
1926            {
1927                result = rounded;
1928            }
1929        }
1930    }
1931
1932    // Heal any residual vertex/edge gaps introduced by the corner-rounding
1933    // surgery (the per-edge results are already healed inside fillet_or_chamfer).
1934    heal_edge_vertex_gaps(&mut result, radius)?;
1935    crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
1936    Ok(settle_cutter_scaffold(result))
1937}
1938
1939/// Variable-radius fillet/chamfer of a GROUP of edges (§4.9.5), the app entry
1940/// for tapered blends: each selected edge (resolved by a point on it) is
1941/// blended with the SAME radius profile `radii` — a list of (edge-fraction,
1942/// radius) stops in [0,1] — applied along that edge's own parameterization.
1943/// Edges are blended independently (no shared-vertex corner rounding; a
1944/// variable-radius star has no single tangent ball), so this is the tapered
1945/// counterpart of `fillet_edges` for the constant case.
1946pub fn fillet_edges_variable(
1947    solid: &BrepSolid,
1948    edge_points: &[Vec3],
1949    edge_names: Option<&[String]>,
1950    radii: &[(f64, f64)],
1951    chamfer: bool,
1952    name: Option<&str>,
1953) -> Result<BrepSolid, KernelRefusal> {
1954    // One blend operation: the reports of an earlier one, never consumed, are
1955    // dropped as it starts (nested calls keep this operation's).
1956    let _operation = crate::blend::BlendOperation::enter();
1957    if edge_points.is_empty() {
1958        return Err(KernelRefusal::input(
1959            KernelStage::Collect,
1960            "variable_selection",
1961            "fillet_edges_variable: no edges selected",
1962        ));
1963    }
1964    let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
1965    fillet_edges_variable_impl(
1966        solid,
1967        edge_points,
1968        edge_names,
1969        &per_edge_stops,
1970        chamfer,
1971        name,
1972        "fillet_edges_variable",
1973        true,
1974    )
1975}
1976
1977/// The shared variable-radius group core: each selected edge `i` is blended
1978/// with ITS OWN stop list `per_edge_stops[i]` (the legacy entry replicates one
1979/// list; the law entries sample a chain-abscissa [`crate::law::RadiusLaw`] per
1980/// edge).  `allow_tapered_sequential` keeps the legacy entry's sequential
1981/// fallback for tapered chains (whose end state is the honest
1982/// "mismatched radii" validation gate); the law entries pass `false` because
1983/// their stop fractions are computed against the ORIGINAL edges — the
1984/// sequential build re-resolves edges on the evolving solid whose shared
1985/// corners are already TRIMMED by earlier blends, which would silently distort
1986/// the law's abscissa mapping (constant stops are immune, so they may still
1987/// fall back).
1988#[allow(clippy::too_many_arguments)]
1989fn fillet_edges_variable_impl(
1990    solid: &BrepSolid,
1991    edge_points: &[Vec3],
1992    edge_names: Option<&[String]>,
1993    per_edge_stops: &[Vec<(f64, f64)>],
1994    chamfer: bool,
1995    name: Option<&str>,
1996    entry: &str,
1997    allow_tapered_sequential: bool,
1998) -> Result<BrepSolid, KernelRefusal> {
1999    debug_assert_eq!(per_edge_stops.len(), edge_points.len());
2000    // A law that is one constant everywhere IS the constant-radius fillet:
2001    // hand it to the constant group, whose corners are constructed (the
2002    // stripe network) rather than composed by boolean.
2003    if let Some(constant) = per_edge_stops
2004        .first()
2005        .and_then(|stops| stops.first())
2006        .map(|(_, radius)| *radius)
2007    {
2008        let uniform = per_edge_stops.iter().all(|stops| {
2009            stops
2010                .iter()
2011                .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
2012        });
2013        if uniform && constant > 0.0 {
2014            return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
2015        }
2016    }
2017    let max_radius = per_edge_stops
2018        .iter()
2019        .flat_map(|stops| stops.iter())
2020        .map(|(_, r)| r.abs())
2021        .fold(0.0_f64, f64::max);
2022
2023    // Chain corners miter exactly like the constant-radius group (§6.9.6):
2024    // per-edge blends on the ORIGINAL solid composed by boolean — Intersect
2025    // when every edge is convex, Union when every edge is concave.  Mixed or
2026    // unclassifiable selections keep the sequential build.  Variable blends
2027    // never round star vertices, so unlike the constant group there is no
2028    // sequential-only star path to protect.
2029    let mut chain_corner = false;
2030    {
2031        use rustc_hash::FxHashSet as HashSet;
2032        let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
2033        for (i, point) in edge_points.iter().enumerate() {
2034            if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
2035                if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
2036                    if let (Ok(a), Ok(b)) =
2037                        (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
2038                    {
2039                        endpoints.push((a, i));
2040                        endpoints.push((b, i));
2041                    }
2042                }
2043            }
2044        }
2045        let mut used = vec![false; endpoints.len()];
2046        for i in 0..endpoints.len() {
2047            if used[i] {
2048                continue;
2049            }
2050            used[i] = true;
2051            let mut edges_here: HashSet<usize> = HashSet::default();
2052            edges_here.insert(endpoints[i].1);
2053            for j in (i + 1)..endpoints.len() {
2054                if used[j] {
2055                    continue;
2056                }
2057                if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
2058                    used[j] = true;
2059                    edges_here.insert(endpoints[j].1);
2060                }
2061            }
2062            if edges_here.len() == 2 {
2063                chain_corner = true;
2064            }
2065        }
2066    }
2067    let miter_operation = if chain_corner {
2068        let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
2069        let mut any_convex = false;
2070        let mut any_concave = false;
2071        for point in edge_points {
2072            match resolve_edge_by_point(solid, *point)
2073                .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
2074            {
2075                Ok(cross) if cross.convex => any_convex = true,
2076                Ok(_) => any_concave = true,
2077                Err(_) => {
2078                    any_convex = true;
2079                    any_concave = true;
2080                    break;
2081                }
2082            }
2083        }
2084        match (any_convex, any_concave) {
2085            (true, false) => Some(crate::BooleanOperation::Intersect),
2086            (false, true) => Some(crate::BooleanOperation::Union),
2087            _ => None,
2088        }
2089    } else {
2090        None
2091    };
2092
2093    // Try the miter first; the variable blend's FITTED boundary curves are
2094    // only ~1e-3 accurate at blend-blend tangencies (unlike the exact
2095    // constant-radius cylinders), so the composition can fail — fall back to
2096    // the sequential build then, which is never worse than the pre-miter
2097    // behavior.  Tightening the taper surface's endpoint fitting is the
2098    // documented follow-up that would make the miter stick.
2099    let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
2100        let options = crate::BooleanOptions::default();
2101        let mut combined: Option<BrepSolid> = None;
2102        let mut failed = false;
2103        for (index, point) in edge_points.iter().enumerate() {
2104            let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
2105                failed = true;
2106                break;
2107            };
2108            let edge_name = per_edge_name(edge_names, name, index);
2109            let Ok(blended) = crate::blend::blend_edge_variable(
2110                solid,
2111                edge_id,
2112                &per_edge_stops[index],
2113                chamfer,
2114                edge_name,
2115            ) else {
2116                failed = true;
2117                break;
2118            };
2119            let next = match combined.take() {
2120                None => blended,
2121                Some(previous) => {
2122                    match crate::boolean_operation(&previous, &blended, operation, &options) {
2123                        Ok(next) => next,
2124                        Err(_) => {
2125                            failed = true;
2126                            break;
2127                        }
2128                    }
2129                }
2130            };
2131            combined = Some(next);
2132        }
2133        if failed {
2134            None
2135        } else {
2136            combined.filter(|s| s.validate().is_empty())
2137        }
2138    } else {
2139        None
2140    };
2141    let mut result = match miter_attempt {
2142        Some(mitered) => mitered,
2143        None => {
2144            // Any stop list that is NOT radius-uniform (the same 1e-12
2145            // relative criterion `blend_edge_variable` uses for its exact
2146            // constant-radius degeneration) makes the sequential build
2147            // abscissa-distorting on trimmed chain edges; law entries refuse
2148            // instead of silently shifting the law.
2149            let tapered = per_edge_stops.iter().any(|stops| {
2150                stops.first().is_some_and(|(_, first)| {
2151                    stops
2152                        .iter()
2153                        .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
2154                })
2155            });
2156            if chain_corner && tapered && !allow_tapered_sequential {
2157                // The transition patch that would carry a taper through the
2158                // corner is not built: a named deferral.
2159                return Err(KernelRefusal::unsupported(
2160                    KernelStage::Sew,
2161                    "tapered_chain_miter",
2162                    format!(
2163                        "{entry}: the tapered blends across the selected chain's shared corners \
2164                         did not compose to a valid solid (the fitted blend boundaries could not \
2165                         be mitered); fillet fewer edges per operation or reduce the taper"
2166                    ),
2167                ));
2168            }
2169            let mut sequential = solid.clone();
2170            for (index, point) in edge_points.iter().enumerate() {
2171                let edge_id = resolve_edge_by_point(&sequential, *point)?;
2172                let edge_name = per_edge_name(edge_names, name, index);
2173                sequential = crate::blend::blend_edge_variable(
2174                    &sequential,
2175                    edge_id,
2176                    &per_edge_stops[index],
2177                    chamfer,
2178                    edge_name,
2179                )?;
2180            }
2181            sequential
2182        }
2183    };
2184    // Heal §6.9 surgery so re-trimmed edges meet their vertices exactly; scale
2185    // the heal bound by the largest radius stop in the taper profile.
2186    heal_edge_vertex_gaps(&mut result, max_radius)?;
2187    crate::blend::fit_planar_charts_to_trims(solid, &mut result)?;
2188    // Final honesty gate: a genuinely TAPERED chain (different radii at the
2189    // shared corner) has mismatched trim stations there — the blends cannot
2190    // meet without a transition patch (not implemented), and the sequential
2191    // surgery silently left broken topology before this gate existed.
2192    let issues = result.validate();
2193    if !issues.is_empty() {
2194        // Two verdicts share the text's shape: the legacy entry names the
2195        // transition patch it does not build (a deferral); the law entries,
2196        // whose taper is continuous through the corner, report the
2197        // composition failing its own validation (a check that tripped).
2198        let (detail, class) = if allow_tapered_sequential {
2199            (
2200                "tapered blends meet at a shared chain vertex with \
2201                 mismatched radii — the radius-transition corner patch is not implemented; \
2202                 fillet the edges in separate operations or use matching stop radii",
2203                RefusalClass::UnsupportedGeometry {
2204                    what: "taper_transition_patch".into(),
2205                },
2206            )
2207        } else {
2208            (
2209                "the composed radius-law blend produced invalid topology — the blends \
2210                 across a shared chain corner failed to reassemble",
2211                RefusalClass::Internal {
2212                    what: "law_chain_topology".into(),
2213                },
2214            )
2215        };
2216        return Err(KernelRefusal::new(
2217            class,
2218            KernelStage::Validate,
2219            format!(
2220                "{entry}: {detail} ({} validation issues, first: {})",
2221                issues.len(),
2222                issues
2223                    .first()
2224                    .map(|issue| issue.message.clone())
2225                    .unwrap_or_default()
2226            ),
2227        ));
2228    }
2229    // The variable-radius lane's own soundness acceptance, the same one the
2230    // constant-radius group exit takes. `validate()` above is an INCIDENCE
2231    // test: a tapered wall that passes through a neighbour, or through itself,
2232    // satisfies every check it makes and still measures a volume.
2233    crate::accept_sound(result, entry)
2234}
2235
2236/// One selected edge placed on the ordered chain, with its arc-length
2237/// parameterization (the caller-side mapping from chain abscissa to the
2238/// `blend_edge_variable` per-edge parameter-fraction stop seam).
2239struct ChainLink {
2240    /// Position of this edge in the caller's `edge_points` selection.
2241    input_index: usize,
2242    /// True when the edge's own t0→t1 parameter direction runs WITH the chain.
2243    forward: bool,
2244    /// Cumulative chord-length table from the edge's t0 end:
2245    /// `(parameter fraction, arc length)`, uniformly spaced in fraction.
2246    arc: Vec<(f64, f64)>,
2247    /// Total arc length of the edge.
2248    length: f64,
2249    /// Chain abscissa at the link's ENTRY vertex (the end reached first when
2250    /// walking the chain from its start).
2251    abscissa: f64,
2252}
2253
2254/// Cumulative chord-length table of one edge, `(parameter fraction, arc
2255/// length)` at uniform fractions.  The sample count doubles until two
2256/// successive total-length estimates agree within `tol` (chord length
2257/// converges O(N⁻²) for smooth curves, so the agreement of the N and 2N
2258/// estimates bounds the remaining error at the same order); straight edges
2259/// converge on the first doubling.  The 16-sample start resolves any
2260/// single-span arc of up to half a turn to sub-percent before refinement; the
2261/// 4096 cap (8 doublings) guards adversarial curves — beyond it the table is
2262/// two orders denser than the blend march's station grid, so finer chords
2263/// cannot move any station's sampled radius meaningfully.
2264fn edge_arc_table(
2265    curve: &NurbsCurve,
2266    t0: f64,
2267    t1: f64,
2268    tol: f64,
2269) -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
2270    let evaluate = |t: f64| curve.evaluate(t).or_refuse(KernelStage::Collect, "arc_table");
2271    let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), KernelRefusal> {
2272        let mut table = Vec::with_capacity(n + 1);
2273        let mut cumulative = 0.0;
2274        let mut previous = evaluate(t0)?;
2275        table.push((0.0, 0.0));
2276        for j in 1..=n {
2277            let fraction = j as f64 / n as f64;
2278            let point = evaluate(t0 + (t1 - t0) * fraction)?;
2279            cumulative += point.sub(previous).length();
2280            previous = point;
2281            table.push((fraction, cumulative));
2282        }
2283        Ok((table, cumulative))
2284    };
2285    let mut n = 16usize;
2286    let (mut table, mut length) = build(n)?;
2287    while n < 4096 {
2288        n *= 2;
2289        let (next_table, next_length) = build(n)?;
2290        let converged = (next_length - length).abs() <= tol;
2291        table = next_table;
2292        length = next_length;
2293        if converged {
2294            break;
2295        }
2296    }
2297    Ok((table, length))
2298}
2299
2300/// Arc length from the edge's t0 end at `fraction` of its parameter span,
2301/// linearly interpolated in the uniform chord table.
2302fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
2303    let fraction = fraction.clamp(0.0, 1.0);
2304    let intervals = table.len() - 1;
2305    let scaled = fraction * intervals as f64;
2306    let index = (scaled.floor() as usize).min(intervals - 1);
2307    let local = scaled - index as f64;
2308    let (_, a) = table[index];
2309    let (_, b) = table[index + 1];
2310    a + (b - a) * local
2311}
2312
2313/// Resolve the selected edges and order them into ONE OPEN CHAIN with
2314/// cumulative arc-length abscissas.  The chain starts at the free endpoint
2315/// belonging to the EARLIEST-selected end edge (so users get the natural
2316/// "first pick carries the law start" orientation); a single selected edge is
2317/// its own chain oriented t0→t1 (which also admits a closed edge — the law's
2318/// end radii must then match, enforced downstream by `blend_edge_variable`).
2319/// Branching (a vertex shared by 3+ selected edges), closed rings of several
2320/// edges, and disconnected selections refuse with named errors.
2321fn resolve_selected_chain(
2322    solid: &BrepSolid,
2323    edge_points: &[Vec3],
2324    entry: &str,
2325    tol: f64,
2326) -> Result<Vec<ChainLink>, KernelRefusal> {
2327    // Endpoint identity uses the kernel-wide COINCIDENCE_DISTANCE_FLOOR — the
2328    // same band the constant-radius group's corner detector applies.
2329    let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
2330
2331    struct Resolved {
2332        edge_id: u64,
2333        start: Vec3,
2334        end: Vec3,
2335        arc: Vec<(f64, f64)>,
2336        length: f64,
2337    }
2338    let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
2339    for point in edge_points {
2340        let edge_id = resolve_edge_by_point(solid, *point)?;
2341        if resolved.iter().any(|r| r.edge_id == edge_id) {
2342            return Err(KernelRefusal::input(
2343                KernelStage::Collect,
2344                "duplicate_edge",
2345                format!("{entry}: the same edge was selected more than once"),
2346            ));
2347        }
2348        let edge = solid
2349            .edges
2350            .iter()
2351            .find(|e| e.id == edge_id)
2352            .ok_or_else(|| {
2353                KernelRefusal::internal(
2354                    KernelStage::Collect,
2355                    "chain_resolved_edge",
2356                    format!("{entry}: resolved edge vanished"),
2357                )
2358            })?;
2359        let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
2360        if !(length > 0.0) {
2361            return Err(KernelRefusal::input(
2362                KernelStage::Collect,
2363                "zero_length_edge",
2364                format!("{entry}: selected edge has zero length"),
2365            ));
2366        }
2367        resolved.push(Resolved {
2368            edge_id,
2369            start: edge
2370                .curve
2371                .evaluate(edge.t0)
2372                .or_refuse(KernelStage::Collect, "chain_edge_start")?,
2373            end: edge
2374                .curve
2375                .evaluate(edge.t1)
2376                .or_refuse(KernelStage::Collect, "chain_edge_end")?,
2377            arc,
2378            length,
2379        });
2380    }
2381    let n = resolved.len();
2382    if n == 1 {
2383        let only = resolved.remove(0);
2384        return Ok(vec![ChainLink {
2385            input_index: 0,
2386            forward: true,
2387            arc: only.arc,
2388            length: only.length,
2389            abscissa: 0.0,
2390        }]);
2391    }
2392
2393    // Cluster the 2n endpoints; each entry is (edge index, is_start_end).
2394    let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
2395    for (i, r) in resolved.iter().enumerate() {
2396        for (point, is_start) in [(r.start, true), (r.end, false)] {
2397            match clusters
2398                .iter_mut()
2399                .find(|(anchor, _)| anchor.sub(point).length() < band)
2400            {
2401                Some((_, members)) => members.push((i, is_start)),
2402                None => clusters.push((point, vec![(i, is_start)])),
2403            }
2404        }
2405    }
2406    if clusters.iter().any(|(_, members)| members.len() > 2) {
2407        return Err(KernelRefusal::input(
2408            KernelStage::Collect,
2409            "chain_branching",
2410            format!(
2411                "{entry}: selected edges must form one open chain \
2412                 (a vertex is shared by three or more selected edges)"
2413            ),
2414        ));
2415    }
2416    let free: Vec<usize> = clusters
2417        .iter()
2418        .enumerate()
2419        .filter(|(_, (_, members))| members.len() == 1)
2420        .map(|(c, _)| c)
2421        .collect();
2422    if free.len() != 2 {
2423        return Err(KernelRefusal::input(
2424            KernelStage::Collect,
2425            "chain_open",
2426            format!(
2427                "{entry}: selected edges must form one OPEN chain \
2428                 (closed rings and disconnected selections are not supported)"
2429            ),
2430        ));
2431    }
2432    // Start at the free end whose edge appears EARLIEST in the selection.
2433    let start_cluster = *free
2434        .iter()
2435        .min_by_key(|&&c| clusters[c].1[0].0)
2436        .expect("two free ends");
2437
2438    // Walk the chain.
2439    let mut links: Vec<ChainLink> = Vec::with_capacity(n);
2440    let mut visited = vec![false; n];
2441    let mut abscissa = 0.0_f64;
2442    let mut cluster = start_cluster;
2443    for _ in 0..n {
2444        let Some(&(edge_index, entered_at_start)) = clusters[cluster]
2445            .1
2446            .iter()
2447            .find(|(edge_index, _)| !visited[*edge_index])
2448        else {
2449            return Err(KernelRefusal::input(
2450                KernelStage::Collect,
2451                "chain_walk",
2452                format!(
2453                    "{entry}: selected edges are not connected into one chain"
2454                ),
2455            ));
2456        };
2457        visited[edge_index] = true;
2458        let r = &resolved[edge_index];
2459        links.push(ChainLink {
2460            input_index: edge_index,
2461            forward: entered_at_start,
2462            arc: r.arc.clone(),
2463            length: r.length,
2464            abscissa,
2465        });
2466        abscissa += r.length;
2467        let exit_point = if entered_at_start { r.end } else { r.start };
2468        cluster = clusters
2469            .iter()
2470            .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
2471            .ok_or_else(|| {
2472                KernelRefusal::internal(
2473                    KernelStage::Collect,
2474                    "chain_cluster",
2475                    format!("{entry}: chain walk lost an endpoint cluster"),
2476                )
2477            })?;
2478    }
2479    if visited.iter().any(|v| !v) {
2480        return Err(KernelRefusal::input(
2481            KernelStage::Collect,
2482            "chain_connected",
2483            format!(
2484                "{entry}: selected edges are not connected into one chain"
2485            ),
2486        ));
2487    }
2488    Ok(links)
2489}
2490
2491/// Sample the law into one edge's `(parameter fraction, radius)` stop list —
2492/// the caller-side bridge from chain abscissa into the `radius_at` closure
2493/// seam that `blend_edge_variable` builds over its stops.
2494///
2495/// `scale` maps chain abscissa into the law's own abscissa units
2496/// (`law.total_length() / chain_length` — proportional, so a law built from
2497/// the measured chain lengths maps 1:1).  The base stop count derives from
2498/// the law's curvature: piecewise-linear sampling of a C1, piecewise-C2
2499/// function over step `h` errs at most `h²·max|r''|/8`, so
2500/// `h = sqrt(8·tol/max|r''|)` holds the sampling error under the SSI fit
2501/// tolerance the variable lane is built to.  That bound is exact for
2502/// arc-length-linear (straight) edges; curved edges bend the
2503/// parameter→abscissa map, so each interval is additionally midpoint-checked
2504/// against the law and bisected on violation (up to 8 halvings — a 4⁸ ≈ 6·10⁴
2505/// error reduction, decisive for any C1 law).  The base count is capped at
2506/// 256 intervals: 4× the blend march's 64-station density
2507/// (blend/stations.rs), beyond which denser stops cannot move any station's
2508/// sampled radius by more than the fit tolerance.
2509fn law_stops_for_link(
2510    link: &ChainLink,
2511    law: &crate::law::RadiusLaw,
2512    scale: f64,
2513    tol: f64,
2514) -> Vec<(f64, f64)> {
2515    let radius_at_fraction = |fraction: f64| -> f64 {
2516        let arc = arc_length_at_fraction(&link.arc, fraction);
2517        let chain_s = if link.forward {
2518            link.abscissa + arc
2519        } else {
2520            link.abscissa + (link.length - arc)
2521        };
2522        law.radius_at(chain_s * scale)
2523    };
2524    // Curvature of the law in edge-fraction units: d²r/df² ≤
2525    // max|r''|·(scale·length)² for the arc-length-linear map.
2526    let curvature = law
2527        .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
2528        * (scale * link.length).powi(2);
2529    let base = if curvature * 0.125 <= tol {
2530        1usize
2531    } else {
2532        ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
2533    };
2534    let mut stops: Vec<(f64, f64)> = (0..=base)
2535        .map(|j| {
2536            let fraction = j as f64 / base as f64;
2537            (fraction, radius_at_fraction(fraction))
2538        })
2539        .collect();
2540    // Midpoint refinement for curved parameter→abscissa maps.
2541    let mut depth = 0usize;
2542    while depth < 8 {
2543        let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
2544        let mut inserted = false;
2545        for pair in stops.windows(2) {
2546            refined.push(pair[0]);
2547            let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
2548            let law_mid = radius_at_fraction(mid_fraction);
2549            let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
2550            if (law_mid - linear_mid).abs() > tol {
2551                refined.push((mid_fraction, law_mid));
2552                inserted = true;
2553            }
2554        }
2555        refined.push(*stops.last().expect("at least two stops"));
2556        stops = refined;
2557        if !inserted {
2558            break;
2559        }
2560        depth += 1;
2561    }
2562    stops
2563}
2564
2565/// Fillet (or chamfer) a chain of edges under a composable radius law
2566/// evaluated on the chain's cumulative arc-length abscissa (the OCCT
2567/// `Law_Composite` model; see [`crate::law::RadiusLaw`]).  The selected edges
2568/// must form ONE OPEN CHAIN (or be a single edge); the chain starts at the
2569/// free end of the earliest-selected end edge, and the law's abscissa maps
2570/// proportionally onto the chain's measured arc length (a law built with the
2571/// chain's own lengths — e.g. [`crate::law::RadiusLaw::from_vertex_radii`] —
2572/// maps 1:1).  Endpoint radii are met exactly; radii at shared chain vertices
2573/// match by the law's continuity, so the per-edge blends miter through the
2574/// corners; a chain whose blends cannot be mitered REFUSES rather than
2575/// distorting the law through the sequential rebuild.
2576pub fn fillet_edges_variable_law(
2577    solid: &BrepSolid,
2578    edge_points: &[Vec3],
2579    edge_names: Option<&[String]>,
2580    law: &crate::law::RadiusLaw,
2581    chamfer: bool,
2582    name: Option<&str>,
2583) -> Result<BrepSolid, KernelRefusal> {
2584    // One blend operation: the reports of an earlier one, never consumed, are
2585    // dropped as it starts (nested calls keep this operation's).
2586    let _operation = crate::blend::BlendOperation::enter();
2587    const ENTRY: &str = "fillet_edges_variable_law";
2588    if edge_points.is_empty() {
2589        return Err(KernelRefusal::input(
2590            KernelStage::Collect,
2591            "law_selection",
2592            format!("{ENTRY}: no edges selected"),
2593        ));
2594    }
2595    let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
2596    let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
2597    fillet_variable_law_on_chain(
2598        solid,
2599        edge_points,
2600        edge_names,
2601        &chain,
2602        law,
2603        chamfer,
2604        name,
2605        ENTRY,
2606        tolerances.intersection_fit,
2607    )
2608}
2609
2610/// The natural per-vertex user model: radius `vertex_radii[i]` at chain
2611/// vertex `i` (in CHAIN order, starting at the free end of the
2612/// earliest-selected edge), smoothly interpolated along the chain
2613/// (monotone C1 — every vertex radius met exactly, no overshoot).  Requires
2614/// exactly one radius per chain vertex (`edges + 1`).
2615pub fn fillet_edges_variable_vertex_radii(
2616    solid: &BrepSolid,
2617    edge_points: &[Vec3],
2618    edge_names: Option<&[String]>,
2619    vertex_radii: &[f64],
2620    chamfer: bool,
2621    name: Option<&str>,
2622) -> Result<BrepSolid, KernelRefusal> {
2623    // One blend operation: the reports of an earlier one, never consumed, are
2624    // dropped as it starts (nested calls keep this operation's).
2625    let _operation = crate::blend::BlendOperation::enter();
2626    const ENTRY: &str = "fillet_edges_variable_vertex_radii";
2627    if edge_points.is_empty() {
2628        return Err(KernelRefusal::input(
2629            KernelStage::Collect,
2630            "vertex_radii_selection",
2631            format!("{ENTRY}: no edges selected"),
2632        ));
2633    }
2634    let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
2635    let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
2636    let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
2637    let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
2638        .map_err(|error| error.with_message(|error| format!("{ENTRY}: {error}")))?;
2639    fillet_variable_law_on_chain(
2640        solid,
2641        edge_points,
2642        edge_names,
2643        &chain,
2644        &law,
2645        chamfer,
2646        name,
2647        ENTRY,
2648        tolerances.intersection_fit,
2649    )
2650}
2651
2652/// Shared law-entry tail: sample per-edge stop lists from the law over the
2653/// resolved chain and run the variable group core (miter-or-refuse: no
2654/// sequential fallback for tapered chains — see `fillet_edges_variable_impl`).
2655#[allow(clippy::too_many_arguments)]
2656fn fillet_variable_law_on_chain(
2657    solid: &BrepSolid,
2658    edge_points: &[Vec3],
2659    edge_names: Option<&[String]>,
2660    chain: &[ChainLink],
2661    law: &crate::law::RadiusLaw,
2662    chamfer: bool,
2663    name: Option<&str>,
2664    entry: &str,
2665    tol: f64,
2666) -> Result<BrepSolid, KernelRefusal> {
2667    let chain_length: f64 = chain.iter().map(|link| link.length).sum();
2668    let scale = law.total_length() / chain_length;
2669    let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
2670    for link in chain {
2671        per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
2672    }
2673    fillet_edges_variable_impl(
2674        solid,
2675        edge_points,
2676        edge_names,
2677        &per_edge_stops,
2678        chamfer,
2679        name,
2680        entry,
2681        false,
2682    )
2683}
2684
2685/// Asymmetric (two-distance) chamfer of a GROUP of edges, the app entry: each
2686/// selected edge (resolved by a point on it) gets a `d1 × d2` bevel (§6.11).
2687/// Edges are chamfered independently — asymmetric chamfers keep sharp vertices,
2688/// so there is no shared-corner blending.
2689pub fn chamfer_edges_asymmetric(
2690    solid: &BrepSolid,
2691    edge_points: &[Vec3],
2692    edge_names: Option<&[String]>,
2693    d1: f64,
2694    d2: f64,
2695    name: Option<&str>,
2696) -> Result<BrepSolid, KernelRefusal> {
2697    // One blend operation: the reports of an earlier one, never consumed, are
2698    // dropped as it starts (nested calls keep this operation's).
2699    let _operation = crate::blend::BlendOperation::enter();
2700    if edge_points.is_empty() {
2701        return Err(KernelRefusal::input(
2702            KernelStage::Collect,
2703            "asymmetric_selection",
2704            "chamfer_edges_asymmetric: no edges selected",
2705        ));
2706    }
2707    let mut result = solid.clone();
2708    // `edge_names[index]` is keyed by INPUT position; the loop resolves each
2709    // point on the EVOLVING `result`, but enumerates the input points in order,
2710    // so the index alignment holds.
2711    for (index, point) in edge_points.iter().enumerate() {
2712        let edge_id = resolve_edge_by_point(&result, *point)?;
2713        let edge_name = per_edge_name(edge_names, name, index);
2714        result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
2715    }
2716    Ok(result)
2717}
2718
2719/// Distance-angle chamfer of a GROUP of edges, the app entry: each selected
2720/// edge (resolved by a point on it) gets a setback `d1` on face 1 and a chamfer
2721/// face at `angle_rad` from face 1 (§6.11); `d2` is constructed per edge.
2722pub fn chamfer_edges_angle(
2723    solid: &BrepSolid,
2724    edge_points: &[Vec3],
2725    edge_names: Option<&[String]>,
2726    d1: f64,
2727    angle_rad: f64,
2728    name: Option<&str>,
2729) -> Result<BrepSolid, KernelRefusal> {
2730    // One blend operation: the reports of an earlier one, never consumed, are
2731    // dropped as it starts (nested calls keep this operation's).
2732    let _operation = crate::blend::BlendOperation::enter();
2733    if edge_points.is_empty() {
2734        return Err(KernelRefusal::input(
2735            KernelStage::Collect,
2736            "angle_selection",
2737            "chamfer_edges_angle: no edges selected",
2738        ));
2739    }
2740    let mut result = solid.clone();
2741    for (index, point) in edge_points.iter().enumerate() {
2742        let edge_id = resolve_edge_by_point(&result, *point)?;
2743        let edge_name = per_edge_name(edge_names, name, index);
2744        result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
2745    }
2746    Ok(result)
2747}
2748
2749/// Debug aid (`BREP_DEBUG_NETWORK`): every face loop as its coedge walk, with
2750/// each edge's stored vertices, so a loop the surgery left open can be read
2751/// against the input it was built from.
2752fn dump_loops_debug(label: &str, solid: &BrepSolid) {
2753    for shell in &solid.shells {
2754        for face in &shell.faces {
2755            for (index, loop_record) in face.loops.iter().enumerate() {
2756                let walk: Vec<String> = loop_record
2757                    .coedges
2758                    .iter()
2759                    .map(|coedge| {
2760                        match solid.edges.iter().find(|e| e.id == coedge.edge_id) {
2761                            Some(edge) => format!(
2762                                "c{}:e{}{}({}->{})",
2763                                coedge.id,
2764                                edge.id,
2765                                if coedge.forward { "+" } else { "-" },
2766                                edge.start_vertex_id,
2767                                edge.end_vertex_id
2768                            ),
2769                            None => format!("c{}:e{}?MISSING", coedge.id, coedge.edge_id),
2770                        }
2771                    })
2772                    .collect();
2773                eprintln!(
2774                    "{label} face {} ({:?}) loop {index}: {}",
2775                    face.id,
2776                    face.name,
2777                    walk.join(" ")
2778                );
2779            }
2780        }
2781    }
2782}