Skip to main content

brep_kernel/edit/direct_edit/
face_offset_sphere.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// Push a SPHERICAL analytic face by OFFSETTING its carrier to a concentric
5// sphere (backlog #4a Sphere × Plane through-centre + #4b off-centre / sphere).
6//
7// The methodology mirrors `offset_ruled_face` (a face is a TRIMMED region of an
8// infinite carrier surface; to push it we replace the carrier by its offset and
9// re-derive the trim against the fixed neighbour carriers):
10//
11//   * The exact offset of a sphere is a CONCENTRIC sphere of radius r ± δ. It is
12//     realised here as the exact uniform scale of the carrier about the sphere
13//     centre by k = r_new / r — an AFFINE map, so the scaled surface is byte-for-
14//     byte the same rational parameterisation (same knots/weights/seam azimuth/
15//     poles), only radially grown. This is the *exact* sphere offset the backlog
16//     item calls for; it is NOT the `offset_surface` sample-and-fit, which was
17//     tried first and does NOT re-recognise as a sphere for a full-sphere face
18//     (its Greville fit degenerates at the poles → `analytic() == None`). The
19//     affine scale keeps the pushed face's OWN-ONLY (seam meridian + pole) and
20//     THROUGH-CENTRE-rim pcurves valid pointwise: because S′(u,v) = c + k·(S − c),
21//     each such edge, scaled about c by the SAME k, lands on S′ at its OWN
22//     unchanged pcurve.
23//
24//   * RIM classification per boundary edge (honest refusal, never a bad solid):
25//       - PLANE through the centre → SCALE (4a): a uniform scale about c maps a
26//         through-centre plane onto ITSELF, so the scaled great-circle rim IS the
27//         exact S′ ∩ plane, no azimuth/seam mismatch.
28//       - PLANE off-centre, or a SPHERE neighbour → RECOMPUTE (4b): the scaled
29//         rim would leave the fixed carrier, so the rim edge is re-derived as
30//         S′ ∩ neighbour. Off-centre small circles: `intersect_plane_quadric`
31//         gives radius √((r±δ)²−h²); sphere × sphere: `intersect_sphere_sphere`
32//         (both via the public `intersect_analytic_pair`). The rim's pushed-face
33//         pcurve is rebuilt on S′; a SEAM-crossing rim (its closure vertex is
34//         shared with an own-only meridian — the axis-perpendicular cap case)
35//         also rebuilds that meridian as the v-subrange of the offset meridian
36//         (an iso-curve preserves the v-parameter exactly, keeping the straight
37//         (u,v) pcurve synchronised) and moves its closure vertex to the new
38//         latitude. Seam-aligned circle construction is used there so the rim
39//         starts on the u = 0 seam.
40//       - coaxial cylinder / cone → RECOMPUTE: the exact latitude circle is
41//         selected from the coaxial-revolution intersector and the ruled
42//         neighbour is extended/retrimmed over the relocated rim.
43//       - non-coaxial cylinder / cone, general revolution / torus / free-form
44//         → refused.
45//     Refused too: a seam-crossing rim on an OBLIQUE plane or a SPHERE neighbour,
46//     a non-closed / multi-edge RECOMPUTED rim, a vanished cap (grown sphere no
47//     longer reaches the plane / spheres separated or tangent), and a push that
48//     collapses or inverts the solid.
49//
50//   * An OPEN rim on the EXACT-SCALE path is supported, and its corners are
51//     carried. A spherical pocket in a box CORNER has three quarter-circle rims,
52//     one per box plane, all three through the centre; the scale moves each
53//     arc's endpoint radially and the box edge that ends there has its trim slid
54//     to follow. That slide is a re-parameterisation, not a re-solve: both
55//     planes of such a corner pass through the sphere centre (which is what put
56//     the rim on this path), so their intersection line does too and the scale
57//     maps the corner along it exactly. A corner that does NOT land on its own
58//     fixed edge, or a slide that would collapse or invert that edge's trim,
59//     refuses — the open-rim analogue of the recomputed path's closed-rim gate.
60// ---------------------------------------------------------------------------
61
62/// Push a SPHERICAL face along its outward normal by `distance` (positive grows
63/// the solid) by replacing its carrier with the concentric offset sphere and
64/// re-trimming its neighbours: planar caps through the centre keep their exact-
65/// scale great-circle rim, while off-centre planes, spheres, and coaxial ruled
66/// neighbours have their rim re-derived as S′ ∩ neighbour.
67///
68/// An OPEN rim (several arcs, as a pocket in a box corner has) is supported on
69/// the exact-scale path, with the fixed edges that end on its corners re-trimmed
70/// to follow them.
71///
72/// Refused cleanly (never a bad solid): non-coaxial cylinder/cone neighbours,
73/// general-revolution/torus/free-form neighbours, seam-crossing oblique or
74/// sphere-neighbour rims, non-closed RECOMPUTED rims, a rim corner that leaves
75/// its own fixed edge or collapses that edge's trim, a vanished cap, and a push
76/// that collapses/inverts the solid.
77pub fn offset_sphere_face(
78    solid: &BrepSolid,
79    face_id: u64,
80    distance: f64,
81) -> Result<BrepSolid, String> {
82    if !distance.is_finite() {
83        return Err("offset_sphere_face: distance must be finite".into());
84    }
85    let scale = solid_model_scale(solid);
86    let tolerance = (scale * 1e-7).max(1e-9);
87    let plane_tolerance = (scale * 1e-6).max(1e-7);
88
89    let (pshell, pface) = find_face(solid, face_id)
90        .ok_or_else(|| format!("offset_sphere_face: no face {face_id}"))?;
91    let pushed = &solid.shells[pshell].faces[pface];
92    let Some(AnalyticSurface::Sphere { frame, radius }) = pushed.surface.analytic() else {
93        return Err("offset_sphere_face: the pushed face is not a sphere".into());
94    };
95    let center = frame.origin;
96    let axis = frame.axis;
97    let seam_x = frame.x_axis;
98    let seam_y = frame.y_axis;
99    let radius = *radius;
100    if radius <= tolerance {
101        return Err("offset_sphere_face: degenerate sphere radius".into());
102    }
103
104    // Sign the push: `distance` is measured along the face's OUTWARD normal. For
105    // a convex ball the outward normal is radial (s = +1) so the radius grows; a
106    // spherical POCKET's outward normal points inward (s = −1) so a positive push
107    // (more material) SHRINKS the cavity. Either way r_new = r + distance·s.
108    let [u0, u1] = pushed.surface.domain_u()?;
109    let [v0, v1] = pushed.surface.domain_v()?;
110    let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
111    let point = pushed.surface.evaluate(um, vm)?;
112    let mut normal = pushed.surface.normal(um, vm)?;
113    if !pushed.same_sense {
114        normal = normal.scale(-1.0);
115    }
116    let radial = point.sub(center);
117    if radial.length() <= tolerance {
118        return Err("offset_sphere_face: degenerate radial direction".into());
119    }
120    let outward_sign = if normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
121    let radius_new = radius + distance * outward_sign;
122    if radius_new <= tolerance {
123        return Err(
124            "offset_sphere_face: the push collapses the sphere to or past its centre — refusing"
125                .into(),
126        );
127    }
128
129    // S′ = exact concentric offset = uniform scale of the carrier about the
130    // centre by k. An affine map, so the parameterisation is preserved exactly.
131    let k = radius_new / radius;
132    let scale_about_center = AffineTransform::new([
133        k, 0.0, 0.0, (1.0 - k) * center.x,
134        0.0, k, 0.0, (1.0 - k) * center.y,
135        0.0, 0.0, k, (1.0 - k) * center.z,
136        0.0, 0.0, 0.0, 1.0,
137    ])?;
138    let s_prime = transform_surface(&pushed.surface, scale_about_center)?;
139    // Defensive: confirm the offset is the concentric sphere we expect.
140    match s_prime.analytic() {
141        Some(AnalyticSurface::Sphere { frame, radius }) => {
142            if frame.origin.sub(center).length() > plane_tolerance
143                || (radius - radius_new).abs() > plane_tolerance.max(radius_new * 1e-9)
144            {
145                return Err("offset_sphere_face: offset carrier is not the expected \
146                            concentric sphere — refusing"
147                    .into());
148            }
149        }
150        _ => {
151            return Err(
152                "offset_sphere_face: offset carrier did not re-recognise as a sphere — refusing"
153                    .into(),
154            )
155        }
156    }
157
158    // Edge -> incident faces, to classify the pushed face's boundary edges into
159    // OWN-ONLY edges (seam meridians + degenerate poles — intrinsic to the sphere)
160    // and RIM edges shared with a fixed neighbour.
161    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
162    for shell in &solid.shells {
163        for face in &shell.faces {
164            for loop_record in &face.loops {
165                for coedge in &loop_record.coedges {
166                    faces_of_edge
167                        .entry(coedge.edge_id)
168                        .or_default()
169                        .push(face.id);
170                }
171            }
172        }
173    }
174    let edge_by_id: HashMap<u64, &EdgeRecord> =
175        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
176
177    // The v-parameter domain of the offset sphere (== the source domain; the
178    // uniform scale preserves the parameterisation exactly).
179    let [dv0, dv1] = s_prime.domain_v()?;
180
181    // Classify. A rim neighbour is one of:
182    //   * PLANE through the centre → exact-scale path (the landed 4a behaviour):
183    //     the scaled great-circle rim stays on the fixed plane.
184    //   * PLANE off-centre / SPHERE / coaxial CYLINDER or CONE → RECOMPUTE:
185    //     the scaled rim leaves the fixed carrier, so re-derive S′ ∩ neighbour.
186    //   * non-coaxial ruled / general revolution / torus / free-form → refused.
187    enum RimKind {
188        OffCentrePlane(Plane),
189        NeighbourSphere,
190        CoaxialRuled,
191    }
192    let mut edges_to_scale: HashSet<u64> = HashSet::default();
193    let mut through_centre_caps: HashSet<u64> = HashSet::default();
194    let mut own_only_edges: Vec<u64> = Vec::new();
195    let mut all_boundary_vertices: HashSet<u64> = HashSet::default();
196    let mut raw_rims: Vec<(u64, u64, RimKind)> = Vec::new();
197
198    for loop_record in &pushed.loops {
199        for coedge in &loop_record.coedges {
200            let edge = *edge_by_id
201                .get(&coedge.edge_id)
202                .ok_or_else(|| format!("offset_sphere_face: missing edge {}", coedge.edge_id))?;
203            all_boundary_vertices.insert(edge.start_vertex_id);
204            all_boundary_vertices.insert(edge.end_vertex_id);
205
206            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
207            if incident.iter().all(|f| *f == face_id) {
208                own_only_edges.push(edge.id); // Seam meridian or degenerate pole.
209                continue;
210            }
211            let neighbour = *incident
212                .iter()
213                .find(|f| **f != face_id)
214                .ok_or_else(|| format!("offset_sphere_face: edge {} has no neighbour", edge.id))?;
215            let (nshell, nface) = find_face(solid, neighbour)
216                .ok_or_else(|| format!("offset_sphere_face: missing neighbour {neighbour}"))?;
217            let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
218            match neighbour_surface.analytic() {
219                Some(AnalyticSurface::Plane { .. }) => {
220                    let plane =
221                        plane_of_surface(neighbour_surface, plane_tolerance, "offset_sphere_face")?;
222                    if center.sub(plane.origin).dot(plane.normal).abs() <= plane_tolerance {
223                        // Through the centre: the exact-scale great-circle path.
224                        through_centre_caps.insert(neighbour);
225                        edges_to_scale.insert(edge.id);
226                    } else {
227                        raw_rims.push((edge.id, neighbour, RimKind::OffCentrePlane(plane)));
228                    }
229                }
230                Some(AnalyticSurface::Sphere { .. }) => {
231                    raw_rims.push((edge.id, neighbour, RimKind::NeighbourSphere));
232                }
233                Some(AnalyticSurface::RuledRevolution { .. }) => {
234                    if !super::face_offset::ruled_neighbour_is_coaxial(
235                        neighbour_surface,
236                        center,
237                        axis,
238                        scale,
239                    ) {
240                        return Err("offset_sphere_face: a cylinder/cone rim neighbour is \
241                                    NON-coaxial (only coaxial sphere × ruled intersections are \
242                                    supported) — refusing"
243                            .into());
244                    }
245                    raw_rims.push((edge.id, neighbour, RimKind::CoaxialRuled));
246                }
247                Some(AnalyticSurface::Torus { .. }) => {
248                    return Err("offset_sphere_face: a toroidal rim neighbour is deferred \
249                                (torus is untouched in this slice)"
250                        .into());
251                }
252                _ => {
253                    return Err("offset_sphere_face: a rim neighbour is not a supported plane, \
254                                sphere, or coaxial cylinder/cone (general revolution and \
255                                free-form neighbours are deferred in this slice)"
256                        .into());
257                }
258            }
259        }
260    }
261
262    // Each recomputed rim must be a single CLOSED edge (start == end vertex); its
263    // closure vertex sits where the rim crosses the sphere seam.
264    let mut closure_vertices: HashSet<u64> = HashSet::default();
265    for (edge_id, _, _) in &raw_rims {
266        let edge = *edge_by_id.get(edge_id).unwrap();
267        if edge.start_vertex_id != edge.end_vertex_id {
268            return Err(
269                "offset_sphere_face: a recomputed rim is not a single closed edge \
270                        (multi-edge / open rims are deferred in this slice)"
271                    .into(),
272            );
273        }
274        closure_vertices.insert(edge.start_vertex_id);
275    }
276
277    // Own-only seam meridians whose end lands on a recomputed rim's closure vertex
278    // must be rebuilt (their rim end moves to the new latitude). Uncoupled seam
279    // meridians and poles just scale.
280    let mut coupled_meridians: HashSet<u64> = HashSet::default();
281    for own in &own_only_edges {
282        let e = *edge_by_id.get(own).unwrap();
283        if e.degenerate {
284            edges_to_scale.insert(*own);
285            continue;
286        }
287        if closure_vertices.contains(&e.start_vertex_id)
288            || closure_vertices.contains(&e.end_vertex_id)
289        {
290            coupled_meridians.insert(*own);
291        } else {
292            edges_to_scale.insert(*own);
293        }
294    }
295    // Every boundary vertex scales EXCEPT the relocated rim-closure vertices.
296    let vertices_to_scale: HashSet<u64> = all_boundary_vertices
297        .iter()
298        .copied()
299        .filter(|v| !closure_vertices.contains(v))
300        .collect();
301
302    // --- Build the recomputed rims (geometry only; applied below) ----------
303    struct RimBuild {
304        edge_id: u64,
305        neighbour_id: u64,
306        neighbour_is_sphere: bool,
307        closure_vertex: u64,
308        circle: NurbsCurve,
309        closure_point: Vec3,
310        v_rim: f64,
311        pushed_pcurve: NurbsCurve,
312    }
313    let mut rim_builds: Vec<RimBuild> = Vec::new();
314    for (edge_id, neighbour, kind) in &raw_rims {
315        let edge = *edge_by_id.get(edge_id).unwrap();
316        let closure_vertex = edge.start_vertex_id;
317        let seam_coupled = coupled_meridians.iter().any(|m| {
318            let e = *edge_by_id.get(m).unwrap();
319            e.start_vertex_id == closure_vertex || e.end_vertex_id == closure_vertex
320        });
321
322        let mut circle = if seam_coupled {
323            // A seam-crossing rim shares its closure vertex with a meridian, so
324            // the new circle MUST start exactly on the seam. Build it in the
325            // sphere's own frame (seam-aligned by construction). Only an
326            // axis-perpendicular (latitude) plane keeps the rim a fixed-u circle;
327            // oblique seam-crossing caps and sphere neighbours are refused.
328            match kind {
329                RimKind::OffCentrePlane(plane) => {
330                    if plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
331                        return Err(
332                            "offset_sphere_face: a seam-crossing rim on an OBLIQUE plane is \
333                                    deferred in this slice (only axis-perpendicular caps)"
334                                .into(),
335                        );
336                    }
337                    let a = plane.origin.sub(center).dot(axis);
338                    let rr2 = radius_new * radius_new - a * a;
339                    if rr2 <= tolerance * tolerance {
340                        return Err(
341                            "offset_sphere_face: the push pulls the grown sphere off its cap \
342                                    plane (the cap vanishes) — refusing"
343                                .into(),
344                        );
345                    }
346                    crate::make_arc(
347                        center.add(axis.scale(a)),
348                        seam_x,
349                        seam_y,
350                        rr2.sqrt(),
351                        0.0,
352                        std::f64::consts::TAU,
353                    )?
354                }
355                RimKind::NeighbourSphere => {
356                    return Err(
357                        "offset_sphere_face: a sphere neighbour whose rim crosses the \
358                                pushed sphere's seam is deferred in this slice"
359                            .into(),
360                    )
361                }
362                RimKind::CoaxialRuled => {
363                    // A coaxial ruled neighbour meets the sphere in latitude
364                    // circles. The analytic intersector below constructs them
365                    // in the pushed sphere's revolution frame, so the selected
366                    // circle starts on this same seam and can move the coupled
367                    // meridian exactly.
368                    let (ns, nf) = find_face(solid, *neighbour).unwrap();
369                    let neighbour_surface = &solid.shells[ns].faces[nf].surface;
370                    let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
371                        .filter(|curves| !curves.is_empty())
372                        .ok_or_else(|| {
373                            "offset_sphere_face: the grown sphere no longer meets its coaxial \
374                             cylinder/cone neighbour — refusing"
375                                .to_string()
376                        })?;
377                    let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
378                    nearest_circle(&curves, old_mid)?
379                }
380            }
381        } else {
382            let (ns, nf) = find_face(solid, *neighbour).unwrap();
383            let neighbour_surface = &solid.shells[ns].faces[nf].surface;
384            let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
385                .filter(|curves| !curves.is_empty())
386                .ok_or_else(|| {
387                    "offset_sphere_face: the grown sphere no longer meets a neighbour (the cap \
388                     vanishes / tangent / separated) — refusing"
389                        .to_string()
390                })?;
391            let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
392            nearest_circle(&curves, old_mid)?
393        };
394
395        // Match the new rim's traversal to the old edge so the preserved coedge
396        // `forward` flags keep the loop winding consistent.
397        let closure_old = edge.curve.evaluate(edge.t0)?;
398        let old_tan = edge
399            .curve
400            .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0))?
401            .sub(closure_old);
402        let [c0, c1] = circle.domain()?;
403        let new_start = circle.evaluate(c0)?;
404        let new_tan = circle.evaluate(c0 + 0.01 * (c1 - c0))?.sub(new_start);
405        if old_tan.dot(new_tan) < 0.0 {
406            circle = circle.reversed()?;
407        }
408        let closure_point = circle.evaluate(circle.domain()?[0])?;
409        let pushed_pcurve = build_pcurve_on_surface(&s_prime, &circle)?;
410        // The rim's v on the offset sphere (constant for a latitude rim; only the
411        // seam-coupled meridian rebuild consumes it).
412        let [q0, q1] = pushed_pcurve.domain()?;
413        let v_rim = pushed_pcurve.evaluate(0.5 * (q0 + q1))?.y;
414
415        rim_builds.push(RimBuild {
416            edge_id: *edge_id,
417            neighbour_id: *neighbour,
418            neighbour_is_sphere: matches!(kind, RimKind::NeighbourSphere),
419            closure_vertex,
420            circle,
421            closure_point,
422            v_rim,
423            pushed_pcurve,
424        });
425    }
426
427    // The offset sphere's u = 0 seam meridian (parameterised by v exactly: an
428    // iso-curve preserves the surface v-parameter, so a straight (u, v) pcurve
429    // stays synchronised with it — a fresh `make_arc` would not).
430    let iso_meridian = s_prime.iso_curve_u(0.0)?;
431
432    // --- Apply to a fresh clone (the input is never mutated) ---------------
433    let mut result = solid.clone();
434
435    // Scale the intrinsic own-only edges + any through-centre great-circle rims.
436    for edge in &mut result.edges {
437        if edges_to_scale.contains(&edge.id) {
438            edge.curve = transform_curve(&edge.curve, scale_about_center)?;
439        }
440    }
441    // Recomputed rim edges take their new circle.
442    for rb in &rim_builds {
443        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rb.edge_id) {
444            let [d0, d1] = rb.circle.domain()?;
445            edge.curve = rb.circle.clone();
446            edge.t0 = d0;
447            edge.t1 = d1;
448        }
449    }
450    // Rebuild each seam-coupled own edge as the matching v-subrange of the
451    // offset meridian. The fixed endpoint can be a pole or another trim split.
452    for mer in &coupled_meridians {
453        let e = *edge_by_id.get(mer).unwrap();
454        let cv = if closure_vertices.contains(&e.start_vertex_id) {
455            e.start_vertex_id
456        } else {
457            e.end_vertex_id
458        };
459        let v_rim = rim_builds
460            .iter()
461            .find(|rb| rb.closure_vertex == cv)
462            .map(|rb| rb.v_rim)
463            .ok_or_else(|| {
464                "offset_sphere_face: a seam meridian is not paired with a rim — refusing"
465                    .to_string()
466            })?;
467        let closure_at_start = e.start_vertex_id == cv;
468        let fixed_vertex = if closure_at_start {
469            e.end_vertex_id
470        } else {
471            e.start_vertex_id
472        };
473        let fixed_old = solid
474            .vertices
475            .iter()
476            .find(|v| v.id == fixed_vertex)
477            .map(|v| v.point)
478            .ok_or_else(|| format!("offset_sphere_face: missing seam vertex {fixed_vertex}"))?;
479        let fixed_new = scale_about_center.point(fixed_old);
480        let fixed_projection = project_point_to_curve(&iso_meridian, fixed_new)?;
481        if fixed_projection.distance > plane_tolerance {
482            return Err(format!(
483                "offset_sphere_face: a coupled own-edge is not on the sphere seam \
484                 (off by {:.3e}) — refusing",
485                fixed_projection.distance
486            ));
487        }
488        let v_start = if closure_at_start {
489            v_rim
490        } else {
491            fixed_projection.u
492        };
493        let v_end = if closure_at_start {
494            fixed_projection.u
495        } else {
496            v_rim
497        };
498        let (curve, t0, t1) = if v_start <= v_end {
499            (iso_meridian.clone(), v_start, v_end)
500        } else {
501            (
502                iso_meridian.reversed()?,
503                dv0 + dv1 - v_start,
504                dv0 + dv1 - v_end,
505            )
506        };
507        if let Some(edge) = result.edges.iter_mut().find(|x| x.id == *mer) {
508            edge.curve = curve;
509            edge.t0 = t0;
510            edge.t1 = t1;
511        }
512    }
513
514    // Vertices: scale the intrinsic ones, relocate the rim-closure ones.
515    for vertex in &mut result.vertices {
516        if vertices_to_scale.contains(&vertex.id) {
517            vertex.point = scale_about_center.point(vertex.point);
518        }
519    }
520    for rb in &rim_builds {
521        if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rb.closure_vertex) {
522            v.point = rb.closure_point;
523        }
524    }
525
526    // The pushed face rides the offset carrier. Own-only + through-centre pcurves
527    // are preserved (scale-invariant in (u, v)); recomputed rims and coupled
528    // meridians get fresh pcurves.
529    {
530        let pface_rec = &mut result.shells[pshell].faces[pface];
531        for loop_record in &mut pface_rec.loops {
532            for coedge in &mut loop_record.coedges {
533                if let Some(rb) = rim_builds.iter().find(|rb| rb.edge_id == coedge.edge_id) {
534                    let mut pcurve = rb.pushed_pcurve.clone();
535                    if !coedge.forward {
536                        pcurve = pcurve.reversed()?;
537                    }
538                    coedge.pcurve = pcurve;
539                } else if coupled_meridians.contains(&coedge.edge_id) {
540                    let e = *edge_by_id.get(&coedge.edge_id).unwrap();
541                    let cv = if closure_vertices.contains(&e.start_vertex_id) {
542                        e.start_vertex_id
543                    } else {
544                        e.end_vertex_id
545                    };
546                    let v_rim = rim_builds
547                        .iter()
548                        .find(|rb| rb.closure_vertex == cv)
549                        .map(|rb| rb.v_rim)
550                        .unwrap();
551                    let closure_at_pcurve_start = (e.start_vertex_id == cv) == coedge.forward;
552                    coedge.pcurve =
553                        patch_meridian_pcurve(&coedge.pcurve, v_rim, closure_at_pcurve_start)?;
554                }
555            }
556        }
557    }
558    result.shells[pshell].faces[pface].surface = s_prime;
559
560    // A coaxial ruled neighbour has its own straight seam meridian ending at
561    // the shared rim's relocated closure vertex. Rebuild that seam between its
562    // current endpoints before extending/retrimming the neighbour carrier.
563    let result_vertex: HashMap<u64, Vec3> =
564        result.vertices.iter().map(|v| (v.id, v.point)).collect();
565    let mut ruled_seams: HashSet<u64> = HashSet::default();
566    for rb in &rim_builds {
567        let (ns, nf) = find_face(&result, rb.neighbour_id)
568            .ok_or_else(|| format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id))?;
569        if !matches!(
570            result.shells[ns].faces[nf].surface.analytic(),
571            Some(AnalyticSurface::RuledRevolution { .. })
572        ) {
573            continue;
574        }
575        for loop_record in &result.shells[ns].faces[nf].loops {
576            for coedge in &loop_record.coedges {
577                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
578                    format!("offset_sphere_face: missing edge {}", coedge.edge_id)
579                })?;
580                let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
581                let is_seam = incident.iter().all(|f| *f == rb.neighbour_id);
582                let touches_rim = closure_vertices.contains(&edge.start_vertex_id)
583                    || closure_vertices.contains(&edge.end_vertex_id);
584                if is_seam && touches_rim {
585                    ruled_seams.insert(edge.id);
586                }
587            }
588        }
589    }
590    let rebuilt_ruled_seams: HashSet<u64> = ruled_seams.iter().copied().collect();
591    for seam_id in ruled_seams {
592        let edge = *edge_by_id
593            .get(&seam_id)
594            .ok_or_else(|| format!("offset_sphere_face: missing ruled seam {seam_id}"))?;
595        let curve = make_line(
596            result_vertex[&edge.start_vertex_id],
597            result_vertex[&edge.end_vertex_id],
598        )?;
599        if let Some(out) = result
600            .edges
601            .iter_mut()
602            .find(|candidate| candidate.id == seam_id)
603        {
604            let [t0, t1] = curve.domain()?;
605            out.curve = curve;
606            out.t0 = t0;
607            out.t1 = t1;
608        }
609    }
610
611    // --- FIXED edges that END on a relocated rim corner --------------------
612    //
613    // Reported 2026-09-01 ("Push face fails"): a spherical pocket in a box
614    // CORNER — three quarter-circle rims, each on a box plane through the
615    // sphere centre — pushed inward produced a solid the validator rejected
616    // with `edge 1 curve start does not match vertex 1 (gap=1.5)`.
617    //
618    // Why nothing caught it. A recomputed rim must be a single CLOSED edge
619    // (the guard a hundred lines up); the EXACT-SCALE path a through-centre
620    // plane takes has no such guard, because a closed great circle has no
621    // endpoint to leave behind. An OPEN rim arc has two, and each is also the
622    // end of a FIXED edge of the two neighbour planes — the box's own axis
623    // edge. The push scales the corner radially; that edge's CURVE does not
624    // move (a plane × plane line the push does not touch) but its TRIM must
625    // follow the corner, or the solid carries an edge whose curve no longer
626    // reaches its own vertex.
627    //
628    // The slide is a re-parameterisation, not a re-solve, and provably so: a
629    // corner of an exact-scale rim is shared by two planes that BOTH pass
630    // through the sphere centre (that is what put the rim on this path), so
631    // their intersection line passes through the centre and the uniform scale
632    // about the centre maps the corner along it exactly. The projection below
633    // is the PROOF of that, not a fit — a corner that does not land on its own
634    // fixed edge (a rim whose neighbour is off-centre, or a corner against a
635    // curved carrier) refuses instead of being nudged.
636    let mut relocated: HashMap<u64, Vec3> = HashMap::default();
637    for vertex in &result.vertices {
638        let Some(old) = solid.vertices.iter().find(|o| o.id == vertex.id) else {
639            continue;
640        };
641        if vertex.point.sub(old.point).length() > tolerance {
642            relocated.insert(vertex.id, vertex.point);
643        }
644    }
645    let already_rebuilt: HashSet<u64> = edges_to_scale
646        .iter()
647        .copied()
648        .chain(rim_builds.iter().map(|rb| rb.edge_id))
649        .chain(coupled_meridians.iter().copied())
650        .chain(rebuilt_ruled_seams.iter().copied())
651        .collect();
652    let mut resized_edges: HashSet<u64> = HashSet::default();
653    for edge in &mut result.edges {
654        if edge.degenerate || already_rebuilt.contains(&edge.id) {
655            continue;
656        }
657        for at_start in [true, false] {
658            let vertex_id = if at_start {
659                edge.start_vertex_id
660            } else {
661                edge.end_vertex_id
662            };
663            let Some(&target) = relocated.get(&vertex_id) else {
664                continue;
665            };
666            let projection = project_point_to_curve(&edge.curve, target)?;
667            if projection.distance > 10.0 * tolerance {
668                return Err(format!(
669                    "offset_sphere_face: the push moves rim corner (vertex {vertex_id}) OFF \
670                     fixed edge {} by {:.3e} — a rim corner that leaves its own side edge \
671                     needs the corner re-solved against the neighbour carriers, which is \
672                     deferred — refusing",
673                    edge.id, projection.distance
674                ));
675            }
676            let [d0, d1] = edge.curve.domain()?;
677            let span = (d1 - d0).max(1e-12);
678            let fixed_t = if at_start { edge.t1 } else { edge.t0 };
679            let old_t = if at_start { edge.t0 } else { edge.t1 };
680            let new_t = projection.u;
681            if (new_t - fixed_t) * (old_t - fixed_t) <= 0.0
682                || (new_t - fixed_t).abs() <= 1e-7 * span
683            {
684                return Err(format!(
685                    "offset_sphere_face: the push collapses or inverts the trim of fixed edge \
686                     {} at rim corner (vertex {vertex_id}) — refusing",
687                    edge.id
688                ));
689            }
690            if at_start {
691                edge.t0 = new_t;
692            } else {
693                edge.t1 = new_t;
694            }
695            resized_edges.insert(edge.id);
696        }
697    }
698
699    // Re-trim the neighbours around their new rims.
700    let final_edges: HashMap<u64, EdgeRecord> =
701        result.edges.iter().map(|e| (e.id, e.clone())).collect();
702    for rb in &rim_builds {
703        let (ns, nf) = find_face(&result, rb.neighbour_id)
704            .ok_or_else(|| format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id))?;
705        if rb.neighbour_is_sphere {
706            // A fixed sphere neighbour: only the shared rim coedge's pcurve moves.
707            let nsurf = result.shells[ns].faces[nf].surface.clone();
708            for loop_record in &mut result.shells[ns].faces[nf].loops {
709                for coedge in &mut loop_record.coedges {
710                    if coedge.edge_id == rb.edge_id {
711                        let mut pcurve = build_pcurve_on_surface(&nsurf, &rb.circle)?;
712                        if !coedge.forward {
713                            pcurve = pcurve.reversed()?;
714                        }
715                        coedge.pcurve = pcurve;
716                    }
717                }
718            }
719        } else if matches!(
720            result.shells[ns].faces[nf].surface.analytic(),
721            Some(AnalyticSurface::RuledRevolution { .. })
722        ) {
723            super::face_offset::retrim_offset_ruled_face(
724                &mut result,
725                rb.neighbour_id,
726                &final_edges,
727                tolerance,
728            )?;
729        } else {
730            let plane = plane_of_surface(
731                &result.shells[ns].faces[nf].surface,
732                plane_tolerance,
733                "offset_sphere_face",
734            )?;
735            retrim_planar_face(
736                &mut result.shells[ns].faces[nf],
737                &plane,
738                &final_edges,
739                scale,
740                "offset_sphere_face",
741            )?;
742            refit_subrange_pcurves(
743                &mut result.shells[ns].faces[nf],
744                &final_edges,
745                tolerance,
746            )?;
747        }
748    }
749    // Through-centre planar caps re-trim around their grown great-circle rims.
750    for cap in &through_centre_caps {
751        let (cshell, cface) = find_face(&result, *cap)
752            .ok_or_else(|| format!("offset_sphere_face: missing cap {cap}"))?;
753        let plane = plane_of_surface(
754            &result.shells[cshell].faces[cface].surface,
755            plane_tolerance,
756            "offset_sphere_face",
757        )?;
758        retrim_planar_face(
759            &mut result.shells[cshell].faces[cface],
760            &plane,
761            &final_edges,
762            scale,
763            "offset_sphere_face",
764        )?;
765        refit_subrange_pcurves(
766            &mut result.shells[cshell].faces[cface],
767            &final_edges,
768            tolerance,
769        )?;
770    }
771    // Any OTHER face carrying an edge whose trim the corner slide moved keeps
772    // its carrier — only the pcurve of that one coedge has to be re-fitted over
773    // the new `[t0, t1]`.
774    for shell in &mut result.shells {
775        for face in &mut shell.faces {
776            if face
777                .loops
778                .iter()
779                .flat_map(|l| &l.coedges)
780                .any(|c| resized_edges.contains(&c.edge_id))
781            {
782                refit_touched_pcurves(
783                    face,
784                    &final_edges,
785                    &resized_edges,
786                    true,
787                    tolerance,
788                    "offset_sphere_face",
789                )?;
790            }
791        }
792    }
793
794    let issues = result.validate();
795    if !issues.is_empty() {
796        return Err(format!(
797            "offset_sphere_face: pushed solid failed validation: {issues:?}"
798        ));
799    }
800    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
801        if before * after <= 0.0 {
802            return Err("offset_sphere_face: the push inverts the solid — refusing".into());
803        }
804    }
805    Ok(result)
806}
807
808/// `retrim_planar_face` rebuilds every pcurve with [`PcurveFit::WholeCurve`],
809/// which maps an edge's WHOLE curve onto the fresh carrier. That is right for a
810/// full-domain edge and WRONG for an edge that is a strict SUBRANGE of its own
811/// curve: `validate()` samples a pcurve over its whole domain against the edge
812/// over `[t0, t1]`, so the pcurve of a partially-trimmed edge reads as a
813/// deviation the size of the trimmed-off overhang. `move_faces` has always
814/// paired its planar re-trim with this pass (`face_move.rs`, the
815/// `refit_touched_pcurves` call after `retrim_planar_face`); the sphere push
816/// did not, which is what made a spherical pocket in a box CORNER fail
817/// validation with `max deviation 5.000000` — exactly the length of box edge
818/// the original sphere had trimmed away.
819///
820/// `only_subrange = true`, so a face whose edges are all full-domain — every
821/// shape the corpus covers — keeps byte-identical pcurves.
822fn refit_subrange_pcurves(
823    face: &mut FaceRecord,
824    edges: &HashMap<u64, EdgeRecord>,
825    tolerance: f64,
826) -> Result<(), String> {
827    let face_edges: HashSet<u64> = face
828        .loops
829        .iter()
830        .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
831        .collect();
832    refit_touched_pcurves(
833        face,
834        edges,
835        &face_edges,
836        true,
837        tolerance,
838        "offset_sphere_face",
839    )
840}
841
842/// The intersection circle whose parameter-midpoint is nearest `reference`
843/// (branch selection when S′ ∩ neighbour returns more than one component).
844fn nearest_circle(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
845    let mut best: Option<(f64, &NurbsCurve)> = None;
846    for curve in curves {
847        let [d0, d1] = curve.domain()?;
848        let mid = curve.evaluate(0.5 * (d0 + d1))?;
849        let distance = mid.sub(reference).length();
850        if best.map(|(known, _)| distance < known).unwrap_or(true) {
851            best = Some((distance, curve));
852        }
853    }
854    best.map(|(_, curve)| curve.clone())
855        .ok_or_else(|| "offset_sphere_face: the sphere ∩ neighbour intersection is empty".into())
856}
857
858/// Move a seam-meridian pcurve's RIM endpoint to the new latitude `v_new`,
859/// keeping its pole endpoint and constant u. The pcurve is a straight (u, v)
860/// parameter line; the pole endpoint is the one whose v is at a domain end
861/// (0 or 1); the other endpoint is the rim that the offset relocated.
862fn patch_meridian_pcurve(
863    pcurve: &NurbsCurve,
864    v_new: f64,
865    closure_at_start: bool,
866) -> Result<NurbsCurve, String> {
867    let [q0, q1] = pcurve.domain()?;
868    let start = pcurve.evaluate(q0)?;
869    let end = pcurve.evaluate(q1)?;
870    let (v_start, v_end) = if closure_at_start {
871        (v_new, end.y)
872    } else {
873        (start.y, v_new)
874    };
875    make_line(
876        Vec3::new(start.x, v_start, 0.0),
877        Vec3::new(end.x, v_end, 0.0),
878    )
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::{make_sphere_brep, AnalyticSurface};
885
886    fn sphere_face_id(solid: &BrepSolid) -> u64 {
887        solid
888            .shells
889            .iter()
890            .flat_map(|shell| &shell.faces)
891            .find(|face| {
892                matches!(
893                    face.surface.analytic(),
894                    Some(AnalyticSurface::Sphere { .. })
895                )
896            })
897            .expect("a spherical face")
898            .id
899    }
900
901    fn sphere_radius(solid: &BrepSolid) -> f64 {
902        solid
903            .shells
904            .iter()
905            .flat_map(|shell| &shell.faces)
906            .find_map(|face| match face.surface.analytic() {
907                Some(AnalyticSurface::Sphere { radius, .. }) => Some(*radius),
908                _ => None,
909            })
910            .expect("a spherical radius")
911    }
912
913    // Radius, measured off the given axis, of the rim edge shared between the
914    // spherical face and a PLANAR neighbour (the small-circle cap rim).
915    fn rim_radius_off_axis(solid: &BrepSolid, axis_point: Vec3, axis: Vec3) -> f64 {
916        let mut faces_of_edge: std::collections::HashMap<u64, Vec<u64>> = Default::default();
917        let mut kind: std::collections::HashMap<u64, &'static str> = Default::default();
918        for shell in &solid.shells {
919            for face in &shell.faces {
920                let k = match face.surface.analytic() {
921                    Some(AnalyticSurface::Sphere { .. }) => "sphere",
922                    Some(AnalyticSurface::Plane { .. }) => "plane",
923                    _ => "other",
924                };
925                kind.insert(face.id, k);
926                for lp in &face.loops {
927                    for ce in &lp.coedges {
928                        faces_of_edge.entry(ce.edge_id).or_default().push(face.id);
929                    }
930                }
931            }
932        }
933        for edge in &solid.edges {
934            if edge.degenerate {
935                continue;
936            }
937            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
938            let has_sphere = incident.iter().any(|f| kind.get(f) == Some(&"sphere"));
939            let has_plane = incident.iter().any(|f| kind.get(f) == Some(&"plane"));
940            if has_sphere && has_plane {
941                let point = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).unwrap();
942                let delta = point.sub(axis_point);
943                let radial = delta.sub(axis.scale(delta.dot(axis)));
944                return radial.length();
945            }
946        }
947        panic!("no sphere×plane rim edge found");
948    }
949
950
951    // --- open (multi-arc) rims: the corners the exact-scale path must carry ---
952
953    /// The 2026-09-01 inbox report's shape, at the direct entry: a sphere
954    /// SUBTRACTED at a box's own corner, so the cavity is a spherical OCTANT
955    /// whose rim is three quarter-circle arcs — one on each of the three box
956    /// planes meeting there, all three through the sphere centre, so all three
957    /// take the exact-scale path.
958    fn box_corner_pocket(size: f64, radius: f64) -> BrepSolid {
959        let block = crate::make_box_brep(Vec3::default(), size, size, size).unwrap();
960        let ball =
961            make_sphere_brep(Vec3::default(), radius, Vec3::new(0.0, 0.0, 1.0)).unwrap();
962        crate::boolean_operation(
963            &block,
964            &ball,
965            crate::BooleanOperation::Subtract,
966            &crate::BooleanOptions {
967                merge_coplanar_faces: true,
968                ..crate::BooleanOptions::default()
969            },
970        )
971        .expect("box − corner ball")
972    }
973
974    /// Every edge's trimmed curve must reach the vertex it is registered
975    /// against. The reported failure was three box edges whose curve still
976    /// started `gap = 1.5` — the push distance — away from their own corner.
977    fn every_edge_reaches_its_vertices(solid: &BrepSolid) {
978        for edge in &solid.edges {
979            if edge.degenerate {
980                continue;
981            }
982            for (parameter, vertex_id) in [
983                (edge.t0, edge.start_vertex_id),
984                (edge.t1, edge.end_vertex_id),
985            ] {
986                let point = edge.curve.evaluate(parameter).expect("edge evaluates");
987                let vertex = solid
988                    .vertices
989                    .iter()
990                    .find(|v| v.id == vertex_id)
991                    .unwrap_or_else(|| panic!("missing vertex {vertex_id}"));
992                let gap = point.sub(vertex.point).length();
993                assert!(
994                    gap < 1e-9,
995                    "edge {} does not reach vertex {vertex_id}: gap {gap}",
996                    edge.id
997                );
998            }
999        }
1000    }
1001
1002    /// An OPEN rim — three arcs, not one closed circle — pushed both ways. The
1003    /// exact-scale path carries each arc's corner radially and the box edge that
1004    /// ends there must follow. The oracle is independent of the kernel's
1005    /// geometry: a spherical OCTANT cavity changing from `r` to `r′` moves
1006    /// exactly `(π/6)(r³ − r′³)` of material, whatever else is in the part.
1007    #[test]
1008    fn push_a_spherical_pocket_in_a_box_corner_carries_its_rim_corners() {
1009        let (size, radius) = (20.0_f64, 5.0_f64);
1010        let solid = box_corner_pocket(size, radius);
1011        assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
1012        // Fixture guard: the rim really is OPEN — three arcs, no closed circle.
1013        let face = sphere_face_id(&solid);
1014        let rim: Vec<&EdgeRecord> = solid
1015            .shells
1016            .iter()
1017            .flat_map(|shell| &shell.faces)
1018            .filter(|f| f.id == face)
1019            .flat_map(|f| &f.loops)
1020            .flat_map(|l| &l.coedges)
1021            .filter_map(|c| solid.edges.iter().find(|e| e.id == c.edge_id))
1022            .collect();
1023        assert_eq!(rim.len(), 3, "an octant cavity has three rim arcs");
1024        assert!(
1025            rim.iter().all(|e| e.start_vertex_id != e.end_vertex_id),
1026            "every rim arc must be OPEN — a closed rim never reaches this path"
1027        );
1028        let before = solid_signed_volume(&solid).unwrap().abs();
1029
1030        for distance in [1.5_f64, -1.5, 3.0, -3.0] {
1031            let pushed = offset_sphere_face(&solid, face, distance)
1032                .unwrap_or_else(|e| panic!("pocket push {distance}: {e}"));
1033            let issues = pushed.validate();
1034            assert!(issues.is_empty(), "push {distance} is invalid: {issues:?}");
1035            every_edge_reaches_its_vertices(&pushed);
1036            // A pocket's outward normal points INTO the solid, so a positive
1037            // push (more material) SHRINKS the cavity.
1038            let shrunk = radius - distance;
1039            assert!(
1040                (sphere_radius(&pushed) - shrunk).abs() < 1e-9,
1041                "push {distance}: radius {} vs {shrunk}",
1042                sphere_radius(&pushed)
1043            );
1044            let gained = solid_signed_volume(&pushed).unwrap().abs() - before;
1045            let expected =
1046                std::f64::consts::PI / 6.0 * (radius.powi(3) - shrunk.powi(3));
1047            // `solid_signed_volume` integrates a spherical patch numerically, so
1048            // the oracle is compared RELATIVELY. Measured here: 8.7e-4 on 43.0,
1049            // 1.6e-3 on 78.3, 1.2e-3 on 61.3, 4.1e-3 on 202.6 — a flat 2e-5
1050            // relative, so 1e-4 is a 5x margin over the quadrature and three
1051            // orders tighter than any heal error: a corner left behind moves
1052            // whole units of material, not thousandths.
1053            assert!(
1054                (gained - expected).abs() < 1e-4 * expected.abs(),
1055                "push {distance}: gained {gained}, expected {expected}"
1056            );
1057            assert_eq!(pushed.edges.len(), solid.edges.len(), "topology untouched");
1058            assert_eq!(
1059                pushed.vertices.len(),
1060                solid.vertices.len(),
1061                "topology untouched"
1062            );
1063        }
1064        assert!(solid.validate().is_empty(), "the input is never mutated");
1065    }
1066
1067    /// The bound on the same push, and the guard that states it. Growing the
1068    /// pocket slides each rim corner along its box edge toward the FAR end; at
1069    /// `r = size` the corner reaches that end and the box edge's trim collapses.
1070    /// The push just inside the bound still works, which is what makes this a
1071    /// bound and not a blanket refusal.
1072    #[test]
1073    fn growing_a_corner_pocket_until_its_rim_corner_reaches_the_far_end_refuses() {
1074        let (size, radius) = (20.0_f64, 5.0_f64);
1075        let solid = box_corner_pocket(size, radius);
1076        let face = sphere_face_id(&solid);
1077        // Just inside: r = 19.5 on a 20 box.
1078        let ok = offset_sphere_face(&solid, face, -(size - radius - 0.5))
1079            .expect("a pocket that still leaves a sliver of box edge heals");
1080        assert!(ok.validate().is_empty(), "{:?}", ok.validate());
1081        every_edge_reaches_its_vertices(&ok);
1082        // At and past it: r >= 20, so the corner lands on (or past) the far
1083        // vertex of every box axis edge.
1084        // Exactly at the far end (r = 20 on a 20 box): the corner lands ON the
1085        // box edge's other vertex, so the trim collapses.
1086        let collapse = offset_sphere_face(&solid, face, -(size - radius))
1087            .expect_err("a rim corner exactly at the far end must refuse");
1088        assert!(
1089            collapse.contains("collapses or inverts the trim of fixed edge"),
1090            "expected the trim-collapse guard, got: {collapse}"
1091        );
1092        // Past it: the scaled corner is no longer ON the box edge at all, which
1093        // is the guard that keeps the slide a re-parameterisation rather than a
1094        // nudge onto the nearest point of a curve it has left.
1095        for over in [0.5_f64, 5.0] {
1096            let error = offset_sphere_face(&solid, face, -(size - radius) - over)
1097                .expect_err("a rim corner past the end of its own edge must refuse");
1098            assert!(
1099                error.contains("moves rim corner") && error.contains("OFF fixed edge"),
1100                "expected the corner-off-its-edge guard, got: {error}"
1101            );
1102        }
1103        assert!(solid.validate().is_empty(), "the input is never mutated");
1104    }
1105
1106    // A FULL sphere pushed OUT (radius grows) and IN (radius shrinks): the carrier
1107    // becomes a concentric sphere, no neighbours, and the volume is exactly the
1108    // ball volume on the new radius. The pure-scale base case.
1109    #[test]
1110    fn push_full_sphere_changes_radius() {
1111        for (d, r_new) in [(2.0_f64, 7.0_f64), (-2.0, 3.0)] {
1112            let sphere =
1113                make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1114            let face = sphere_face_id(&sphere);
1115            let pushed = offset_sphere_face(&sphere, face, d)
1116                .unwrap_or_else(|e| panic!("push full sphere by {d}: {e}"));
1117            let issues = pushed.validate();
1118            assert!(issues.is_empty(), "validate {d}: {issues:?}");
1119            assert!(
1120                (sphere_radius(&pushed) - r_new).abs() < 1e-9,
1121                "radius after push {d}: got {}, want {r_new}",
1122                sphere_radius(&pushed)
1123            );
1124            let got = solid_signed_volume(&pushed).unwrap().abs();
1125            let expected = 4.0 / 3.0 * std::f64::consts::PI * r_new.powi(3);
1126            assert!(
1127                (got - expected).abs() < 1e-2,
1128                "ball volume {got}, want {expected}"
1129            );
1130        }
1131    }
1132
1133    // A HEMISPHERICAL POCKET: a box with a half-sphere cavity whose rim is a
1134    // great circle on the box's top face (a plane through the sphere centre).
1135    // Pushing the spherical face +δ GROWS the solid (fills the cavity → the
1136    // pocket radius shrinks by δ); −δ deepens it. The top face (multi-loop, the
1137    // rim is an internal loop) re-trims around the grown great circle.
1138    #[test]
1139    fn push_hemispherical_pocket_resizes_the_cavity() {
1140        let r = 5.0f64;
1141        for (d, r_new) in [(1.0_f64, 4.0_f64), (-1.0, 6.0)] {
1142            let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1143            let ball =
1144                make_sphere_brep(Vec3::new(10.0, 10.0, 10.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1145            let options = crate::BooleanOptions {
1146                merge_coplanar_faces: true,
1147                ..crate::BooleanOptions::default()
1148            };
1149            let pocket = crate::boolean_operation(
1150                &block,
1151                &ball,
1152                crate::BooleanOperation::Subtract,
1153                &options,
1154            )
1155            .unwrap();
1156            let v0 = solid_signed_volume(&pocket).unwrap().abs();
1157            let face = sphere_face_id(&pocket);
1158
1159            let pushed = offset_sphere_face(&pocket, face, d)
1160                .unwrap_or_else(|e| panic!("push pocket by {d}: {e}"));
1161            let issues = pushed.validate();
1162            assert!(issues.is_empty(), "validate {d}: {issues:?}");
1163            assert!(
1164                (sphere_radius(&pushed) - r_new).abs() < 1e-6,
1165                "pocket radius after push {d}: got {}, want {r_new}",
1166                sphere_radius(&pushed)
1167            );
1168            // The cavity is a hemisphere (2/3 π r³). Growing the solid by +d
1169            // shrinks the cavity from r to r_new, adding 2/3 π (r³ − r_new³).
1170            let got = solid_signed_volume(&pushed).unwrap().abs();
1171            let cavity_before = 2.0 / 3.0 * std::f64::consts::PI * r.powi(3);
1172            let cavity_after = 2.0 / 3.0 * std::f64::consts::PI * r_new.powi(3);
1173            let expected = v0 + (cavity_before - cavity_after);
1174            assert!(
1175                (got - expected).abs() < 1e-2,
1176                "pocket push {d}: volume {got}, want {expected}"
1177            );
1178        }
1179    }
1180
1181    // Pushing a full sphere IN by its whole radius (or past it) must refuse, not
1182    // emit a degenerate/inverted solid.
1183    #[test]
1184    fn push_full_sphere_through_centre_refuses() {
1185        let sphere = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1186        let face = sphere_face_id(&sphere);
1187        let err = offset_sphere_face(&sphere, face, -5.0).expect_err("collapse must refuse");
1188        assert!(err.contains("centre") || err.contains("refus"), "unexpected: {err}");
1189    }
1190
1191    // The off-centre small-circle cap (rim plane above the sphere centre): the
1192    // fixed box-top plane (z=10) cuts the sphere off-centre by h=3. Building the
1193    // pocket keeps the LOWER cap (south pole), so its outward normal points into
1194    // the void → a positive push SHRINKS the cavity (r_new = r − d). After the
1195    // push the rim is the small circle where z=10 meets the grown sphere, radius
1196    // √(r_new² − h²); the seam meridian rebuilds to the new latitude.
1197    #[test]
1198    fn push_offcentre_spherical_cap_resizes() {
1199        let r = 5.0f64;
1200        let h = 3.0f64; // plane z=10, centre z=7.
1201        for (d, r_new) in [(1.0_f64, 4.0_f64), (-1.0, 6.0)] {
1202            let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1203            let ball =
1204                make_sphere_brep(Vec3::new(10.0, 10.0, 7.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1205            let options = crate::BooleanOptions {
1206                merge_coplanar_faces: true,
1207                ..crate::BooleanOptions::default()
1208            };
1209            let pocket = crate::boolean_operation(
1210                &block,
1211                &ball,
1212                crate::BooleanOperation::Subtract,
1213                &options,
1214            )
1215            .unwrap();
1216            let face = sphere_face_id(&pocket);
1217            let pushed = offset_sphere_face(&pocket, face, d)
1218                .unwrap_or_else(|e| panic!("push off-centre cap by {d}: {e}"));
1219            let issues = pushed.validate();
1220            assert!(issues.is_empty(), "validate {d}: {issues:?}");
1221            assert!(
1222                (sphere_radius(&pushed) - r_new).abs() < 1e-6,
1223                "off-centre cap radius after push {d}: got {}, want {r_new}",
1224                sphere_radius(&pushed)
1225            );
1226            // The rim is the small circle where z=10 meets the grown sphere.
1227            let want_rim = (r_new * r_new - h * h).sqrt();
1228            let got_rim = rim_radius_off_axis(
1229                &pushed,
1230                Vec3::new(10.0, 10.0, 0.0),
1231                Vec3::new(0.0, 0.0, 1.0),
1232            );
1233            assert!(
1234                (got_rim - want_rim).abs() < 1e-6,
1235                "rim radius after push {d}: got {got_rim}, want {want_rim}"
1236            );
1237        }
1238    }
1239
1240    // Off-centre cap with the rim plane BELOW the centre: box bottom z=0, ball
1241    // centre z=3, radius 5 (the ball pokes through the bottom, h=3). The kept cap
1242    // now contains the NORTH pole (z=8), so the seam meridian runs pole→rim with
1243    // its pole at v=1 — exercising the reversed iso-meridian subrange and the
1244    // north-pole detection that the above-centre fixture never touches.
1245    #[test]
1246    fn push_offcentre_northern_cap_resizes() {
1247        let r = 5.0f64;
1248        let h = 3.0f64; // plane z=0, centre z=3.
1249        for (d, r_new) in [(1.0_f64, 4.0_f64), (-1.0, 6.0)] {
1250            let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1251            let ball =
1252                make_sphere_brep(Vec3::new(10.0, 10.0, 3.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1253            let options = crate::BooleanOptions {
1254                merge_coplanar_faces: true,
1255                ..crate::BooleanOptions::default()
1256            };
1257            let pocket = crate::boolean_operation(
1258                &block,
1259                &ball,
1260                crate::BooleanOperation::Subtract,
1261                &options,
1262            )
1263            .unwrap();
1264            let face = sphere_face_id(&pocket);
1265            let pushed = offset_sphere_face(&pocket, face, d)
1266                .unwrap_or_else(|e| panic!("push northern off-centre cap by {d}: {e}"));
1267            let issues = pushed.validate();
1268            assert!(issues.is_empty(), "validate {d}: {issues:?}");
1269            assert!(
1270                (sphere_radius(&pushed) - r_new).abs() < 1e-6,
1271                "northern cap radius after push {d}: got {}, want {r_new}",
1272                sphere_radius(&pushed)
1273            );
1274            let want_rim = (r_new * r_new - h * h).sqrt();
1275            let got_rim = rim_radius_off_axis(
1276                &pushed,
1277                Vec3::new(10.0, 10.0, 0.0),
1278                Vec3::new(0.0, 0.0, 1.0),
1279            );
1280            assert!(
1281                (got_rim - want_rim).abs() < 1e-6,
1282                "northern rim radius after push {d}: got {got_rim}, want {want_rim}"
1283            );
1284        }
1285    }
1286
1287    // Pushing the off-centre cap OUT far enough that the grown sphere no longer
1288    // reaches the fixed plane (r_new = 2.5 < h = 3) must refuse cleanly.
1289    #[test]
1290    fn push_offcentre_cap_vanish_refuses() {
1291        let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1292        let ball =
1293            make_sphere_brep(Vec3::new(10.0, 10.0, 7.0), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1294        let options = crate::BooleanOptions {
1295            merge_coplanar_faces: true,
1296            ..crate::BooleanOptions::default()
1297        };
1298        let pocket =
1299            crate::boolean_operation(&block, &ball, crate::BooleanOperation::Subtract, &options)
1300                .unwrap();
1301        let face = sphere_face_id(&pocket);
1302        // Positive push SHRINKS the pocket sphere; d=2.5 → r_new=2.5 < h=3.
1303        let err = offset_sphere_face(&pocket, face, 2.5).expect_err("a vanished cap must refuse");
1304        assert!(
1305            err.contains("vanish") || err.contains("collapse") || err.contains("centre"),
1306            "unexpected refusal: {err}"
1307        );
1308    }
1309
1310    // SPHERE × SPHERE: the union of two overlapping balls; each spherical face's
1311    // rim neighbour is the OTHER sphere. Pushing one sphere out grows its radius,
1312    // moving the intersection circle to intersect_sphere_sphere(grown, fixed).
1313    // Both spheres re-trim around the new circle.
1314    #[test]
1315    fn push_sphere_with_sphere_neighbour_resizes() {
1316        for (d, r_new) in [(1.0_f64, 7.0_f64), (-1.0, 5.0)] {
1317            let a = make_sphere_brep(Vec3::new(0.0, 0.0, 0.0), 6.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1318            let b = make_sphere_brep(Vec3::new(8.0, 0.0, 0.0), 6.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1319            let options = crate::BooleanOptions {
1320                merge_coplanar_faces: true,
1321                ..crate::BooleanOptions::default()
1322            };
1323            let uni =
1324                crate::boolean_operation(&a, &b, crate::BooleanOperation::Union, &options).unwrap();
1325            // Push sphere A (centred at the origin).
1326            let face = uni
1327                .shells
1328                .iter()
1329                .flat_map(|shell| &shell.faces)
1330                .filter(|f| matches!(f.surface.analytic(), Some(AnalyticSurface::Sphere { .. })))
1331                .find(|f| {
1332                    matches!(
1333                        f.surface.analytic(),
1334                        Some(AnalyticSurface::Sphere { frame, .. }) if frame.origin.length() < 1e-6
1335                    )
1336                })
1337                .expect("sphere A face")
1338                .id;
1339            let pushed = offset_sphere_face(&uni, face, d)
1340                .unwrap_or_else(|e| panic!("push sphere-neighbour by {d}: {e}"));
1341            let issues = pushed.validate();
1342            assert!(issues.is_empty(), "validate {d}: {issues:?}");
1343            // Sphere A's carrier grew to r_new; the fixed B is unchanged (r=6).
1344            let radii: Vec<f64> = pushed
1345                .shells
1346                .iter()
1347                .flat_map(|shell| &shell.faces)
1348                .filter_map(|f| match f.surface.analytic() {
1349                    Some(AnalyticSurface::Sphere { radius, .. }) => Some(*radius),
1350                    _ => None,
1351                })
1352                .collect();
1353            assert!(
1354                radii.iter().any(|r| (r - r_new).abs() < 1e-6),
1355                "grown sphere radius {r_new} not found in {radii:?}"
1356            );
1357            assert!(
1358                radii.iter().any(|r| (r - 6.0).abs() < 1e-6),
1359                "fixed sphere radius 6 not found in {radii:?}"
1360            );
1361        }
1362    }
1363
1364    // SPHERE × CYLINDER: a coaxial stem emerging from a ball gives the retained
1365    // spherical face one cylindrical rim neighbour. Pushing the sphere moves
1366    // that exact latitude circle and axially re-trims the fixed cylinder wall.
1367    #[test]
1368    fn push_sphere_with_coaxial_cylinder_neighbour_resizes() {
1369        for (d, r_new) in [(0.5_f64, 5.5_f64), (-0.5, 4.5)] {
1370            let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1371            let cylinder = crate::make_cylinder_brep(
1372                Vec3::new(0.0, 0.0, 3.0),
1373                Vec3::new(0.0, 0.0, 1.0),
1374                2.0,
1375                5.0,
1376            )
1377            .unwrap();
1378            let joined = crate::boolean_operation(
1379                &ball,
1380                &cylinder,
1381                crate::BooleanOperation::Union,
1382                &crate::BooleanOptions::default(),
1383            )
1384            .unwrap();
1385            let face = sphere_face_id(&joined);
1386            let pushed = offset_sphere_face(&joined, face, d)
1387                .unwrap_or_else(|e| panic!("push sphere×cylinder by {d}: {e}"));
1388            assert!(pushed.validate().is_empty());
1389            assert!((sphere_radius(&pushed) - r_new).abs() < 1e-6);
1390            // The cylinder r=2 cuts the grown sphere at z=√(r²−4).
1391            let want_z = (r_new * r_new - 4.0).sqrt();
1392            let rim_z = pushed
1393                .edges
1394                .iter()
1395                .filter(|edge| edge.start_vertex_id == edge.end_vertex_id && !edge.degenerate)
1396                .map(|edge| edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).unwrap().z)
1397                .min_by(|a, b| (a - want_z).abs().total_cmp(&(b - want_z).abs()))
1398                .expect("closed sphere×cylinder rim");
1399            assert!(
1400                (rim_z - want_z).abs() < 1e-6,
1401                "rim z={rim_z}, want {want_z}"
1402            );
1403        }
1404    }
1405
1406    // SPHERE × CONE uses the same coaxial profile intersector but exercises a
1407    // sloped ruled generatrix and axial carrier extension.
1408    #[test]
1409    fn push_sphere_with_coaxial_cone_neighbour_resizes() {
1410        for (d, r_new) in [(0.35_f64, 5.35_f64), (-0.35, 4.65)] {
1411            let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1412            let cone = crate::make_cone_brep(
1413                Vec3::new(0.0, 0.0, 3.0),
1414                Vec3::new(0.0, 0.0, 1.0),
1415                2.0,
1416                1.0,
1417                5.0,
1418            )
1419            .unwrap();
1420            let joined = crate::boolean_operation(
1421                &ball,
1422                &cone,
1423                crate::BooleanOperation::Union,
1424                &crate::BooleanOptions::default(),
1425            )
1426            .unwrap();
1427            let face = sphere_face_id(&joined);
1428            let pushed = offset_sphere_face(&joined, face, d)
1429                .unwrap_or_else(|e| panic!("push sphere×cone by {d}: {e}"));
1430            let issues = pushed.validate();
1431            assert!(issues.is_empty(), "validate {d}: {issues:?}");
1432            assert!((sphere_radius(&pushed) - r_new).abs() < 1e-6);
1433            assert!(pushed.shells.iter().flat_map(|s| &s.faces).any(|face| {
1434                matches!(
1435                    face.surface.analytic(),
1436                    Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
1437                        if (rho0 - rho1).abs() > 1e-6
1438                )
1439            }));
1440        }
1441    }
1442
1443    // A parallel-offset cylinder has a non-circular spatial intersection with
1444    // the sphere. Keep that outside this exact coaxial slice and refuse before
1445    // mutating topology.
1446    #[test]
1447    fn push_sphere_with_noncoaxial_cylinder_neighbour_refuses() {
1448        let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1449        let cylinder =
1450            crate::make_cylinder_brep(Vec3::new(1.0, 0.0, 3.0), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0)
1451                .unwrap();
1452        let joined = crate::boolean_operation(
1453            &ball,
1454            &cylinder,
1455            crate::BooleanOperation::Union,
1456            &crate::BooleanOptions::default(),
1457        )
1458        .unwrap();
1459        let face = sphere_face_id(&joined);
1460        let err = offset_sphere_face(&joined, face, 0.25)
1461            .expect_err("non-coaxial sphere×cylinder must refuse");
1462        assert!(err.contains("NON-coaxial"), "unexpected refusal: {err}");
1463        assert!(joined.validate().is_empty(), "the input solid was mutated");
1464    }
1465
1466    #[test]
1467    fn push_sphere_off_coaxial_cylinder_refuses_cleanly() {
1468        let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1469        let cylinder =
1470            crate::make_cylinder_brep(Vec3::new(0.0, 0.0, 3.0), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0)
1471                .unwrap();
1472        let joined = crate::boolean_operation(
1473            &ball,
1474            &cylinder,
1475            crate::BooleanOperation::Union,
1476            &crate::BooleanOptions::default(),
1477        )
1478        .unwrap();
1479        let face = sphere_face_id(&joined);
1480        // r'=1.5 cannot meet the fixed r=2 cylinder, but does not collapse the
1481        // sphere itself. This must be a typed separation refusal.
1482        let err = offset_sphere_face(&joined, face, -3.5)
1483            .expect_err("separated sphere×cylinder must refuse");
1484        assert!(
1485            err.contains("no longer meets") || err.contains("refus"),
1486            "unexpected refusal: {err}"
1487        );
1488    }
1489}