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