Skip to main content

brep_kernel/blending/fillet/
edges.rs

1use super::*;
2
3/// Constant-radius rolling-ball fillet of one edge (Golovanov §4.9 march,
4/// §6.9 surgery; the cutter only where the march refuses).
5pub fn fillet_edge(
6    solid: &BrepSolid,
7    edge_id: u64,
8    radius: f64,
9    name: Option<&str>,
10) -> Result<BrepSolid, String> {
11    check_mixed_concavity(solid, edge_id, "fillet_edge")?;
12    check_support_extent(solid, edge_id, radius, "fillet_edge")?;
13    fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::GeneralFirst)
14}
15
16/// `fillet_edge` with the cutter first: the input the corner-closure lanes
17/// (`round_convex_corner`, the mixed-convexity closures) were written
18/// against.  Their tests build it directly; production reaches those lanes
19/// only through the group's sequential composition, which is cutter-first
20/// for the same reason.
21pub(crate) fn fillet_edge_cutter(
22    solid: &BrepSolid,
23    edge_id: u64,
24    radius: f64,
25    name: Option<&str>,
26) -> Result<BrepSolid, String> {
27    check_mixed_concavity(solid, edge_id, "fillet_edge")?;
28    check_support_extent(solid, edge_id, radius, "fillet_edge")?;
29    fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default(), Lane::CutterFirst)
30}
31
32/// Equal-leg chamfer of one edge (Golovanov §6.11: the same construction
33/// with the arc's chord).
34pub fn chamfer_edge(
35    solid: &BrepSolid,
36    edge_id: u64,
37    distance: f64,
38    name: Option<&str>,
39) -> Result<BrepSolid, String> {
40    check_mixed_concavity(solid, edge_id, "chamfer_edge")?;
41    check_support_extent(solid, edge_id, distance, "chamfer_edge")?;
42    fillet_or_chamfer(solid, edge_id, distance, true, name, ToolEnds::default(), Lane::GeneralFirst)
43}
44
45/// Build and apply the chamfer tool for one straight edge from an already-built
46/// cross-section profile (curve index 1 is the chamfer chord / blend wall).
47/// Shared by the two-distance and distance-angle asymmetric entries.
48fn apply_chamfer_offsets_profile(
49    solid: &BrepSolid,
50    cross: &EdgeCross,
51    profile: &[NurbsCurve],
52    name: Option<&str>,
53) -> Result<BrepSolid, String> {
54    let mut tool = match &cross.path {
55        EdgePath::Straight { direction, length } => {
56            extrude_profile_brep(profile, *direction, *length)?
57        }
58        EdgePath::Circular { .. } => {
59            return Err(
60                "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
61                 in this slice (asymmetric chamfer on general/curved edges is out of scope)"
62                    .into(),
63            );
64        }
65    };
66    if let Some(name) = name {
67        // Side faces are emitted in input-curve order; the chamfer wall is the
68        // second profile curve (index 1).
69        let mut side_index = 0usize;
70        for shell in &mut tool.shells {
71            for face in &mut shell.faces {
72                if side_index == 1 && face.name.is_none() {
73                    face.name = Some(name.to_string());
74                }
75                side_index += 1;
76                if side_index >= profile.len() {
77                    break;
78                }
79            }
80        }
81    }
82    apply_tool(solid, &tool, cross.convex)
83}
84
85/// Asymmetric (two-distance) chamfer of one STRAIGHT edge between two planar
86/// faces (Golovanov §6.11): setback `d1` along face 1 and `d2` along face 2 —
87/// the standard CAD "d1 × d2" bevel.  General/curved edges are out of scope for
88/// this slice and return a clear error.
89pub fn chamfer_edge_asymmetric(
90    solid: &BrepSolid,
91    edge_id: u64,
92    d1: f64,
93    d2: f64,
94    name: Option<&str>,
95) -> Result<BrepSolid, String> {
96    if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
97        return Err("chamfer_edge_asymmetric: both setback distances must be positive".into());
98    }
99    // `analyze_edge` only uses the radius to size the orientation probe step;
100    // the smaller setback keeps that probe inside both faces.
101    let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
102    let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
103    apply_chamfer_offsets_profile(solid, &cross, &profile, name)
104}
105
106/// Distance-angle chamfer of one STRAIGHT edge between two planar faces
107/// (Golovanov §6.11): setback `d1` along face 1 and angle `angle_rad` between
108/// the chamfer face and face 1.  `d2` is constructed geometrically in the
109/// cross-section plane (see `chamfer_angle_second_distance`), then the
110/// two-distance builder is applied.
111pub fn chamfer_edge_angle(
112    solid: &BrepSolid,
113    edge_id: u64,
114    d1: f64,
115    angle_rad: f64,
116    name: Option<&str>,
117) -> Result<BrepSolid, String> {
118    if !(d1 > 0.0) || !d1.is_finite() {
119        return Err("chamfer_edge_angle: setback distance d1 must be positive".into());
120    }
121    let cross = analyze_edge(solid, edge_id, d1)?;
122    let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
123    let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
124    apply_chamfer_offsets_profile(solid, &cross, &profile, name)
125}
126
127/// Heal §6.9 fillet/chamfer surgery output so every edge curve endpoint sits
128/// exactly on its vertex.
129///
130/// The direct §6.9 surgery re-uses trimmed original edges (e.g. the straight
131/// side edges meeting a filleted edge at a corner) whose endpoints are computed
132/// by a separate trim/intersection from the freshly-built blend spring/contact
133/// curves that define the shared corner vertex.  When the input itself carries
134/// solver noise (a sketch whose "equal" points sit ~5e-5 apart), that noise is
135/// amplified by the surface–surface intersections to ~1e-4 gaps between the
136/// re-trimmed edge's endpoint and the corner vertex — a hair non-watertight,
137/// enough to trip the topology validator's 1e-5 vertex band.
138///
139/// This mirrors the boolean assembler's endpoint weld
140/// (`commit_nearby_edge_endpoints`): snap the CURVE endpoints exactly onto the
141/// vertex, and ONLY for endpoints that fall outside the validator band (so a
142/// clean, already-watertight blend is left byte-identical), and ONLY within a
143/// bound derived from the blend radius (`radius * 1e-3`, floored at the proven
144/// 1e-4 boolean-weld radius) — far below the radius and feature size, so a
145/// genuine modeling gap is never masked.
146pub(super) fn heal_edge_vertex_gaps(solid: &mut BrepSolid, radius: f64) -> Result<(), String> {
147    // commit_nearby_edge_endpoints skips edges whose endpoints are already
148    // inside the 1e-5 validator band and rejects gaps beyond `search.max(1e-4)`;
149    // a radius-scaled search adds headroom for noisier inputs while staying
150    // tiny relative to the blend (0.1% of radius) — clean outputs are untouched.
151    let search = (radius.abs() * 1e-3).max(1e-7);
152    crate::boolean::commit_nearby_edge_endpoints(solid, search)
153}
154
155/// Resolve a 3D point that lies ON an edge to that edge's id (ids do not
156/// survive the app-side decode, so callers identify edges geometrically —
157/// the same scheme as the single-edge blend entry).
158fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, String> {
159    let mut best: Option<(u64, f64)> = None;
160    for edge in &solid.edges {
161        let Ok(projection) = crate::project_point_to_curve(&edge.curve, point) else {
162            continue;
163        };
164        let clamped = projection
165            .u
166            .clamp(edge.t0.min(edge.t1), edge.t0.max(edge.t1));
167        let Ok(sample) = edge.curve.evaluate(clamped) else {
168            continue;
169        };
170        let distance = sample.sub(point).length();
171        if best.map(|(_, known)| distance < known).unwrap_or(true) {
172            best = Some((edge.id, distance));
173        }
174    }
175    match best {
176        Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
177        Some((_, distance)) => Err(format!(
178            "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
179        )),
180        None => Err("fillet_edges: solid has no edges".into()),
181    }
182}
183
184/// Fillet (or chamfer) a GROUP of edges as ONE operation, and — for fillets —
185/// round the convex "star" vertices where three or more of the selected edges
186/// meet (Golovanov §6.9.7).  This is the whole multi-edge fillet in a single
187/// kernel call: the caller passes the object plus one 3D point on each edge,
188/// and the kernel orchestrates the filleting and corner blending against the
189/// full topology (so acute corners resolve coherently instead of being
190/// stitched edge-by-edge by the app).  A corner the kernel cannot round (e.g.
191/// non-orthogonal beyond support, or a general no-common-ball star) is left as
192/// the edge fillets rather than failing the whole group.
193///
194/// When the WHOLE selection cannot be blended as one unit — a shared convex
195/// corner where a revolve axis/pole edge meets the adjacent cap edges can
196/// defeat the sequential corner surgery even though each edge and every proper
197/// SUBSET of the selection blends cleanly (the three fillets converge on the
198/// pole with no single end face across the corner) — we do NOT hard-reject the
199/// whole selection (which makes the app refuse it outright with "does not yet
200/// support the selected edge geometry").  Instead we blend the LARGEST subset
201/// of the selected edges that yields a VALID solid, dropping only the edge(s)
202/// that cannot co-blend at the corner.  A selection that already composes is
203/// returned unchanged (byte-identical) — the subset search only runs after the
204/// full-group attempt errors.
205///
206/// `edge_names` (when `Some`) is the per-edge blend-FACE name parallel to
207/// `edge_points` — each grown wall is named after ITS originating edge; `None`
208/// names every wall with the single base `name` (the legacy/test behavior,
209/// byte-identical to before). The whole `*_edges` family takes the same
210/// `edge_names` slot in the same position.
211pub fn fillet_edges(
212    solid: &BrepSolid,
213    edge_points: &[Vec3],
214    edge_names: Option<&[String]>,
215    radius: f64,
216    chamfer: bool,
217    name: Option<&str>,
218) -> Result<BrepSolid, String> {
219    if !(radius > 0.0) || !radius.is_finite() {
220        return Err("fillet_edges: radius must be positive".into());
221    }
222    if edge_points.is_empty() {
223        return Err("fillet_edges: no edges selected".into());
224    }
225    // The rolling ball must reach both supports of every SELECTED edge — see
226    // `check_support_extent`.  Checked ONCE, here, against the solid the
227    // selection was made on: inside the group build the faces have already
228    // been eaten into by earlier blends of the same selection, and a fillet
229    // legitimately runs off that remainder (two rounds pinching on a shared
230    // face).  Dropping edges cannot rescue an oversized radius either, so
231    // this runs before the subset search rather than inside it.
232    let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
233    for point in edge_points {
234        let edge_id = resolve_edge_by_point(solid, *point)?;
235        check_mixed_concavity(solid, edge_id, entry)?;
236        check_support_extent(solid, edge_id, radius, entry)?;
237    }
238
239    match fillet_edges_group(solid, edge_points, edge_names, radius, chamfer, name) {
240        Ok(result) => Ok(result),
241        Err(group_err) => {
242            // Fewer than two edges: nothing to drop, so the group error is final.
243            // Cap the combinatorial search so a large malformed selection cannot
244            // explode (the full group carries the common case; the search is a
245            // rare fallback).
246            let n = edge_points.len();
247            if n < 2 || n > 12 {
248                return Err(group_err);
249            }
250            // Drop the fewest edges first (largest surviving subset), trying the
251            // drop-sets in lexicographic order so the result is deterministic.
252            // Return the first subset that blends to a VALID (watertight) solid.
253            for drop in 1..n {
254                for dropped in index_combinations(n, drop) {
255                    let kept: Vec<Vec3> = (0..n)
256                        .filter(|i| !dropped.contains(i))
257                        .map(|i| edge_points[i])
258                        .collect();
259                    // Subset the per-edge blend-face names with the IDENTICAL
260                    // drop-set so `kept_names[k]` still names `kept[k]`.
261                    let kept_names: Option<Vec<String>> = edge_names.map(|names| {
262                        (0..n)
263                            .filter(|i| !dropped.contains(i))
264                            .map(|i| names[i].clone())
265                            .collect()
266                    });
267                    if let Ok(result) = fillet_edges_group(
268                        solid,
269                        &kept,
270                        kept_names.as_deref(),
271                        radius,
272                        chamfer,
273                        name,
274                    ) {
275                        if result.validate().is_empty() {
276                            return Ok(result);
277                        }
278                    }
279                }
280            }
281            Err(group_err)
282        }
283    }
284}
285
286/// The blend-FACE name for the `i`-th selected edge: its per-edge name when the
287/// caller supplied the parallel `edge_names` (feature path — each wall named
288/// after its originating edge, `{fid}:BLEND:{edge}`), else the single base
289/// `name` for every wall (the legacy/test path, byte-identical to before).
290fn per_edge_name<'a>(
291    edge_names: Option<&'a [String]>,
292    base: Option<&'a str>,
293    i: usize,
294) -> Option<&'a str> {
295    match edge_names {
296        Some(names) => names.get(i).map(|value| value.as_str()),
297        None => base,
298    }
299}
300
301/// The star-corner patch name: `{base}:CORNER:{sorted+join of adjacent edge
302/// names}` when per-edge names were supplied (feature path), else the single
303/// `base` (legacy/test path — the corner keeps the wall name, pre-change
304/// behavior).  `base` is `{fid}:BLEND` and each `edge_names[i]` is the composed
305/// `{fid}:BLEND:{edge}`, so stripping the `{base}:` prefix recovers the bare
306/// originating-edge name for the join.  Unique per corner: two distinct corners
307/// never share the same set of >=3 selected edges.
308fn corner_face_name(
309    edge_names: Option<&[String]>,
310    base: Option<&str>,
311    adjacent: &[usize],
312) -> Option<String> {
313    match (edge_names, base) {
314        (Some(names), Some(base)) => {
315            let prefix = format!("{base}:");
316            let mut raws: Vec<&str> = adjacent
317                .iter()
318                .filter_map(|&i| names.get(i))
319                .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
320                .collect();
321            raws.sort_unstable();
322            raws.dedup();
323            Some(format!("{base}:CORNER:{}", raws.join("+")))
324        }
325        _ => base.map(|value| value.to_string()),
326    }
327}
328
329/// All ways to choose `k` distinct indices from `0..n`, in lexicographic order.
330fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
331    let mut out = Vec::new();
332    if k == 0 || k > n {
333        return out;
334    }
335    let mut idx: Vec<usize> = (0..k).collect();
336    loop {
337        out.push(idx.clone());
338        // Advance to the next combination (like counting with carry).
339        let mut i = k;
340        loop {
341            if i == 0 {
342                return out;
343            }
344            i -= 1;
345            if idx[i] != i + n - k {
346                break;
347            }
348        }
349        idx[i] += 1;
350        for j in (i + 1)..k {
351            idx[j] = idx[j - 1] + 1;
352        }
353    }
354}
355
356/// Blend the WHOLE selection as one group (the single-shot multi-edge fillet).
357/// Errors if any selected edge or the shared-corner surgery cannot compose;
358/// `fillet_edges` wraps this with a maximal-valid-subset fallback.
359fn fillet_edges_group(
360    solid: &BrepSolid,
361    edge_points: &[Vec3],
362    edge_names: Option<&[String]>,
363    radius: f64,
364    chamfer: bool,
365    name: Option<&str>,
366) -> Result<BrepSolid, String> {
367    use rustc_hash::FxHashSet as HashSet;
368
369    // Fuse-first operand heal (Lever A) before the multi-edge surgery: snap the
370    // input's near-coincident / off-plane vertices (e.g. a revolve pole apex
371    // sitting a few microns off the axis) to exact and re-anchor incident
372    // edges.  The selected edges are resolved geometrically below, so a
373    // sub-heal_tol vertex move never changes which edges are picked; a clean
374    // input is left byte-identical.
375    let mut healed_input = solid.clone();
376    let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
377    crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
378    let solid = &healed_input;
379
380    // 1. Detect convex corners from the ORIGINAL solid: a point that is an
381    //    endpoint of >=3 of the selected edges (a cube/prism-style vertex).
382    let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
383    // The extent each selected edge has BEFORE any blend trims it, so the
384    // sequential build can run every cutter through the shared corners.
385    let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
386    let mut selected_edge_ids: Vec<u64> = Vec::with_capacity(edge_points.len());
387    for (i, point) in edge_points.iter().enumerate() {
388        let edge_id = resolve_edge_by_point(solid, *point)?;
389        let edge = solid
390            .edges
391            .iter()
392            .find(|e| e.id == edge_id)
393            .ok_or("fillet_edges: resolved edge vanished")?;
394        let (start, end) = (edge.curve.evaluate(edge.t0)?, edge.curve.evaluate(edge.t1)?);
395        endpoints.push((start, i));
396        endpoints.push((end, i));
397        original_extents.push((start, end));
398        selected_edge_ids.push(edge_id);
399    }
400    let mut corners: Vec<Vec3> = Vec::new();
401    // The selected-edge INPUT INDICES meeting at each star corner (parallel to
402    // `corners`), sorted — used to name the corner patch after its adjacent
403    // edges (`{fid}:BLEND:CORNER:{e_a}+{e_b}+…`), UNIQUE per corner because no
404    // two distinct corners share the same set of >=3 selected edges.
405    let mut corner_edges: Vec<Vec<usize>> = Vec::new();
406    let mut chain_corner_count = 0usize;
407    let mut chain_corners: Vec<(Vec3, [usize; 2])> = Vec::new();
408    let mut used = vec![false; endpoints.len()];
409    for i in 0..endpoints.len() {
410        if used[i] {
411            continue;
412        }
413        used[i] = true;
414        let mut edges_here: HashSet<usize> = HashSet::default();
415        edges_here.insert(endpoints[i].1);
416        for j in (i + 1)..endpoints.len() {
417            if used[j] {
418                continue;
419            }
420            if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
421                used[j] = true;
422                edges_here.insert(endpoints[j].1);
423            }
424        }
425        if edges_here.len() >= 3 {
426            corners.push(endpoints[i].0);
427            let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
428            adjacent.sort_unstable();
429            corner_edges.push(adjacent);
430        } else if edges_here.len() == 2 {
431            chain_corner_count += 1;
432            let mut adjacent = edges_here.into_iter().collect::<Vec<_>>();
433            adjacent.sort_unstable();
434            chain_corners.push((endpoints[i].0, [adjacent[0], adjacent[1]]));
435        }
436    }
437
438    // A FACE selection may contain several disconnected boundary components
439    // (the common example is an outer perimeter plus a circular hole rim).
440    // Miter composition is only meaningful inside one connected edge graph.
441    // Feeding every component into the same INTERSECT/UNION combines multiple
442    // independently filleted copies of otherwise untouched support faces; the
443    // boolean then imprints those coincident copies and can leave redundant
444    // seams (the reported through-hole cylinder split into three faces).
445    //
446    // Partition by shared original endpoints and process components in input
447    // order.  Each recursive call sees one connected component, so it follows
448    // the existing miter/sequential path without recursion cycling.  Distinct
449    // components are then composed sequentially on the evolving solid.
450    let mut component_of = vec![usize::MAX; edge_points.len()];
451    let mut components: Vec<Vec<usize>> = Vec::new();
452    for seed in 0..edge_points.len() {
453        if component_of[seed] != usize::MAX {
454            continue;
455        }
456        let component_index = components.len();
457        component_of[seed] = component_index;
458        let mut component = vec![seed];
459        let mut cursor = 0;
460        while cursor < component.len() {
461            let current = component[cursor];
462            cursor += 1;
463            for candidate in 0..edge_points.len() {
464                if component_of[candidate] != usize::MAX {
465                    continue;
466                }
467                let connected = [original_extents[current].0, original_extents[current].1]
468                    .into_iter()
469                    .any(|a| {
470                        [original_extents[candidate].0, original_extents[candidate].1]
471                            .into_iter()
472                            .any(|b| a.sub(b).length() < 1e-6)
473                    });
474                if connected {
475                    component_of[candidate] = component_index;
476                    component.push(candidate);
477                }
478            }
479        }
480        component.sort_unstable();
481        components.push(component);
482    }
483    if components.len() > 1 {
484        let mut separated = solid.clone();
485        for component in components {
486            let points = component
487                .iter()
488                .map(|index| edge_points[*index])
489                .collect::<Vec<_>>();
490            let names = edge_names.map(|all| {
491                component
492                    .iter()
493                    .map(|index| all[*index].clone())
494                    .collect::<Vec<_>>()
495            });
496            separated =
497                fillet_edges_group(&separated, &points, names.as_deref(), radius, chamfer, name)?;
498        }
499        return Ok(separated);
500    }
501
502    // 2a. The stripe network (blend/network.rs) is the lane for EVERY
503    //     selection: each stripe is marched against THIS solid, each shared
504    //     vertex is solved before anything is cut — a star closed by a patch
505    //     of the corner ball, a two-edge corner by the seam between the two
506    //     blends, a re-entrant corner by a horn torus, a tangent pair by a
507    //     flush join, an unselected tangent continuation by a cap — and
508    //     nothing is subtracted, so no cutter can overshoot into a neighbour
509    //     and no leftover cap has to be identified afterwards.  It refuses
510    //     BY NAME on what it does not yet construct (mixed-convexity corners,
511    //     no-common-ball stars, chamfer corners, pinched edges), and those
512    //     fall through to the cutter composition below.
513    if std::env::var("BREP_NO_NETWORK").is_err() {
514        let network_names: Vec<Option<String>> = (0..edge_points.len())
515            .map(|index| per_edge_name(edge_names, name, index).map(str::to_string))
516            .collect();
517        let network_corner_name =
518            |adjacent: &[usize]| corner_face_name(edge_names, name, adjacent);
519        match crate::blend::blend_star_network(
520            solid,
521            &selected_edge_ids,
522            radius,
523            chamfer,
524            &network_names,
525            &network_corner_name,
526        ) {
527            Ok(mut network) => {
528                // Fail-safe like the rest of the ladder: a heal or validation
529                // problem in the network result falls through to the cutter,
530                // it does not fail the group.  So does an UNTRIMMED result: the
531                // network re-trims only each stripe's two mates, and
532                // `check_blend_interference` is what notices a third face
533                // crossing the swept volume -- `validate` cannot, because a
534                // self-intersecting solid is still a watertight one.
535                let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
536                let healed = heal_edge_vertex_gaps(&mut network, radius);
537                let issues = network.validate();
538                let interference =
539                    check_blend_interference(solid, &network, &selected_edge_ids, entry);
540                if healed.is_ok() && issues.is_empty() && interference.is_ok() {
541                    return Ok(network);
542                }
543                if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
544                    eprintln!(
545                        "network result rejected: heal={healed:?} issues={issues:?} \
546                         interference={interference:?}"
547                    );
548                }
549            }
550            Err(refusal) => {
551                if std::env::var("BREP_DEBUG_NETWORK").is_ok() {
552                    eprintln!("network refused: {refusal}");
553                }
554            }
555        }
556    }
557
558    // 2. Build the blends.
559    //
560    //    CHAIN corners (§6.9.6 — exactly TWO selected edges share a vertex):
561    //    the sequential build truncates the later blend where it runs into the
562    //    earlier one and closes it with a flat bulkhead across the fillet
563    //    channel — a hard step, not a transition.  For selections containing
564    //    chain corners, blend each edge FULL-LENGTH on the ORIGINAL solid and
565    //    INTERSECT the per-edge results instead: the removal volumes union, so
566    //    adjacent blends run through the shared corner and trim each other
567    //    along their intersection curve — the standard MITER corner, tangent
568    //    to the shared face at the seam's tangency end.  Non-adjacent edges
569    //    are unaffected (their removals are disjoint, intersection ≡
570    //    sequential).
571    //
572    //    Selections without chain corners keep the sequential build unchanged
573    //    (star corners are rounded in step 3 against exactly the sequential
574    //    geometry round_convex_corner was built for).
575    //    CONVEXITY GUARD: a convex blend REMOVES material (fillet = orig −
576    //    cut), a concave blend ADDS it (orig + pad).  Full-length blends
577    //    combine as orig − ∪cuts + ∪pads, so the per-edge results compose by
578    //    INTERSECTION when every edge is convex and by UNION when every edge
579    //    is concave; a mixed selection has no single composition and falls
580    //    back to the sequential build.
581    // Fillet stars keep the sequential topology required by their sphere
582    // patches. Chamfer stars have no subsequent corner patch: compose their
583    // full-length removals too, so all bevel planes meet at the corner.
584    let miter_operation = if (chain_corner_count > 0 || !corners.is_empty())
585        && (chamfer || corners.is_empty())
586    {
587        let mut any_convex = false;
588        let mut any_concave = false;
589        for point in edge_points {
590            match resolve_edge_by_point(solid, *point)
591                .and_then(|edge_id| analyze_edge(solid, edge_id, radius))
592            {
593                Ok(cross) if cross.convex => any_convex = true,
594                Ok(_) => any_concave = true,
595                // Unknown edge class: let the sequential path produce its own
596                // (more specific) error or result.
597                Err(_) => {
598                    any_convex = true;
599                    any_concave = true;
600                    break;
601                }
602            }
603        }
604        match (any_convex, any_concave) {
605            (true, false) => Some(crate::BooleanOperation::Intersect),
606            (false, true) => Some(crate::BooleanOperation::Union),
607            _ => None,
608        }
609    } else {
610        None
611    };
612
613    // Fillet/chamfer each edge in turn, resolving its point on the evolving
614    // solid (ids shift as earlier fillets rewrite topology; the midpoint of an
615    // edge is untouched by the corner surgery of the others).  This is the
616    // baseline build used directly for non-miter selections AND as the
617    // fallback when the miter composition below cannot reassemble.
618    // The composition's per-edge lane: cutter-first ONLY when a corner
619    // closure will run afterwards and read cutter-shaped topology (a star or
620    // a chain corner).  A selection with no shared vertex — a lone closed
621    // rim, a lone chamfer, disconnected edges — has no such closure, so each
622    // edge gets the march first there too.
623    let composition_lane = if corners.is_empty() && chain_corner_count == 0 {
624        Lane::GeneralFirst
625    } else {
626        Lane::CutterFirst
627    };
628    let build_sequential = || -> Result<BrepSolid, String> {
629        let mut sequential = solid.clone();
630        for (index, point) in edge_points.iter().enumerate() {
631            let edge_id = resolve_edge_by_point(&sequential, *point)?;
632            // Earlier cutters in this loop TRIM the edges that share a corner
633            // with them; extend this cutter back over what they took so the
634            // two removal volumes union through the corner (§6.9.6 miter)
635            // instead of leaving a wedge of material standing behind a flush
636            // end cap.  Untouched edges get a zero pad and the historical
637            // flush cutter.
638            let ends = tool_ends_to_original_extent(
639                &sequential,
640                edge_id,
641                radius,
642                original_extents[index],
643                if chamfer { &[] } else { &corners },
644            );
645            // This edge's blend wall carries the name of THIS input edge
646            // (`edge_names[index]`); a smooth chain that engulfs several edges
647            // is named after the FIRST such input edge processed here (input
648            // order), the chain's representative.
649            let edge_name = per_edge_name(edge_names, name, index);
650            sequential = fillet_or_chamfer(
651                &sequential,
652                edge_id,
653                radius,
654                chamfer,
655                edge_name,
656                ends,
657                composition_lane,
658            )?;
659        }
660        Ok(sequential)
661    };
662
663    let mut result = if let Some(operation) = miter_operation {
664        let options = crate::BooleanOptions::default();
665        let mut combined: Result<Option<BrepSolid>, String> = Ok(None);
666        for (index, point) in edge_points.iter().enumerate() {
667            let edge_id = resolve_edge_by_point(solid, *point)?;
668            let edge_name = per_edge_name(edge_names, name, index);
669            let blended = {
670                let entry = if chamfer { "chamfer_edges" } else { "fillet_edges" };
671                check_mixed_concavity(solid, edge_id, entry)?;
672                check_support_extent(solid, edge_id, radius, entry)?;
673                fillet_or_chamfer(
674                    solid,
675                    edge_id,
676                    radius,
677                    chamfer,
678                    edge_name,
679                    ToolEnds::default(),
680                    Lane::CutterFirst,
681                )?
682            };
683            combined = match combined {
684                Err(e) => Err(e),
685                Ok(None) => Ok(Some(blended)),
686                Ok(Some(previous)) => {
687                    crate::boolean_operation(&previous, &blended, operation, &options)
688                        .map(Some)
689                        .map_err(|error| {
690                            format!("fillet_edges: chain-corner miter composition failed: {error}")
691                        })
692                }
693            };
694            if combined.is_err() {
695                break;
696            }
697        }
698        // A §6.9.6 miter (per-edge blends intersected/unioned through the
699        // shared chain corners) can fail to reassemble on faces whose
700        // fragmented boundary does not close — e.g. a planar cap whose ENTIRE
701        // perimeter is selected, where fragment_face reports an "incomplete
702        // run".  Rather than let `fillet_edges` silently DROP a selected edge
703        // to recover a valid subset (the reported defect: not every edge of
704        // the face gets a fillet), fall back to the sequential build, which
705        // blends EVERY selected edge (chain corners get a flat bulkhead
706        // instead of a miter).  Only if that also fails to produce a valid
707        // solid do we surface the miter error so the caller's
708        // maximal-valid-subset search can still run.
709        match combined {
710            Ok(Some(mitered)) => mitered,
711            Ok(None) => return Err("fillet_edges: no edges selected".into()),
712            Err(miter_err) => match build_sequential() {
713                Ok(seq) if seq.validate().is_empty() => seq,
714                _ => return Err(miter_err),
715            },
716        }
717    } else {
718        build_sequential()?
719    };
720
721    // A re-entrant vertex of a selected face perimeter has two selected,
722    // convex cap-wall edges and one UNSELECTED concave wall-wall edge.  The
723    // two cutter volumes only touch there, so the boolean miter leaves a
724    // triangular planar end-cap instead of carrying the rolling ball around
725    // the corner.  Close that exact orthogonal class with its horn-torus
726    // sector (major radius = minor radius = fillet radius).
727    if !chamfer {
728        for (corner, adjacent) in &chain_corners {
729            let has_concave_unselected_edge = solid.edges.iter().any(|edge| {
730                if selected_edge_ids.contains(&edge.id) {
731                    return false;
732                }
733                let Ok(a) = edge.curve.evaluate(edge.t0) else {
734                    return false;
735                };
736                let Ok(b) = edge.curve.evaluate(edge.t1) else {
737                    return false;
738                };
739                (a.sub(*corner).length() < 1e-6 || b.sub(*corner).length() < 1e-6)
740                    && analyze_edge(solid, edge.id, radius)
741                        .map(|cross| !cross.convex)
742                        .unwrap_or(false)
743            });
744            if !has_concave_unselected_edge {
745                continue;
746            }
747            let corner_name = corner_face_name(edge_names, name, adjacent);
748            if let Ok(rounded) = crate::blend::round_concave_chain_corner(
749                &result,
750                solid,
751                *corner,
752                [
753                    selected_edge_ids[adjacent[0]],
754                    selected_edge_ids[adjacent[1]],
755                ],
756                radius,
757                corner_name.as_deref(),
758            ) {
759                result = rounded;
760            }
761        }
762    }
763
764    // 3. Round the convex corners (fillets only — chamfers keep sharp
765    //    vertices).  A corner that cannot be rounded is left as the edge
766    //    fillets so the group still succeeds.  Each corner patch is named after
767    //    the selected edges meeting there (`{fid}:BLEND:CORNER:{e_a}+…`) so no
768    //    two corners collide and the patch stays under the `{fid}:BLEND` prefix.
769    if !chamfer {
770        for (ci, corner) in corners.iter().enumerate() {
771            let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
772            if let Ok(rounded) =
773                crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref())
774            {
775                result = rounded;
776            }
777        }
778    }
779
780    // Heal any residual vertex/edge gaps introduced by the corner-rounding
781    // surgery (the per-edge results are already healed inside fillet_or_chamfer).
782    heal_edge_vertex_gaps(&mut result, radius)?;
783    Ok(result)
784}
785
786/// Variable-radius fillet/chamfer of a GROUP of edges (§4.9.5), the app entry
787/// for tapered blends: each selected edge (resolved by a point on it) is
788/// blended with the SAME radius profile `radii` — a list of (edge-fraction,
789/// radius) stops in [0,1] — applied along that edge's own parameterization.
790/// Edges are blended independently (no shared-vertex corner rounding; a
791/// variable-radius star has no single tangent ball), so this is the tapered
792/// counterpart of `fillet_edges` for the constant case.
793pub fn fillet_edges_variable(
794    solid: &BrepSolid,
795    edge_points: &[Vec3],
796    edge_names: Option<&[String]>,
797    radii: &[(f64, f64)],
798    chamfer: bool,
799    name: Option<&str>,
800) -> Result<BrepSolid, String> {
801    if edge_points.is_empty() {
802        return Err("fillet_edges_variable: no edges selected".into());
803    }
804    let per_edge_stops: Vec<Vec<(f64, f64)>> = vec![radii.to_vec(); edge_points.len()];
805    fillet_edges_variable_impl(
806        solid,
807        edge_points,
808        edge_names,
809        &per_edge_stops,
810        chamfer,
811        name,
812        "fillet_edges_variable",
813        true,
814    )
815}
816
817/// The shared variable-radius group core: each selected edge `i` is blended
818/// with ITS OWN stop list `per_edge_stops[i]` (the legacy entry replicates one
819/// list; the law entries sample a chain-abscissa [`crate::law::RadiusLaw`] per
820/// edge).  `allow_tapered_sequential` keeps the legacy entry's sequential
821/// fallback for tapered chains (whose end state is the honest
822/// "mismatched radii" validation gate); the law entries pass `false` because
823/// their stop fractions are computed against the ORIGINAL edges — the
824/// sequential build re-resolves edges on the evolving solid whose shared
825/// corners are already TRIMMED by earlier blends, which would silently distort
826/// the law's abscissa mapping (constant stops are immune, so they may still
827/// fall back).
828#[allow(clippy::too_many_arguments)]
829fn fillet_edges_variable_impl(
830    solid: &BrepSolid,
831    edge_points: &[Vec3],
832    edge_names: Option<&[String]>,
833    per_edge_stops: &[Vec<(f64, f64)>],
834    chamfer: bool,
835    name: Option<&str>,
836    entry: &str,
837    allow_tapered_sequential: bool,
838) -> Result<BrepSolid, String> {
839    debug_assert_eq!(per_edge_stops.len(), edge_points.len());
840    // A law that is one constant everywhere IS the constant-radius fillet:
841    // hand it to the constant group, whose corners are constructed (the
842    // stripe network) rather than composed by boolean.
843    if let Some(constant) = per_edge_stops
844        .first()
845        .and_then(|stops| stops.first())
846        .map(|(_, radius)| *radius)
847    {
848        let uniform = per_edge_stops.iter().all(|stops| {
849            stops
850                .iter()
851                .all(|(_, radius)| (radius - constant).abs() <= 1e-12 * (1.0 + constant.abs()))
852        });
853        if uniform && constant > 0.0 {
854            return fillet_edges(solid, edge_points, edge_names, constant, chamfer, name);
855        }
856    }
857    let max_radius = per_edge_stops
858        .iter()
859        .flat_map(|stops| stops.iter())
860        .map(|(_, r)| r.abs())
861        .fold(0.0_f64, f64::max);
862
863    // Chain corners miter exactly like the constant-radius group (§6.9.6):
864    // per-edge blends on the ORIGINAL solid composed by boolean — Intersect
865    // when every edge is convex, Union when every edge is concave.  Mixed or
866    // unclassifiable selections keep the sequential build.  Variable blends
867    // never round star vertices, so unlike the constant group there is no
868    // sequential-only star path to protect.
869    let mut chain_corner = false;
870    {
871        use rustc_hash::FxHashSet as HashSet;
872        let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
873        for (i, point) in edge_points.iter().enumerate() {
874            if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
875                if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
876                    if let (Ok(a), Ok(b)) =
877                        (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
878                    {
879                        endpoints.push((a, i));
880                        endpoints.push((b, i));
881                    }
882                }
883            }
884        }
885        let mut used = vec![false; endpoints.len()];
886        for i in 0..endpoints.len() {
887            if used[i] {
888                continue;
889            }
890            used[i] = true;
891            let mut edges_here: HashSet<usize> = HashSet::default();
892            edges_here.insert(endpoints[i].1);
893            for j in (i + 1)..endpoints.len() {
894                if used[j] {
895                    continue;
896                }
897                if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
898                    used[j] = true;
899                    edges_here.insert(endpoints[j].1);
900                }
901            }
902            if edges_here.len() == 2 {
903                chain_corner = true;
904            }
905        }
906    }
907    let miter_operation = if chain_corner {
908        let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
909        let mut any_convex = false;
910        let mut any_concave = false;
911        for point in edge_points {
912            match resolve_edge_by_point(solid, *point)
913                .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
914            {
915                Ok(cross) if cross.convex => any_convex = true,
916                Ok(_) => any_concave = true,
917                Err(_) => {
918                    any_convex = true;
919                    any_concave = true;
920                    break;
921                }
922            }
923        }
924        match (any_convex, any_concave) {
925            (true, false) => Some(crate::BooleanOperation::Intersect),
926            (false, true) => Some(crate::BooleanOperation::Union),
927            _ => None,
928        }
929    } else {
930        None
931    };
932
933    // Try the miter first; the variable blend's FITTED boundary curves are
934    // only ~1e-3 accurate at blend-blend tangencies (unlike the exact
935    // constant-radius cylinders), so the composition can fail — fall back to
936    // the sequential build then, which is never worse than the pre-miter
937    // behavior.  Tightening the taper surface's endpoint fitting is the
938    // documented follow-up that would make the miter stick.
939    let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
940        let options = crate::BooleanOptions::default();
941        let mut combined: Option<BrepSolid> = None;
942        let mut failed = false;
943        for (index, point) in edge_points.iter().enumerate() {
944            let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
945                failed = true;
946                break;
947            };
948            let edge_name = per_edge_name(edge_names, name, index);
949            let Ok(blended) = crate::blend::blend_edge_variable(
950                solid,
951                edge_id,
952                &per_edge_stops[index],
953                chamfer,
954                edge_name,
955            ) else {
956                failed = true;
957                break;
958            };
959            let next = match combined.take() {
960                None => blended,
961                Some(previous) => {
962                    match crate::boolean_operation(&previous, &blended, operation, &options) {
963                        Ok(next) => next,
964                        Err(_) => {
965                            failed = true;
966                            break;
967                        }
968                    }
969                }
970            };
971            combined = Some(next);
972        }
973        if failed {
974            None
975        } else {
976            combined.filter(|s| s.validate().is_empty())
977        }
978    } else {
979        None
980    };
981    let mut result = match miter_attempt {
982        Some(mitered) => mitered,
983        None => {
984            // Any stop list that is NOT radius-uniform (the same 1e-12
985            // relative criterion `blend_edge_variable` uses for its exact
986            // constant-radius degeneration) makes the sequential build
987            // abscissa-distorting on trimmed chain edges; law entries refuse
988            // instead of silently shifting the law.
989            let tapered = per_edge_stops.iter().any(|stops| {
990                stops.first().is_some_and(|(_, first)| {
991                    stops
992                        .iter()
993                        .any(|(_, r)| (r - first).abs() > 1e-12 * (1.0 + first.abs()))
994                })
995            });
996            if chain_corner && tapered && !allow_tapered_sequential {
997                return Err(format!(
998                    "{entry}: the tapered blends across the selected chain's shared corners \
999                     did not compose to a valid solid (the fitted blend boundaries could not \
1000                     be mitered); fillet fewer edges per operation or reduce the taper"
1001                ));
1002            }
1003            let mut sequential = solid.clone();
1004            for (index, point) in edge_points.iter().enumerate() {
1005                let edge_id = resolve_edge_by_point(&sequential, *point)?;
1006                let edge_name = per_edge_name(edge_names, name, index);
1007                sequential = crate::blend::blend_edge_variable(
1008                    &sequential,
1009                    edge_id,
1010                    &per_edge_stops[index],
1011                    chamfer,
1012                    edge_name,
1013                )?;
1014            }
1015            sequential
1016        }
1017    };
1018    // Heal §6.9 surgery so re-trimmed edges meet their vertices exactly; scale
1019    // the heal bound by the largest radius stop in the taper profile.
1020    heal_edge_vertex_gaps(&mut result, max_radius)?;
1021    // Final honesty gate: a genuinely TAPERED chain (different radii at the
1022    // shared corner) has mismatched trim stations there — the blends cannot
1023    // meet without a transition patch (not implemented), and the sequential
1024    // surgery silently left broken topology before this gate existed.
1025    let issues = result.validate();
1026    if !issues.is_empty() {
1027        let detail = if allow_tapered_sequential {
1028            "tapered blends meet at a shared chain vertex with \
1029             mismatched radii — the radius-transition corner patch is not implemented; \
1030             fillet the edges in separate operations or use matching stop radii"
1031        } else {
1032            "the composed radius-law blend produced invalid topology — the blends \
1033             across a shared chain corner failed to reassemble"
1034        };
1035        return Err(format!(
1036            "{entry}: {detail} ({} validation issues, first: {})",
1037            issues.len(),
1038            issues
1039                .first()
1040                .map(|issue| issue.message.clone())
1041                .unwrap_or_default()
1042        ));
1043    }
1044    Ok(result)
1045}
1046
1047/// One selected edge placed on the ordered chain, with its arc-length
1048/// parameterization (the caller-side mapping from chain abscissa to the
1049/// `blend_edge_variable` per-edge parameter-fraction stop seam).
1050struct ChainLink {
1051    /// Position of this edge in the caller's `edge_points` selection.
1052    input_index: usize,
1053    /// True when the edge's own t0→t1 parameter direction runs WITH the chain.
1054    forward: bool,
1055    /// Cumulative chord-length table from the edge's t0 end:
1056    /// `(parameter fraction, arc length)`, uniformly spaced in fraction.
1057    arc: Vec<(f64, f64)>,
1058    /// Total arc length of the edge.
1059    length: f64,
1060    /// Chain abscissa at the link's ENTRY vertex (the end reached first when
1061    /// walking the chain from its start).
1062    abscissa: f64,
1063}
1064
1065/// Cumulative chord-length table of one edge, `(parameter fraction, arc
1066/// length)` at uniform fractions.  The sample count doubles until two
1067/// successive total-length estimates agree within `tol` (chord length
1068/// converges O(N⁻²) for smooth curves, so the agreement of the N and 2N
1069/// estimates bounds the remaining error at the same order); straight edges
1070/// converge on the first doubling.  The 16-sample start resolves any
1071/// single-span arc of up to half a turn to sub-percent before refinement; the
1072/// 4096 cap (8 doublings) guards adversarial curves — beyond it the table is
1073/// two orders denser than the blend march's station grid, so finer chords
1074/// cannot move any station's sampled radius meaningfully.
1075fn edge_arc_table(
1076    curve: &NurbsCurve,
1077    t0: f64,
1078    t1: f64,
1079    tol: f64,
1080) -> Result<(Vec<(f64, f64)>, f64), String> {
1081    let build = |n: usize| -> Result<(Vec<(f64, f64)>, f64), String> {
1082        let mut table = Vec::with_capacity(n + 1);
1083        let mut cumulative = 0.0;
1084        let mut previous = curve.evaluate(t0)?;
1085        table.push((0.0, 0.0));
1086        for j in 1..=n {
1087            let fraction = j as f64 / n as f64;
1088            let point = curve.evaluate(t0 + (t1 - t0) * fraction)?;
1089            cumulative += point.sub(previous).length();
1090            previous = point;
1091            table.push((fraction, cumulative));
1092        }
1093        Ok((table, cumulative))
1094    };
1095    let mut n = 16usize;
1096    let (mut table, mut length) = build(n)?;
1097    while n < 4096 {
1098        n *= 2;
1099        let (next_table, next_length) = build(n)?;
1100        let converged = (next_length - length).abs() <= tol;
1101        table = next_table;
1102        length = next_length;
1103        if converged {
1104            break;
1105        }
1106    }
1107    Ok((table, length))
1108}
1109
1110/// Arc length from the edge's t0 end at `fraction` of its parameter span,
1111/// linearly interpolated in the uniform chord table.
1112fn arc_length_at_fraction(table: &[(f64, f64)], fraction: f64) -> f64 {
1113    let fraction = fraction.clamp(0.0, 1.0);
1114    let intervals = table.len() - 1;
1115    let scaled = fraction * intervals as f64;
1116    let index = (scaled.floor() as usize).min(intervals - 1);
1117    let local = scaled - index as f64;
1118    let (_, a) = table[index];
1119    let (_, b) = table[index + 1];
1120    a + (b - a) * local
1121}
1122
1123/// Resolve the selected edges and order them into ONE OPEN CHAIN with
1124/// cumulative arc-length abscissas.  The chain starts at the free endpoint
1125/// belonging to the EARLIEST-selected end edge (so users get the natural
1126/// "first pick carries the law start" orientation); a single selected edge is
1127/// its own chain oriented t0→t1 (which also admits a closed edge — the law's
1128/// end radii must then match, enforced downstream by `blend_edge_variable`).
1129/// Branching (a vertex shared by 3+ selected edges), closed rings of several
1130/// edges, and disconnected selections refuse with named errors.
1131fn resolve_selected_chain(
1132    solid: &BrepSolid,
1133    edge_points: &[Vec3],
1134    entry: &str,
1135    tol: f64,
1136) -> Result<Vec<ChainLink>, String> {
1137    // Endpoint identity uses the kernel-wide COINCIDENCE_DISTANCE_FLOOR — the
1138    // same band the constant-radius group's corner detector applies.
1139    let band = crate::tolerance::COINCIDENCE_DISTANCE_FLOOR;
1140
1141    struct Resolved {
1142        edge_id: u64,
1143        start: Vec3,
1144        end: Vec3,
1145        arc: Vec<(f64, f64)>,
1146        length: f64,
1147    }
1148    let mut resolved: Vec<Resolved> = Vec::with_capacity(edge_points.len());
1149    for point in edge_points {
1150        let edge_id = resolve_edge_by_point(solid, *point)?;
1151        if resolved.iter().any(|r| r.edge_id == edge_id) {
1152            return Err(format!("{entry}: the same edge was selected more than once"));
1153        }
1154        let edge = solid
1155            .edges
1156            .iter()
1157            .find(|e| e.id == edge_id)
1158            .ok_or_else(|| format!("{entry}: resolved edge vanished"))?;
1159        let (arc, length) = edge_arc_table(&edge.curve, edge.t0, edge.t1, tol)?;
1160        if !(length > 0.0) {
1161            return Err(format!("{entry}: selected edge has zero length"));
1162        }
1163        resolved.push(Resolved {
1164            edge_id,
1165            start: edge.curve.evaluate(edge.t0)?,
1166            end: edge.curve.evaluate(edge.t1)?,
1167            arc,
1168            length,
1169        });
1170    }
1171    let n = resolved.len();
1172    if n == 1 {
1173        let only = resolved.remove(0);
1174        return Ok(vec![ChainLink {
1175            input_index: 0,
1176            forward: true,
1177            arc: only.arc,
1178            length: only.length,
1179            abscissa: 0.0,
1180        }]);
1181    }
1182
1183    // Cluster the 2n endpoints; each entry is (edge index, is_start_end).
1184    let mut clusters: Vec<(Vec3, Vec<(usize, bool)>)> = Vec::new();
1185    for (i, r) in resolved.iter().enumerate() {
1186        for (point, is_start) in [(r.start, true), (r.end, false)] {
1187            match clusters
1188                .iter_mut()
1189                .find(|(anchor, _)| anchor.sub(point).length() < band)
1190            {
1191                Some((_, members)) => members.push((i, is_start)),
1192                None => clusters.push((point, vec![(i, is_start)])),
1193            }
1194        }
1195    }
1196    if clusters.iter().any(|(_, members)| members.len() > 2) {
1197        return Err(format!(
1198            "{entry}: selected edges must form one open chain \
1199             (a vertex is shared by three or more selected edges)"
1200        ));
1201    }
1202    let free: Vec<usize> = clusters
1203        .iter()
1204        .enumerate()
1205        .filter(|(_, (_, members))| members.len() == 1)
1206        .map(|(c, _)| c)
1207        .collect();
1208    if free.len() != 2 {
1209        return Err(format!(
1210            "{entry}: selected edges must form one OPEN chain \
1211             (closed rings and disconnected selections are not supported)"
1212        ));
1213    }
1214    // Start at the free end whose edge appears EARLIEST in the selection.
1215    let start_cluster = *free
1216        .iter()
1217        .min_by_key(|&&c| clusters[c].1[0].0)
1218        .expect("two free ends");
1219
1220    // Walk the chain.
1221    let mut links: Vec<ChainLink> = Vec::with_capacity(n);
1222    let mut visited = vec![false; n];
1223    let mut abscissa = 0.0_f64;
1224    let mut cluster = start_cluster;
1225    for _ in 0..n {
1226        let Some(&(edge_index, entered_at_start)) = clusters[cluster]
1227            .1
1228            .iter()
1229            .find(|(edge_index, _)| !visited[*edge_index])
1230        else {
1231            return Err(format!(
1232                "{entry}: selected edges are not connected into one chain"
1233            ));
1234        };
1235        visited[edge_index] = true;
1236        let r = &resolved[edge_index];
1237        links.push(ChainLink {
1238            input_index: edge_index,
1239            forward: entered_at_start,
1240            arc: r.arc.clone(),
1241            length: r.length,
1242            abscissa,
1243        });
1244        abscissa += r.length;
1245        let exit_point = if entered_at_start { r.end } else { r.start };
1246        cluster = clusters
1247            .iter()
1248            .position(|(anchor, _)| anchor.sub(exit_point).length() < band)
1249            .ok_or_else(|| format!("{entry}: chain walk lost an endpoint cluster"))?;
1250    }
1251    if visited.iter().any(|v| !v) {
1252        return Err(format!(
1253            "{entry}: selected edges are not connected into one chain"
1254        ));
1255    }
1256    Ok(links)
1257}
1258
1259/// Sample the law into one edge's `(parameter fraction, radius)` stop list —
1260/// the caller-side bridge from chain abscissa into the `radius_at` closure
1261/// seam that `blend_edge_variable` builds over its stops.
1262///
1263/// `scale` maps chain abscissa into the law's own abscissa units
1264/// (`law.total_length() / chain_length` — proportional, so a law built from
1265/// the measured chain lengths maps 1:1).  The base stop count derives from
1266/// the law's curvature: piecewise-linear sampling of a C1, piecewise-C2
1267/// function over step `h` errs at most `h²·max|r''|/8`, so
1268/// `h = sqrt(8·tol/max|r''|)` holds the sampling error under the SSI fit
1269/// tolerance the variable lane is built to.  That bound is exact for
1270/// arc-length-linear (straight) edges; curved edges bend the
1271/// parameter→abscissa map, so each interval is additionally midpoint-checked
1272/// against the law and bisected on violation (up to 8 halvings — a 4⁸ ≈ 6·10⁴
1273/// error reduction, decisive for any C1 law).  The base count is capped at
1274/// 256 intervals: 4× the blend march's 64-station density
1275/// (blend/stations.rs), beyond which denser stops cannot move any station's
1276/// sampled radius by more than the fit tolerance.
1277fn law_stops_for_link(
1278    link: &ChainLink,
1279    law: &crate::law::RadiusLaw,
1280    scale: f64,
1281    tol: f64,
1282) -> Vec<(f64, f64)> {
1283    let radius_at_fraction = |fraction: f64| -> f64 {
1284        let arc = arc_length_at_fraction(&link.arc, fraction);
1285        let chain_s = if link.forward {
1286            link.abscissa + arc
1287        } else {
1288            link.abscissa + (link.length - arc)
1289        };
1290        law.radius_at(chain_s * scale)
1291    };
1292    // Curvature of the law in edge-fraction units: d²r/df² ≤
1293    // max|r''|·(scale·length)² for the arc-length-linear map.
1294    let curvature = law
1295        .max_second_derivative(link.abscissa * scale, (link.abscissa + link.length) * scale)
1296        * (scale * link.length).powi(2);
1297    let base = if curvature * 0.125 <= tol {
1298        1usize
1299    } else {
1300        ((curvature / (8.0 * tol)).sqrt().ceil() as usize).clamp(1, 256)
1301    };
1302    let mut stops: Vec<(f64, f64)> = (0..=base)
1303        .map(|j| {
1304            let fraction = j as f64 / base as f64;
1305            (fraction, radius_at_fraction(fraction))
1306        })
1307        .collect();
1308    // Midpoint refinement for curved parameter→abscissa maps.
1309    let mut depth = 0usize;
1310    while depth < 8 {
1311        let mut refined: Vec<(f64, f64)> = Vec::with_capacity(stops.len());
1312        let mut inserted = false;
1313        for pair in stops.windows(2) {
1314            refined.push(pair[0]);
1315            let mid_fraction = 0.5 * (pair[0].0 + pair[1].0);
1316            let law_mid = radius_at_fraction(mid_fraction);
1317            let linear_mid = 0.5 * (pair[0].1 + pair[1].1);
1318            if (law_mid - linear_mid).abs() > tol {
1319                refined.push((mid_fraction, law_mid));
1320                inserted = true;
1321            }
1322        }
1323        refined.push(*stops.last().expect("at least two stops"));
1324        stops = refined;
1325        if !inserted {
1326            break;
1327        }
1328        depth += 1;
1329    }
1330    stops
1331}
1332
1333/// Fillet (or chamfer) a chain of edges under a composable radius law
1334/// evaluated on the chain's cumulative arc-length abscissa (the OCCT
1335/// `Law_Composite` model; see [`crate::law::RadiusLaw`]).  The selected edges
1336/// must form ONE OPEN CHAIN (or be a single edge); the chain starts at the
1337/// free end of the earliest-selected end edge, and the law's abscissa maps
1338/// proportionally onto the chain's measured arc length (a law built with the
1339/// chain's own lengths — e.g. [`crate::law::RadiusLaw::from_vertex_radii`] —
1340/// maps 1:1).  Endpoint radii are met exactly; radii at shared chain vertices
1341/// match by the law's continuity, so the per-edge blends miter through the
1342/// corners; a chain whose blends cannot be mitered REFUSES rather than
1343/// distorting the law through the sequential rebuild.
1344pub fn fillet_edges_variable_law(
1345    solid: &BrepSolid,
1346    edge_points: &[Vec3],
1347    edge_names: Option<&[String]>,
1348    law: &crate::law::RadiusLaw,
1349    chamfer: bool,
1350    name: Option<&str>,
1351) -> Result<BrepSolid, String> {
1352    const ENTRY: &str = "fillet_edges_variable_law";
1353    if edge_points.is_empty() {
1354        return Err(format!("{ENTRY}: no edges selected"));
1355    }
1356    let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1357    let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
1358    fillet_variable_law_on_chain(
1359        solid,
1360        edge_points,
1361        edge_names,
1362        &chain,
1363        law,
1364        chamfer,
1365        name,
1366        ENTRY,
1367        tolerances.intersection_fit,
1368    )
1369}
1370
1371/// The natural per-vertex user model: radius `vertex_radii[i]` at chain
1372/// vertex `i` (in CHAIN order, starting at the free end of the
1373/// earliest-selected edge), smoothly interpolated along the chain
1374/// (monotone C1 — every vertex radius met exactly, no overshoot).  Requires
1375/// exactly one radius per chain vertex (`edges + 1`).
1376pub fn fillet_edges_variable_vertex_radii(
1377    solid: &BrepSolid,
1378    edge_points: &[Vec3],
1379    edge_names: Option<&[String]>,
1380    vertex_radii: &[f64],
1381    chamfer: bool,
1382    name: Option<&str>,
1383) -> Result<BrepSolid, String> {
1384    const ENTRY: &str = "fillet_edges_variable_vertex_radii";
1385    if edge_points.is_empty() {
1386        return Err(format!("{ENTRY}: no edges selected"));
1387    }
1388    let tolerances = crate::KernelTolerances::for_solid(solid, 1e-7);
1389    let chain = resolve_selected_chain(solid, edge_points, ENTRY, tolerances.intersection_fit)?;
1390    let lengths: Vec<f64> = chain.iter().map(|link| link.length).collect();
1391    let law = crate::law::RadiusLaw::from_vertex_radii(&lengths, vertex_radii)
1392        .map_err(|error| format!("{ENTRY}: {error}"))?;
1393    fillet_variable_law_on_chain(
1394        solid,
1395        edge_points,
1396        edge_names,
1397        &chain,
1398        &law,
1399        chamfer,
1400        name,
1401        ENTRY,
1402        tolerances.intersection_fit,
1403    )
1404}
1405
1406/// Shared law-entry tail: sample per-edge stop lists from the law over the
1407/// resolved chain and run the variable group core (miter-or-refuse: no
1408/// sequential fallback for tapered chains — see `fillet_edges_variable_impl`).
1409#[allow(clippy::too_many_arguments)]
1410fn fillet_variable_law_on_chain(
1411    solid: &BrepSolid,
1412    edge_points: &[Vec3],
1413    edge_names: Option<&[String]>,
1414    chain: &[ChainLink],
1415    law: &crate::law::RadiusLaw,
1416    chamfer: bool,
1417    name: Option<&str>,
1418    entry: &str,
1419    tol: f64,
1420) -> Result<BrepSolid, String> {
1421    let chain_length: f64 = chain.iter().map(|link| link.length).sum();
1422    let scale = law.total_length() / chain_length;
1423    let mut per_edge_stops: Vec<Vec<(f64, f64)>> = vec![Vec::new(); edge_points.len()];
1424    for link in chain {
1425        per_edge_stops[link.input_index] = law_stops_for_link(link, law, scale, tol);
1426    }
1427    fillet_edges_variable_impl(
1428        solid,
1429        edge_points,
1430        edge_names,
1431        &per_edge_stops,
1432        chamfer,
1433        name,
1434        entry,
1435        false,
1436    )
1437}
1438
1439/// Asymmetric (two-distance) chamfer of a GROUP of edges, the app entry: each
1440/// selected edge (resolved by a point on it) gets a `d1 × d2` bevel (§6.11).
1441/// Edges are chamfered independently — asymmetric chamfers keep sharp vertices,
1442/// so there is no shared-corner blending.
1443pub fn chamfer_edges_asymmetric(
1444    solid: &BrepSolid,
1445    edge_points: &[Vec3],
1446    edge_names: Option<&[String]>,
1447    d1: f64,
1448    d2: f64,
1449    name: Option<&str>,
1450) -> Result<BrepSolid, String> {
1451    if edge_points.is_empty() {
1452        return Err("chamfer_edges_asymmetric: no edges selected".into());
1453    }
1454    let mut result = solid.clone();
1455    // `edge_names[index]` is keyed by INPUT position; the loop resolves each
1456    // point on the EVOLVING `result`, but enumerates the input points in order,
1457    // so the index alignment holds.
1458    for (index, point) in edge_points.iter().enumerate() {
1459        let edge_id = resolve_edge_by_point(&result, *point)?;
1460        let edge_name = per_edge_name(edge_names, name, index);
1461        result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
1462    }
1463    Ok(result)
1464}
1465
1466/// Distance-angle chamfer of a GROUP of edges, the app entry: each selected
1467/// edge (resolved by a point on it) gets a setback `d1` on face 1 and a chamfer
1468/// face at `angle_rad` from face 1 (§6.11); `d2` is constructed per edge.
1469pub fn chamfer_edges_angle(
1470    solid: &BrepSolid,
1471    edge_points: &[Vec3],
1472    edge_names: Option<&[String]>,
1473    d1: f64,
1474    angle_rad: f64,
1475    name: Option<&str>,
1476) -> Result<BrepSolid, String> {
1477    if edge_points.is_empty() {
1478        return Err("chamfer_edges_angle: no edges selected".into());
1479    }
1480    let mut result = solid.clone();
1481    for (index, point) in edge_points.iter().enumerate() {
1482        let edge_id = resolve_edge_by_point(&result, *point)?;
1483        let edge_name = per_edge_name(edge_names, name, index);
1484        result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
1485    }
1486    Ok(result)
1487}