Skip to main content

brep_kernel/blending/fillet/
edges.rs

1use super::*;
2
3/// Constant-radius rolling-ball fillet of one straight edge between two
4/// planar faces (Golovanov §6.9 cross-section; boolean topology surgery).
5pub fn fillet_edge(
6    solid: &BrepSolid,
7    edge_id: u64,
8    radius: f64,
9    name: Option<&str>,
10) -> Result<BrepSolid, String> {
11    fillet_or_chamfer(solid, edge_id, radius, false, name, ToolEnds::default())
12}
13
14/// Equal-leg chamfer of one straight edge between two planar faces
15/// (Golovanov §6.11: the same construction with the arc's chord).
16pub fn chamfer_edge(
17    solid: &BrepSolid,
18    edge_id: u64,
19    distance: f64,
20    name: Option<&str>,
21) -> Result<BrepSolid, String> {
22    fillet_or_chamfer(solid, edge_id, distance, true, name, ToolEnds::default())
23}
24
25/// Build and apply the chamfer tool for one straight edge from an already-built
26/// cross-section profile (curve index 1 is the chamfer chord / blend wall).
27/// Shared by the two-distance and distance-angle asymmetric entries.
28fn apply_chamfer_offsets_profile(
29    solid: &BrepSolid,
30    cross: &EdgeCross,
31    profile: &[NurbsCurve],
32    name: Option<&str>,
33) -> Result<BrepSolid, String> {
34    let mut tool = match &cross.path {
35        EdgePath::Straight { direction, length } => {
36            extrude_profile_brep(profile, *direction, *length)?
37        }
38        EdgePath::Circular { .. } => {
39            return Err(
40                "chamfer_edge_asymmetric: only straight edges on planar faces are supported \
41                 in this slice (asymmetric chamfer on general/curved edges is out of scope)"
42                    .into(),
43            );
44        }
45    };
46    if let Some(name) = name {
47        // Side faces are emitted in input-curve order; the chamfer wall is the
48        // second profile curve (index 1).
49        let mut side_index = 0usize;
50        for shell in &mut tool.shells {
51            for face in &mut shell.faces {
52                if side_index == 1 && face.name.is_none() {
53                    face.name = Some(name.to_string());
54                }
55                side_index += 1;
56                if side_index >= profile.len() {
57                    break;
58                }
59            }
60        }
61    }
62    apply_tool(solid, &tool, cross.convex)
63}
64
65/// Asymmetric (two-distance) chamfer of one STRAIGHT edge between two planar
66/// faces (Golovanov §6.11): setback `d1` along face 1 and `d2` along face 2 —
67/// the standard CAD "d1 × d2" bevel.  General/curved edges are out of scope for
68/// this slice and return a clear error.
69pub fn chamfer_edge_asymmetric(
70    solid: &BrepSolid,
71    edge_id: u64,
72    d1: f64,
73    d2: f64,
74    name: Option<&str>,
75) -> Result<BrepSolid, String> {
76    if !(d1 > 0.0) || !(d2 > 0.0) || !d1.is_finite() || !d2.is_finite() {
77        return Err("chamfer_edge_asymmetric: both setback distances must be positive".into());
78    }
79    // `analyze_edge` only uses the radius to size the orientation probe step;
80    // the smaller setback keeps that probe inside both faces.
81    let cross = analyze_edge(solid, edge_id, d1.min(d2))?;
82    let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
83    apply_chamfer_offsets_profile(solid, &cross, &profile, name)
84}
85
86/// Distance-angle chamfer of one STRAIGHT edge between two planar faces
87/// (Golovanov §6.11): setback `d1` along face 1 and angle `angle_rad` between
88/// the chamfer face and face 1.  `d2` is constructed geometrically in the
89/// cross-section plane (see `chamfer_angle_second_distance`), then the
90/// two-distance builder is applied.
91pub fn chamfer_edge_angle(
92    solid: &BrepSolid,
93    edge_id: u64,
94    d1: f64,
95    angle_rad: f64,
96    name: Option<&str>,
97) -> Result<BrepSolid, String> {
98    if !(d1 > 0.0) || !d1.is_finite() {
99        return Err("chamfer_edge_angle: setback distance d1 must be positive".into());
100    }
101    let cross = analyze_edge(solid, edge_id, d1)?;
102    let d2 = chamfer_angle_second_distance(&cross, d1, angle_rad)?;
103    let profile = chamfer_cross_section_offsets(&cross, d1, d2)?;
104    apply_chamfer_offsets_profile(solid, &cross, &profile, name)
105}
106
107/// Heal §6.9 fillet/chamfer surgery output so every edge curve endpoint sits
108/// exactly on its vertex.
109///
110/// The direct §6.9 surgery re-uses trimmed original edges (e.g. the straight
111/// side edges meeting a filleted edge at a corner) whose endpoints are computed
112/// by a separate trim/intersection from the freshly-built blend spring/contact
113/// curves that define the shared corner vertex.  When the input itself carries
114/// solver noise (a sketch whose "equal" points sit ~5e-5 apart), that noise is
115/// amplified by the surface–surface intersections to ~1e-4 gaps between the
116/// re-trimmed edge's endpoint and the corner vertex — a hair non-watertight,
117/// enough to trip the topology validator's 1e-5 vertex band.
118///
119/// This mirrors the boolean assembler's endpoint weld
120/// (`commit_nearby_edge_endpoints`): snap the CURVE endpoints exactly onto the
121/// vertex, and ONLY for endpoints that fall outside the validator band (so a
122/// clean, already-watertight blend is left byte-identical), and ONLY within a
123/// bound derived from the blend radius (`radius * 1e-3`, floored at the proven
124/// 1e-4 boolean-weld radius) — far below the radius and feature size, so a
125/// genuine modeling gap is never masked.
126pub(super) fn heal_edge_vertex_gaps(solid: &mut BrepSolid, radius: f64) -> Result<(), String> {
127    // commit_nearby_edge_endpoints skips edges whose endpoints are already
128    // inside the 1e-5 validator band and rejects gaps beyond `search.max(1e-4)`;
129    // a radius-scaled search adds headroom for noisier inputs while staying
130    // tiny relative to the blend (0.1% of radius) — clean outputs are untouched.
131    let search = (radius.abs() * 1e-3).max(1e-7);
132    crate::boolean::commit_nearby_edge_endpoints(solid, search)
133}
134
135/// Resolve a 3D point that lies ON an edge to that edge's id (ids do not
136/// survive the app-side decode, so callers identify edges geometrically —
137/// the same scheme as the single-edge blend entry).
138fn resolve_edge_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, String> {
139    let mut best: Option<(u64, f64)> = None;
140    for edge in &solid.edges {
141        let Ok(projection) = crate::project_point_to_curve(&edge.curve, point) else {
142            continue;
143        };
144        let clamped = projection
145            .u
146            .clamp(edge.t0.min(edge.t1), edge.t0.max(edge.t1));
147        let Ok(sample) = edge.curve.evaluate(clamped) else {
148            continue;
149        };
150        let distance = sample.sub(point).length();
151        if best.map(|(_, known)| distance < known).unwrap_or(true) {
152            best = Some((edge.id, distance));
153        }
154    }
155    match best {
156        Some((edge_id, distance)) if distance <= 1e-3 => Ok(edge_id),
157        Some((_, distance)) => Err(format!(
158            "fillet_edges: no edge within tolerance of the point (nearest {distance:.6})"
159        )),
160        None => Err("fillet_edges: solid has no edges".into()),
161    }
162}
163
164/// Fillet (or chamfer) a GROUP of edges as ONE operation, and — for fillets —
165/// round the convex "star" vertices where three or more of the selected edges
166/// meet (Golovanov §6.9.7).  This is the whole multi-edge fillet in a single
167/// kernel call: the caller passes the object plus one 3D point on each edge,
168/// and the kernel orchestrates the filleting and corner blending against the
169/// full topology (so acute corners resolve coherently instead of being
170/// stitched edge-by-edge by the app).  A corner the kernel cannot round (e.g.
171/// non-orthogonal beyond support, or a general no-common-ball star) is left as
172/// the edge fillets rather than failing the whole group.
173///
174/// When the WHOLE selection cannot be blended as one unit — a shared convex
175/// corner where a revolve axis/pole edge meets the adjacent cap edges can
176/// defeat the sequential corner surgery even though each edge and every proper
177/// SUBSET of the selection blends cleanly (the three fillets converge on the
178/// pole with no single end face across the corner) — we do NOT hard-reject the
179/// whole selection (which makes the app refuse it outright with "does not yet
180/// support the selected edge geometry").  Instead we blend the LARGEST subset
181/// of the selected edges that yields a VALID solid, dropping only the edge(s)
182/// that cannot co-blend at the corner.  A selection that already composes is
183/// returned unchanged (byte-identical) — the subset search only runs after the
184/// full-group attempt errors.
185///
186/// `edge_names` (when `Some`) is the per-edge blend-FACE name parallel to
187/// `edge_points` — each grown wall is named after ITS originating edge; `None`
188/// names every wall with the single base `name` (the legacy/test behavior,
189/// byte-identical to before). The whole `*_edges` family takes the same
190/// `edge_names` slot in the same position.
191pub fn fillet_edges(
192    solid: &BrepSolid,
193    edge_points: &[Vec3],
194    edge_names: Option<&[String]>,
195    radius: f64,
196    chamfer: bool,
197    name: Option<&str>,
198) -> Result<BrepSolid, String> {
199    if !(radius > 0.0) || !radius.is_finite() {
200        return Err("fillet_edges: radius must be positive".into());
201    }
202    if edge_points.is_empty() {
203        return Err("fillet_edges: no edges selected".into());
204    }
205
206    match fillet_edges_group(solid, edge_points, edge_names, radius, chamfer, name) {
207        Ok(result) => Ok(result),
208        Err(group_err) => {
209            // Fewer than two edges: nothing to drop, so the group error is final.
210            // Cap the combinatorial search so a large malformed selection cannot
211            // explode (the full group carries the common case; the search is a
212            // rare fallback).
213            let n = edge_points.len();
214            if n < 2 || n > 12 {
215                return Err(group_err);
216            }
217            // Drop the fewest edges first (largest surviving subset), trying the
218            // drop-sets in lexicographic order so the result is deterministic.
219            // Return the first subset that blends to a VALID (watertight) solid.
220            for drop in 1..n {
221                for dropped in index_combinations(n, drop) {
222                    let kept: Vec<Vec3> = (0..n)
223                        .filter(|i| !dropped.contains(i))
224                        .map(|i| edge_points[i])
225                        .collect();
226                    // Subset the per-edge blend-face names with the IDENTICAL
227                    // drop-set so `kept_names[k]` still names `kept[k]`.
228                    let kept_names: Option<Vec<String>> = edge_names.map(|names| {
229                        (0..n)
230                            .filter(|i| !dropped.contains(i))
231                            .map(|i| names[i].clone())
232                            .collect()
233                    });
234                    if let Ok(result) = fillet_edges_group(
235                        solid,
236                        &kept,
237                        kept_names.as_deref(),
238                        radius,
239                        chamfer,
240                        name,
241                    ) {
242                        if result.validate().is_empty() {
243                            return Ok(result);
244                        }
245                    }
246                }
247            }
248            Err(group_err)
249        }
250    }
251}
252
253/// The blend-FACE name for the `i`-th selected edge: its per-edge name when the
254/// caller supplied the parallel `edge_names` (feature path — each wall named
255/// after its originating edge, `{fid}:BLEND:{edge}`), else the single base
256/// `name` for every wall (the legacy/test path, byte-identical to before).
257fn per_edge_name<'a>(
258    edge_names: Option<&'a [String]>,
259    base: Option<&'a str>,
260    i: usize,
261) -> Option<&'a str> {
262    match edge_names {
263        Some(names) => names.get(i).map(|value| value.as_str()),
264        None => base,
265    }
266}
267
268/// The star-corner patch name: `{base}:CORNER:{sorted+join of adjacent edge
269/// names}` when per-edge names were supplied (feature path), else the single
270/// `base` (legacy/test path — the corner keeps the wall name, pre-change
271/// behavior).  `base` is `{fid}:BLEND` and each `edge_names[i]` is the composed
272/// `{fid}:BLEND:{edge}`, so stripping the `{base}:` prefix recovers the bare
273/// originating-edge name for the join.  Unique per corner: two distinct corners
274/// never share the same set of >=3 selected edges.
275fn corner_face_name(
276    edge_names: Option<&[String]>,
277    base: Option<&str>,
278    adjacent: &[usize],
279) -> Option<String> {
280    match (edge_names, base) {
281        (Some(names), Some(base)) => {
282            let prefix = format!("{base}:");
283            let mut raws: Vec<&str> = adjacent
284                .iter()
285                .filter_map(|&i| names.get(i))
286                .map(|composed| composed.strip_prefix(&prefix).unwrap_or(composed.as_str()))
287                .collect();
288            raws.sort_unstable();
289            raws.dedup();
290            Some(format!("{base}:CORNER:{}", raws.join("+")))
291        }
292        _ => base.map(|value| value.to_string()),
293    }
294}
295
296/// All ways to choose `k` distinct indices from `0..n`, in lexicographic order.
297fn index_combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
298    let mut out = Vec::new();
299    if k == 0 || k > n {
300        return out;
301    }
302    let mut idx: Vec<usize> = (0..k).collect();
303    loop {
304        out.push(idx.clone());
305        // Advance to the next combination (like counting with carry).
306        let mut i = k;
307        loop {
308            if i == 0 {
309                return out;
310            }
311            i -= 1;
312            if idx[i] != i + n - k {
313                break;
314            }
315        }
316        idx[i] += 1;
317        for j in (i + 1)..k {
318            idx[j] = idx[j - 1] + 1;
319        }
320    }
321}
322
323/// Blend the WHOLE selection as one group (the single-shot multi-edge fillet).
324/// Errors if any selected edge or the shared-corner surgery cannot compose;
325/// `fillet_edges` wraps this with a maximal-valid-subset fallback.
326fn fillet_edges_group(
327    solid: &BrepSolid,
328    edge_points: &[Vec3],
329    edge_names: Option<&[String]>,
330    radius: f64,
331    chamfer: bool,
332    name: Option<&str>,
333) -> Result<BrepSolid, String> {
334    use rustc_hash::FxHashSet as HashSet;
335
336    // Fuse-first operand heal (Lever A) before the multi-edge surgery: snap the
337    // input's near-coincident / off-plane vertices (e.g. a revolve pole apex
338    // sitting a few microns off the axis) to exact and re-anchor incident
339    // edges.  The selected edges are resolved geometrically below, so a
340    // sub-heal_tol vertex move never changes which edges are picked; a clean
341    // input is left byte-identical.
342    let mut healed_input = solid.clone();
343    let heal_policy = crate::KernelTolerances::for_solid(&healed_input, 1e-7);
344    crate::heal::heal_operands(&mut healed_input, &heal_policy)?;
345    let solid = &healed_input;
346
347    // 1. Detect convex corners from the ORIGINAL solid: a point that is an
348    //    endpoint of >=3 of the selected edges (a cube/prism-style vertex).
349    let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
350    // The extent each selected edge has BEFORE any blend trims it, so the
351    // sequential build can run every cutter through the shared corners.
352    let mut original_extents: Vec<(Vec3, Vec3)> = Vec::with_capacity(edge_points.len());
353    for (i, point) in edge_points.iter().enumerate() {
354        let edge_id = resolve_edge_by_point(solid, *point)?;
355        let edge = solid
356            .edges
357            .iter()
358            .find(|e| e.id == edge_id)
359            .ok_or("fillet_edges: resolved edge vanished")?;
360        let (start, end) = (edge.curve.evaluate(edge.t0)?, edge.curve.evaluate(edge.t1)?);
361        endpoints.push((start, i));
362        endpoints.push((end, i));
363        original_extents.push((start, end));
364    }
365    let mut corners: Vec<Vec3> = Vec::new();
366    // The selected-edge INPUT INDICES meeting at each star corner (parallel to
367    // `corners`), sorted — used to name the corner patch after its adjacent
368    // edges (`{fid}:BLEND:CORNER:{e_a}+{e_b}+…`), UNIQUE per corner because no
369    // two distinct corners share the same set of >=3 selected edges.
370    let mut corner_edges: Vec<Vec<usize>> = Vec::new();
371    let mut chain_corner_count = 0usize;
372    let mut used = vec![false; endpoints.len()];
373    for i in 0..endpoints.len() {
374        if used[i] {
375            continue;
376        }
377        used[i] = true;
378        let mut edges_here: HashSet<usize> = HashSet::default();
379        edges_here.insert(endpoints[i].1);
380        for j in (i + 1)..endpoints.len() {
381            if used[j] {
382                continue;
383            }
384            if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
385                used[j] = true;
386                edges_here.insert(endpoints[j].1);
387            }
388        }
389        if edges_here.len() >= 3 {
390            corners.push(endpoints[i].0);
391            let mut adjacent: Vec<usize> = edges_here.into_iter().collect();
392            adjacent.sort_unstable();
393            corner_edges.push(adjacent);
394        } else if edges_here.len() == 2 {
395            chain_corner_count += 1;
396        }
397    }
398
399    // 2. Build the blends.
400    //
401    //    CHAIN corners (§6.9.6 — exactly TWO selected edges share a vertex):
402    //    the sequential build truncates the later blend where it runs into the
403    //    earlier one and closes it with a flat bulkhead across the fillet
404    //    channel — a hard step, not a transition.  For selections containing
405    //    chain corners, blend each edge FULL-LENGTH on the ORIGINAL solid and
406    //    INTERSECT the per-edge results instead: the removal volumes union, so
407    //    adjacent blends run through the shared corner and trim each other
408    //    along their intersection curve — the standard MITER corner, tangent
409    //    to the shared face at the seam's tangency end.  Non-adjacent edges
410    //    are unaffected (their removals are disjoint, intersection ≡
411    //    sequential).
412    //
413    //    Selections without chain corners keep the sequential build unchanged
414    //    (star corners are rounded in step 3 against exactly the sequential
415    //    geometry round_convex_corner was built for).
416    //    CONVEXITY GUARD: a convex blend REMOVES material (fillet = orig −
417    //    cut), a concave blend ADDS it (orig + pad).  Full-length blends
418    //    combine as orig − ∪cuts + ∪pads, so the per-edge results compose by
419    //    INTERSECTION when every edge is convex and by UNION when every edge
420    //    is concave; a mixed selection has no single composition and falls
421    //    back to the sequential build.
422    // Star selections (any >=3 corner) keep the sequential build outright:
423    // round_convex_corner's surgery is built against sequential geometry, and
424    // a mitered star would lose its sphere patch.
425    let miter_operation = if chain_corner_count > 0 && corners.is_empty() {
426        let mut any_convex = false;
427        let mut any_concave = false;
428        for point in edge_points {
429            match resolve_edge_by_point(solid, *point)
430                .and_then(|edge_id| analyze_edge(solid, edge_id, radius))
431            {
432                Ok(cross) if cross.convex => any_convex = true,
433                Ok(_) => any_concave = true,
434                // Unknown edge class: let the sequential path produce its own
435                // (more specific) error or result.
436                Err(_) => {
437                    any_convex = true;
438                    any_concave = true;
439                    break;
440                }
441            }
442        }
443        match (any_convex, any_concave) {
444            (true, false) => Some(crate::BooleanOperation::Intersect),
445            (false, true) => Some(crate::BooleanOperation::Union),
446            _ => None,
447        }
448    } else {
449        None
450    };
451
452    // Fillet/chamfer each edge in turn, resolving its point on the evolving
453    // solid (ids shift as earlier fillets rewrite topology; the midpoint of an
454    // edge is untouched by the corner surgery of the others).  This is the
455    // baseline build used directly for non-miter selections AND as the
456    // fallback when the miter composition below cannot reassemble.
457    let build_sequential = || -> Result<BrepSolid, String> {
458        let mut sequential = solid.clone();
459        for (index, point) in edge_points.iter().enumerate() {
460            let edge_id = resolve_edge_by_point(&sequential, *point)?;
461            // Earlier cutters in this loop TRIM the edges that share a corner
462            // with them; extend this cutter back over what they took so the
463            // two removal volumes union through the corner (§6.9.6 miter)
464            // instead of leaving a wedge of material standing behind a flush
465            // end cap.  Untouched edges get a zero pad and the historical
466            // flush cutter.
467            let ends = tool_ends_to_original_extent(
468                &sequential,
469                edge_id,
470                radius,
471                original_extents[index],
472                &corners,
473            );
474            // This edge's blend wall carries the name of THIS input edge
475            // (`edge_names[index]`); a smooth chain that engulfs several edges
476            // is named after the FIRST such input edge processed here (input
477            // order), the chain's representative.
478            let edge_name = per_edge_name(edge_names, name, index);
479            sequential = fillet_or_chamfer(&sequential, edge_id, radius, chamfer, edge_name, ends)?;
480        }
481        Ok(sequential)
482    };
483
484    let mut result = if let Some(operation) = miter_operation {
485        let options = crate::BooleanOptions::default();
486        let mut combined: Result<Option<BrepSolid>, String> = Ok(None);
487        for (index, point) in edge_points.iter().enumerate() {
488            let edge_id = resolve_edge_by_point(solid, *point)?;
489            let edge_name = per_edge_name(edge_names, name, index);
490            let blended = if chamfer {
491                chamfer_edge(solid, edge_id, radius, edge_name)?
492            } else {
493                fillet_edge(solid, edge_id, radius, edge_name)?
494            };
495            combined = match combined {
496                Err(e) => Err(e),
497                Ok(None) => Ok(Some(blended)),
498                Ok(Some(previous)) => {
499                    crate::boolean_operation(&previous, &blended, operation, &options)
500                        .map(Some)
501                        .map_err(|error| {
502                            format!("fillet_edges: chain-corner miter composition failed: {error}")
503                        })
504                }
505            };
506            if combined.is_err() {
507                break;
508            }
509        }
510        // A §6.9.6 miter (per-edge blends intersected/unioned through the
511        // shared chain corners) can fail to reassemble on faces whose
512        // fragmented boundary does not close — e.g. a planar cap whose ENTIRE
513        // perimeter is selected, where fragment_face reports an "incomplete
514        // run".  Rather than let `fillet_edges` silently DROP a selected edge
515        // to recover a valid subset (the reported defect: not every edge of
516        // the face gets a fillet), fall back to the sequential build, which
517        // blends EVERY selected edge (chain corners get a flat bulkhead
518        // instead of a miter).  Only if that also fails to produce a valid
519        // solid do we surface the miter error so the caller's
520        // maximal-valid-subset search can still run.
521        match combined {
522            Ok(Some(mitered)) => mitered,
523            Ok(None) => return Err("fillet_edges: no edges selected".into()),
524            Err(miter_err) => match build_sequential() {
525                Ok(seq) if seq.validate().is_empty() => seq,
526                _ => return Err(miter_err),
527            },
528        }
529    } else {
530        build_sequential()?
531    };
532
533    // 3. Round the convex corners (fillets only — chamfers keep sharp
534    //    vertices).  A corner that cannot be rounded is left as the edge
535    //    fillets so the group still succeeds.  Each corner patch is named after
536    //    the selected edges meeting there (`{fid}:BLEND:CORNER:{e_a}+…`) so no
537    //    two corners collide and the patch stays under the `{fid}:BLEND` prefix.
538    if !chamfer {
539        for (ci, corner) in corners.iter().enumerate() {
540            let corner_name = corner_face_name(edge_names, name, &corner_edges[ci]);
541            if let Ok(rounded) =
542                crate::blend::round_convex_corner(&result, *corner, radius, corner_name.as_deref())
543            {
544                result = rounded;
545            }
546        }
547    }
548
549    // Heal any residual vertex/edge gaps introduced by the corner-rounding
550    // surgery (the per-edge results are already healed inside fillet_or_chamfer).
551    heal_edge_vertex_gaps(&mut result, radius)?;
552    Ok(result)
553}
554
555/// Variable-radius fillet/chamfer of a GROUP of edges (§4.9.5), the app entry
556/// for tapered blends: each selected edge (resolved by a point on it) is
557/// blended with the SAME radius profile `radii` — a list of (edge-fraction,
558/// radius) stops in [0,1] — applied along that edge's own parameterization.
559/// Edges are blended independently (no shared-vertex corner rounding; a
560/// variable-radius star has no single tangent ball), so this is the tapered
561/// counterpart of `fillet_edges` for the constant case.
562pub fn fillet_edges_variable(
563    solid: &BrepSolid,
564    edge_points: &[Vec3],
565    edge_names: Option<&[String]>,
566    radii: &[(f64, f64)],
567    chamfer: bool,
568    name: Option<&str>,
569) -> Result<BrepSolid, String> {
570    if edge_points.is_empty() {
571        return Err("fillet_edges_variable: no edges selected".into());
572    }
573    let max_radius = radii.iter().map(|(_, r)| r.abs()).fold(0.0_f64, f64::max);
574
575    // Chain corners miter exactly like the constant-radius group (§6.9.6):
576    // per-edge blends on the ORIGINAL solid composed by boolean — Intersect
577    // when every edge is convex, Union when every edge is concave.  Mixed or
578    // unclassifiable selections keep the sequential build.  Variable blends
579    // never round star vertices, so unlike the constant group there is no
580    // sequential-only star path to protect.
581    let mut chain_corner = false;
582    {
583        use rustc_hash::FxHashSet as HashSet;
584        let mut endpoints: Vec<(Vec3, usize)> = Vec::with_capacity(edge_points.len() * 2);
585        for (i, point) in edge_points.iter().enumerate() {
586            if let Ok(edge_id) = resolve_edge_by_point(solid, *point) {
587                if let Some(edge) = solid.edges.iter().find(|e| e.id == edge_id) {
588                    if let (Ok(a), Ok(b)) =
589                        (edge.curve.evaluate(edge.t0), edge.curve.evaluate(edge.t1))
590                    {
591                        endpoints.push((a, i));
592                        endpoints.push((b, i));
593                    }
594                }
595            }
596        }
597        let mut used = vec![false; endpoints.len()];
598        for i in 0..endpoints.len() {
599            if used[i] {
600                continue;
601            }
602            used[i] = true;
603            let mut edges_here: HashSet<usize> = HashSet::default();
604            edges_here.insert(endpoints[i].1);
605            for j in (i + 1)..endpoints.len() {
606                if used[j] {
607                    continue;
608                }
609                if endpoints[i].0.sub(endpoints[j].0).length() < 1e-6 {
610                    used[j] = true;
611                    edges_here.insert(endpoints[j].1);
612                }
613            }
614            if edges_here.len() == 2 {
615                chain_corner = true;
616            }
617        }
618    }
619    let miter_operation = if chain_corner {
620        let probe_radius = if max_radius > 0.0 { max_radius } else { 1.0 };
621        let mut any_convex = false;
622        let mut any_concave = false;
623        for point in edge_points {
624            match resolve_edge_by_point(solid, *point)
625                .and_then(|edge_id| analyze_edge(solid, edge_id, probe_radius))
626            {
627                Ok(cross) if cross.convex => any_convex = true,
628                Ok(_) => any_concave = true,
629                Err(_) => {
630                    any_convex = true;
631                    any_concave = true;
632                    break;
633                }
634            }
635        }
636        match (any_convex, any_concave) {
637            (true, false) => Some(crate::BooleanOperation::Intersect),
638            (false, true) => Some(crate::BooleanOperation::Union),
639            _ => None,
640        }
641    } else {
642        None
643    };
644
645    // Try the miter first; the variable blend's FITTED boundary curves are
646    // only ~1e-3 accurate at blend-blend tangencies (unlike the exact
647    // constant-radius cylinders), so the composition can fail — fall back to
648    // the sequential build then, which is never worse than the pre-miter
649    // behavior.  Tightening the taper surface's endpoint fitting is the
650    // documented follow-up that would make the miter stick.
651    let miter_attempt: Option<BrepSolid> = if let Some(operation) = miter_operation {
652        let options = crate::BooleanOptions::default();
653        let mut combined: Option<BrepSolid> = None;
654        let mut failed = false;
655        for (index, point) in edge_points.iter().enumerate() {
656            let Ok(edge_id) = resolve_edge_by_point(solid, *point) else {
657                failed = true;
658                break;
659            };
660            let edge_name = per_edge_name(edge_names, name, index);
661            let Ok(blended) =
662                crate::blend::blend_edge_variable(solid, edge_id, radii, chamfer, edge_name)
663            else {
664                failed = true;
665                break;
666            };
667            let next = match combined.take() {
668                None => blended,
669                Some(previous) => {
670                    match crate::boolean_operation(&previous, &blended, operation, &options) {
671                        Ok(next) => next,
672                        Err(_) => {
673                            failed = true;
674                            break;
675                        }
676                    }
677                }
678            };
679            combined = Some(next);
680        }
681        if failed {
682            None
683        } else {
684            combined.filter(|s| s.validate().is_empty())
685        }
686    } else {
687        None
688    };
689    let mut result = match miter_attempt {
690        Some(mitered) => mitered,
691        None => {
692            let mut sequential = solid.clone();
693            for (index, point) in edge_points.iter().enumerate() {
694                let edge_id = resolve_edge_by_point(&sequential, *point)?;
695                let edge_name = per_edge_name(edge_names, name, index);
696                sequential = crate::blend::blend_edge_variable(
697                    &sequential,
698                    edge_id,
699                    radii,
700                    chamfer,
701                    edge_name,
702                )?;
703            }
704            sequential
705        }
706    };
707    // Heal §6.9 surgery so re-trimmed edges meet their vertices exactly; scale
708    // the heal bound by the largest radius stop in the taper profile.
709    heal_edge_vertex_gaps(&mut result, max_radius)?;
710    // Final honesty gate: a genuinely TAPERED chain (different radii at the
711    // shared corner) has mismatched trim stations there — the blends cannot
712    // meet without a transition patch (not implemented), and the sequential
713    // surgery silently left broken topology before this gate existed.
714    let issues = result.validate();
715    if !issues.is_empty() {
716        return Err(format!(
717            "fillet_edges_variable: tapered blends meet at a shared chain vertex with \
718             mismatched radii — the radius-transition corner patch is not implemented; \
719             fillet the edges in separate operations or use matching stop radii \
720             ({} validation issues, first: {})",
721            issues.len(),
722            issues
723                .first()
724                .map(|issue| issue.message.clone())
725                .unwrap_or_default()
726        ));
727    }
728    Ok(result)
729}
730
731/// Asymmetric (two-distance) chamfer of a GROUP of edges, the app entry: each
732/// selected edge (resolved by a point on it) gets a `d1 × d2` bevel (§6.11).
733/// Edges are chamfered independently — asymmetric chamfers keep sharp vertices,
734/// so there is no shared-corner blending.
735pub fn chamfer_edges_asymmetric(
736    solid: &BrepSolid,
737    edge_points: &[Vec3],
738    edge_names: Option<&[String]>,
739    d1: f64,
740    d2: f64,
741    name: Option<&str>,
742) -> Result<BrepSolid, String> {
743    if edge_points.is_empty() {
744        return Err("chamfer_edges_asymmetric: no edges selected".into());
745    }
746    let mut result = solid.clone();
747    // `edge_names[index]` is keyed by INPUT position; the loop resolves each
748    // point on the EVOLVING `result`, but enumerates the input points in order,
749    // so the index alignment holds.
750    for (index, point) in edge_points.iter().enumerate() {
751        let edge_id = resolve_edge_by_point(&result, *point)?;
752        let edge_name = per_edge_name(edge_names, name, index);
753        result = chamfer_edge_asymmetric(&result, edge_id, d1, d2, edge_name)?;
754    }
755    Ok(result)
756}
757
758/// Distance-angle chamfer of a GROUP of edges, the app entry: each selected
759/// edge (resolved by a point on it) gets a setback `d1` on face 1 and a chamfer
760/// face at `angle_rad` from face 1 (§6.11); `d2` is constructed per edge.
761pub fn chamfer_edges_angle(
762    solid: &BrepSolid,
763    edge_points: &[Vec3],
764    edge_names: Option<&[String]>,
765    d1: f64,
766    angle_rad: f64,
767    name: Option<&str>,
768) -> Result<BrepSolid, String> {
769    if edge_points.is_empty() {
770        return Err("chamfer_edges_angle: no edges selected".into());
771    }
772    let mut result = solid.clone();
773    for (index, point) in edge_points.iter().enumerate() {
774        let edge_id = resolve_edge_by_point(&result, *point)?;
775        let edge_name = per_edge_name(edge_names, name, index);
776        result = chamfer_edge_angle(&result, edge_id, d1, angle_rad, edge_name)?;
777    }
778    Ok(result)
779}