Skip to main content

brep_kernel/edit/direct_edit/
face_offset.rs

1use super::*;
2
3#[path = "face_offset_plan.rs"]
4mod plan;
5use plan::RebuildPlan;
6#[path = "face_offset_tangent.rs"]
7mod tangent;
8
9// ---------------------------------------------------------------------------
10// Push a CURVED analytic face (cylinder / cone) by OFFSETTING its carrier.
11//
12// The methodology (user directive 2026-08-28, the architecture principle made
13// literal): a face is a TRIMMED region of an infinite carrier surface. To push
14// a curved face we OFFSET its untrimmed carrier surface, then re-derive the trim
15// as the INTERSECTIONS of that offset surface with the (unchanged) untrimmed
16// carriers of the neighbour faces — `intersect_analytic_pair` does the surface ∩
17// surface, `build_pcurve_on_surface` re-trims. This is the same engine the
18// boolean imprint uses; here one surface (the pushed face's) is replaced by its
19// offset and the incident edges are recut.
20//
21// Slice 1 scope (honest refusals, never a bad solid): the PUSHED face is a
22// cylinder or cone (`RuledRevolution`); its neighbours across each boundary edge
23// are PLANAR. Ruled/sphere/torus neighbours and free-form pushed faces are
24// deferred.
25// ---------------------------------------------------------------------------
26
27/// The exact analytic offset of a ruled-revolution CARRIER surface (cylinder or
28/// cone) by `signed_distance` along its OUTWARD normal — the untrimmed surface
29/// S′ whose re-intersection with the neighbour carriers gives the new trim.
30///
31/// A normal offset of a ruled revolution is another ruled revolution with the
32/// SAME axis and half-angle: in the (axial z, radial ρ) meridian, the generatrix
33/// line `ρ = rho0 + m·z` (m = dρ/dz = (rho1−rho0)/height; m = 0 for a cylinder)
34/// offsets to the PARALLEL line `ρ = rho0 + m·z + δ·√(1+m²)` — a uniform radial
35/// growth `grow = δ·√(1+m²)` at every z. So S′ is built by revolving the grown
36/// generatrix about the SAME frame, over the SAME axial span `[0, height]` and
37/// seam azimuth (`frame.x_axis`) as the source — the span keeps the neighbour
38/// caps IN the surface's domain (an axis-translation offset would slide the
39/// finite patch off them) and the shared seam meridian stays put.
40///
41/// `signed_distance > 0` grows the surface outward (radius increases). Refuses a
42/// carrier whose grown radius reaches/crosses the axis at either end.
43fn offset_ruled_carrier(
44    surface: &NurbsSurface,
45    signed_distance: f64,
46    tolerance: f64,
47) -> Result<NurbsSurface, String> {
48    let Some(AnalyticSurface::RuledRevolution {
49        frame,
50        rho0,
51        rho1,
52        height,
53    }) = surface.analytic()
54    else {
55        return Err("offset_ruled_carrier: face carrier is not a ruled revolution".into());
56    };
57    let (frame, rho0, rho1, height) = (frame.clone(), *rho0, *rho1, *height);
58    let slope = (rho1 - rho0) / height;
59    let grow = signed_distance * (1.0 + slope * slope).sqrt();
60    let rho0_new = rho0 + grow;
61    let rho1_new = rho1 + grow;
62    if rho0_new <= tolerance || rho1_new <= tolerance {
63        return Err(
64            "offset (push): the ruled carrier collapses to or past its axis — refusing".into(),
65        );
66    }
67    // Revolve the grown generatrix over the SAME axial span + seam azimuth.
68    let start = frame.origin.add(frame.x_axis.scale(rho0_new));
69    let end = frame
70        .origin
71        .add(frame.axis.scale(height))
72        .add(frame.x_axis.scale(rho1_new));
73    let generatrix = make_line(start, end)?;
74    make_revolution(frame.origin, frame.axis, &generatrix, std::f64::consts::TAU)
75}
76
77/// The face's OUTWARD unit normal at its parameter-domain midpoint, oriented by
78/// `same_sense` (the convention `push_face::planar_face_normal` uses).
79fn outward_normal_mid(face: &FaceRecord) -> Result<(Vec3, Vec3), String> {
80    let [u0, u1] = face.surface.domain_u()?;
81    let [v0, v1] = face.surface.domain_v()?;
82    let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
83    let point = face.surface.evaluate(um, vm)?;
84    let mut normal = face.surface.normal(um, vm)?;
85    if !face.same_sense {
86        normal = normal.scale(-1.0);
87    }
88    Ok((point, normal))
89}
90
91/// Push a CYLINDER or CONE face along its outward normal by `distance` (positive
92/// grows the solid) by OFFSETTING its carrier surface and re-deriving the trim as
93/// the intersection of that offset surface with the neighbour carriers.
94///
95/// Slice-1 scope (honest refusal, never a bad solid): a full-revolution
96/// cylinder/cone SIDE face whose neighbour across every non-seam boundary edge is
97/// PLANAR (its end caps). The pushed face's own periodic SEAM edge rides the
98/// offset carrier's meridian; each rim edge re-intersects its cap
99/// (`intersect_analytic_pair`, exact circle); the caps re-trim as planar faces.
100/// Ruled/curved neighbours and non-full-revolution ruled faces are deferred.
101pub fn offset_ruled_face(
102    solid: &BrepSolid,
103    face_id: u64,
104    distance: f64,
105) -> Result<BrepSolid, String> {
106    if !distance.is_finite() {
107        return Err("offset_ruled_face: distance must be finite".into());
108    }
109    if let Some(directory) = census_directory() {
110        record_push_census(&directory, solid, face_id, distance);
111    }
112    let scale = solid_model_scale(solid);
113    let tolerance = (scale * 1e-7).max(1e-9);
114    let plane_tolerance = (scale * 1e-6).max(1e-7);
115
116    let (pshell, pface) =
117        find_face(solid, face_id).ok_or_else(|| format!("offset_ruled_face: no face {face_id}"))?;
118    let pushed = &solid.shells[pshell].faces[pface];
119    let Some(AnalyticSurface::RuledRevolution { frame, .. }) = pushed.surface.analytic() else {
120        return Err("offset_ruled_face: the pushed face is not a cylinder or cone".into());
121    };
122    let (frame_origin, frame_axis) = (frame.origin, frame.axis);
123
124    // Sign the offset: the carrier grows outward (radius +) along the face's
125    // OUTWARD normal. If that normal points radially inward (a hole wall), a
126    // positive push shrinks the radius.
127    let (mid_point, mid_normal) = outward_normal_mid(pushed)?;
128    let radial = {
129        let d = mid_point.sub(frame_origin);
130        d.sub(frame_axis.scale(d.dot(frame_axis)))
131    };
132    if radial.length() <= tolerance {
133        return Err("offset_ruled_face: degenerate radial direction (face on the axis)".into());
134    }
135    let outward_sign = if mid_normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
136    let signed_distance = distance * outward_sign;
137
138    let s_prime = offset_ruled_carrier(&pushed.surface, signed_distance, tolerance)?;
139
140    // Edge -> incident faces, to classify each of the pushed face's boundary
141    // edges as a SEAM (both incidences are the pushed face) or a RIM (the other
142    // face is the fixed neighbour cap).
143    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
144    for shell in &solid.shells {
145        for face in &shell.faces {
146            for loop_record in &face.loops {
147                for coedge in &loop_record.coedges {
148                    faces_of_edge
149                        .entry(coedge.edge_id)
150                        .or_default()
151                        .push(face.id);
152                }
153            }
154        }
155    }
156    let edge_by_id: HashMap<u64, &EdgeRecord> =
157        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
158
159    let mut new_curve: HashMap<u64, NurbsCurve> = HashMap::default();
160    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
161    let mut cap_faces: HashSet<u64> = HashSet::default();
162    // Coaxial ruled neighbours (a stepped/telescoping bore or boss: a cylinder
163    // meeting a coaxial cone, or two coaxial cones) — retrimmed on their own
164    // (axially-grown) carriers rather than as planar caps.
165    let mut ruled_faces: HashSet<u64> = HashSet::default();
166    // CURVED neighbours re-intersected through the shared generic lane — a
167    // sphere dome, a fillet torus, a general revolution, a non-coaxial
168    // cylinder. Their carriers do NOT move and need no growth (unlike a plane's
169    // finite patch or a ruled band's axial span, a sphere's and a torus's
170    // domains are already closed over their whole surface, and a re-intersected
171    // rim by construction lands on the carrier it was intersected with), so
172    // they are re-trimmed in place: pcurves rebuilt around the moved rim, and
173    // every OTHER edge they own re-trimmed by parameter on the curve it already
174    // carries — see `retrim_curved_neighbour_ranges`.
175    let mut curved_faces: HashSet<u64> = HashSet::default();
176    let mut seam_edges: Vec<u64> = Vec::new();
177    // Current vertex positions, for rebuilding a ruled neighbour's seam whose
178    // far endpoint (its unmoved rim vertex) is not relocated by any rim.
179    let vertex_pos: HashMap<u64, Vec3> =
180        solid.vertices.iter().map(|v| (v.id, v.point)).collect();
181    // The mouth's TANGENT vertices, where the rim touches a floor that a third
182    // face pinches to a point (a bore inscribed in a hex pocket). Read off
183    // topology before the rim pass, so a non-planar floor refuses by name.
184    let tangents =
185        tangent::tangent_vertices(solid, face_id, &faces_of_edge, &edge_by_id, plane_tolerance)?;
186
187    // OPEN rim arcs (a multi-loop pushed face: a window / slot cut through the
188    // wall). Collected here and trimmed in a second pass, once every corner
189    // vertex has been solved, so both arcs meeting at a corner agree on it.
190    let mut open_rims: Vec<OpenRim> = Vec::new();
191    // Every RIM edge rebuilt below, closed circles and open arcs alike: the
192    // pushed face's pcurves for these are refitted when a multi-loop rebuild
193    // ran, because the replacement conic carries its own parameterization.
194    let mut rim_edges: Vec<u64> = Vec::new();
195
196    // PRE-PASS — a HOLE cut clean through the wall by ONE crossing carrier.
197    //
198    // A window bored by a crossing cylinder, a dome or a torus leaves an
199    // interior loop every one of whose edges is shared with the SAME neighbour
200    // face, and whose corner vertices have valence two: no third face meets
201    // there, so no triple point determines them and `resolve_open_rim_end` —
202    // which solves a triple point against a PLANE — has nothing to solve. The
203    // loop is one closed section that the arrangement stored as arcs, and the
204    // right rebuild is to re-intersect once and cut the section at the samples
205    // nearest the vertices it replaces. Handled here, ahead of the per-edge
206    // loop, because the decision is a property of the whole loop.
207    let mut hole_edges: HashSet<u64> = HashSet::default();
208    for loop_record in &pushed.loops {
209        let Some(hole) = single_neighbour_hole(
210            loop_record,
211            face_id,
212            solid,
213            &faces_of_edge,
214            &edge_by_id,
215            frame_origin,
216            frame_axis,
217            scale,
218        )?
219        else {
220            continue;
221        };
222        rebuild_single_neighbour_hole(
223            solid,
224            &s_prime,
225            &hole,
226            &edge_by_id,
227            &vertex_pos,
228            tolerance,
229            scale,
230            &mut new_curve,
231            &mut new_vertex,
232        )?;
233        for edge_id in &hole.edge_ids {
234            hole_edges.insert(*edge_id);
235            if !rim_edges.contains(edge_id) {
236                rim_edges.push(*edge_id);
237            }
238        }
239        curved_faces.insert(hole.neighbour);
240    }
241
242    for loop_record in &pushed.loops {
243        let coedge_count = loop_record.coedges.len();
244        for coedge_index in 0..coedge_count {
245            let coedge = &loop_record.coedges[coedge_index];
246            let edge = *edge_by_id
247                .get(&coedge.edge_id)
248                .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
249            if hole_edges.contains(&edge.id) {
250                continue; // rebuilt whole, by the pre-pass above.
251            }
252            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
253            let is_seam = incident.iter().all(|f| *f == face_id);
254            if is_seam {
255                if !seam_edges.contains(&edge.id) {
256                    seam_edges.push(edge.id);
257                }
258                continue;
259            }
260            // RIM: the fixed neighbour is the other face. Three lanes, in
261            // decreasing exactness and increasing generality:
262            //
263            //   * a PLANE (an end cap) or a ruled revolution COAXIAL with the
264            //     pushed carrier (a stepped/telescoping bore or boss) — the two
265            //     original lanes, whose closed forms are reached by the exact
266            //     call this function has always made, unchanged;
267            //   * ANY OTHER carrier — sphere, torus, general revolution,
268            //     non-coaxial cylinder/cone, free-form — through the shared
269            //     re-intersection service (`offset/reintersect.rs`), which tries
270            //     the same closed forms first and marches the pair when they
271            //     decline. This is the lane the audit's §2.3 says push-face
272            //     lacks and offset-shell has.
273            //
274            // The generic lane is admitted only where THIS rebuild can express
275            // the answer: a CLOSED rim, whose single vertex is a bookkeeping
276            // seam point rather than a triple point. An open arc against a
277            // curved neighbour needs `resolve_open_rim_end` to solve a triple
278            // point against a curved `N'`, which it cannot (it takes a plane),
279            // so that stays a refusal with its own reason.
280            let neighbour = *incident
281                .iter()
282                .find(|f| **f != face_id)
283                .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", edge.id))?;
284            let (nshell, nface) = find_face(solid, neighbour)
285                .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
286            let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
287            let is_plane =
288                matches!(neighbour_surface.analytic(), Some(AnalyticSurface::Plane { .. }));
289            let is_coaxial_ruled =
290                ruled_neighbour_is_coaxial(neighbour_surface, frame_origin, frame_axis, scale);
291            let is_curved = !is_plane && !is_coaxial_ruled;
292            let separated = || {
293                "offset_ruled_face: the pushed carrier no longer meets a neighbour \
294                 (the push separated them, or the rim left the neighbour's domain) — refusing"
295                    .to_string()
296            };
297            // New rim = offset carrier ∩ neighbour carrier, the branch nearest
298            // the old edge.
299            let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
300            let mut marched = false;
301            let curves = if is_curved {
302                if edge.start_vertex_id != edge.end_vertex_id {
303                    return Err(format!(
304                        "offset_ruled_face: the rim against curved neighbour {neighbour} is an \
305                         OPEN arc (edge {}); an arc's corner is a triple point solved against a \
306                         PLANE only — deferred (refusing)",
307                        edge.id
308                    ));
309                }
310                let policy = MarchPolicy {
311                    tolerance,
312                    // The rebuilt rim must sit on BOTH carriers tightly enough
313                    // that its pcurves build and `validate` accepts them; the
314                    // free-form push's own residual gate (0.05% of model scale)
315                    // is the in-tree precedent for "how far an approximate
316                    // offset result may be off".
317                    residual_tolerance: (scale * 5e-4).max(5e-6),
318                    // The boundary being replaced is the best seed set for the
319                    // boundary replacing it.
320                    seeds: edge_seeds(edge, 9)?,
321                };
322                match reintersect_carriers(&s_prime, neighbour_surface, &policy) {
323                    Ok(found) => {
324                        marched = found.lane == RimLane::Marched;
325                        if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
326                            eprintln!(
327                                "RIM neighbour {neighbour}: lane {:?}, {} branch(es), \
328                                 residual {:.3e} (gate {:.3e})",
329                                found.lane,
330                                found.sections.len(),
331                                found.residual,
332                                policy.residual_tolerance
333                            );
334                        }
335                        found.curves()
336                    }
337                    Err(ReintersectRefusal::Separated) => return Err(separated()),
338                    Err(other) => {
339                        return Err(format!("offset_ruled_face: {}", other.describe()));
340                    }
341                }
342            } else {
343                // UNCHANGED: the exact call, with the same arguments in the same
344                // order, so every pair this function answered before is answered
345                // bit-identically now.
346                intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
347                    .filter(|curves| !curves.is_empty())
348                    .ok_or_else(separated)?
349            };
350            let rim = nearest_curve(&curves, old_mid)?;
351            if !rim_edges.contains(&edge.id) {
352                rim_edges.push(edge.id);
353            }
354            if edge.start_vertex_id == edge.end_vertex_id {
355                // A closed rim is a whole conic, so it must also run the way the
356                // edge it replaces ran — see `match_closed_rim_direction`.
357                let rim = if marched {
358                    // A MARCHED section begins wherever the trace was seeded,
359                    // not on the carrier's seam, so the start-tangent test would
360                    // compare two unrelated places. Ask the same question at the
361                    // old curve's nearest parameter instead.
362                    match_marched_rim_direction(rim, edge)?
363                } else {
364                    match_closed_rim_direction(rim, edge)?
365                };
366                // CLOSED rim (the canonical cap circle of a single-loop push):
367                // its seam-azimuth point is the relocated corner and matches the
368                // offset carrier's meridian. A recognized carrier's u = 0 IS its
369                // seam, and the shared rim's one seam vertex sits on both
370                // carriers' seams, so this placement keeps the coaxial
371                // neighbour's straight meridian on its carrier too. A marched
372                // section has no such convention, so its own domain start is
373                // where its single vertex goes — the vertex is a bookkeeping
374                // split of a closed curve, not a geometric corner.
375                let seam_point = if marched {
376                    let [d0, _] = rim.domain()?;
377                    rim.evaluate(d0)?
378                } else {
379                    rim.evaluate(0.0)?
380                };
381                new_vertex.insert(edge.start_vertex_id, seam_point);
382                new_vertex.insert(edge.end_vertex_id, seam_point);
383                new_curve.insert(edge.id, rim);
384            } else {
385                // OPEN rim: an arc / generatrix bounding a window cut through
386                // the wall. `rim` is the WHOLE conic the two carriers share, so
387                // each endpoint must be re-solved (it is the triple point
388                // `S' ∩ N ∩ N'` against the ADJACENT rim's neighbour, or the
389                // pushed carrier's own seam) and the conic trimmed between them.
390                // Collapsing both onto `rim.evaluate(0.0)` — the closed-rim rule
391                // — is what used to tear a multi-loop push apart.
392                let previous =
393                    &loop_record.coedges[(coedge_index + coedge_count - 1) % coedge_count];
394                let next = &loop_record.coedges[(coedge_index + 1) % coedge_count];
395                let (at_start, at_end) = if coedge.forward {
396                    (previous, next)
397                } else {
398                    (next, previous)
399                };
400                let mut resolve = |adjacent_coedge: &CoedgeRecord,
401                                   vertex_id: u64|
402                 -> Result<RimEnd, String> {
403                    let adjacent = *edge_by_id.get(&adjacent_coedge.edge_id).ok_or_else(|| {
404                        format!(
405                            "offset_ruled_face: missing edge {}",
406                            adjacent_coedge.edge_id
407                        )
408                    })?;
409                    let old_point = vertex_pos
410                        .get(&vertex_id)
411                        .copied()
412                        .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
413                    let (end, point) = resolve_open_rim_end(
414                        solid,
415                        face_id,
416                        &faces_of_edge,
417                        &rim,
418                        adjacent,
419                        old_point,
420                        plane_tolerance,
421                    )?;
422                    // Both arcs meeting at a corner solve the SAME triple point
423                    // from their own conic; a disagreement means the branch pick
424                    // went to different roots, so refuse rather than tear.
425                    match new_vertex.get(&vertex_id).copied() {
426                        Some(existing) if existing.sub(point).length() > plane_tolerance => {
427                            return Err(
428                                "offset_ruled_face: the two rims meeting at a multi-loop corner \
429                                 disagree on its new position — refusing"
430                                    .into(),
431                            )
432                        }
433                        Some(_) => {}
434                        None => {
435                            new_vertex.insert(vertex_id, point);
436                        }
437                    }
438                    Ok(end)
439                };
440                let start = resolve(at_start, edge.start_vertex_id)?;
441                let end = resolve(at_end, edge.end_vertex_id)?;
442                open_rims.push(OpenRim {
443                    edge_id: edge.id,
444                    conic: rim,
445                    start,
446                    end,
447                    old_mid,
448                    start_vertex_id: edge.start_vertex_id,
449                    end_vertex_id: edge.end_vertex_id,
450                });
451            }
452            if is_plane {
453                cap_faces.insert(neighbour);
454            } else if is_coaxial_ruled {
455                ruled_faces.insert(neighbour);
456            } else {
457                curved_faces.insert(neighbour);
458            }
459        }
460    }
461
462    // Second pass: trim each open rim's conic to the arc BETWEEN its two solved
463    // endpoints — the one that CONTAINS the old edge, picked by the old
464    // midpoint's parameter — and orient it start-vertex → end-vertex.
465    //
466    // Both endpoints are parameters on the same conic (a seam end is solved on
467    // the seam meridian, see `RimEnd::Seam`), so there is one rule for every
468    // arc: the wanted piece is `[low, high]` when the midpoint lies in it, and
469    // the COMPLEMENT — the piece that runs the other way round, across the
470    // conic's period boundary — when it does not. The complement used to be an
471    // unconditional refusal; it is an ordinary arc whenever one of its two
472    // halves is empty, which is exactly the case of an endpoint that landed ON
473    // the period boundary. The reporter's gear bore has two of them: its top
474    // rim is split at the carrier's own seam azimuth, so the vertex there
475    // solves to parameter 0 while the arc it starts is `[0.9946, 1]`.
476    for rim in &open_rims {
477        let [d0, d1] = rim.conic.domain()?;
478        let middle = project_point_to_curve(&rim.conic, rim.old_mid)?.u;
479        let (a, b) = (rim.start.parameter(), rim.end.parameter());
480        let (low, high) = if a <= b { (a, b) } else { (b, a) };
481        if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
482            let describe = |end: RimEnd| match end {
483                RimEnd::Corner(u) => format!("Corner({u:.9})"),
484                RimEnd::Seam(u) => format!("Seam({u:.9})"),
485            };
486            let at = |vid: u64| {
487                new_vertex
488                    .get(&vid)
489                    .map(|p: &Vec3| format!("({:.5},{:.5},{:.5})", p.x, p.y, p.z))
490                    .unwrap_or_else(|| "-".to_string())
491            };
492            eprintln!(
493                "OPENRIM edge {} domain=[{d0},{d1}] start={} end={} middle={middle:.9} \
494                 v{}{} -> v{}{}",
495                rim.edge_id,
496                describe(rim.start),
497                describe(rim.end),
498                rim.start_vertex_id,
499                at(rim.start_vertex_id),
500                rim.end_vertex_id,
501                at(rim.end_vertex_id),
502            );
503        }
504        if let (RimEnd::Seam(_), RimEnd::Seam(_)) = (rim.start, rim.end) {
505            return Err(
506                "offset_ruled_face: a multi-loop rim arc ends on the seam at BOTH ends — \
507                 refusing"
508                    .into(),
509            );
510        }
511        let mut trimmed = rim_arc_between(&rim.conic, low, high, middle, tolerance)?;
512        let start_point = *new_vertex.get(&rim.start_vertex_id).ok_or_else(|| {
513            "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
514        })?;
515        let end_point = *new_vertex.get(&rim.end_vertex_id).ok_or_else(|| {
516            "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
517        })?;
518        let [t0, _] = trimmed.domain()?;
519        let head = trimmed.evaluate(t0)?;
520        if head.sub(start_point).length() > head.sub(end_point).length() {
521            trimmed = trimmed.reversed()?;
522        }
523        new_curve.insert(rim.edge_id, trimmed);
524    }
525
526    // A tangent vertex cannot follow the rim — its flat stays — so it is split:
527    // the plan gets the new rim vertex, and a bridge edge or a merged floor.
528    // With no tangent vertex the plan stays geometry-only.
529    let mut plan = RebuildPlan::default();
530    tangent::plan_tangent_vertices(
531        solid,
532        face_id,
533        &tangents,
534        &faces_of_edge,
535        &edge_by_id,
536        &vertex_pos,
537        &s_prime,
538        plane_tolerance,
539        &mut new_vertex,
540        &mut cap_faces,
541        &mut plan,
542    )?;
543
544    let pos = |vid: u64| -> Vec3 {
545        new_vertex
546            .get(&vid)
547            .copied()
548            .unwrap_or_else(|| vertex_pos[&vid])
549    };
550
551    // Seam edge(s): the straight generatrix segment of the offset carrier, from
552    // the relocated bottom seam vertex to the top one. Both endpoints were placed
553    // on S′'s seam meridian by the rim re-intersections above, so a line between
554    // them rides u = 0 of S′ over the SAME axial range the seam had — using the
555    // full `iso_curve_u` meridian would wrongly span the untrimmed carrier (a
556    // boolean-drilled wall's surface runs past the plate faces).
557    for seam_id in &seam_edges {
558        let seam = *edge_by_id
559            .get(seam_id)
560            .ok_or_else(|| format!("offset_ruled_face: missing seam edge {seam_id}"))?;
561        let start = *new_vertex.get(&seam.start_vertex_id).ok_or_else(|| {
562            "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
563        })?;
564        let end = *new_vertex.get(&seam.end_vertex_id).ok_or_else(|| {
565            "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
566        })?;
567        new_curve.insert(*seam_id, make_line(start, end)?);
568    }
569
570    // A COAXIAL ruled neighbour also has its own straight seam meridian ending
571    // at the shared rim's (now moved) seam vertex. Rebuild it as the chord
572    // between its endpoints — one relocated by the rim, the other its unmoved
573    // rim vertex — which, both lying on the neighbour's straight generatrix, is
574    // exactly the meridian segment.
575    for ruled in &ruled_faces {
576        let (nshell, nface) = find_face(solid, *ruled)
577            .ok_or_else(|| format!("offset_ruled_face: missing ruled neighbour {ruled}"))?;
578        for loop_record in &solid.shells[nshell].faces[nface].loops {
579            for coedge in &loop_record.coedges {
580                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
581                    format!("offset_ruled_face: missing edge {}", coedge.edge_id)
582                })?;
583                let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
584                let is_seam = incident.iter().all(|f| *f == *ruled);
585                let touches_moved = new_vertex.contains_key(&edge.start_vertex_id)
586                    || new_vertex.contains_key(&edge.end_vertex_id);
587                if is_seam && touches_moved && !new_curve.contains_key(&edge.id) {
588                    new_curve.insert(
589                        edge.id,
590                        make_line(pos(edge.start_vertex_id), pos(edge.end_vertex_id))?,
591                    );
592                }
593            }
594        }
595    }
596
597    // A CURVED neighbour keeps its surface, so it keeps every 3D CURVE it owns
598    // — a sphere's seam meridian is the same great-circle arc after the push as
599    // before it. What changes is where that curve is TRIMMED: the endpoint the
600    // moved rim carried with it slides along the curve to a new parameter.
601    // Re-solving it as a parameter on the existing curve is EXACT, and it is
602    // strictly better than rebuilding: the coaxial-ruled lane above can chord
603    // its seam only because a ruled revolution's meridian is straight, and a
604    // sphere's is not.
605    let mut new_range: HashMap<u64, (f64, f64)> = HashMap::default();
606    for curved in &curved_faces {
607        let (nshell, nface) = find_face(solid, *curved)
608            .ok_or_else(|| format!("offset_ruled_face: missing curved neighbour {curved}"))?;
609        for loop_record in &solid.shells[nshell].faces[nface].loops {
610            for coedge in &loop_record.coedges {
611                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
612                    format!("offset_ruled_face: missing edge {}", coedge.edge_id)
613                })?;
614                if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
615                    continue;
616                }
617                let start_moved = new_vertex.get(&edge.start_vertex_id).copied();
618                let end_moved = new_vertex.get(&edge.end_vertex_id).copied();
619                if start_moved.is_none() && end_moved.is_none() {
620                    continue;
621                }
622                if edge.start_vertex_id == edge.end_vertex_id {
623                    // A closed or degenerate edge of the neighbour (its own
624                    // opposite rim, or a pole) whose vertex a rim relocated:
625                    // the whole curve would have to move, which this lane does
626                    // not do.
627                    return Err(format!(
628                        "offset_ruled_face: the push moved the vertex of curved neighbour \
629                         {curved}'s CLOSED edge {} — deferred (refusing)",
630                        edge.id
631                    ));
632                }
633                let mut range = (edge.t0, edge.t1);
634                for (moved, slot) in [(start_moved, 0usize), (end_moved, 1usize)] {
635                    let Some(point) = moved else { continue };
636                    let projection = project_point_to_curve(&edge.curve, point)?;
637                    if projection.distance > plane_tolerance {
638                        return Err(format!(
639                            "offset_ruled_face: a relocated rim vertex left curved neighbour \
640                             {curved}'s edge {} (off by {:.3e}) — refusing",
641                            edge.id, projection.distance
642                        ));
643                    }
644                    if slot == 0 {
645                        range.0 = projection.u;
646                    } else {
647                        range.1 = projection.u;
648                    }
649                }
650                new_range.insert(edge.id, range);
651            }
652        }
653    }
654    // Fail-safe: every relocated vertex must belong only to edges this push
655    // rebuilt or re-trimmed. A vertex that also ends an edge of some other face
656    // would leave that face's boundary behind, which is exactly the silent
657    // tear the ruled lane's own corner guard refuses.
658    if !curved_faces.is_empty() {
659        for edge in &solid.edges {
660            if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
661                continue;
662            }
663            if new_vertex.contains_key(&edge.start_vertex_id)
664                || new_vertex.contains_key(&edge.end_vertex_id)
665            {
666                return Err(format!(
667                    "offset_ruled_face: relocating a curved neighbour's rim moved the end of \
668                     edge {}, which this push does not rebuild — refusing",
669                    edge.id
670                ));
671            }
672        }
673    }
674
675    // A multi-loop window's corner is a TRIPLE point, so it is also the end of a
676    // third edge that belongs to neither the pushed face nor a rim: the two
677    // fixed neighbour planes' own shared edge (a slot floor meeting a slot
678    // side wall). Both carriers are fixed, so that edge stays on their
679    // intersection line and the chord between its (possibly relocated)
680    // endpoints IS the rebuilt edge. Skipped entirely when no open rim was
681    // rebuilt, so the single-loop paths are untouched.
682    if !open_rims.is_empty() {
683        let mut corner_edges: Vec<(u64, NurbsCurve)> = Vec::new();
684        for edge in &solid.edges {
685            if new_curve.contains_key(&edge.id) {
686                continue;
687            }
688            if !new_vertex.contains_key(&edge.start_vertex_id)
689                && !new_vertex.contains_key(&edge.end_vertex_id)
690            {
691                continue;
692            }
693            if edge.curve.straight_segment(tolerance).is_none() {
694                return Err(
695                    "offset_ruled_face: the push moved the end of a CURVED edge that is not a \
696                     rebuilt rim — refusing"
697                        .into(),
698                );
699            }
700            let start = pos(edge.start_vertex_id);
701            let end = pos(edge.end_vertex_id);
702            for neighbour in faces_of_edge.get(&edge.id).cloned().unwrap_or_default() {
703                if !cap_faces.contains(&neighbour) {
704                    // Name the vertex, the edge and the face. This is the
705                    // refusal a rim corner PINNED by a third, fixed face gives
706                    // — the reporter's gear bore is inscribed in a hex pocket,
707                    // so each of its six tangent corners also ends the bottom
708                    // edge of a hex flat, and moving the rim off those flats is
709                    // a topology change (the six floor faces merge) that push
710                    // face does not perform. Without the ids the message says
711                    // nothing about which corner is stuck.
712                    let stuck = [edge.start_vertex_id, edge.end_vertex_id]
713                        .into_iter()
714                        .find(|vertex| new_vertex.contains_key(vertex))
715                        .unwrap_or(edge.start_vertex_id);
716                    return Err(format!(
717                        "offset_ruled_face: the push relocates rim corner vertex {stuck}, which \
718                         also ends edge {} of face {neighbour} — a face this push does not \
719                         re-trim, so the rim would tear away from it (refusing)",
720                        edge.id
721                    ));
722                }
723                let (nshell, nface) = find_face(solid, neighbour).ok_or_else(|| {
724                    format!("offset_ruled_face: missing neighbour {neighbour}")
725                })?;
726                let plane = plane_of_surface(
727                    &solid.shells[nshell].faces[nface].surface,
728                    plane_tolerance,
729                    "offset_ruled_face",
730                )?;
731                for point in [start, end] {
732                    if point.sub(plane.origin).dot(plane.normal).abs() > plane_tolerance {
733                        return Err(
734                            "offset_ruled_face: a relocated corner left one of its fixed \
735                             neighbour planes — refusing"
736                                .into(),
737                        );
738                    }
739                }
740            }
741            corner_edges.push((edge.id, make_line(start, end)?));
742        }
743        for (edge_id, curve) in corner_edges {
744            new_curve.insert(edge_id, curve);
745        }
746    }
747
748    // --- Apply the plan to a fresh clone (the input is never mutated) -------
749    //
750    // Everything above is the classification step; what it decided is the plan.
751    plan.curves = new_curve;
752    plan.ranges = new_range;
753    plan.moved = new_vertex;
754    let (mut result, applied) = plan.apply(solid)?;
755    // Every edge this push touched, by curve, by trim range or by being added —
756    // the selective re-fit's work list.
757    let changed_edges = plan.changed_edges(&applied);
758    let (pshell, pface) = find_face(&result, face_id)
759        .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
760    // The pushed face rides the offset carrier. S′ shares the source's parameter
761    // domain + seam azimuth (same make_revolution frame/span), so the face's
762    // existing (u, v) pcurves stay valid when every rim stayed at its axial
763    // station (the planar-cap push) — only the surface swaps.
764    result.shells[pshell].faces[pface].surface = s_prime;
765
766    let final_edges: HashMap<u64, EdgeRecord> =
767        result.edges.iter().map(|e| (e.id, e.clone())).collect();
768
769    // A COAXIAL ruled neighbour moves the shared rim ALONG the axis, so the
770    // pushed face's pcurve for that rim (and the seam whose endpoint moved) is
771    // no longer at its old v — refit both the pushed face and every ruled
772    // neighbour on their (axially-grown-if-needed) carriers. The planar-only
773    // push skips this and keeps the exact pcurve reuse above.
774    //
775    // A CURVED neighbour moves the shared rim just as much (a dome's rim climbs
776    // the sphere as the rod it caps grows), so it joins the same pass — but its
777    // own carrier neither moves nor grows: a sphere, a torus and a full
778    // revolution are already closed over their whole surface, and the rim was
779    // intersected WITH that surface, so it is on it by construction. Its retrim
780    // is therefore the same driver with a growth that does nothing.
781    if !ruled_faces.is_empty() {
782        retrim_offset_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
783        for ruled in &ruled_faces {
784            retrim_offset_ruled_face(&mut result, *ruled, &final_edges, tolerance)?;
785        }
786        for curved in &curved_faces {
787            refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
788        }
789    } else if !curved_faces.is_empty() {
790        // The pushed carrier still has to GROW where the rim climbed past its
791        // axial span — the same exact prolongation the ruled lane uses — but its
792        // pcurves are re-fitted selectively, not wholesale.
793        let (gshell, gface) = find_face(&result, face_id)
794            .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
795        let samples = boundary_samples(
796            &result.shells[gshell].faces[gface],
797            &final_edges,
798            "offset_ruled_face",
799        )?;
800        extend_ruled_neighbour_over(&mut result, face_id, &samples, tolerance)?;
801        refit_changed_pcurves(&mut result, face_id, &final_edges, &changed_edges, tolerance)?;
802        for curved in &curved_faces {
803            refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
804        }
805    } else if !rim_edges.is_empty() {
806        // Every rebuilt RIM needs a fresh pcurve on S'. An open rim because its
807        // azimuth span (an arc) or station (a generatrix) genuinely moved; a
808        // CLOSED rim because the replacement conic carries the INTERSECTOR's
809        // parameterization, which need not be the one the incoming curve had —
810        // a boolean-cut cylinder's cap circle comes back re-parameterized, and
811        // a closed rim under an OBLIQUE planar cap is an ellipse whose height
812        // profile v(u) changes with the offset (the cap plane climbs as the
813        // carrier grows: measured 0.382 against a 0.206 limit when the old
814        // pcurve was kept on the oblique-cut cone of PushFaceTest2). So this
815        // pass runs for every rebuilt rim, not only when an open rim exists;
816        // for a perpendicular cap circle it rebuilds the same v = const line.
817        // `validate` pairs pcurve to curve by FRACTION of their domains, so a
818        // pointwise-identical circle with a different parameter distribution
819        // still reads as a gross deviation (measured 5.96 and 12.0 on the slot
820        // fixture, against a 0.394 limit).
821        //
822        // The SEAM edges keep their u — and with it the two coedges sitting on
823        // OPPOSITE sides of the periodic seam — but their v is REFRESHED, which
824        // is where this used to be wrong.
825        //
826        // The claim that stood here was that a seam is "rebuilt as a straight
827        // chord over the same axial range, so fraction ↦ height is unchanged".
828        // That is true of a cap PERPENDICULAR to the axis and false of an
829        // OBLIQUE one: a tilted plane meets the generatrix at azimuth `u` at an
830        // axial station that depends on the RADIUS, so changing the radius by
831        // `d` slides the seam's endpoint along the axis by `d·tan θ`, where `θ`
832        // is the angle between the axis and the cap's normal. Measured on a
833        // radius-3 bore drilled at 30° and pushed by 0.5: the seam's pcurve sat
834        // `2.887e-1` off its own edge — exactly `0.5·tan 30°` — and
835        // `validate()`'s scale-derived pcurve limit passed it, so the only
836        // symptom was the body's volume, `1.9e-7` relative against its closed
837        // form where the axis-aligned control reads `1.6e-13`.
838        let rebuilt: HashSet<u64> = rim_edges.iter().copied().collect();
839        let seams: HashSet<u64> = seam_edges.iter().copied().collect();
840        let surface = result.shells[pshell].faces[pface].surface.clone();
841        let [u_start, u_end] = surface.domain_u()?;
842        let u_period = u_end - u_start;
843        for loop_record in &mut result.shells[pshell].faces[pface].loops {
844            for coedge in &mut loop_record.coedges {
845                if !rebuilt.contains(&coedge.edge_id) {
846                    continue;
847                }
848                let edge = final_edges
849                    .get(&coedge.edge_id)
850                    .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
851                let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
852                if !coedge.forward {
853                    pcurve = pcurve.reversed()?;
854                }
855                coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
856            }
857        }
858        // The seam pass, in the same loop shape but keeping u: a generatrix's
859        // azimuth does not change under a radial offset, only the station its
860        // ends sit at.
861        for loop_record in &mut result.shells[pshell].faces[pface].loops {
862            for coedge in &mut loop_record.coedges {
863                if !seams.contains(&coedge.edge_id) {
864                    continue;
865                }
866                let edge = final_edges
867                    .get(&coedge.edge_id)
868                    .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
869                coedge.pcurve = reseat_seam_pcurve(&surface, &coedge.pcurve, edge, coedge.forward)?;
870            }
871        }
872    }
873
874    // The fixed caps re-trim as planar faces around their grown rim circles.
875    for cap in &cap_faces {
876        let (cshell, cface) = find_face(&result, *cap)
877            .ok_or_else(|| format!("offset_ruled_face: missing cap {cap}"))?;
878        let plane = plane_of_surface(
879            &result.shells[cshell].faces[cface].surface,
880            plane_tolerance,
881            "offset_ruled_face",
882        )?;
883        retrim_planar_face(
884            &mut result.shells[cshell].faces[cface],
885            &plane,
886            &final_edges,
887            scale,
888            "offset_ruled_face",
889        )?;
890    }
891
892    let issues = result.validate();
893    if !issues.is_empty() {
894        if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
895            // Which coedge of the pushed face drifted, and by how much, with
896            // the pcurve's endpoints so a re-anchoring or direction mistake is
897            // visible next to a fitting one.
898            let (pshell, pface) = find_face(&result, face_id).expect("pushed face survives");
899            let face = &result.shells[pshell].faces[pface];
900            for (li, loop_record) in face.loops.iter().enumerate() {
901                for coedge in &loop_record.coedges {
902                    let Some(edge) = result.edges.iter().find(|e| e.id == coedge.edge_id) else {
903                        continue;
904                    };
905                    let (Ok([p0, p1]), Ok(c0)) = (coedge.pcurve.domain(), edge.curve.evaluate(edge.t0))
906                    else {
907                        continue;
908                    };
909                    let mut worst = 0.0f64;
910                    for k in 0..=32 {
911                        let f = k as f64 / 32.0;
912                        let t = if coedge.forward {
913                            edge.t0 + (edge.t1 - edge.t0) * f
914                        } else {
915                            edge.t1 - (edge.t1 - edge.t0) * f
916                        };
917                        if let (Ok(q), Ok(target)) =
918                            (coedge.pcurve.evaluate(p0 + (p1 - p0) * f), edge.curve.evaluate(t))
919                        {
920                            if let Ok(on_s) = face.surface.evaluate(q.x, q.y) {
921                                worst = worst.max(on_s.sub(target).length());
922                            }
923                        }
924                    }
925                    let (Ok(a), Ok(b)) = (coedge.pcurve.evaluate(p0), coedge.pcurve.evaluate(p1)) else {
926                        continue;
927                    };
928                    eprintln!(
929                        "PUSH-DEBUG face {} loop {li} edge {} fwd={} rebuilt={} t=[{:.4},{:.4}] dom={:?} \
930                         curve(t0)=({:.4},{:.4},{:.4}) pcurve ({:.4},{:.4})->({:.4},{:.4}) dev={worst:.4}",
931                        face.id,
932                        edge.id,
933                        coedge.forward,
934                        rim_edges.contains(&edge.id),
935                        edge.t0,
936                        edge.t1,
937                        edge.curve.domain().ok(),
938                        c0.x,
939                        c0.y,
940                        c0.z,
941                        a.x,
942                        a.y,
943                        b.x,
944                        b.y
945                    );
946                }
947            }
948        }
949        return Err(format!(
950            "offset_ruled_face: pushed solid failed validation: {issues:?}"
951        ));
952    }
953    if let (Ok(before), Ok(after)) =
954        (solid_signed_volume(solid), solid_signed_volume(&result))
955    {
956        if before * after <= 0.0 {
957            return Err("offset_ruled_face: the push inverts the solid — refusing".into());
958        }
959    }
960    Ok(result)
961}
962
963/// Put a SEAM meridian's pcurve back on its own (rebuilt) chord without moving
964/// it off the periodic seam: the u coordinates are carried over verbatim from
965/// the pcurve being replaced — they are what put the two coedges of a seam on
966/// OPPOSITE sides of the period — and only v is re-solved.
967///
968/// `v` is solved exactly rather than fitted. A ruled revolution is affine in
969/// `v` along a fixed `u`, so the station of a point on that generatrix is its
970/// projection onto the segment the v-domain spans, and two surface evaluations
971/// give the whole map.
972fn reseat_seam_pcurve(
973    surface: &NurbsSurface,
974    pcurve: &NurbsCurve,
975    edge: &EdgeRecord,
976    forward: bool,
977) -> Result<NurbsCurve, String> {
978    let [q0, q1] = pcurve.domain()?;
979    let (head, tail) = (pcurve.evaluate(q0)?, pcurve.evaluate(q1)?);
980    let [v0, v1] = surface.domain_v()?;
981    let station = |u: f64, target: Vec3| -> Result<f64, String> {
982        let base = surface.evaluate(u, v0)?;
983        let top = surface.evaluate(u, v1)?;
984        let direction = top.sub(base);
985        let length_squared = direction.length_squared();
986        if length_squared <= 0.0 {
987            return Err(
988                "offset_ruled_face: the pushed carrier's generatrix has zero length — refusing"
989                    .into(),
990            );
991        }
992        Ok(v0 + (v1 - v0) * (target.sub(base).dot(direction) / length_squared))
993    };
994    let (at_head, at_tail) = if forward {
995        (edge.curve.evaluate(edge.t0)?, edge.curve.evaluate(edge.t1)?)
996    } else {
997        (edge.curve.evaluate(edge.t1)?, edge.curve.evaluate(edge.t0)?)
998    };
999    let reseated = crate::make_line(
1000        Vec3::new(head.x, station(head.x, at_head)?, 0.0),
1001        Vec3::new(tail.x, station(tail.x, at_tail)?, 0.0),
1002    )?;
1003    // Checked, not asserted: the reseated pcurve must land on the chord it
1004    // carries, at both ends and in the middle.
1005    let [r0, r1] = reseated.domain()?;
1006    for (parameter, target) in [(r0, at_head), (r1, at_tail)] {
1007        let uv = reseated.evaluate(parameter)?;
1008        let drift = surface.evaluate(uv.x, uv.y)?.sub(target).length();
1009        if drift > 1e-6 * (1.0 + target.length()) {
1010            return Err(format!(
1011                "offset_ruled_face: the reseated seam pcurve misses its own chord by \
1012                 {drift:.3e} — refusing"
1013            ));
1014        }
1015    }
1016    Ok(reseated)
1017}
1018
1019/// Where an OPEN rim arc's endpoint sits on the rebuilt conic — in every case
1020/// a PARAMETER on that conic, solved from geometry.
1021#[derive(Clone, Copy)]
1022enum RimEnd {
1023    /// A genuine corner: the TRIPLE point `offset carrier ∩ this rim's
1024    /// neighbour ∩ the ADJACENT rim's neighbour`, carried as the conic
1025    /// parameter that lands on it.
1026    Corner(f64),
1027    /// The pushed carrier's periodic SEAM split this rim in two, so the
1028    /// endpoint rides the offset carrier's seam MERIDIAN — the parameter at
1029    /// which the conic crosses that meridian's axial half-plane.
1030    ///
1031    /// It is NOT parameter 0 of the conic. `S'` is revolved about the same
1032    /// frame as the source carrier, so parameter 0 sits at `frame.x_axis`'s
1033    /// azimuth — and a face's topological seam edge need not be there. An
1034    /// imported cylinder can carry its seam edge anywhere on the carrier: the
1035    /// reporter's gear bore (`IMPORT3D1_Face_29`) has its seam edge at
1036    /// u = 0.5, a full 180° from `frame.x_axis`, and reading it as parameter 0
1037    /// relocated both seam vertices to the wrong meridian and trimmed each
1038    /// abutting arc from `[0, corner]` instead of `[0.5, corner]`.
1039    Seam(f64),
1040}
1041
1042impl RimEnd {
1043    /// The conic parameter this endpoint lands on.
1044    fn parameter(self) -> f64 {
1045        match self {
1046            RimEnd::Corner(parameter) | RimEnd::Seam(parameter) => parameter,
1047        }
1048    }
1049}
1050
1051/// One open rim arc, held between the two passes of the rebuild.
1052struct OpenRim {
1053    edge_id: u64,
1054    /// The WHOLE conic `S' ∩ N`, before trimming.
1055    conic: NurbsCurve,
1056    start: RimEnd,
1057    end: RimEnd,
1058    /// The OLD edge's midpoint: picks which of the two complementary arcs
1059    /// between the corners is the one this edge actually was.
1060    old_mid: Vec3,
1061    start_vertex_id: u64,
1062    end_vertex_id: u64,
1063}
1064
1065/// Resolve ONE endpoint of an open rim arc against the edge that follows it
1066/// around the loop.
1067///
1068/// Two cases, and nothing else is admitted:
1069/// * the adjacent edge is the pushed face's own SEAM — the endpoint rides the
1070///   offset carrier's seam meridian, i.e. it is where this rim's conic crosses
1071///   that meridian's axial half-plane ([`seam_axial_plane`], the same closed
1072///   form the single-neighbour-hole lane pins its corners with);
1073/// * the adjacent edge's fixed neighbour `N'` is a PLANE — the endpoint is the
1074///   triple point `S' ∩ N ∩ N'`, i.e. where this rim's conic (already the
1075///   `S' ∩ N` curve) crosses `N'`. The crossing nearest the old vertex is the
1076///   branch, so a conic that meets `N'` twice picks the right corner.
1077///
1078/// A conic that LIES IN `N'` means the boolean merely split one rim in two
1079/// (`N == N'`); there is no triple point, and the split simply keeps its
1080/// azimuth on the rebuilt conic. Any other adjacent neighbour (a curved
1081/// carrier) makes `plane_of_surface` refuse, which is the fail-safe.
1082fn resolve_open_rim_end(
1083    solid: &BrepSolid,
1084    face_id: u64,
1085    faces_of_edge: &HashMap<u64, Vec<u64>>,
1086    conic: &NurbsCurve,
1087    adjacent: &EdgeRecord,
1088    old_point: Vec3,
1089    plane_tolerance: f64,
1090) -> Result<(RimEnd, Vec3), String> {
1091    let incident = faces_of_edge.get(&adjacent.id).cloned().unwrap_or_default();
1092    if incident.iter().all(|f| *f == face_id) {
1093        let (pshell, pface) = find_face(solid, face_id)
1094            .ok_or_else(|| format!("offset_ruled_face: missing pushed face {face_id}"))?;
1095        let meridian = seam_axial_plane(&solid.shells[pshell].faces[pface].surface, adjacent)
1096            .ok_or_else(|| {
1097                format!(
1098                    "offset_ruled_face: the pushed face's seam (edge {}) is not a meridian of \
1099                     its carrier — refusing",
1100                    adjacent.id
1101                )
1102            })?;
1103        // The meridian's plane is a WHOLE axial plane, so it also carries the
1104        // opposite azimuth; the seam vertex being replaced picks the right one.
1105        // The two are a diameter apart and the new one is |r − r'| from the old,
1106        // which is smaller than r + r' for any push the carrier survives.
1107        let parameter = conic_crossing_nearest(conic, &meridian, old_point, plane_tolerance)?
1108            .ok_or_else(|| {
1109                format!(
1110                    "offset_ruled_face: the rebuilt rim never crosses the pushed face's own seam \
1111                     meridian (edge {}) — refusing",
1112                    adjacent.id
1113                )
1114            })?;
1115        return Ok((RimEnd::Seam(parameter), conic.evaluate(parameter)?));
1116    }
1117    let other = *incident
1118        .iter()
1119        .find(|f| **f != face_id)
1120        .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", adjacent.id))?;
1121    let (nshell, nface) = find_face(solid, other)
1122        .ok_or_else(|| format!("offset_ruled_face: missing neighbour {other}"))?;
1123    let plane = plane_of_surface(
1124        &solid.shells[nshell].faces[nface].surface,
1125        plane_tolerance,
1126        "offset_ruled_face",
1127    )?;
1128    if curve_lies_in_plane(conic, &plane, plane_tolerance)? {
1129        let projection = project_point_to_curve(conic, old_point)?;
1130        return Ok((RimEnd::Corner(projection.u), conic.evaluate(projection.u)?));
1131    }
1132    census_push("corner_crossings", || {
1133        let [d0, d1] = conic.domain().unwrap_or([f64::NAN, f64::NAN]);
1134        let height = |t: f64| conic.evaluate(t).map(|p| p.sub(plane.origin).dot(plane.normal)).unwrap_or(f64::NAN);
1135        serde_json::json!({
1136            "h_start": height(d0),
1137            "h_end": height(d1),
1138            "roots": plane_crossing_params(conic, &plane).map(|roots| roots.iter().map(|t| (t - d0) / (d1 - d0)).collect::<Vec<_>>()).unwrap_or_default(),
1139            "closed": conic.evaluate(d0).and_then(|a| conic.evaluate(d1).map(|b| a.sub(b).length())).unwrap_or(f64::NAN) <= plane_tolerance,
1140        })
1141    });
1142    let mut best: Option<(f64, f64)> = None;
1143    for parameter in plane_crossing_params(conic, &plane)? {
1144        let distance = conic.evaluate(parameter)?.sub(old_point).length();
1145        if best.map(|(best, _)| distance < best).unwrap_or(true) {
1146            best = Some((distance, parameter));
1147        }
1148    }
1149    let (_, parameter) = best.ok_or_else(|| {
1150        "offset_ruled_face: a multi-loop rim corner no longer meets its adjacent neighbour \
1151         (the push pulled the window off it) — refusing"
1152            .to_string()
1153    })?;
1154    Ok((RimEnd::Corner(parameter), conic.evaluate(parameter)?))
1155}
1156
1157/// TRUE when the whole curve lies in the plane (so it cannot cross it): the
1158/// adjacent rim shares this rim's carrier plane and the shared vertex is a
1159/// SPLIT point, not a triple point.
1160fn curve_lies_in_plane(
1161    curve: &NurbsCurve,
1162    plane: &Plane,
1163    plane_tolerance: f64,
1164) -> Result<bool, String> {
1165    let [d0, d1] = curve.domain()?;
1166    for step in 0..=16 {
1167        let t = d0 + (d1 - d0) * step as f64 / 16.0;
1168        if curve
1169            .evaluate(t)?
1170            .sub(plane.origin)
1171            .dot(plane.normal)
1172            .abs()
1173            > plane_tolerance
1174        {
1175            return Ok(false);
1176        }
1177    }
1178    Ok(true)
1179}
1180
1181/// Every parameter at which `curve` crosses `plane`, by dense sampling of the
1182/// signed plane distance plus bisection on each sign change. The curves here
1183/// are conics (a circle crosses a slot wall twice, a generatrix line crosses a
1184/// slot floor once), so a sampled sign-change sweep finds every root; bisection
1185/// then drives it to the last bit rather than to a fit tolerance.
1186fn plane_crossing_params(curve: &NurbsCurve, plane: &Plane) -> Result<Vec<f64>, String> {
1187    let [d0, d1] = curve.domain()?;
1188    let signed = |t: f64| -> Result<f64, String> {
1189        Ok(curve.evaluate(t)?.sub(plane.origin).dot(plane.normal))
1190    };
1191    const SAMPLES: usize = 512;
1192    let mut roots = Vec::new();
1193    let mut previous = (d0, signed(d0)?);
1194    for index in 1..=SAMPLES {
1195        let t = d0 + (d1 - d0) * index as f64 / SAMPLES as f64;
1196        let value = signed(t)?;
1197        if previous.1 == 0.0 {
1198            roots.push(previous.0);
1199        } else if (previous.1 < 0.0) != (value < 0.0) {
1200            let (mut low, mut high) = (previous.0, t);
1201            let mut low_value = previous.1;
1202            for _ in 0..100 {
1203                let middle = 0.5 * (low + high);
1204                if middle <= low || middle >= high {
1205                    break;
1206                }
1207                let middle_value = signed(middle)?;
1208                if (low_value < 0.0) != (middle_value < 0.0) {
1209                    high = middle;
1210                } else {
1211                    low = middle;
1212                    low_value = middle_value;
1213                }
1214            }
1215            roots.push(0.5 * (low + high));
1216        }
1217        previous = (t, value);
1218    }
1219    if previous.1 == 0.0 {
1220        roots.push(previous.0);
1221    }
1222    Ok(roots)
1223}
1224
1225/// The parameter at which `curve` crosses `plane` NEAREST `reference`, or
1226/// `None` when it never crosses it.
1227///
1228/// Written for the seam endpoint of an open rim, where the plane is an axial
1229/// half-plane's whole plane and the crossing wanted is the one replacing a
1230/// known old point. Two things [`plane_crossing_params`] alone does not give:
1231///
1232/// * **the domain ENDS count.** A closed conic whose parameter origin already
1233///   sits on the plane — the canonical case, where the carrier's seam azimuth
1234///   IS `frame.x_axis` — has a root exactly at `d0` (== `d1`). The sampled
1235///   sweep only reports it when the signed distance there is *bit*-zero, and
1236///   a bisected near-endpoint root is a few ulp off the exact end. So both
1237///   ends are offered first, and a root that duplicates an offered end is
1238///   dropped: the canonical case then returns `d0` exactly, and
1239///   `conic.evaluate(d0)` stays the point this function has always returned.
1240/// * **a pick.** An axial plane cuts a conic about the same axis twice, at
1241///   opposite azimuths; only the old point can say which is the seam's.
1242fn conic_crossing_nearest(
1243    curve: &NurbsCurve,
1244    plane: &Plane,
1245    reference: Vec3,
1246    plane_tolerance: f64,
1247) -> Result<Option<f64>, String> {
1248    let [d0, d1] = curve.domain()?;
1249    let span = (d1 - d0).max(1e-12);
1250    let mut candidates: Vec<f64> = Vec::new();
1251    let mut ends = [false, false];
1252    for (slot, end) in [d0, d1].into_iter().enumerate() {
1253        if curve.evaluate(end)?.sub(plane.origin).dot(plane.normal).abs() <= plane_tolerance {
1254            ends[slot] = true;
1255            candidates.push(end);
1256        }
1257    }
1258    for root in plane_crossing_params(curve, plane)? {
1259        if (ends[0] && (root - d0).abs() <= 1e-6 * span)
1260            || (ends[1] && (root - d1).abs() <= 1e-6 * span)
1261        {
1262            continue;
1263        }
1264        candidates.push(root);
1265    }
1266    let mut best: Option<(f64, f64)> = None;
1267    for parameter in candidates {
1268        let distance = curve.evaluate(parameter)?.sub(reference).length();
1269        if best.map(|(known, _)| distance < known).unwrap_or(true) {
1270            best = Some((distance, parameter));
1271        }
1272    }
1273    Ok(best.map(|(_, parameter)| parameter))
1274}
1275
1276/// The piece of `curve` over `[from, to]`. `NurbsCurve::split` keeps the parent
1277/// parameterization, so the result's own domain IS `[from, to]` — which is what
1278/// the edge's `t0`/`t1` are then set from.
1279fn subcurve(curve: &NurbsCurve, from: f64, to: f64) -> Result<NurbsCurve, String> {
1280    let [d0, d1] = curve.domain()?;
1281    let span = (d1 - d0).max(1e-12);
1282    if to - from <= 1e-9 * span {
1283        return Err("offset_ruled_face: a rebuilt rim arc collapsed to a point — refusing".into());
1284    }
1285    let mut trimmed = curve.clone();
1286    if to < d1 - 1e-9 * span {
1287        trimmed = trimmed.split(to)?.0;
1288    }
1289    if from > d0 + 1e-9 * span {
1290        trimmed = trimmed.split(from)?.1;
1291    }
1292    Ok(trimmed)
1293}
1294
1295/// The arc of `conic` between the parameters `low <= high` that CONTAINS
1296/// `middle` — the old edge's own midpoint, which is what says which of the two
1297/// complementary arcs the edge being replaced actually was.
1298///
1299/// `[low, high]` when the midpoint lies in it. Otherwise the edge is the
1300/// COMPLEMENT, the arc that runs the other way round across the conic's period
1301/// boundary, and that is an ordinary sub-arc exactly when one of its two halves
1302/// — `[d0, low]` and `[high, d1]` — is empty, i.e. when an endpoint landed ON
1303/// the boundary. It does, whenever a rim was split at the pushed carrier's own
1304/// seam azimuth: the reporter's gear bore (`IMPORT3D1_Face_29`) carries such a
1305/// split, and its two arcs there are `[0.9946, 1]` (endpoint at 0) and
1306/// `[0, 0.5]` (endpoint at 1). Both used to be refused as "wraps the pushed
1307/// carrier's periodic seam" although neither needs anything joined.
1308///
1309/// The genuinely two-piece complement stays a refusal: joining two rational
1310/// NURBS pieces across the boundary is a curve rebuild this lane does not do,
1311/// and no corpus document asks for one.
1312fn rim_arc_between(
1313    conic: &NurbsCurve,
1314    low: f64,
1315    high: f64,
1316    middle: f64,
1317    tolerance: f64,
1318) -> Result<NurbsCurve, String> {
1319    let [d0, d1] = conic.domain()?;
1320    if middle >= low && middle <= high {
1321        return subcurve(conic, low, high);
1322    }
1323    // The two halves of the complement can only be ONE arc if the conic closes.
1324    if conic.evaluate(d0)?.sub(conic.evaluate(d1)?).length() > tolerance {
1325        return Err(
1326            "offset_ruled_face: a multi-loop rim arc runs outside its OPEN conic's domain — \
1327             refusing"
1328                .into(),
1329        );
1330    }
1331    let span = (d1 - d0).max(1e-12);
1332    let head_is_empty = low - d0 <= 1e-9 * span;
1333    let tail_is_empty = d1 - high <= 1e-9 * span;
1334    match (head_is_empty, tail_is_empty) {
1335        (true, false) => subcurve(conic, high, d1),
1336        (false, true) => subcurve(conic, d0, low),
1337        _ => Err(
1338            "offset_ruled_face: a multi-loop rim arc crosses the rebuilt conic's period \
1339             boundary with material on BOTH sides, so it is two pieces to join — deferred \
1340             (refusing)"
1341                .into(),
1342        ),
1343    }
1344}
1345
1346/// Orient a rebuilt CLOSED rim the way the edge it replaces ran.
1347///
1348/// `intersect_analytic_pair` always emits its conics in its OWN direction
1349/// (increasing azimuth about the carrier frame). The edge being replaced need
1350/// not run that way: a wall's top and bottom cap circles are traversed in
1351/// OPPOSITE directions around the face loop, so one of them arrives decreasing.
1352/// Both are the same point set and a closed rim's two endpoints are the same
1353/// vertex, so `validate` cannot tell them apart — but the face's pcurve for the
1354/// reversed one runs `u: 1 → 0`, and handing the loop an increasing rim tears it
1355/// open in parameter space, which the Green's-theorem volume integral reads as a
1356/// completely different solid (measured 138.18 against a true 1024.11).
1357///
1358/// Decided on the START TANGENT, which is local and exact: a closed rim starts
1359/// on the carrier's seam and so does the rebuilt conic, so the two tangents
1360/// there either agree or oppose. OPEN rims are not orientated here — they are
1361/// trimmed to their solved corners first and then turned to run
1362/// start-vertex → end-vertex.
1363fn match_closed_rim_direction(
1364    rim: NurbsCurve,
1365    previous: &EdgeRecord,
1366) -> Result<NurbsCurve, String> {
1367    let [d0, _] = rim.domain()?;
1368    let incoming = previous.curve.derivatives(previous.t0, 1)?;
1369    let rebuilt = rim.derivatives(d0, 1)?;
1370    if incoming[1].dot(rebuilt[1]) < 0.0 {
1371        return rim.reversed();
1372    }
1373    Ok(rim)
1374}
1375
1376/// Put a freshly fitted pcurve back on the periodic BRANCH of `u` that the
1377/// pcurve it replaces used, by the whole-period shift that aligns their starts.
1378///
1379/// This is not cosmetic. `build_pcurve_on_surface` CLAMPS an analytic carrier's
1380/// parameters into `[u0, u1]`, but a face loop on a full revolution legitimately
1381/// carries `u` one period PAST the domain: the two coedges of the periodic seam
1382/// sit on opposite sides of it, so the coedge that closes the loop across the
1383/// seam runs (say) `u: 1 → 2`. `validate` accepts either branch — it evaluates
1384/// the surface with `evaluate_extended`, which wraps — but the Green's-theorem
1385/// area/volume integral does NOT: it integrates the wire as drawn in parameter
1386/// space, and a rim dropped back to `u: 0 → 1` tears the loop open and yields a
1387/// nonsense volume (measured 138.18 for a solid whose true volume is 1024.11).
1388///
1389/// The replacement rim traverses the same path in the same direction as the edge
1390/// it replaces — only its parameter DISTRIBUTION and (for an open arc) its
1391/// endpoints change — so aligning the starts fixes the branch, and the endpoint
1392/// check refuses anything that is not merely re-anchored.
1393fn reanchor_pcurve_u(
1394    pcurve: &NurbsCurve,
1395    previous: &NurbsCurve,
1396    u_period: f64,
1397) -> Result<NurbsCurve, String> {
1398    if !(u_period.is_finite() && u_period > 0.0) {
1399        return Ok(pcurve.clone());
1400    }
1401    let [a0, a1] = pcurve.domain()?;
1402    let [b0, b1] = previous.domain()?;
1403    let shift = ((previous.evaluate(b0)?.x - pcurve.evaluate(a0)?.x) / u_period).round() * u_period;
1404    let shifted = if shift == 0.0 {
1405        pcurve.clone()
1406    } else {
1407        let controls = pcurve
1408            .control_points
1409            .iter()
1410            .map(|control| {
1411                let mut point = control.point()?;
1412                point.x += shift;
1413                Ok(crate::Vec4::from_point(point, control.w))
1414            })
1415            .collect::<Result<Vec<_>, String>>()?;
1416        NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), controls)?
1417    };
1418    // Both ends must land within a quarter period of the ones they replace: a
1419    // rim that reversed direction, or that jumped a branch mid-curve, is not a
1420    // re-anchoring and must not be silently accepted.
1421    let end_drift = (shifted.evaluate(a1)?.x - previous.evaluate(b1)?.x).abs();
1422    if end_drift > 0.25 * u_period {
1423        return Err(format!(
1424            "offset_ruled_face: a rebuilt rim traverses the carrier's periodic parameter \
1425             differently from the edge it replaces (end drift {end_drift}) — refusing"
1426        ));
1427    }
1428    Ok(shifted)
1429}
1430
1431/// TRUE when `neighbour` is a ruled revolution (cylinder or cone) sharing the
1432/// pushed carrier's axis LINE — same axis direction (up to sign) and the axes
1433/// coincident. Only such a neighbour has a closed-form re-intersection with the
1434/// offset carrier (`intersect_coaxial_revolutions`); the tolerances mirror that
1435/// intersector so an accepted neighbour is one it can actually solve.
1436pub(super) fn ruled_neighbour_is_coaxial(
1437    neighbour: &NurbsSurface,
1438    axis_origin: Vec3,
1439    axis_dir: Vec3,
1440    scale: f64,
1441) -> bool {
1442    let Some(AnalyticSurface::RuledRevolution { frame, .. }) = neighbour.analytic() else {
1443        return false;
1444    };
1445    if frame.axis.dot(axis_dir).abs() < 1.0 - 1e-9 {
1446        return false;
1447    }
1448    let offset = frame.origin.sub(axis_origin);
1449    let perpendicular = offset.sub(axis_dir.scale(offset.dot(axis_dir)));
1450    perpendicular.length() <= 1e-9 * scale.max(1.0)
1451}
1452
1453/// Re-trim a ruled-revolution face (the pushed carrier itself, now S′, or a
1454/// coaxial ruled neighbour) whose boundary moved axially: grow the carrier along
1455/// its axis to cover the updated boundary (`extend_ruled_neighbour_over`, exact),
1456/// then rebuild every pcurve from the already-updated edge curves. The offset
1457/// analogue of `retrim_planar_face` — all loops are visited, so holes carry.
1458///
1459/// The three-phase body is `crate::offset_retrim::retrim_face_in_solid`; this is
1460/// the ruled growth strategy and the `offset_ruled_face` refusal prefix.
1461pub(super) fn retrim_offset_ruled_face(
1462    result: &mut BrepSolid,
1463    face_id: u64,
1464    final_edges: &HashMap<u64, EdgeRecord>,
1465    tolerance: f64,
1466) -> Result<(), String> {
1467    let (shell, face_pos) = find_face(result, face_id)
1468        .ok_or_else(|| format!("offset_ruled_face: missing ruled face {face_id}"))?;
1469    retrim_face_in_solid(
1470        result,
1471        shell,
1472        face_pos,
1473        final_edges,
1474        |solid, points| extend_ruled_neighbour_over(solid, face_id, points, tolerance),
1475        tolerance,
1476        "offset_ruled_face",
1477    )
1478}
1479
1480/// A window through the wall bounded entirely by ONE crossing carrier.
1481struct SingleNeighbourHole {
1482    neighbour: u64,
1483    /// Every edge of the loop, in loop order, de-duplicated.
1484    edge_ids: Vec<u64>,
1485    /// Corner vertices that are ALSO the end of one of the neighbour's own
1486    /// edges — its seam meridian, in every case the corpus reaches. Such a
1487    /// corner is NOT a free bookkeeping split: it is pinned to that meridian,
1488    /// so it moves along it rather than to the nearest point of the new
1489    /// section. `(vertex id, the neighbour edge it is pinned to)`.
1490    pinned: Vec<(u64, u64)>,
1491}
1492
1493/// The axial half-plane a revolution carrier's SEAM meridian lies in.
1494///
1495/// Every seam of every revolution carrier this lane admits — a cylinder, a
1496/// cone, a sphere, a torus, a general revolution — is a meridian, and a
1497/// meridian lies in the half-plane spanned by the axis and its own radial
1498/// direction. So "where does the new section cross the neighbour's seam" is a
1499/// curve × plane crossing, in closed form, for all five at once. Returns `None`
1500/// for a carrier that is not a revolution or a seam that runs ON the axis.
1501fn seam_axial_plane(surface: &NurbsSurface, seam: &EdgeRecord) -> Option<Plane> {
1502    let structure = crate::revolution_structure(surface)?;
1503    let midpoint = seam
1504        .curve
1505        .evaluate(0.5 * (seam.t0 + seam.t1))
1506        .ok()?;
1507    let offset = midpoint.sub(structure.frame.origin);
1508    let radial = offset
1509        .sub(structure.frame.axis.scale(offset.dot(structure.frame.axis)))
1510        .normalized()
1511        .ok()?;
1512    let normal = structure.frame.axis.cross(radial).normalized().ok()?;
1513    Some(Plane {
1514        origin: structure.frame.origin,
1515        u_dir: structure.frame.axis,
1516        v_dir: radial,
1517        normal,
1518    })
1519}
1520
1521/// Recognise a loop that is a hole cut by ONE crossing carrier, or say why not.
1522///
1523/// Four conditions, each of which the rebuild depends on and none of which it
1524/// can check afterwards:
1525///
1526/// * every coedge's other face is the SAME neighbour — otherwise a corner IS a
1527///   triple point and belongs to `resolve_open_rim_end`;
1528/// * that neighbour is neither planar nor coaxial-ruled — the two lanes with
1529///   their own exact rebuilds, which must keep them (bit-identity);
1530/// * every edge is OPEN (a closed edge is the single-rim lane's business) —
1531///   except a loop that is ONE closed edge whose vertex is PINNED to the
1532///   neighbour's seam. That vertex is a corner, not the bookkeeping split the
1533///   single-rim lane places at the march's domain start (which lands it off the
1534///   seam: 2.923 on the crossing-pipe tee), and it is exactly the two-arc
1535///   window this lane already rebuilt, stored as the one edge it is;
1536/// * every corner vertex has valence two across the WHOLE solid, so relocating
1537///   it cannot strand a third face's boundary. This is what rules out the hole
1538///   that straddles the pushed carrier's periodic seam: its arcs are stitched
1539///   into the outer loop, so the loop fails the first condition long before the
1540///   valence test — and either way it is refused rather than torn.
1541///
1542/// Returns `Ok(None)` for a loop that is simply not this shape (the outer loop
1543/// of any ordinary push), so the caller falls through to the lanes it always
1544/// used.
1545#[allow(clippy::too_many_arguments)]
1546fn single_neighbour_hole(
1547    loop_record: &crate::topology::LoopRecord,
1548    face_id: u64,
1549    solid: &BrepSolid,
1550    faces_of_edge: &HashMap<u64, Vec<u64>>,
1551    edge_by_id: &HashMap<u64, &EdgeRecord>,
1552    frame_origin: Vec3,
1553    frame_axis: Vec3,
1554    scale: f64,
1555) -> Result<Option<SingleNeighbourHole>, String> {
1556    let debug = std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok();
1557    let mut neighbour: Option<u64> = None;
1558    let mut edge_ids: Vec<u64> = Vec::new();
1559    for coedge in &loop_record.coedges {
1560        let edge = *edge_by_id
1561            .get(&coedge.edge_id)
1562            .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1563        if edge.start_vertex_id == edge.end_vertex_id && loop_record.coedges.len() != 1 {
1564            if debug { eprintln!("HOLE reject: edge {} is closed", edge.id); }
1565            return Ok(None); // a closed rim among others: not one window.
1566        }
1567        let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
1568        let others: Vec<u64> = incident.into_iter().filter(|f| *f != face_id).collect();
1569        if others.len() != 1 {
1570            if debug { eprintln!("HOLE reject: edge {} has {} others", edge.id, others.len()); }
1571            return Ok(None); // a seam, or a non-manifold edge.
1572        }
1573        match neighbour {
1574            Some(known) if known != others[0] => {
1575                if debug { eprintln!("HOLE reject: mixed neighbours {known} / {}", others[0]); }
1576                return Ok(None);
1577            }
1578            Some(_) => {}
1579            None => neighbour = Some(others[0]),
1580        }
1581        if !edge_ids.contains(&edge.id) {
1582            edge_ids.push(edge.id);
1583        }
1584    }
1585    let Some(neighbour) = neighbour else {
1586        return Ok(None);
1587    };
1588    // A lone CLOSED edge qualifies only once its vertex is shown to be pinned
1589    // (below); an OPEN lone edge never closes a window.
1590    let lone_closed = edge_ids.len() == 1
1591        && edge_by_id
1592            .get(&edge_ids[0])
1593            .is_some_and(|edge| edge.start_vertex_id == edge.end_vertex_id);
1594    if edge_ids.len() < 2 && !lone_closed {
1595        if debug { eprintln!("HOLE reject: only {} edges", edge_ids.len()); }
1596        return Ok(None);
1597    }
1598    let (nshell, nface) = find_face(solid, neighbour)
1599        .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
1600    let surface = &solid.shells[nshell].faces[nface].surface;
1601    if matches!(surface.analytic(), Some(AnalyticSurface::Plane { .. }))
1602        || ruled_neighbour_is_coaxial(surface, frame_origin, frame_axis, scale)
1603    {
1604        if debug { eprintln!("HOLE reject: neighbour {neighbour} is planar/coaxial"); }
1605        return Ok(None); // the two lanes that already have exact rebuilds.
1606    }
1607    // Every corner is either a free bookkeeping split (nothing else ends there)
1608    // or PINNED to one edge of the neighbour — its seam. Anything else ending
1609    // at a corner makes it a genuine junction of three or more faces, which
1610    // this lane does not solve.
1611    let neighbour_edges: HashSet<u64> = solid.shells[nshell].faces[nface]
1612        .loops
1613        .iter()
1614        .flat_map(|loop_record| &loop_record.coedges)
1615        .map(|coedge| coedge.edge_id)
1616        .collect();
1617    let mut pinned: Vec<(u64, u64)> = Vec::new();
1618    for edge_id in &edge_ids {
1619        let edge = *edge_by_id
1620            .get(edge_id)
1621            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1622        for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1623            for other in &solid.edges {
1624                if other.start_vertex_id != vertex_id && other.end_vertex_id != vertex_id {
1625                    continue;
1626                }
1627                if edge_ids.contains(&other.id) {
1628                    continue;
1629                }
1630                if !neighbour_edges.contains(&other.id) {
1631                    if debug {
1632                        eprintln!("HOLE reject: vertex {vertex_id} also on foreign edge {}", other.id);
1633                    }
1634                    return Ok(None);
1635                }
1636                if pinned
1637                    .iter()
1638                    .any(|(known, pin)| *known == vertex_id && *pin != other.id)
1639                {
1640                    if debug {
1641                        eprintln!("HOLE reject: vertex {vertex_id} pinned by two neighbour edges");
1642                    }
1643                    return Ok(None);
1644                }
1645                if !pinned.iter().any(|(known, _)| *known == vertex_id) {
1646                    pinned.push((vertex_id, other.id));
1647                }
1648            }
1649        }
1650    }
1651    if lone_closed && pinned.is_empty() {
1652        // A free closed rim: its vertex is bookkeeping, the single-rim lane's.
1653        if debug { eprintln!("HOLE reject: lone closed edge {} is not pinned", edge_ids[0]); }
1654        return Ok(None);
1655    }
1656    if debug {
1657        eprintln!("HOLE accept: neighbour {neighbour} edges {edge_ids:?} pinned {pinned:?}");
1658    }
1659    Ok(Some(SingleNeighbourHole {
1660        neighbour,
1661        edge_ids,
1662        pinned,
1663    }))
1664}
1665
1666/// Rebuild a single-neighbour hole: re-intersect once, then cut the section.
1667#[allow(clippy::too_many_arguments)]
1668fn rebuild_single_neighbour_hole(
1669    solid: &BrepSolid,
1670    s_prime: &NurbsSurface,
1671    hole: &SingleNeighbourHole,
1672    edge_by_id: &HashMap<u64, &EdgeRecord>,
1673    vertex_pos: &HashMap<u64, Vec3>,
1674    tolerance: f64,
1675    scale: f64,
1676    new_curve: &mut HashMap<u64, NurbsCurve>,
1677    new_vertex: &mut HashMap<u64, Vec3>,
1678) -> Result<(), String> {
1679    let (nshell, nface) = find_face(solid, hole.neighbour)
1680        .ok_or_else(|| format!("offset_ruled_face: missing neighbour {}", hole.neighbour))?;
1681    let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
1682
1683    // Seed the march with the WHOLE old loop: the section replacing it runs
1684    // near it for any push small against the feature, and a blind seed grid on
1685    // two large carriers can miss a small window entirely.
1686    let mut seeds: Vec<Vec3> = Vec::new();
1687    let mut reference: Vec<Vec3> = Vec::new();
1688    for edge_id in &hole.edge_ids {
1689        let edge = *edge_by_id
1690            .get(edge_id)
1691            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1692        let samples = edge_seeds(edge, 9)?;
1693        reference.extend(samples.iter().copied());
1694        seeds.extend(samples);
1695    }
1696    let policy = MarchPolicy {
1697        tolerance,
1698        residual_tolerance: (scale * 5e-4).max(5e-6),
1699        seeds,
1700    };
1701    let found = match reintersect_carriers(s_prime, neighbour_surface, &policy) {
1702        Ok(found) => found,
1703        Err(ReintersectRefusal::Separated) => {
1704            return Err(
1705                "offset_ruled_face: the pushed carrier no longer meets a neighbour \
1706                 (the push separated them, or the rim left the neighbour's domain) — refusing"
1707                    .into(),
1708            )
1709        }
1710        Err(other) => return Err(format!("offset_ruled_face: {}", other.describe())),
1711    };
1712    if found.lane != RimLane::Marched {
1713        // An analytic section has no polyline to cut, and the exact lanes that
1714        // produce one already have their own arc rebuild. Refusing here keeps
1715        // this lane from re-deciding a case the closed forms own.
1716        return Err(format!(
1717            "offset_ruled_face: the window bounded by face {} re-intersects in CLOSED FORM, \
1718             whose arc rebuild is the analytic lane's — deferred (refusing)",
1719            hole.neighbour
1720        ));
1721    }
1722    if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
1723        eprintln!(
1724            "HOLE rebuild neighbour {}: lane {:?}, {} branch(es), residual {:.3e} \
1725             (gate {:.3e})",
1726            hole.neighbour,
1727            found.lane,
1728            found.sections.len(),
1729            found.residual,
1730            policy.residual_tolerance
1731        );
1732    }
1733    // Which branch is THIS window: two windows cut by one crossing carrier
1734    // share a surface and so come back as two branches of one section set.
1735    let section = found
1736        .nearest_section(&reference)
1737        .map_err(|error| format!("offset_ruled_face: {error}"))?;
1738
1739    // Corners first, so every arc is cut against the same relocated vertices.
1740    //
1741    // A corner PINNED to the neighbour's seam is not free to go to the nearest
1742    // sample: it must stay on that meridian, so it goes where the new section
1743    // CROSSES the meridian's axial half-plane. Placing it at the nearest sample
1744    // instead leaves it off the seam by the amount the section drifted, which
1745    // the neighbour's own re-trim then reports as "a relocated rim vertex left
1746    // curved neighbour N's edge" — a refusal where a correct answer exists.
1747    for edge_id in &hole.edge_ids {
1748        let edge = *edge_by_id
1749            .get(edge_id)
1750            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1751        for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1752            if new_vertex.contains_key(&vertex_id) {
1753                continue;
1754            }
1755            let old = *vertex_pos
1756                .get(&vertex_id)
1757                .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
1758            let point = match hole
1759                .pinned
1760                .iter()
1761                .find(|(known, _)| *known == vertex_id)
1762                .map(|(_, pin)| *pin)
1763            {
1764                Some(pin) => {
1765                    let seam = *edge_by_id
1766                        .get(&pin)
1767                        .ok_or_else(|| format!("offset_ruled_face: missing edge {pin}"))?;
1768                    let plane = seam_axial_plane(neighbour_surface, seam).ok_or_else(|| {
1769                        format!(
1770                            "offset_ruled_face: the window's corner is pinned to edge {pin} of a \
1771                             neighbour that is not a surface of revolution — refusing"
1772                        )
1773                    })?;
1774                    // The correct crossing is inside the window itself, so the
1775                    // search reach is the window's own extent about this corner
1776                    // — the opposite meridian cannot win.
1777                    let reach = reference
1778                        .iter()
1779                        .map(|point| point.sub(old).length())
1780                        .fold(0.0f64, f64::max)
1781                        .max(tolerance * 100.0);
1782                    // A strict sign change, not `curve_plane_crossing_or_end_near`:
1783                    // `arc_of_section` cuts only a CLOSED traced section, and a
1784                    // closed curve does not stop on the seam plane — it passes
1785                    // through it, and where its parameter origin lies on the
1786                    // plane its two ends carry the same round-off, so the first
1787                    // or the last sampled interval brackets that crossing either
1788                    // way. Measured on every pinned corner in the lib, suite and
1789                    // case populations: the strict and the end-accepting crossing
1790                    // are the same point.
1791                    census_push("seam_crossings", || {
1792                        crossing_note(
1793                            &section.curve,
1794                            &plane,
1795                            curve_plane_crossing_near(&section.curve, &plane, old, reach),
1796                            curve_plane_crossing_or_end_near(&section.curve, &plane, old, reach, tolerance),
1797                            section.closed,
1798                        )
1799                    });
1800                    curve_plane_crossing_near(&section.curve, &plane, old, reach)
1801                        .map(|(_, point)| point)
1802                        .ok_or_else(|| {
1803                            format!(
1804                                "offset_ruled_face: the rebuilt window never crosses the seam \
1805                                 (edge {pin}) its corner is pinned to — refusing"
1806                            )
1807                        })?
1808                }
1809                None => section_corner(section, old)
1810                    .map_err(|error| format!("offset_ruled_face: {error}"))?,
1811            };
1812            new_vertex.insert(vertex_id, point);
1813        }
1814    }
1815    for edge_id in &hole.edge_ids {
1816        let edge = *edge_by_id
1817            .get(edge_id)
1818            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1819        let from = new_vertex[&edge.start_vertex_id];
1820        let to = new_vertex[&edge.end_vertex_id];
1821        // A closed edge asks `arc_of_section` for the whole section, whose
1822        // direction is read off a point the old edge reaches early: a quarter
1823        // of the way round by ARC LENGTH, and an open arc's midpoint the same
1824        // way. A fraction of the edge's PARAMETER range is that point only for a
1825        // uniform speed law; the crossing-pipe tee's window re-weighted by a
1826        // Möbius substitution put its quarter-parameter point past the half
1827        // turn, cut the section the wrong way round, and refused as "a rebuilt
1828        // rim traverses the carrier's periodic parameter differently".
1829        let through = if edge.start_vertex_id == edge.end_vertex_id {
1830            arc_length_stations(edge, 5, true)?[1]
1831        } else {
1832            arc_length_stations(edge, 3, true)?[1]
1833        };
1834        let arc = arc_of_section(section, from, to, through, tolerance)
1835            .map_err(|error| format!("offset_ruled_face: {error}"))?;
1836        new_curve.insert(edge.id, arc);
1837    }
1838    Ok(())
1839}
1840
1841/// Re-fit ONLY the pcurves whose edge actually changed, each back onto the
1842/// periodic branch of `u` the pcurve it replaces was on.
1843///
1844/// This is the CURVED-neighbour lane's re-trim, and it is deliberately not
1845/// [`crate::offset_retrim::retrim_face_in_solid`]. That driver rebuilds *every*
1846/// pcurve of the face, which is right for a ruled revolution (whose seam
1847/// coedges are straight generatrices whose rebuilt pcurves land back on their
1848/// own sides by luck of the parameterization) and **measurably wrong** for a
1849/// sphere or a torus. Measured on the ball-capped rod, `d = +0.5`:
1850///
1851/// | face | rebuilt-all | correct (independently built r = 3.5 solid) |
1852/// |---|---|---|
1853/// | wall seam coedge | `u: 0 → 0` | `u: 1 → 1` |
1854/// | dome seam coedge | `u: 0 → 0` | `u: 1 → 1` |
1855/// | dome pole coedge | `(0,1) → (0,1)` | `(1,1) → (0,1)` |
1856///
1857/// with a resulting solid that `validate()` accepts and whose Green's-theorem
1858/// volume reads **207.86 against the true 534.10** — the dome's parameter-space
1859/// loop collapsed to zero area because both of its seam sides ended up on the
1860/// same branch. Exactly the failure [`reanchor_pcurve_u`] was written for.
1861///
1862/// Two rules together fix it, and both are needed:
1863/// * **touch only what moved.** A neighbour that keeps its surface keeps every
1864///   pcurve whose edge did not change; rebuilding one is a chance to land on
1865///   the wrong branch for no gain.
1866/// * **re-anchor what is rebuilt**, by the whole-period shift that aligns its
1867///   start with the pcurve it replaces — the rebuilt rim traverses the same path
1868///   in the same direction, so aligning the starts fixes the branch, and
1869///   [`reanchor_pcurve_u`]'s end-drift check refuses anything that is not
1870///   merely re-anchored.
1871fn refit_changed_pcurves(
1872    result: &mut BrepSolid,
1873    face_id: u64,
1874    final_edges: &HashMap<u64, EdgeRecord>,
1875    changed: &HashSet<u64>,
1876    tolerance: f64,
1877) -> Result<(), String> {
1878    let (shell, face_pos) = find_face(result, face_id)
1879        .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
1880    let surface = result.shells[shell].faces[face_pos].surface.clone();
1881    let [u_start, u_end] = surface.domain_u()?;
1882    let u_period = u_end - u_start;
1883    for loop_record in &mut result.shells[shell].faces[face_pos].loops {
1884        for coedge in &mut loop_record.coedges {
1885            if !changed.contains(&coedge.edge_id) {
1886                continue;
1887            }
1888            let edge = final_edges
1889                .get(&coedge.edge_id)
1890                .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1891            // Same subrange rule as `offset_retrim::rebuild_loop_pcurves`, so a rim that is
1892            // a strict piece of a full-domain curve keeps the range-aware fit it
1893            // has always had.
1894            let [d0, d1] = edge.curve.domain()?;
1895            let span = (d1 - d0).max(1e-12);
1896            let is_subrange =
1897                (edge.t0 - d0).abs() > 1e-9 * span || (edge.t1 - d1).abs() > 1e-9 * span;
1898            let pcurve = if is_subrange {
1899                build_pcurve_on_surface_range(
1900                    &surface,
1901                    &edge.curve,
1902                    edge.t0,
1903                    edge.t1,
1904                    coedge.forward,
1905                    tolerance,
1906                )?
1907            } else {
1908                let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
1909                if !coedge.forward {
1910                    pcurve = pcurve.reversed()?;
1911                }
1912                pcurve
1913            };
1914            coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
1915        }
1916    }
1917    Ok(())
1918}
1919
1920/// The curve in `curves` whose midpoint is nearest `reference` (branch selection
1921/// for a surface∩surface intersection that returns more than one component).
1922fn nearest_curve(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
1923    let mut best: Option<(f64, &NurbsCurve)> = None;
1924    for curve in curves {
1925        let mid = curve.evaluate(0.5)?;
1926        let d = mid.sub(reference).length();
1927        if best.map(|(best_d, _)| d < best_d).unwrap_or(true) {
1928            best = Some((d, curve));
1929        }
1930    }
1931    best.map(|(_, curve)| curve.clone())
1932        .ok_or_else(|| "offset_ruled_face: empty intersection".into())
1933}
1934