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// BREP private tests: cd14f87aca569a9a