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