Skip to main content

brep_kernel/blending/fillet/
edges.rs

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