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