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