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