Skip to main content

brep_kernel/edit/direct_edit/
face_offset_sphere.rs

1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3
4// ---------------------------------------------------------------------------
5// Push a SPHERICAL analytic face by OFFSETTING its carrier to a concentric
6// sphere (backlog #4a Sphere × Plane through-centre + #4b off-centre / sphere).
7//
8// The methodology mirrors `offset_ruled_face` (a face is a TRIMMED region of an
9// infinite carrier surface; to push it we replace the carrier by its offset and
10// re-derive the trim against the fixed neighbour carriers):
11//
12//   * The exact offset of a sphere is a CONCENTRIC sphere of radius r ± δ. It is
13//     realised here as the exact uniform scale of the carrier about the sphere
14//     centre by k = r_new / r — an AFFINE map, so the scaled surface is byte-for-
15//     byte the same rational parameterisation (same knots/weights/seam azimuth/
16//     poles), only radially grown. This is the *exact* sphere offset the backlog
17//     item calls for; it is NOT the `offset_surface` sample-and-fit, which was
18//     tried first and does NOT re-recognise as a sphere for a full-sphere face
19//     (its Greville fit degenerates at the poles → `analytic() == None`). The
20//     affine scale keeps the pushed face's OWN-ONLY (seam meridian + pole) and
21//     THROUGH-CENTRE-rim pcurves valid pointwise: because S′(u,v) = c + k·(S − c),
22//     each such edge, scaled about c by the SAME k, lands on S′ at its OWN
23//     unchanged pcurve.
24//
25//   * RIM classification per boundary edge (honest refusal, never a bad solid):
26//       - PLANE through the centre → SCALE (4a): a uniform scale about c maps a
27//         through-centre plane onto ITSELF, so the scaled great-circle rim IS the
28//         exact S′ ∩ plane, no azimuth/seam mismatch.
29//       - PLANE off-centre, or a SPHERE neighbour → RECOMPUTE (4b): the scaled
30//         rim would leave the fixed carrier, so the rim edge is re-derived as
31//         S′ ∩ neighbour. Off-centre small circles: `intersect_plane_quadric`
32//         gives radius √((r±δ)²−h²); sphere × sphere: `intersect_sphere_sphere`
33//         (both via the public `intersect_analytic_pair`). The rim's pushed-face
34//         pcurve is rebuilt on S′; a SEAM-crossing rim (its closure vertex is
35//         shared with an own-only meridian — the axis-perpendicular cap case)
36//         also rebuilds that meridian as the v-subrange of the offset meridian
37//         (an iso-curve preserves the v-parameter exactly, keeping the straight
38//         (u,v) pcurve synchronised) and moves its closure vertex to the new
39//         latitude. Seam-aligned circle construction is used there so the rim
40//         starts on the u = 0 seam.
41//       - coaxial cylinder / cone → RECOMPUTE: the exact latitude circle is
42//         selected from the coaxial-revolution intersector and the ruled
43//         neighbour is extended/retrimmed over the relocated rim.
44//       - non-coaxial cylinder / cone, general revolution / torus / free-form
45//         → refused.
46//     Refused too: a seam-crossing rim on an OBLIQUE plane or a SPHERE neighbour,
47//     a non-closed / multi-edge RECOMPUTED rim, a vanished cap (grown sphere no
48//     longer reaches the plane / spheres separated or tangent), and a push that
49//     collapses or inverts the solid.
50//
51//   * An OPEN rim on the EXACT-SCALE path is supported, and its corners are
52//     carried. A spherical pocket in a box CORNER has three quarter-circle rims,
53//     one per box plane, all three through the centre; the scale moves each
54//     arc's endpoint radially and the box edge that ends there has its trim slid
55//     to follow. That slide is a re-parameterisation, not a re-solve: both
56//     planes of such a corner pass through the sphere centre (which is what put
57//     the rim on this path), so their intersection line does too and the scale
58//     maps the corner along it exactly. A corner that does NOT land on its own
59//     fixed edge, or a slide that would collapse or invert that edge's trim,
60//     refuses — the open-rim analogue of the recomputed path's closed-rim gate.
61// ---------------------------------------------------------------------------
62
63/// Push a SPHERICAL face along its outward normal by `distance` (positive grows
64/// the solid) by replacing its carrier with the concentric offset sphere and
65/// re-trimming its neighbours: planar caps through the centre keep their exact-
66/// scale great-circle rim, while off-centre planes, spheres, and coaxial ruled
67/// neighbours have their rim re-derived as S′ ∩ neighbour.
68///
69/// An OPEN rim (several arcs, as a pocket in a box corner has) is supported on
70/// the exact-scale path, with the fixed edges that end on its corners re-trimmed
71/// to follow them.
72///
73/// Refused cleanly (never a bad solid): non-coaxial cylinder/cone neighbours,
74/// general-revolution/torus/free-form neighbours, seam-crossing oblique or
75/// sphere-neighbour rims, non-closed RECOMPUTED rims, a rim corner that leaves
76/// its own fixed edge or collapses that edge's trim, a vanished cap, and a push
77/// that collapses/inverts the solid.
78pub fn offset_sphere_face(
79    solid: &BrepSolid,
80    face_id: u64,
81    distance: f64,
82) -> Result<BrepSolid, KernelRefusal> {
83    if !distance.is_finite() {
84        return Err(KernelRefusal::input(
85            KernelStage::Collect,
86            "distance",
87            "offset_sphere_face: distance must be finite",
88        ));
89    }
90    let scale = solid_model_scale(solid);
91    let tolerance = (scale * 1e-7).max(1e-9);
92    let plane_tolerance = (scale * 1e-6).max(1e-7);
93
94    let (pshell, pface) = find_face(solid, face_id)
95        .ok_or_else(|| {
96            KernelRefusal::input(
97                KernelStage::Collect,
98                "face_id",
99                format!("offset_sphere_face: no face {face_id}"),
100            )
101        })?;
102    let pushed = &solid.shells[pshell].faces[pface];
103    let Some(AnalyticSurface::Sphere { frame, radius }) = pushed.surface.analytic() else {
104        return Err(KernelRefusal::input(
105            KernelStage::Collect,
106            "not_sphere",
107            "offset_sphere_face: the pushed face is not a sphere",
108        ));
109    };
110    let center = frame.origin;
111    let axis = frame.axis;
112    let seam_x = frame.x_axis;
113    let seam_y = frame.y_axis;
114    let radius = *radius;
115    if radius <= tolerance {
116        return Err(KernelRefusal::input(
117            KernelStage::Collect,
118            "degenerate_radius",
119            "offset_sphere_face: degenerate sphere radius",
120        ));
121    }
122
123    // Sign the push: `distance` is measured along the face's OUTWARD normal. For
124    // a convex ball the outward normal is radial (s = +1) so the radius grows; a
125    // spherical POCKET's outward normal points inward (s = −1) so a positive push
126    // (more material) SHRINKS the cavity. Either way r_new = r + distance·s.
127    let [u0, u1] = pushed.surface.domain_u().or_refuse(KernelStage::Classify, "domain_u")?;
128    let [v0, v1] = pushed.surface.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
129    let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
130    let point = pushed.surface.evaluate(um, vm).or_refuse(KernelStage::Classify, "evaluate")?;
131    let mut normal = pushed.surface.normal(um, vm).or_refuse(KernelStage::Classify, "normal")?;
132    if !pushed.same_sense {
133        normal = normal.scale(-1.0);
134    }
135    let radial = point.sub(center);
136    if radial.length() <= tolerance {
137        return Err(KernelRefusal::internal(
138            KernelStage::Classify,
139            "radial_direction",
140            "offset_sphere_face: degenerate radial direction",
141        ));
142    }
143    let outward_sign = if normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
144    let radius_new = radius + distance * outward_sign;
145    if radius_new <= tolerance {
146        return Err(KernelRefusal::input(
147            KernelStage::Classify,
148            "collapse",
149            "offset_sphere_face: the push collapses the sphere to or past its centre — refusing",
150        ));
151    }
152
153    // S′ = exact concentric offset = uniform scale of the carrier about the
154    // centre by k. An affine map, so the parameterisation is preserved exactly.
155    let k = radius_new / radius;
156    let scale_about_center = AffineTransform::new([
157        k, 0.0, 0.0, (1.0 - k) * center.x,
158        0.0, k, 0.0, (1.0 - k) * center.y,
159        0.0, 0.0, k, (1.0 - k) * center.z,
160        0.0, 0.0, 0.0, 1.0,
161    ]).or_refuse(KernelStage::Refine, "affine_transform")?;
162    let s_prime = transform_surface(&pushed.surface, scale_about_center).or_refuse(KernelStage::Refine, "transform_surface")?;
163    // Defensive: confirm the offset is the concentric sphere we expect.
164    match s_prime.analytic() {
165        Some(AnalyticSurface::Sphere { frame, radius }) => {
166            if frame.origin.sub(center).length() > plane_tolerance
167                || (radius - radius_new).abs() > plane_tolerance.max(radius_new * 1e-9)
168            {
169                return Err(KernelRefusal::internal(
170                    KernelStage::Refine,
171                    "offset_carrier",
172                    "offset_sphere_face: offset carrier is not the expected \
173                            concentric sphere — refusing",
174                ));
175            }
176        }
177        _ => {
178            return Err(KernelRefusal::internal(
179                KernelStage::Refine,
180                "offset_recognition",
181                "offset_sphere_face: offset carrier did not re-recognise as a sphere — refusing",
182            ))
183        }
184    }
185
186    // Edge -> incident faces, to classify the pushed face's boundary edges into
187    // OWN-ONLY edges (seam meridians + degenerate poles — intrinsic to the sphere)
188    // and RIM edges shared with a fixed neighbour.
189    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
190    for shell in &solid.shells {
191        for face in &shell.faces {
192            for loop_record in &face.loops {
193                for coedge in &loop_record.coedges {
194                    faces_of_edge
195                        .entry(coedge.edge_id)
196                        .or_default()
197                        .push(face.id);
198                }
199            }
200        }
201    }
202    let edge_by_id: HashMap<u64, &EdgeRecord> =
203        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
204
205    // The v-parameter domain of the offset sphere (== the source domain; the
206    // uniform scale preserves the parameterisation exactly).
207    let [dv0, dv1] = s_prime.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
208
209    // Classify. A rim neighbour is one of:
210    //   * PLANE through the centre → exact-scale path (the landed 4a behaviour):
211    //     the scaled great-circle rim stays on the fixed plane.
212    //   * PLANE off-centre / SPHERE / coaxial CYLINDER or CONE → RECOMPUTE:
213    //     the scaled rim leaves the fixed carrier, so re-derive S′ ∩ neighbour.
214    //   * non-coaxial ruled / general revolution / torus / free-form → refused.
215    enum RimKind {
216        OffCentrePlane(Plane),
217        NeighbourSphere,
218        CoaxialRuled,
219    }
220    let mut edges_to_scale: HashSet<u64> = HashSet::default();
221    let mut through_centre_caps: HashSet<u64> = HashSet::default();
222    let mut own_only_edges: Vec<u64> = Vec::new();
223    let mut all_boundary_vertices: HashSet<u64> = HashSet::default();
224    let mut raw_rims: Vec<(u64, u64, RimKind)> = Vec::new();
225
226    for loop_record in &pushed.loops {
227        for coedge in &loop_record.coedges {
228            let edge = *edge_by_id
229                .get(&coedge.edge_id)
230                .ok_or_else(|| {
231                    KernelRefusal::internal(
232                        KernelStage::Classify,
233                        "missing_edge",
234                        format!("offset_sphere_face: missing edge {}", coedge.edge_id),
235                    )
236                })?;
237            all_boundary_vertices.insert(edge.start_vertex_id);
238            all_boundary_vertices.insert(edge.end_vertex_id);
239
240            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
241            if incident.iter().all(|f| *f == face_id) {
242                own_only_edges.push(edge.id); // Seam meridian or degenerate pole.
243                continue;
244            }
245            let neighbour = *incident
246                .iter()
247                .find(|f| **f != face_id)
248                .ok_or_else(|| {
249                    KernelRefusal::internal(
250                        KernelStage::Classify,
251                        "no_neighbour",
252                        format!("offset_sphere_face: edge {} has no neighbour", edge.id),
253                    )
254                })?;
255            let (nshell, nface) = find_face(solid, neighbour)
256                .ok_or_else(|| {
257                    KernelRefusal::internal(
258                        KernelStage::Classify,
259                        "missing_neighbour",
260                        format!("offset_sphere_face: missing neighbour {neighbour}"),
261                    )
262                })?;
263            let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
264            match neighbour_surface.analytic() {
265                Some(AnalyticSurface::Plane { .. }) => {
266                    let plane =
267                        plane_of_surface(neighbour_surface, plane_tolerance, "offset_sphere_face")?;
268                    if center.sub(plane.origin).dot(plane.normal).abs() <= plane_tolerance {
269                        // Through the centre: the exact-scale great-circle path.
270                        through_centre_caps.insert(neighbour);
271                        edges_to_scale.insert(edge.id);
272                    } else {
273                        raw_rims.push((edge.id, neighbour, RimKind::OffCentrePlane(plane)));
274                    }
275                }
276                Some(AnalyticSurface::Sphere { .. }) => {
277                    raw_rims.push((edge.id, neighbour, RimKind::NeighbourSphere));
278                }
279                Some(AnalyticSurface::RuledRevolution { .. }) => {
280                    if !super::face_offset::ruled_neighbour_is_coaxial(
281                        neighbour_surface,
282                        center,
283                        axis,
284                        scale,
285                    ) {
286                        return Err(KernelRefusal::unsupported(
287                            KernelStage::Classify,
288                            "non_coaxial_ruled",
289                            "offset_sphere_face: a cylinder/cone rim neighbour is \
290                                    NON-coaxial (only coaxial sphere × ruled intersections are \
291                                    supported) — refusing",
292                        ));
293                    }
294                    raw_rims.push((edge.id, neighbour, RimKind::CoaxialRuled));
295                }
296                Some(AnalyticSurface::Torus { .. }) => {
297                    return Err(KernelRefusal::unsupported(
298                        KernelStage::Classify,
299                        "torus_neighbour",
300                        "offset_sphere_face: a toroidal rim neighbour is deferred \
301                                (torus is untouched in this slice)",
302                    ));
303                }
304                _ => {
305                    return Err(KernelRefusal::unsupported(
306                        KernelStage::Classify,
307                        "neighbour_kind",
308                        "offset_sphere_face: a rim neighbour is not a supported plane, \
309                                sphere, or coaxial cylinder/cone (general revolution and \
310                                free-form neighbours are deferred in this slice)",
311                    ));
312                }
313            }
314        }
315    }
316
317    // Each recomputed rim must be a single CLOSED edge (start == end vertex); its
318    // closure vertex sits where the rim crosses the sphere seam.
319    let mut closure_vertices: HashSet<u64> = HashSet::default();
320    for (edge_id, _, _) in &raw_rims {
321        let edge = *edge_by_id.get(edge_id).unwrap();
322        if edge.start_vertex_id != edge.end_vertex_id {
323            return Err(KernelRefusal::unsupported(
324                KernelStage::Classify,
325                "open_rim",
326                "offset_sphere_face: a recomputed rim is not a single closed edge \
327                        (multi-edge / open rims are deferred in this slice)",
328            ));
329        }
330        closure_vertices.insert(edge.start_vertex_id);
331    }
332
333    // Own-only seam meridians whose end lands on a recomputed rim's closure vertex
334    // must be rebuilt (their rim end moves to the new latitude). Uncoupled seam
335    // meridians and poles just scale.
336    let mut coupled_meridians: HashSet<u64> = HashSet::default();
337    for own in &own_only_edges {
338        let e = *edge_by_id.get(own).unwrap();
339        if e.degenerate {
340            edges_to_scale.insert(*own);
341            continue;
342        }
343        if closure_vertices.contains(&e.start_vertex_id)
344            || closure_vertices.contains(&e.end_vertex_id)
345        {
346            coupled_meridians.insert(*own);
347        } else {
348            edges_to_scale.insert(*own);
349        }
350    }
351    // Every boundary vertex scales EXCEPT the relocated rim-closure vertices.
352    let vertices_to_scale: HashSet<u64> = all_boundary_vertices
353        .iter()
354        .copied()
355        .filter(|v| !closure_vertices.contains(v))
356        .collect();
357
358    // --- Build the recomputed rims (geometry only; applied below) ----------
359    struct RimBuild {
360        edge_id: u64,
361        neighbour_id: u64,
362        neighbour_is_sphere: bool,
363        closure_vertex: u64,
364        circle: NurbsCurve,
365        closure_point: Vec3,
366        v_rim: f64,
367        pushed_pcurve: NurbsCurve,
368    }
369    let mut rim_builds: Vec<RimBuild> = Vec::new();
370    for (edge_id, neighbour, kind) in &raw_rims {
371        let edge = *edge_by_id.get(edge_id).unwrap();
372        let closure_vertex = edge.start_vertex_id;
373        let seam_coupled = coupled_meridians.iter().any(|m| {
374            let e = *edge_by_id.get(m).unwrap();
375            e.start_vertex_id == closure_vertex || e.end_vertex_id == closure_vertex
376        });
377
378        let mut circle = if seam_coupled {
379            // A seam-crossing rim shares its closure vertex with a meridian, so
380            // the new circle MUST start exactly on the seam. Build it in the
381            // sphere's own frame (seam-aligned by construction). Only an
382            // axis-perpendicular (latitude) plane keeps the rim a fixed-u circle;
383            // oblique seam-crossing caps and sphere neighbours are refused.
384            match kind {
385                RimKind::OffCentrePlane(plane) => {
386                    if plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
387                        return Err(KernelRefusal::unsupported(
388                            KernelStage::Classify,
389                            "oblique_seam_rim",
390                            "offset_sphere_face: a seam-crossing rim on an OBLIQUE plane is \
391                                    deferred in this slice (only axis-perpendicular caps)",
392                        ));
393                    }
394                    let a = plane.origin.sub(center).dot(axis);
395                    let rr2 = radius_new * radius_new - a * a;
396                    if rr2 <= tolerance * tolerance {
397                        return Err(KernelRefusal::unsupported(
398                            KernelStage::Refine,
399                            "cap_vanishes",
400                            "offset_sphere_face: the push pulls the grown sphere off its cap \
401                                    plane (the cap vanishes) — refusing",
402                        ));
403                    }
404                    crate::make_arc(
405                        center.add(axis.scale(a)),
406                        seam_x,
407                        seam_y,
408                        rr2.sqrt(),
409                        0.0,
410                        std::f64::consts::TAU,
411                    ).or_refuse(KernelStage::Refine, "make_arc")?
412                }
413                RimKind::NeighbourSphere => {
414                    return Err(KernelRefusal::unsupported(
415                        KernelStage::Classify,
416                        "sphere_seam_rim",
417                        "offset_sphere_face: a sphere neighbour whose rim crosses the \
418                                pushed sphere's seam is deferred in this slice",
419                    ))
420                }
421                RimKind::CoaxialRuled => {
422                    // A coaxial ruled neighbour meets the sphere in latitude
423                    // circles. The analytic intersector below constructs them
424                    // in the pushed sphere's revolution frame, so the selected
425                    // circle starts on this same seam and can move the coupled
426                    // meridian exactly.
427                    let (ns, nf) = find_face(solid, *neighbour).unwrap();
428                    let neighbour_surface = &solid.shells[ns].faces[nf].surface;
429                    let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
430                        .filter(|curves| !curves.is_empty())
431                        .ok_or_else(|| {
432                            KernelRefusal::unsupported(
433                                KernelStage::Refine,
434                                "ruled_separated",
435                                "offset_sphere_face: the grown sphere no longer meets its coaxial \
436                             cylinder/cone neighbour — refusing",
437                            )
438                        })?;
439                    let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Refine, "evaluate")?;
440                    nearest_circle(&curves, old_mid)?
441                }
442            }
443        } else {
444            let (ns, nf) = find_face(solid, *neighbour).unwrap();
445            let neighbour_surface = &solid.shells[ns].faces[nf].surface;
446            let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
447                .filter(|curves| !curves.is_empty())
448                .ok_or_else(|| {
449                    KernelRefusal::unsupported(
450                        KernelStage::Refine,
451                        "separated",
452                        "offset_sphere_face: the grown sphere no longer meets a neighbour (the cap \
453                     vanishes / tangent / separated) — refusing",
454                    )
455                })?;
456            let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Refine, "evaluate")?;
457            nearest_circle(&curves, old_mid)?
458        };
459
460        // Match the new rim's traversal to the old edge so the preserved coedge
461        // `forward` flags keep the loop winding consistent.
462        let closure_old = edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Refine, "evaluate")?;
463        let old_tan = edge
464            .curve
465            .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0)).or_refuse(KernelStage::Refine, "evaluate")?
466            .sub(closure_old);
467        let [c0, c1] = circle.domain().or_refuse(KernelStage::Refine, "domain")?;
468        let new_start = circle.evaluate(c0).or_refuse(KernelStage::Refine, "evaluate")?;
469        let new_tan = circle.evaluate(c0 + 0.01 * (c1 - c0)).or_refuse(KernelStage::Refine, "evaluate")?.sub(new_start);
470        if old_tan.dot(new_tan) < 0.0 {
471            circle = circle.reversed().or_refuse(KernelStage::Refine, "reversed")?;
472        }
473        let closure_point = circle.evaluate(circle.domain().or_refuse(KernelStage::Refine, "domain")?[0]).or_refuse(KernelStage::Refine, "evaluate")?;
474        let pushed_pcurve = build_pcurve_on_surface(&s_prime, &circle).or_refuse(KernelStage::Refine, "build_pcurve_on_surface")?;
475        // The rim's v on the offset sphere (constant for a latitude rim; only the
476        // seam-coupled meridian rebuild consumes it).
477        let [q0, q1] = pushed_pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
478        let v_rim = pushed_pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Refine, "evaluate")?.y;
479
480        rim_builds.push(RimBuild {
481            edge_id: *edge_id,
482            neighbour_id: *neighbour,
483            neighbour_is_sphere: matches!(kind, RimKind::NeighbourSphere),
484            closure_vertex,
485            circle,
486            closure_point,
487            v_rim,
488            pushed_pcurve,
489        });
490    }
491
492    // The offset sphere's u = 0 seam meridian (parameterised by v exactly: an
493    // iso-curve preserves the surface v-parameter, so a straight (u, v) pcurve
494    // stays synchronised with it — a fresh `make_arc` would not).
495    let iso_meridian = s_prime.iso_curve_u(0.0).or_refuse(KernelStage::Refine, "iso_curve_u")?;
496
497    // --- Apply to a fresh clone (the input is never mutated) ---------------
498    let mut result = solid.clone();
499
500    // Scale the intrinsic own-only edges + any through-centre great-circle rims.
501    for edge in &mut result.edges {
502        if edges_to_scale.contains(&edge.id) {
503            edge.curve = transform_curve(&edge.curve, scale_about_center).or_refuse(KernelStage::Fragment, "transform_curve")?;
504        }
505    }
506    // Recomputed rim edges take their new circle.
507    for rb in &rim_builds {
508        if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rb.edge_id) {
509            let [d0, d1] = rb.circle.domain().or_refuse(KernelStage::Fragment, "domain")?;
510            edge.curve = rb.circle.clone();
511            edge.t0 = d0;
512            edge.t1 = d1;
513        }
514    }
515    // Rebuild each seam-coupled own edge as the matching v-subrange of the
516    // offset meridian. The fixed endpoint can be a pole or another trim split.
517    for mer in &coupled_meridians {
518        let e = *edge_by_id.get(mer).unwrap();
519        let cv = if closure_vertices.contains(&e.start_vertex_id) {
520            e.start_vertex_id
521        } else {
522            e.end_vertex_id
523        };
524        let v_rim = rim_builds
525            .iter()
526            .find(|rb| rb.closure_vertex == cv)
527            .map(|rb| rb.v_rim)
528            .ok_or_else(|| {
529                KernelRefusal::internal(
530                    KernelStage::Fragment,
531                    "meridian_pairing",
532                    "offset_sphere_face: a seam meridian is not paired with a rim — refusing",
533                )
534            })?;
535        let closure_at_start = e.start_vertex_id == cv;
536        let fixed_vertex = if closure_at_start {
537            e.end_vertex_id
538        } else {
539            e.start_vertex_id
540        };
541        let fixed_old = solid
542            .vertices
543            .iter()
544            .find(|v| v.id == fixed_vertex)
545            .map(|v| v.point)
546            .ok_or_else(|| {
547                KernelRefusal::internal(
548                    KernelStage::Fragment,
549                    "seam_vertex",
550                    format!("offset_sphere_face: missing seam vertex {fixed_vertex}"),
551                )
552            })?;
553        let fixed_new = scale_about_center.point(fixed_old);
554        let fixed_projection = project_point_to_curve(&iso_meridian, fixed_new).or_refuse(KernelStage::Fragment, "project_point_to_curve")?;
555        if fixed_projection.distance > plane_tolerance {
556            return Err(KernelRefusal::internal(
557                KernelStage::Fragment,
558                "seam_projection",
559                format!(
560                "offset_sphere_face: a coupled own-edge is not on the sphere seam \
561                 (off by {:.3e}) — refusing",
562                fixed_projection.distance
563            ),
564            ));
565        }
566        let v_start = if closure_at_start {
567            v_rim
568        } else {
569            fixed_projection.u
570        };
571        let v_end = if closure_at_start {
572            fixed_projection.u
573        } else {
574            v_rim
575        };
576        let (curve, t0, t1) = if v_start <= v_end {
577            (iso_meridian.clone(), v_start, v_end)
578        } else {
579            (
580                iso_meridian.reversed().or_refuse(KernelStage::Fragment, "reversed")?,
581                dv0 + dv1 - v_start,
582                dv0 + dv1 - v_end,
583            )
584        };
585        if let Some(edge) = result.edges.iter_mut().find(|x| x.id == *mer) {
586            edge.curve = curve;
587            edge.t0 = t0;
588            edge.t1 = t1;
589        }
590    }
591
592    // Vertices: scale the intrinsic ones, relocate the rim-closure ones.
593    for vertex in &mut result.vertices {
594        if vertices_to_scale.contains(&vertex.id) {
595            vertex.point = scale_about_center.point(vertex.point);
596        }
597    }
598    for rb in &rim_builds {
599        if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rb.closure_vertex) {
600            v.point = rb.closure_point;
601        }
602    }
603
604    // The pushed face rides the offset carrier. Own-only + through-centre pcurves
605    // are preserved (scale-invariant in (u, v)); recomputed rims and coupled
606    // meridians get fresh pcurves.
607    {
608        let pface_rec = &mut result.shells[pshell].faces[pface];
609        for loop_record in &mut pface_rec.loops {
610            for coedge in &mut loop_record.coedges {
611                if let Some(rb) = rim_builds.iter().find(|rb| rb.edge_id == coedge.edge_id) {
612                    let mut pcurve = rb.pushed_pcurve.clone();
613                    if !coedge.forward {
614                        pcurve = pcurve.reversed().or_refuse(KernelStage::Fragment, "reversed")?;
615                    }
616                    coedge.pcurve = pcurve;
617                } else if coupled_meridians.contains(&coedge.edge_id) {
618                    let e = *edge_by_id.get(&coedge.edge_id).unwrap();
619                    let cv = if closure_vertices.contains(&e.start_vertex_id) {
620                        e.start_vertex_id
621                    } else {
622                        e.end_vertex_id
623                    };
624                    let v_rim = rim_builds
625                        .iter()
626                        .find(|rb| rb.closure_vertex == cv)
627                        .map(|rb| rb.v_rim)
628                        .unwrap();
629                    let closure_at_pcurve_start = (e.start_vertex_id == cv) == coedge.forward;
630                    coedge.pcurve =
631                        patch_meridian_pcurve(&coedge.pcurve, v_rim, closure_at_pcurve_start)?;
632                }
633            }
634        }
635    }
636    result.shells[pshell].faces[pface].surface = s_prime;
637
638    // A coaxial ruled neighbour has its own straight seam meridian ending at
639    // the shared rim's relocated closure vertex. Rebuild that seam between its
640    // current endpoints before extending/retrimming the neighbour carrier.
641    let result_vertex: HashMap<u64, Vec3> =
642        result.vertices.iter().map(|v| (v.id, v.point)).collect();
643    let mut ruled_seams: HashSet<u64> = HashSet::default();
644    for rb in &rim_builds {
645        let (ns, nf) = find_face(&result, rb.neighbour_id).ok_or_else(|| {
646            KernelRefusal::internal(
647                KernelStage::Fragment,
648                "ruled_seam_neighbour",
649                format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id),
650            )
651        })?;
652        if !matches!(
653            result.shells[ns].faces[nf].surface.analytic(),
654            Some(AnalyticSurface::RuledRevolution { .. })
655        ) {
656            continue;
657        }
658        for loop_record in &result.shells[ns].faces[nf].loops {
659            for coedge in &loop_record.coedges {
660                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
661                    KernelRefusal::internal(
662                        KernelStage::Fragment,
663                        "ruled_seam_edge",
664                        format!("offset_sphere_face: missing edge {}", coedge.edge_id),
665                    )
666                })?;
667                let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
668                let is_seam = incident.iter().all(|f| *f == rb.neighbour_id);
669                let touches_rim = closure_vertices.contains(&edge.start_vertex_id)
670                    || closure_vertices.contains(&edge.end_vertex_id);
671                if is_seam && touches_rim {
672                    ruled_seams.insert(edge.id);
673                }
674            }
675        }
676    }
677    let rebuilt_ruled_seams: HashSet<u64> = ruled_seams.iter().copied().collect();
678    for seam_id in ruled_seams {
679        let edge = *edge_by_id
680            .get(&seam_id)
681            .ok_or_else(|| {
682                KernelRefusal::internal(
683                    KernelStage::Fragment,
684                    "missing_ruled_seam",
685                    format!("offset_sphere_face: missing ruled seam {seam_id}"),
686                )
687            })?;
688        let curve = make_line(
689            result_vertex[&edge.start_vertex_id],
690            result_vertex[&edge.end_vertex_id],
691        ).or_refuse(KernelStage::Fragment, "make_line")?;
692        if let Some(out) = result
693            .edges
694            .iter_mut()
695            .find(|candidate| candidate.id == seam_id)
696        {
697            let [t0, t1] = curve.domain().or_refuse(KernelStage::Fragment, "domain")?;
698            out.curve = curve;
699            out.t0 = t0;
700            out.t1 = t1;
701        }
702    }
703
704    // --- FIXED edges that END on a relocated rim corner --------------------
705    //
706    // Reported 2026-09-01 ("Push face fails"): a spherical pocket in a box
707    // CORNER — three quarter-circle rims, each on a box plane through the
708    // sphere centre — pushed inward produced a solid the validator rejected
709    // with `edge 1 curve start does not match vertex 1 (gap=1.5)`.
710    //
711    // Why nothing caught it. A recomputed rim must be a single CLOSED edge
712    // (the guard a hundred lines up); the EXACT-SCALE path a through-centre
713    // plane takes has no such guard, because a closed great circle has no
714    // endpoint to leave behind. An OPEN rim arc has two, and each is also the
715    // end of a FIXED edge of the two neighbour planes — the box's own axis
716    // edge. The push scales the corner radially; that edge's CURVE does not
717    // move (a plane × plane line the push does not touch) but its TRIM must
718    // follow the corner, or the solid carries an edge whose curve no longer
719    // reaches its own vertex.
720    //
721    // The slide is a re-parameterisation, not a re-solve, and provably so: a
722    // corner of an exact-scale rim is shared by two planes that BOTH pass
723    // through the sphere centre (that is what put the rim on this path), so
724    // their intersection line passes through the centre and the uniform scale
725    // about the centre maps the corner along it exactly. The projection below
726    // is the PROOF of that, not a fit — a corner that does not land on its own
727    // fixed edge (a rim whose neighbour is off-centre, or a corner against a
728    // curved carrier) refuses instead of being nudged.
729    let mut relocated: HashMap<u64, Vec3> = HashMap::default();
730    for vertex in &result.vertices {
731        let Some(old) = solid.vertices.iter().find(|o| o.id == vertex.id) else {
732            continue;
733        };
734        if vertex.point.sub(old.point).length() > tolerance {
735            relocated.insert(vertex.id, vertex.point);
736        }
737    }
738    let already_rebuilt: HashSet<u64> = edges_to_scale
739        .iter()
740        .copied()
741        .chain(rim_builds.iter().map(|rb| rb.edge_id))
742        .chain(coupled_meridians.iter().copied())
743        .chain(rebuilt_ruled_seams.iter().copied())
744        .collect();
745    let mut resized_edges: HashSet<u64> = HashSet::default();
746    for edge in &mut result.edges {
747        if edge.degenerate || already_rebuilt.contains(&edge.id) {
748            continue;
749        }
750        for at_start in [true, false] {
751            let vertex_id = if at_start {
752                edge.start_vertex_id
753            } else {
754                edge.end_vertex_id
755            };
756            let Some(&target) = relocated.get(&vertex_id) else {
757                continue;
758            };
759            let projection = project_point_to_curve(&edge.curve, target).or_refuse(KernelStage::Fragment, "project_point_to_curve")?;
760            if projection.distance > 10.0 * tolerance {
761                return Err(KernelRefusal::unsupported(
762                    KernelStage::Fragment,
763                    "corner_off_edge",
764                    format!(
765                    "offset_sphere_face: the push moves rim corner (vertex {vertex_id}) OFF \
766                     fixed edge {} by {:.3e} — a rim corner that leaves its own side edge \
767                     needs the corner re-solved against the neighbour carriers, which is \
768                     deferred — refusing",
769                    edge.id, projection.distance
770                ),
771                ));
772            }
773            let [d0, d1] = edge.curve.domain().or_refuse(KernelStage::Fragment, "domain")?;
774            let span = (d1 - d0).max(1e-12);
775            let fixed_t = if at_start { edge.t1 } else { edge.t0 };
776            let old_t = if at_start { edge.t0 } else { edge.t1 };
777            let new_t = projection.u;
778            if (new_t - fixed_t) * (old_t - fixed_t) <= 0.0
779                || (new_t - fixed_t).abs() <= 1e-7 * span
780            {
781                return Err(KernelRefusal::unsupported(
782                    KernelStage::Fragment,
783                    "trim_collapse",
784                    format!(
785                    "offset_sphere_face: the push collapses or inverts the trim of fixed edge \
786                     {} at rim corner (vertex {vertex_id}) — refusing",
787                    edge.id
788                ),
789                ));
790            }
791            if at_start {
792                edge.t0 = new_t;
793            } else {
794                edge.t1 = new_t;
795            }
796            resized_edges.insert(edge.id);
797        }
798    }
799
800    // Re-trim the neighbours around their new rims.
801    let final_edges: HashMap<u64, EdgeRecord> =
802        result.edges.iter().map(|e| (e.id, e.clone())).collect();
803    for rb in &rim_builds {
804        let (ns, nf) = find_face(&result, rb.neighbour_id).ok_or_else(|| {
805            KernelRefusal::internal(
806                KernelStage::Sew,
807                "retrim_neighbour",
808                format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id),
809            )
810        })?;
811        if rb.neighbour_is_sphere {
812            // A fixed sphere neighbour: only the shared rim coedge's pcurve moves.
813            let nsurf = result.shells[ns].faces[nf].surface.clone();
814            for loop_record in &mut result.shells[ns].faces[nf].loops {
815                for coedge in &mut loop_record.coedges {
816                    if coedge.edge_id == rb.edge_id {
817                        let mut pcurve = build_pcurve_on_surface(&nsurf, &rb.circle).or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
818                        if !coedge.forward {
819                            pcurve = pcurve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
820                        }
821                        coedge.pcurve = pcurve;
822                    }
823                }
824            }
825        } else if matches!(
826            result.shells[ns].faces[nf].surface.analytic(),
827            Some(AnalyticSurface::RuledRevolution { .. })
828        ) {
829            super::face_offset::retrim_offset_ruled_face(
830                &mut result,
831                rb.neighbour_id,
832                &final_edges,
833                tolerance,
834            )?;
835        } else {
836            let plane = plane_of_surface(
837                &result.shells[ns].faces[nf].surface,
838                plane_tolerance,
839                "offset_sphere_face",
840            )?;
841            retrim_planar_face(
842                &mut result.shells[ns].faces[nf],
843                &plane,
844                &final_edges,
845                scale,
846                "offset_sphere_face",
847            )?;
848            refit_subrange_pcurves(
849                &mut result.shells[ns].faces[nf],
850                &final_edges,
851                tolerance,
852            )?;
853        }
854    }
855    // Through-centre planar caps re-trim around their grown great-circle rims.
856    for cap in &through_centre_caps {
857        let (cshell, cface) = find_face(&result, *cap)
858            .ok_or_else(|| {
859                KernelRefusal::internal(
860                    KernelStage::Sew,
861                    "missing_cap",
862                    format!("offset_sphere_face: missing cap {cap}"),
863                )
864            })?;
865        let plane = plane_of_surface(
866            &result.shells[cshell].faces[cface].surface,
867            plane_tolerance,
868            "offset_sphere_face",
869        )?;
870        retrim_planar_face(
871            &mut result.shells[cshell].faces[cface],
872            &plane,
873            &final_edges,
874            scale,
875            "offset_sphere_face",
876        )?;
877        refit_subrange_pcurves(
878            &mut result.shells[cshell].faces[cface],
879            &final_edges,
880            tolerance,
881        )?;
882    }
883    // Any OTHER face carrying an edge whose trim the corner slide moved keeps
884    // its carrier — only the pcurve of that one coedge has to be re-fitted over
885    // the new `[t0, t1]`.
886    for shell in &mut result.shells {
887        for face in &mut shell.faces {
888            if face
889                .loops
890                .iter()
891                .flat_map(|l| &l.coedges)
892                .any(|c| resized_edges.contains(&c.edge_id))
893            {
894                refit_touched_pcurves(
895                    face,
896                    &final_edges,
897                    &resized_edges,
898                    true,
899                    tolerance,
900                    "offset_sphere_face",
901                )?;
902            }
903        }
904    }
905
906    let issues = result.validate();
907    if !issues.is_empty() {
908        return Err(KernelRefusal::internal(
909            KernelStage::Validate,
910            "validate",
911            format!(
912            "offset_sphere_face: pushed solid failed validation: {issues:?}"
913        ),
914        ));
915    }
916    // Sign only, through the per-face term memo (`solid_signed_volume_for_sign`:
917    // the verdict against the bar is the exact integral's; a face read about
918    // the same reference before is served its term). 2026-10-17, the
919    // shelled-box report: the exact reads cost 0.26 s a body here.
920    if let (Ok(before), Ok(after)) =
921        (crate::signed_volume_against(solid, 0.0), crate::signed_volume_against(&result, 0.0))
922    {
923        if before * after <= 0.0 {
924            return Err(KernelRefusal::internal(
925                KernelStage::Validate,
926                INVERTED,
927                "offset_sphere_face: the push inverts the solid — refusing",
928            ));
929        }
930    }
931    Ok(result)
932}
933
934/// `retrim_planar_face` USED TO rebuild every pcurve over the edge's WHOLE
935/// curve, which is right for a full-domain edge and WRONG for an edge that is a
936/// strict SUBRANGE of its own curve. It is subrange-aware itself since
937/// 2026-09-16, when the same defect surfaced a third time in
938/// `delete_face_and_heal`, which had no such pass; this one is kept because it
939/// is what produces this caller's final pcurves at its own tolerance, and
940/// removing it is a separate change. The history, for why it exists: `validate()` samples a pcurve over its whole domain against the edge
941/// over `[t0, t1]`, so the pcurve of a partially-trimmed edge reads as a
942/// deviation the size of the trimmed-off overhang. `move_faces` has always
943/// paired its planar re-trim with this pass (`face_move.rs`, the
944/// `refit_touched_pcurves` call after `retrim_planar_face`); the sphere push
945/// did not, which is what made a spherical pocket in a box CORNER fail
946/// validation with `max deviation 5.000000` — exactly the length of box edge
947/// the original sphere had trimmed away.
948///
949/// `only_subrange = true`, so a face whose edges are all full-domain — every
950/// shape the corpus covers — keeps byte-identical pcurves.
951fn refit_subrange_pcurves(
952    face: &mut FaceRecord,
953    edges: &HashMap<u64, EdgeRecord>,
954    tolerance: f64,
955) -> Result<(), KernelRefusal> {
956    let face_edges: HashSet<u64> = face
957        .loops
958        .iter()
959        .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
960        .collect();
961    refit_touched_pcurves(
962        face,
963        edges,
964        &face_edges,
965        true,
966        tolerance,
967        "offset_sphere_face",
968    )
969    
970}
971
972/// The intersection circle whose parameter-midpoint is nearest `reference`
973/// (branch selection when S′ ∩ neighbour returns more than one component).
974fn nearest_circle(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, KernelRefusal> {
975    let mut best: Option<(f64, &NurbsCurve)> = None;
976    for curve in curves {
977        let [d0, d1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
978        let mid = curve.evaluate(0.5 * (d0 + d1)).or_refuse(KernelStage::Refine, "evaluate")?;
979        let distance = mid.sub(reference).length();
980        if best.map(|(known, _)| distance < known).unwrap_or(true) {
981            best = Some((distance, curve));
982        }
983    }
984    best.map(|(_, curve)| curve.clone())
985        .ok_or_else(|| {
986            KernelRefusal::internal(
987                KernelStage::Refine,
988                "empty_intersection",
989                "offset_sphere_face: the sphere ∩ neighbour intersection is empty",
990            )
991        })
992}
993
994/// Move a seam-meridian pcurve's RIM endpoint to the new latitude `v_new`,
995/// keeping its pole endpoint and constant u. The pcurve is a straight (u, v)
996/// parameter line; the pole endpoint is the one whose v is at a domain end
997/// (0 or 1); the other endpoint is the rim that the offset relocated.
998fn patch_meridian_pcurve(
999    pcurve: &NurbsCurve,
1000    v_new: f64,
1001    closure_at_start: bool,
1002) -> Result<NurbsCurve, KernelRefusal> {
1003    let [q0, q1] = pcurve.domain().or_refuse(KernelStage::Fragment, "domain")?;
1004    let start = pcurve.evaluate(q0).or_refuse(KernelStage::Fragment, "evaluate")?;
1005    let end = pcurve.evaluate(q1).or_refuse(KernelStage::Fragment, "evaluate")?;
1006    let (v_start, v_end) = if closure_at_start {
1007        (v_new, end.y)
1008    } else {
1009        (start.y, v_new)
1010    };
1011    make_line(
1012        Vec3::new(start.x, v_start, 0.0),
1013        Vec3::new(end.x, v_end, 0.0),
1014    )
1015    .or_refuse(KernelStage::Fragment, "make_line")
1016}
1017