Skip to main content

brep_kernel/blending/blend/corner/
convex.rs

1use super::*;
2
3/// §6.9.7 vertex ("star") blend: round a convex trihedral corner whose three
4/// incident edges are ALREADY filleted, by pure topology surgery — no
5/// booleans.  `corner` is the ORIGINAL sharp corner coordinate (already
6/// trimmed away by the edge fillets).  A spherical octant of `radius`,
7/// tangent to all three cylindrical fillets along their tangent circles, is
8/// sewn into the notch; the three fillet loops are closed on those tangent
9/// circles and the leftover flat caps are removed.
10///
11/// Dispatch beyond the convex trihedral fast path:
12///   * N>3 walls with a common tangent ball → spherical N-gon patch;
13///   * N≥4 walls with NO common ball → `round_general_star` (N=4 Coons);
14///   * trihedral corner with ONE CONCAVE incident edge (fillet tangent
15///     vertices missing) → `round_mixed_concave_corner` (torus sector);
16///   * CURVED-WALL trihedral corner (two planar walls + one cylindrical
17///     wall through the vertex, all three incident edges convex and
18///     filleted) → corner ball solved against the offset cylinder, then the
19///     same tangent-arc rebuild + spherical-triangle patch;
20///   * anything else (all/multi-concave, tilted concave axis, MIXED
21///     curved-wall corners, non-cylindrical curved walls, N≥5
22///     no-common-ball) refuses with a message naming the configuration.
23pub fn round_convex_corner(
24    solid: &BrepSolid,
25    corner: Vec3,
26    radius: f64,
27    name: Option<&str>,
28) -> Result<BrepSolid, KernelRefusal> {
29    // One blend operation: the reports of an earlier one, never consumed, are
30    // dropped as it starts (nested calls keep this operation's).
31    let _operation = crate::blend::BlendOperation::enter();
32    use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
33    if !(radius > 0.0) {
34        return Err(KernelRefusal::input(
35            KernelStage::Collect,
36            "radius",
37            "round_convex_corner: radius must be positive",
38        ));
39    }
40    let mut result = solid.clone();
41
42    // Vertex position lookup.
43    let vpoint: HashMap<u64, Vec3> = result.vertices.iter().map(|v| (v.id, v.point)).collect();
44    let edge_ends: HashMap<u64, (u64, u64)> = result
45        .edges
46        .iter()
47        .map(|e| (e.id, (e.start_vertex_id, e.end_vertex_id)))
48        .collect();
49    let coedge_ends_3d = |co: &CoedgeRecord| -> Option<(Vec3, Vec3)> {
50        let (s, e) = edge_ends.get(&co.edge_id)?;
51        Some((*vpoint.get(s)?, *vpoint.get(e)?))
52    };
53
54    if result.vertices.is_empty() {
55        return Err(KernelRefusal::input(
56            KernelStage::Collect,
57            "empty_solid",
58            "round_convex_corner: solid has no vertices",
59        ));
60    }
61
62    // 1. The three planar faces whose plane passes through `corner`; collect
63    //    one OUTWARD normal per distinct direction.  Outwardness comes from
64    //    the face record itself (geometric plane normal × same_sense): a
65    //    vertex-centroid sign heuristic breaks exactly at MIXED-convexity
66    //    corners — an L-prism's reentrant corner sits ON the centroid's
67    //    mirror plane, where the sign test is degenerate and flips the two
68    //    wall normals inward.
69    let mut normals: Vec<Vec3> = Vec::new();
70    for shell in &result.shells {
71        for face in &shell.faces {
72            let Some(crate::AnalyticSurface::Plane {
73                origin,
74                u_dir,
75                v_dir,
76                ..
77            }) = face.surface.analytic()
78            else {
79                continue;
80            };
81            let Ok(n) = u_dir.cross(*v_dir).normalized() else {
82                continue;
83            };
84            // Plane must pass through the corner point.
85            if n.dot(corner.sub(*origin)).abs() > 1e-6 {
86                continue;
87            }
88            // Face must border the corner region (skip coincident far faces).
89            // The nearest surviving coedge vertex of a corner wall recedes from
90            // the corner as the dihedral sharpens: the fillet contact line sits
91            // ~r/tan(θ/2) out, so a fixed 3·r window (fine for right/obtuse
92            // corners) DROPS the far wall + cap on acute corners (d ≈ 3.1·r at
93            // 38°, 3.7·r near 30°) and the corner then looks like it has only
94            // one planar face.  Widen the window to 6·r — still only ever
95            // matches faces whose plane already passes through the corner (the
96            // walls meeting there), so no unrelated far face is pulled in.
97            let near_corner = face.loops.iter().flat_map(|l| &l.coedges).any(|co| {
98                coedge_ends_3d(co)
99                    .map(|(a, b)| {
100                        a.sub(corner).length() < 6.0 * radius
101                            || b.sub(corner).length() < 6.0 * radius
102                    })
103                    .unwrap_or(false)
104            });
105            if !near_corner {
106                continue;
107            }
108            let outward = if face.same_sense { n } else { n.scale(-1.0) };
109            if !normals.iter().any(|m| m.dot(outward) > 1.0 - 1e-6) {
110                normals.push(outward);
111            }
112        }
113    }
114    // A STEP face in a wall's own plane, facing the other way, is not a wall
115    // of this corner: the walls of one convex corner bound ONE material
116    // wedge, so no two of them are antiparallel. The 2026-10-17 slab report
117    // (`tests/suites/inbox_20261017_fillet_slab_corner_sphere.rs`): the
118    // r = 0.8 round of a slab's short end-face edge exposes, in the end
119    // face's plane y = 10, the box material standing behind the slab, as a
120    // planar face facing −y 2.4 (3·r) from the corner — inside the 6·r
121    // window above. The scan then read four normals (+z, −x, +y, −y), the
122    // least-squares ball had no root, and `round_general_star` refused a
123    // three-wall corner, so the corner stayed as the flat-capped edge rounds.
124    // Prune by the same proof the curved-wall root uses: drop one member of
125    // each antiparallel pair, in every combination, and keep the one triple
126    // whose three tangent vertices `C + r·nᵢ` the edge rounds left behind.
127    // No antiparallel pair, a triple already, or no / several proven triples
128    // leave the scan as it was.
129    if normals.len() > 3 {
130        if let Some(walls) = prune_step_walls(&result, corner, radius, &normals) {
131            normals = walls;
132        }
133    }
134    // CURVED-WALL star corners (§6.9.7): one wall is a CYLINDER through the
135    // corner (e.g. a bulged prism wall meeting two planes at a vertex).  The
136    // corner-ball construction generalizes cleanly — C sits at distance r
137    // inside each plane and at axis distance R−r (boss) / R+r (bore) from
138    // the cylinder, and every blend face's corner cross-section through C is
139    // STILL a radius-r circle centered at C (the corner ball's characteristic
140    // circle on each constant-radius canal blend: the plane×plane and
141    // plane×cylinder blends have C on their centre path, the rim blend is a
142    // canal torus with C on its centre circle).  So after solving C from the
143    // two offset planes + offset cylinder (closed-form quadratic on the
144    // offset-plane intersection line) and taking the cylinder's contact
145    // normal (T_cyl − C)/r as the third "wall normal", the ENTIRE planar
146    // machinery below — tangent-vertex reuse, fresh tangent-arc rebuild of
147    // each blend's corner end, notch-cap removal, general spherical-triangle
148    // patch — applies verbatim.  The wall faces themselves must only be kept
149    // out of the blend-face rebuild (a curved WALL borders tangent vertices
150    // too; blends never contain the sharp corner, walls do).
151    //
152    // Support boundary (refusals below name it): exactly TWO planar walls +
153    // ONE cylindrical wall.  All three incident edges convex and filleted →
154    // the corner-ball root proven by its tangent vertices, sphere patch.
155    // ONE CONCAVE plane×cylinder edge (the semicircular-boss fixture) → no
156    // convex corner-ball root exists and the solve falls through to
157    // `round_mixed_concave_curved_corner`: the concave blend's axis is the
158    // offset-plane × offset-cylinder intersection LINE (both walls extruded
159    // along the cap normal), and the concave-vertex torus-sector closure
160    // applies about it.  Still refused with named messages: a cylinder axis
161    // TILTED against the cap normal (the blend's centre path is then a
162    // curve, no exact revolution), missing fillets / tangency vertices,
163    // unsupported composition orders, ≥2 cylindrical walls, and
164    // non-cylindrical curved carriers (cone/sphere/torus/freeform walls).
165    let mut curved_wall_face_ids: HashSet<u64> = HashSet::default();
166    let mut curved_centre: Option<Vec3> = None;
167    // A MIXED curved-wall corner whose LAST-composed cap fillet overshot the
168    // corner resurfaces a strip of the wall plane with the OPPOSITE outward
169    // normal (the strip fronts the overshoot chamber), so the corner shows
170    // THREE plane normals containing an ANTIPARALLEL pair — a configuration
171    // no genuine trihedral corner produces (its tangency system is
172    // singular).  Route it through the curved-wall lane, trying each way of
173    // dropping one antiparallel member (the mixed detector's tangency-vertex
174    // proof selects the genuine wall).
175    let antiparallel_pair = if normals.len() == 3 {
176        (0..3).find_map(|i| {
177            ((i + 1)..3)
178                .find(|&j| normals[i].dot(normals[j]) < -(1.0 - 1e-6))
179                .map(|j| (i, j))
180        })
181    } else {
182        None
183    };
184    if normals.len() < 3 || antiparallel_pair.is_some() {
185        struct CylWall {
186            axis_point: Vec3,
187            axis_dir: Vec3,
188            radius: f64,
189            outward_away: bool,
190        }
191        let mut cylinders: Vec<CylWall> = Vec::new();
192        let mut other_curved = 0usize;
193        for shell in &result.shells {
194            for face in &shell.faces {
195                if matches!(
196                    face.surface.analytic(),
197                    Some(crate::AnalyticSurface::Plane { .. })
198                ) {
199                    continue;
200                }
201                let Ok(proj) = crate::project_point_to_surface(&face.surface, corner) else {
202                    continue;
203                };
204                if proj.distance > 1e-6 {
205                    continue;
206                }
207                let near_corner = face.loops.iter().flat_map(|l| &l.coedges).any(|co| {
208                    coedge_ends_3d(co)
209                        .map(|(a, b)| {
210                            a.sub(corner).length() < 6.0 * radius
211                                || b.sub(corner).length() < 6.0 * radius
212                        })
213                        .unwrap_or(false)
214                });
215                if !near_corner {
216                    continue;
217                }
218                curved_wall_face_ids.insert(face.id);
219                let Some((axis_point, axis_dir, cyl_radius)) = cylinder_wall_carrier(&face.surface)
220                else {
221                    other_curved += 1;
222                    continue;
223                };
224                let Ok(n_geom) = face.surface.normal(proj.u, proj.v) else {
225                    other_curved += 1;
226                    continue;
227                };
228                let outward = if face.same_sense {
229                    n_geom
230                } else {
231                    n_geom.scale(-1.0)
232                };
233                let rel = corner.sub(axis_point);
234                let Ok(radial) = rel.sub(axis_dir.scale(rel.dot(axis_dir))).normalized() else {
235                    other_curved += 1; // corner on the axis: degenerate carrier
236                    continue;
237                };
238                let outward_away = outward.dot(radial) > 0.0;
239                let duplicate = cylinders.iter().any(|cyl| {
240                    cyl.axis_dir.cross(axis_dir).length() < 1e-6
241                        && {
242                            let between = axis_point.sub(cyl.axis_point);
243                            between
244                                .sub(cyl.axis_dir.scale(between.dot(cyl.axis_dir)))
245                                .length()
246                                < 1e-6
247                        }
248                        && (cyl.radius - cyl_radius).abs() < 1e-6
249                        && cyl.outward_away == outward_away
250                });
251                if !duplicate {
252                    cylinders.push(CylWall {
253                        axis_point,
254                        axis_dir,
255                        radius: cyl_radius,
256                        outward_away,
257                    });
258                }
259            }
260        }
261        if curved_wall_face_ids.is_empty() && antiparallel_pair.is_none() {
262            return Err(KernelRefusal::input(
263                KernelStage::Classify,
264                "planar_walls",
265                format!(
266                    "round_convex_corner: expected 3 or more planar faces at the corner, found {}",
267                    normals.len()
268                ),
269            ));
270        }
271        if !curved_wall_face_ids.is_empty() {
272            if cylinders.len() != 1
273                || other_curved != 0
274                || !(normals.len() == 2 || antiparallel_pair.is_some())
275            {
276                return Err(KernelRefusal::unsupported(
277                    KernelStage::Classify,
278                    "curved_wall_mix",
279                    format!(
280                    "round_convex_corner: star corner with a curved (non-planar) wall carrier is \
281                     only supported as two planar walls + one cylindrical wall — found {} planar, \
282                     {} cylindrical and {} other curved wall carrier(s) through the corner \
283                     (§6.9.7 curved-wall vertex blend)",
284                    normals.len(),
285                    cylinders.len(),
286                    other_curved
287                    ),
288                ));
289            }
290            let cyl = &cylinders[0];
291            if let Some((i, j)) = antiparallel_pair {
292                // Only a mixed-convexity overshoot presents an antiparallel
293                // wall-plane pair; no convex corner ball can exist.  Try the
294                // curved-mixed closure with either antiparallel member dropped.
295                let k = 3 - i - j;
296                let mut mixed_reasons: Vec<String> = Vec::new();
297                // Both attempts run the same detector; the LAST refusal's
298                // class carries the joined text, so the inner class survives
299                // the wrap.
300                let mut carried: Option<KernelRefusal> = None;
301                for wall in [normals[i], normals[j]] {
302                    match round_mixed_concave_curved_corner(
303                        solid,
304                        corner,
305                        radius,
306                        [wall, normals[k]],
307                        cyl.axis_point,
308                        cyl.axis_dir,
309                        cyl.radius,
310                        cyl.outward_away,
311                        name,
312                    ) {
313                        Ok(done) => return Ok(done),
314                        Err(why) => {
315                            mixed_reasons.push(why.message.clone());
316                            carried = Some(why);
317                        }
318                    }
319                }
320                let message = format!(
321                    "round_convex_corner: star corner with a curved (non-planar) wall \
322                     carrier shows an antiparallel wall-plane pair (a mixed-convexity \
323                     overshoot signature — no corner-ball root exists for a genuine \
324                     trihedral corner here), and the mixed-convexity curved-wall closure \
325                     refused: {} (§6.9.7 curved-wall vertex blend)",
326                    mixed_reasons.join(" / ")
327                );
328                return Err(match carried {
329                    Some(why) => why.with_message(|_| message),
330                    None => KernelRefusal::internal(
331                        KernelStage::Classify,
332                        "antiparallel_attempts",
333                        message,
334                    ),
335                });
336            }
337            let candidates = curved_wall_ball_candidates(
338                corner,
339                radius,
340                normals[0],
341                normals[1],
342                cyl.axis_point,
343                cyl.axis_dir,
344                cyl.radius,
345                cyl.outward_away,
346            )
347            .map_err(|error| {
348                error.with_message(|why| {
349                    format!(
350                    "round_convex_corner: star corner with a curved (non-planar) wall carrier: \
351                     {why} (§6.9.7 curved-wall vertex blend)"
352                    )
353                })
354            })?;
355            // The valid root is PROVEN by the tangent vertices the three edge
356            // fillets left behind: all three contact points C + r·nᵢ must be
357            // existing vertices.  No root qualifying means the corner is not the
358            // all-convex filleted configuration (e.g. the boss fixture's MIXED
359            // corner with its concave plane×cylinder edge).
360            let matched = candidates.iter().find(|(centre, n_contact)| {
361                [normals[0], normals[1], *n_contact].iter().all(|n| {
362                    let ideal = centre.add(n.scale(radius));
363                    result
364                        .vertices
365                        .iter()
366                        .any(|v| v.point.sub(ideal).length() < 1e-6)
367                })
368            });
369            let Some((centre, n_contact)) = matched else {
370                // MIXED-convexity curved-wall corner (one CONCAVE plane×cylinder
371                // edge): no convex corner ball carries the tangency vertices, but
372                // the concave-vertex torus-sector closure generalizes — both
373                // walls are extruded along the cap normal, so the concave
374                // blend's axis is the offset-plane × offset-cylinder
375                // intersection LINE and the closure is an exact revolution.
376                // `result` is still an untouched clone of `solid` here.
377                return round_mixed_concave_curved_corner(
378                    solid,
379                    corner,
380                    radius,
381                    [normals[0], normals[1]],
382                    cyl.axis_point,
383                    cyl.axis_dir,
384                    cyl.radius,
385                    cyl.outward_away,
386                    name,
387                )
388                .map_err(|error| {
389                    error.with_message(|mixed| {
390                        format!(
391                        "round_convex_corner: star corner with a curved (non-planar) wall \
392                         carrier: no corner-ball root has all three convex-fillet tangent \
393                         vertices (not an all-convex curved-wall corner with its three \
394                         incident edges filleted), and the corner is not a supported \
395                         mixed-convexity curved-wall corner ({mixed}) (§6.9.7 curved-wall \
396                         vertex blend)"
397                        )
398                    })
399                });
400            };
401            curved_centre = Some(*centre);
402            normals.push(*n_contact);
403        }
404        // (antiparallel pair with no curved wall carrier: fall through to
405        // the planar solve, which reports the singular tangency system.)
406    }
407    let n_faces = normals.len();
408    // The EXACT iso-parameter octant fast path applies ONLY to an orthogonal
409    // TRIHEDRAL corner (3 mutually perpendicular PLANAR faces).  Any other
410    // trihedral angle — including every curved-wall corner — uses the general
411    // fitted spherical-triangle patch, and N>3 faces (a §6.9.7 "star", e.g. a
412    // pyramid apex) use the general spherical N-gon.
413    let orthogonal = curved_centre.is_none()
414        && n_faces == 3
415        && (0..3).all(|i| ((i + 1)..3).all(|j| normals[i].dot(normals[j]).abs() <= 1e-6));
416
417    // 2. Ball centre C: the point at distance r inside every face plane.  Each
418    //    plane passes through `corner`, so with d = C − corner the tangency
419    //    conditions are nᵢ·d = −r (i = 1..N).  A trihedral corner (N=3) is a
420    //    determined 3×3 system with an EXACT solution; N>3 is OVER-determined,
421    //    so solve LEAST-SQUARES via the normal equations AᵀA d = Aᵀb (rows of
422    //    A = nᵢ, b = −r) and REQUIRE the residual to vanish — a common tangent
423    //    ball (a single rolling ball tangent to all N faces) exists only then.
424    //    If it does NOT, the N faces form a GENERAL star that would need
425    //    Golovanov's fillet-the-fillet-intersection recursion; this vertex
426    //    patch cannot sew it, so refuse gracefully (no crash, no bad solid).
427    //    A CURVED-WALL corner arrives here with C already solved from the two
428    //    offset planes + offset cylinder (its third "normal" is the contact
429    //    direction, which depends on C, so the planar linear solve does not
430    //    apply).
431    let c = if let Some(centre) = curved_centre {
432        centre
433    } else if n_faces == 3 {
434        // Three CONVEX `−r` offset planes meeting in one point — the same
435        // closed form as the corner's plane PAIRS, one row wider (audit §11).
436        // The audit's Slice 2b table listed three configurations; this is the
437        // fourth, and it is in the same directory.
438        let normals3 = [normals[0], normals[1], normals[2]];
439        let distances = [-radius, -radius, -radius];
440        let centre = offset_plane_triple(corner, normals3, distances)
441            // The pre-slice line was `solve_small(..)?`, so this refusal text
442            // was `solve_small`'s own. Kept verbatim: this slice moves no
443            // message.
444            .map_err(|_| {
445                KernelRefusal::ill_posed(
446                    KernelStage::Classify,
447                    "singular_tangency",
448                    "solve_small: singular matrix",
449                )
450            })?;
451        offset_pair_diag::plane_triple(
452            "convex::corner_ball",
453            corner,
454            normals3,
455            distances,
456            Some(centre),
457        );
458        centre
459    } else {
460        let mut ata = [[0.0f64; 3]; 3];
461        let mut atb = [0.0f64; 3];
462        for nrm in &normals {
463            let ni = [nrm.x, nrm.y, nrm.z];
464            for a in 0..3 {
465                atb[a] += ni[a] * (-radius);
466                for b in 0..3 {
467                    ata[a][b] += ni[a] * ni[b];
468                }
469            }
470        }
471        let d = crate::fit::solve_small::<3>(ata, atb, 3).map_err(|_| {
472            KernelRefusal::ill_posed(
473                KernelStage::Classify,
474                "singular_tangency_lsq",
475                "round_convex_corner: tangency normal equations are singular",
476            )
477        })?;
478        let c = corner.add(Vec3::new(d[0], d[1], d[2]));
479        let worst = normals.iter().fold(0.0f64, |acc, nrm| {
480            acc.max((nrm.dot(c.sub(corner)) + radius).abs())
481        });
482        if worst > 1e-6 * radius {
483            // No single ball is tangent to all N cylinders along a circle (the N
484            // fillet-cylinder axes are not concurrent): this is Golovanov's
485            // §6.9.7 GENERAL "star". Close the notch with an N-sided patch
486            // tangent to each cylinder along a boundary arc instead of a
487            // spherical N-gon. `result` is still the untouched edge-filleted
488            // clone at this point (nothing above mutates it).
489            return round_general_star(&result, corner, radius, name);
490        }
491        c
492    };
493
494    // 3. Tangent point Tᵢ = C + r·nᵢ per face; reuse the matching existing
495    //    tangent vertex id created by the edge fillets.  If any tangent
496    //    vertex is MISSING the corner is not the all-convex configuration
497    //    this sphere-patch path serves — for a trihedral corner, try the
498    //    MIXED-convexity path (one concave edge, §6.9.7): its fillets leave
499    //    tangency vertices elsewhere (the concave blend's contact points).
500    let mut t_ids: Vec<u64> = Vec::with_capacity(n_faces);
501    let mut missing_tangent: Option<Vec3> = None;
502    for nrm in &normals {
503        let ideal = c.add(nrm.scale(radius));
504        match result
505            .vertices
506            .iter()
507            .find(|v| v.point.sub(ideal).length() < 1e-6)
508        {
509            Some(found) => t_ids.push(found.id),
510            None => {
511                missing_tangent = Some(ideal);
512                break;
513            }
514        }
515    }
516    if let Some(ideal) = missing_tangent {
517        if n_faces == 3 && curved_centre.is_none() {
518            // `result` is still an untouched clone of `solid` here.  (A
519            // curved-wall corner never lands here — its root was selected by
520            // exactly this tangent-vertex existence test — and the planar
521            // mixed-concave machinery would misread its cylinder wall.)
522            return round_mixed_concave_corner(solid, corner, radius, &normals, name).map_err(
523                |error| {
524                    error.with_message(|mixed| {
525                        format!(
526                        "round_convex_corner: no convex tangent vertex near {ideal:?}, and the \
527                         corner is not a supported mixed-convexity trihedral corner ({mixed})"
528                        )
529                    })
530                },
531            );
532        }
533        return Err(KernelRefusal::unsupported(
534            KernelStage::Classify,
535            "concave_star_n4",
536            format!(
537            "round_convex_corner: no existing tangent vertex near {ideal:?} (a star corner \
538             with concave incident edges is unsupported for N>3 walls)"
539            ),
540        ));
541    }
542    let t_id_set: HashSet<u64> = t_ids.iter().copied().collect();
543
544    // id allocator across the whole solid's shared id space.
545    let mut next_id = crate::blend::edge::fresh_id_source(&result);
546
547    // Tolerance for on-plane / corner-side tests, scaled to the ball radius.
548    let plane_tol = 1e-6 * radius;
549
550    // Map each edge id to the faces using it (cap adjacency below).
551    let mut edge_faces: HashMap<u64, Vec<u64>> = HashMap::default();
552    for shell in &result.shells {
553        for face in &shell.faces {
554            for lp in &face.loops {
555                for co in &lp.coedges {
556                    edge_faces.entry(co.edge_id).or_default().push(face.id);
557                }
558            }
559        }
560    }
561
562    // The curved "fillet" faces at this corner: non-planar faces touching a
563    // tangent vertex.  Needed both to scope the flat-cap search and (below) to
564    // drive the per-fillet corner rebuild.  A curved WALL also borders tangent
565    // vertices (its contact curves with two blends cross at T_cyl on its own
566    // boundary), so wall carriers are excluded explicitly.
567    let corner_fillet_faces: HashSet<u64> = result
568        .shells
569        .iter()
570        .flat_map(|s| &s.faces)
571        .filter(|face| {
572            !matches!(
573                face.surface.analytic(),
574                Some(crate::AnalyticSurface::Plane { .. })
575            ) && !curved_wall_face_ids.contains(&face.id)
576                && face.loops.iter().flat_map(|l| &l.coedges).any(|co| {
577                    edge_ends
578                        .get(&co.edge_id)
579                        .map(|&(s, e)| t_id_set.contains(&s) || t_id_set.contains(&e))
580                        .unwrap_or(false)
581                })
582        })
583        .map(|f| f.id)
584        .collect();
585
586    // Flat caps: the small planar faces filling the notch.  For an ACUTE
587    // corner the leftover sharp vertex and the fillet-fillet intersection
588    // vertices sit OUTSIDE the tangent ball, so a fixed 1.45r window (fine for
589    // obtuse corners) MISSES them.  Scope the corner cluster generously by
590    // |corner-C| + 2r — which always contains the sharp-corner projections and
591    // the fillet-fillet intersections of radius-r fillets while excluding the
592    // far model — keep only planar faces fully inside that scope, and grow the
593    // set out from the corner fillets across shared edges so every cap that
594    // hangs off a fillet (directly or through another cap) is removed.
595    //
596    // A pure distance-to-C window is NOT enough on its own: at a sharp corner
597    // the ball centre C recedes from the corner as ~r/sin(θ/2), so |corner-C|
598    // (and hence the scope) grows, while the REAL neighbouring model face (the
599    // trimmed top cap / a wall) shrinks — its surviving triangle can fall
600    // ENTIRELY inside the scope ball and be swallowed as junk, tearing a hole
601    // in the solid (the r=1.5, 43° corner: its top cap's far corners sit 6.3
602    // and 7.1 from C, both inside a 7.35 scope).  The notch junk, by contrast,
603    // clusters BETWEEN the sharp corner and C: every junk vertex lies on the
604    // corner side of the plane through C ⟂ (corner-C), i.e. its signed reach
605    // s = (v-C)·(corner-C) is ≥ 0 (tangent points sit at small +s).  A real
606    // model face reaches PAST C the other way (s ≈ −(a few)·r at its far
607    // vertices).  So additionally require every cap vertex to satisfy
608    // s ≥ −0.5r·|corner-C|: keeps all genuine junk removable while protecting
609    // any face that extends into the model interior beyond C.
610    let corner_scope = corner.sub(c).length() + 2.0 * radius;
611    let corner_dir = corner.sub(c);
612    let near_side_limit = -0.5 * radius * corner_dir.length();
613    // The NOTCH of a trihedral corner: the corner side of the three fillets'
614    // tangent-circle planes (through C, normal nᵢ × nⱼ, oriented toward the
615    // corner — the plane the per-fillet rebuild below cuts each wall at). A
616    // planar face in a WALL's own plane (through the corner) with a vertex
617    // past any of them is that wall's remnant beyond its contact lines, not
618    // notch junk, however small and close: the 2026-10-17 slab report's end
619    // face kept 0.95 of area between two r = 0.8 rounds and an r = 5 arc,
620    // every vertex inside the scope ball and the near-side limit, and was
621    // swallowed as a cap — four open edges, and the corner stayed flat. The
622    // tangent points sit ON two planes and strictly inside the third (the
623    // triple product fixes the sign), so a wall remnant inside the notch —
624    // the first round's bulkhead quarter-disc in the x = 0 wall — is still
625    // removed. N > 3 stars keep the scope rules alone.
626    let notch_axes: Vec<Vec3> = if n_faces == 3 {
627        [(0usize, 1usize), (1, 2), (0, 2)]
628            .into_iter()
629            .filter_map(|(i, j)| normals[i].cross(normals[j]).normalized().ok())
630            .map(|axis| if corner_dir.dot(axis) < 0.0 { axis.scale(-1.0) } else { axis })
631            .collect()
632    } else {
633        Vec::new()
634    };
635    let wall_remnant = |face: &FaceRecord| -> bool {
636        if notch_axes.is_empty() {
637            return false;
638        }
639        let Some(crate::AnalyticSurface::Plane { origin, u_dir, v_dir, .. }) = face.surface.analytic() else {
640            return false;
641        };
642        let Ok(n) = u_dir.cross(*v_dir).normalized() else {
643            return false;
644        };
645        if n.dot(corner.sub(*origin)).abs() > 1e-6 {
646            return false;
647        }
648        face.loops.iter().flat_map(|l| &l.coedges).any(|co| {
649            coedge_ends_3d(co)
650                .map(|(a, b)| {
651                    notch_axes.iter().any(|axis| a.sub(c).dot(*axis) < -plane_tol || b.sub(c).dot(*axis) < -plane_tol)
652                })
653                .unwrap_or(false)
654        })
655    };
656    let candidate_cap = |face: &FaceRecord| -> bool {
657        matches!(
658            face.surface.analytic(),
659            Some(crate::AnalyticSurface::Plane { .. })
660        ) && !wall_remnant(face)
661            && face.loops.iter().flat_map(|l| &l.coedges).all(|co| {
662            coedge_ends_3d(co)
663                .map(|(a, b)| {
664                    a.sub(c).length() <= corner_scope
665                        && b.sub(c).length() <= corner_scope
666                        && a.sub(c).dot(corner_dir) >= near_side_limit
667                        && b.sub(c).dot(corner_dir) >= near_side_limit
668                })
669                .unwrap_or(false)
670        })
671    };
672    let mut cap_face_ids: HashSet<u64> = HashSet::default();
673    loop {
674        let mut added = false;
675        for shell in &result.shells {
676            for face in &shell.faces {
677                if cap_face_ids.contains(&face.id) || !candidate_cap(face) {
678                    continue;
679                }
680                let borders_corner = face.loops.iter().flat_map(|l| &l.coedges).any(|co| {
681                    edge_faces
682                        .get(&co.edge_id)
683                        .map(|fs| {
684                            fs.iter().any(|fid| {
685                                corner_fillet_faces.contains(fid) || cap_face_ids.contains(fid)
686                            })
687                        })
688                        .unwrap_or(false)
689                });
690                if borders_corner {
691                    cap_face_ids.insert(face.id);
692                    added = true;
693                }
694            }
695        }
696        if !added {
697            break;
698        }
699    }
700
701    // Fillet faces: curved (non-planar) faces touching a tangent vertex.
702    // For each, replace the corner-end coedge run with ONE fresh tangent arc.
703    let mut new_loops: HashMap<u64, Vec<CoedgeRecord>> = HashMap::default();
704    let mut fresh_edges: Vec<EdgeRecord> = Vec::new();
705    let mut fresh_arcs: Vec<(u64, u64, u64)> = Vec::new(); // (edge_id, start_vid, end_vid)
706    let mut fillet_shell: Option<usize> = None;
707    let mut fillet_count = 0usize;
708
709    for (si, shell) in result.shells.iter().enumerate() {
710        for face in &shell.faces {
711            let is_plane = matches!(
712                face.surface.analytic(),
713                Some(crate::AnalyticSurface::Plane { .. })
714            );
715            if is_plane || curved_wall_face_ids.contains(&face.id) {
716                continue;
717            }
718            let touches_tangent = face.loops.iter().flat_map(|l| &l.coedges).any(|co| {
719                let (s, e) = match edge_ends.get(&co.edge_id) {
720                    Some(v) => *v,
721                    None => return false,
722                };
723                t_id_set.contains(&s) || t_id_set.contains(&e)
724            });
725            if !touches_tangent {
726                continue;
727            }
728            if face.loops.len() != 1 {
729                return Err(KernelRefusal::unsupported(
730                    KernelStage::Fragment,
731                    "multi_loop",
732                    format!(
733                        "round_convex_corner: fillet face {} has {} loops",
734                        face.id,
735                        face.loops.len()
736                    ),
737                ));
738            }
739            let coedges = &face.loops[0].coedges;
740            let n = coedges.len();
741
742            // Pair this fillet with the TWO tangent vertices it borders, by
743            // GEOMETRY: the tangent vertices that appear as endpoints of its
744            // loop (its two longitudinal contact lines reach them).  We must
745            // NOT read the messy sequential coedge run's loose ends — for an
746            // ACUTE corner those land on a spurious fillet-fillet vertex.
747            let mut border_tvs: Vec<u64> = Vec::new();
748            for co in coedges {
749                if let Some(&(s, e)) = edge_ends.get(&co.edge_id) {
750                    for v in [s, e] {
751                        if t_id_set.contains(&v) && !border_tvs.contains(&v) {
752                            border_tvs.push(v);
753                        }
754                    }
755                }
756            }
757            if border_tvs.len() != 2 {
758                return Err(KernelRefusal::unsupported(
759                    KernelStage::Fragment,
760                    "tangent_vertex_pair",
761                    format!(
762                    "round_convex_corner: fillet face {} borders {} tangent vertices \
763                     (expected exactly 2); acute geometry too degenerate to rebuild",
764                    face.id,
765                    border_tvs.len()
766                    ),
767                ));
768            }
769            let bpt0 = *vpoint.get(&border_tvs[0]).unwrap();
770            let bpt1 = *vpoint.get(&border_tvs[1]).unwrap();
771            // Tangent-circle plane of this cylinder fillet: sphere∩cylinder is a
772            // radius-r circle in the plane through C ⟂ the cylinder axis, and
773            // both tangent points lie on it (each Tᵢ−C ⟂ the axis), so the
774            // plane normal is (Ta−C)×(Tb−C).  Orient it TOWARD the sharp corner.
775            // Everything on the corner side of this plane is notch junk (flat
776            // caps, fillet-fillet intersection vertices/edges) that the fresh
777            // tangent arc replaces; everything on the far side is the real
778            // fillet body to keep.  This trims/rebuilds the corner end at the
779            // tangent circle regardless of how the sequential fillets cut it.
780            let mut axis = bpt0.sub(c).cross(bpt1.sub(c)).normalized().map_err(|_| {
781                KernelRefusal::ill_posed(
782                    KernelStage::Fragment,
783                    "colinear_tangents",
784                    format!(
785                        "round_convex_corner: fillet face {} tangent points are colinear with C",
786                        face.id
787                    ),
788                )
789            })?;
790            if corner.sub(c).dot(axis) < 0.0 {
791                axis = axis.scale(-1.0);
792            }
793            let corner_side = |p: Vec3| p.sub(c).dot(axis) >= -plane_tol;
794            let is_corner: Vec<bool> = coedges
795                .iter()
796                .map(|co| {
797                    coedge_ends_3d(co)
798                        .map(|(a, b)| corner_side(a) && corner_side(b))
799                        .unwrap_or(false)
800                })
801                .collect();
802            let anchor = (0..n).find(|&i| !is_corner[i]).ok_or_else(|| {
803                KernelRefusal::unsupported(
804                    KernelStage::Fragment,
805                    "all_corner_end",
806                    format!(
807                        "round_convex_corner: fillet face {} is all corner-end",
808                        face.id
809                    ),
810                )
811            })?;
812            let (prefix, run, suffix) = partition_corner_loop(coedges, &is_corner, anchor);
813            if run.is_empty() {
814                return Err(KernelRefusal::unsupported(
815                    KernelStage::Fragment,
816                    "no_corner_run",
817                    format!(
818                        "round_convex_corner: fillet face {} has no corner-end coedge",
819                        face.id
820                    ),
821                ));
822            }
823            // T_a: traversal-end vertex of the coedge before the run.
824            let before = prefix.last().ok_or_else(|| {
825                KernelRefusal::internal(
826                    KernelStage::Fragment,
827                    "run_predecessor",
828                    format!(
829                        "round_convex_corner: fillet face {} corner run has no predecessor",
830                        face.id
831                    ),
832                )
833            })?;
834            let (bs, be) = *edge_ends.get(&before.edge_id).unwrap();
835            let t_a = if before.forward { be } else { bs };
836            let uv_a = before
837                .pcurve
838                .evaluate(1.0)
839                .or_refuse(KernelStage::Fragment, "evaluate")?;
840            // The coedge after the run (wraps to prefix[0] if the run trails).
841            let after = suffix.first().or_else(|| prefix.first()).ok_or_else(|| {
842                KernelRefusal::internal(
843                    KernelStage::Fragment,
844                    "run_successor",
845                    format!(
846                        "round_convex_corner: fillet face {} corner run has no successor",
847                        face.id
848                    ),
849                )
850            })?;
851            let (as_, ae) = *edge_ends.get(&after.edge_id).unwrap();
852            let t_b = if after.forward { as_ } else { ae };
853            let uv_b = after
854                .pcurve
855                .evaluate(0.0)
856                .or_refuse(KernelStage::Fragment, "evaluate")?;
857
858            if !t_id_set.contains(&t_a) || !t_id_set.contains(&t_b) || t_a == t_b {
859                return Err(KernelRefusal::unsupported(
860                    KernelStage::Fragment,
861                    "run_ends",
862                    format!(
863                    "round_convex_corner: fillet face {} corner ends are not two distinct tangent vertices ({t_a},{t_b})",
864                    face.id
865                    ),
866                ));
867            }
868
869            // Fresh tangent-circle arc T_a -> T_b (radius-r arc centered at C).
870            let ta_pt = *vpoint.get(&t_a).unwrap();
871            let tb_pt = *vpoint.get(&t_b).unwrap();
872            let x_axis = ta_pt
873                .sub(c)
874                .normalized()
875                .or_refuse(KernelStage::Fragment, "normalized")?;
876            let rb = tb_pt.sub(c);
877            let y_axis = rb
878                .sub(x_axis.scale(rb.dot(x_axis)))
879                .normalized()
880                .or_refuse(KernelStage::Fragment, "normalized")?;
881            // Sweep the ACTUAL angle Tₐ–C–T_b (= angle between the two face
882            // normals); only 90° for orthogonal corners.  make_arc segments it
883            // (up to a full turn) so any convex angle in (0, π) is exact.
884            let sweep = x_axis
885                .dot(rb.normalized().or_refuse(KernelStage::Fragment, "normalized")?)
886                .clamp(-1.0, 1.0)
887                .acos();
888            let arc = crate::make_arc(c, x_axis, y_axis, radius, 0.0, sweep)
889                .or_refuse(KernelStage::Fragment, "make_arc")?;
890            let arc_id = next_id();
891            fresh_edges.push(EdgeRecord {
892                id: arc_id,
893                curve: arc,
894                t0: 0.0,
895                t1: 1.0,
896                start_vertex_id: t_a,
897                end_vertex_id: t_b,
898                degenerate: false,
899                name: None,
900            });
901            fresh_arcs.push((arc_id, t_a, t_b));
902
903            // Fresh coedge on the fillet: param-line at the tangent v* from the
904            // T_a contact to the T_b contact, running the loop direction.
905            let fresh_co = CoedgeRecord {
906                id: next_id(),
907                edge_id: arc_id,
908                forward: true,
909                pcurve: crate::sweep_topology::parameter_line(uv_a.x, uv_a.y, uv_b.x, uv_b.y)
910                    .or_refuse(KernelStage::Fragment, "parameter_line")?,
911            };
912            let mut rebuilt = prefix;
913            rebuilt.push(fresh_co);
914            rebuilt.extend(suffix);
915            new_loops.insert(face.id, rebuilt);
916            fillet_count += 1;
917            if fillet_shell.is_none() {
918                fillet_shell = Some(si);
919            }
920        }
921    }
922    if fillet_count != n_faces {
923        return Err(KernelRefusal::unsupported(
924            KernelStage::Classify,
925            "fillet_count",
926            format!(
927            "round_convex_corner: expected {n_faces} fillet faces at the corner, found {fillet_count}"
928            ),
929        ));
930    }
931    let octant_shell = fillet_shell.unwrap();
932
933    // 4. Build the vertex patch reusing the fresh arcs + existing tangent
934    //    vertices.  An orthogonal trihedral corner uses the EXACT iso-parameter
935    //    octant (with its degenerate pole); any other trihedral corner uses the
936    //    general fitted spherical-TRIANGLE patch; N>3 faces use the general
937    //    spherical N-GON — both are the same builder (N real arc coedges, no
938    //    pole edge, fitted pcurves).
939    let octant_face = if orthogonal {
940        // Right-handed trihedral frame + m-ordered tangent vertices, exactly as
941        // the octant fast path expects (unchanged 3-face behaviour).
942        let mut m = [normals[0], normals[1], normals[2]];
943        if m[0].cross(m[1]).dot(m[2]) < 0.0 {
944            m.swap(0, 1);
945        }
946        let mut oct_ids = [0u64; 3];
947        let mut oct_pts = [Vec3::default(); 3];
948        for k in 0..3 {
949            let ideal = c.add(m[k].scale(radius));
950            let found = result
951                .vertices
952                .iter()
953                .find(|v| v.point.sub(ideal).length() < 1e-6)
954                .ok_or_else(|| {
955                    KernelRefusal::internal(
956                        KernelStage::Fragment,
957                        "octant_vertex",
958                        format!("round_convex_corner: no octant tangent vertex near {ideal:?}"),
959                    )
960                })?;
961            oct_ids[k] = found.id;
962            oct_pts[k] = found.point;
963        }
964        let (face, pole_edge) = build_octant_face(
965            c,
966            m,
967            radius,
968            oct_ids,
969            oct_pts,
970            &fresh_arcs,
971            name,
972            &mut next_id,
973        )?;
974        result.edges.push(pole_edge);
975        face
976    } else {
977        build_general_corner_patch(c, radius, &normals, &fresh_edges, true, name, &mut next_id)?
978    };
979
980    // 5. Splice everything in: new edges, rebuilt fillet loops, drop caps, add
981    //    the octant face.
982    result.edges.extend(fresh_edges);
983    for (si, shell) in result.shells.iter_mut().enumerate() {
984        shell.faces.retain(|f| !cap_face_ids.contains(&f.id));
985        for face in shell.faces.iter_mut() {
986            if let Some(coedges) = new_loops.get(&face.id) {
987                face.loops[0].coedges = coedges.clone();
988            }
989        }
990        if si == octant_shell {
991            shell.faces.push(octant_face.clone());
992        }
993    }
994
995    // Remove now-orphaned edges (used by no coedge) and vertices (referenced by
996    // no surviving edge).
997    let mut edge_uses: HashMap<u64, usize> = HashMap::default();
998    for shell in &result.shells {
999        for face in &shell.faces {
1000            for lp in &face.loops {
1001                for co in &lp.coedges {
1002                    *edge_uses.entry(co.edge_id).or_default() += 1;
1003                }
1004            }
1005        }
1006    }
1007    result
1008        .edges
1009        .retain(|e| edge_uses.get(&e.id).copied().unwrap_or(0) > 0);
1010    let referenced: HashSet<u64> = result
1011        .edges
1012        .iter()
1013        .flat_map(|e| [e.start_vertex_id, e.end_vertex_id])
1014        .collect();
1015    result.vertices.retain(|v| referenced.contains(&v.id));
1016
1017    // 6. Validate; surface the issues to the caller if any.
1018    let issues = result.validate();
1019    if !issues.is_empty() {
1020        return Err(KernelRefusal::internal(
1021            KernelStage::Validate,
1022            "validate",
1023            format!("round_convex_corner: {issues:?}"),
1024        ));
1025    }
1026    Ok(result)
1027}
1028
1029/// The three walls among `normals` (more than three outward plane normals
1030/// through the corner, at least one antiparallel pair) proven by their
1031/// tangent vertices: for every way of dropping one member of each
1032/// antiparallel pair that leaves exactly three normals, the ball at distance
1033/// `r` inside all three planes (`offset_plane_triple`) must have its three
1034/// tangent points `C + r·nᵢ` as vertices of `solid` (to 1e-6). `Some` only
1035/// when exactly one such triple exists; a scan without an antiparallel pair,
1036/// or with none or several proven triples, is `None` and left alone.
1037fn prune_step_walls(solid: &BrepSolid, corner: Vec3, radius: f64, normals: &[Vec3]) -> Option<Vec<Vec3>> {
1038    let mut pairs: Vec<(usize, usize)> = Vec::new();
1039    for i in 0..normals.len() {
1040        for j in (i + 1)..normals.len() {
1041            if normals[i].dot(normals[j]) < -(1.0 - 1e-6)
1042                && !pairs.iter().any(|(a, b)| *a == i || *b == i || *a == j || *b == j)
1043            {
1044                pairs.push((i, j));
1045            }
1046        }
1047    }
1048    if pairs.is_empty() || normals.len() - pairs.len() != 3 {
1049        return None;
1050    }
1051    let has_vertex = |ideal: Vec3| solid.vertices.iter().any(|v| v.point.sub(ideal).length() < 1e-6);
1052    let mut proven: Vec<Vec<Vec3>> = Vec::new();
1053    for choice in 0..(1usize << pairs.len()) {
1054        let dropped: Vec<usize> = pairs
1055            .iter()
1056            .enumerate()
1057            .map(|(k, (i, j))| if choice & (1 << k) == 0 { *j } else { *i })
1058            .collect();
1059        let kept: Vec<Vec3> = (0..normals.len()).filter(|i| !dropped.contains(i)).map(|i| normals[i]).collect();
1060        if kept.len() != 3 {
1061            continue;
1062        }
1063        let Ok(centre) = offset_plane_triple(corner, [kept[0], kept[1], kept[2]], [-radius; 3]) else {
1064            continue;
1065        };
1066        if kept.iter().all(|n| has_vertex(centre.add(n.scale(radius)))) {
1067            proven.push(kept);
1068        }
1069    }
1070    if proven.len() == 1 {
1071        proven.pop()
1072    } else {
1073        None
1074    }
1075}