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