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