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}