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