Skip to main content

brep_kernel/edit/direct_edit/
face_offset.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// Push a CURVED analytic face (cylinder / cone) by OFFSETTING its carrier.
5//
6// The methodology (user directive 2026-08-28, the architecture principle made
7// literal): a face is a TRIMMED region of an infinite carrier surface. To push
8// a curved face we OFFSET its untrimmed carrier surface, then re-derive the trim
9// as the INTERSECTIONS of that offset surface with the (unchanged) untrimmed
10// carriers of the neighbour faces — `intersect_analytic_pair` does the surface ∩
11// surface, `build_pcurve_on_surface` re-trims. This is the same engine the
12// boolean imprint uses; here one surface (the pushed face's) is replaced by its
13// offset and the incident edges are recut.
14//
15// Slice 1 scope (honest refusals, never a bad solid): the PUSHED face is a
16// cylinder or cone (`RuledRevolution`); its neighbours across each boundary edge
17// are PLANAR. Ruled/sphere/torus neighbours and free-form pushed faces are
18// deferred.
19// ---------------------------------------------------------------------------
20
21/// The exact analytic offset of a ruled-revolution CARRIER surface (cylinder or
22/// cone) by `signed_distance` along its OUTWARD normal — the untrimmed surface
23/// S′ whose re-intersection with the neighbour carriers gives the new trim.
24///
25/// A normal offset of a ruled revolution is another ruled revolution with the
26/// SAME axis and half-angle: in the (axial z, radial ρ) meridian, the generatrix
27/// line `ρ = rho0 + m·z` (m = dρ/dz = (rho1−rho0)/height; m = 0 for a cylinder)
28/// offsets to the PARALLEL line `ρ = rho0 + m·z + δ·√(1+m²)` — a uniform radial
29/// growth `grow = δ·√(1+m²)` at every z. So S′ is built by revolving the grown
30/// generatrix about the SAME frame, over the SAME axial span `[0, height]` and
31/// seam azimuth (`frame.x_axis`) as the source — the span keeps the neighbour
32/// caps IN the surface's domain (an axis-translation offset would slide the
33/// finite patch off them) and the shared seam meridian stays put.
34///
35/// `signed_distance > 0` grows the surface outward (radius increases). Refuses a
36/// carrier whose grown radius reaches/crosses the axis at either end.
37fn offset_ruled_carrier(
38    surface: &NurbsSurface,
39    signed_distance: f64,
40    tolerance: f64,
41) -> Result<NurbsSurface, String> {
42    let Some(AnalyticSurface::RuledRevolution {
43        frame,
44        rho0,
45        rho1,
46        height,
47    }) = surface.analytic()
48    else {
49        return Err("offset_ruled_carrier: face carrier is not a ruled revolution".into());
50    };
51    let (frame, rho0, rho1, height) = (frame.clone(), *rho0, *rho1, *height);
52    let slope = (rho1 - rho0) / height;
53    let grow = signed_distance * (1.0 + slope * slope).sqrt();
54    let rho0_new = rho0 + grow;
55    let rho1_new = rho1 + grow;
56    if rho0_new <= tolerance || rho1_new <= tolerance {
57        return Err(
58            "offset (push): the ruled carrier collapses to or past its axis — refusing".into(),
59        );
60    }
61    // Revolve the grown generatrix over the SAME axial span + seam azimuth.
62    let start = frame.origin.add(frame.x_axis.scale(rho0_new));
63    let end = frame
64        .origin
65        .add(frame.axis.scale(height))
66        .add(frame.x_axis.scale(rho1_new));
67    let generatrix = make_line(start, end)?;
68    make_revolution(frame.origin, frame.axis, &generatrix, std::f64::consts::TAU)
69}
70
71/// The face's OUTWARD unit normal at its parameter-domain midpoint, oriented by
72/// `same_sense` (the convention `push_face::planar_face_normal` uses).
73fn outward_normal_mid(face: &FaceRecord) -> Result<(Vec3, Vec3), String> {
74    let [u0, u1] = face.surface.domain_u()?;
75    let [v0, v1] = face.surface.domain_v()?;
76    let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
77    let point = face.surface.evaluate(um, vm)?;
78    let mut normal = face.surface.normal(um, vm)?;
79    if !face.same_sense {
80        normal = normal.scale(-1.0);
81    }
82    Ok((point, normal))
83}
84
85/// Push a CYLINDER or CONE face along its outward normal by `distance` (positive
86/// grows the solid) by OFFSETTING its carrier surface and re-deriving the trim as
87/// the intersection of that offset surface with the neighbour carriers.
88///
89/// Slice-1 scope (honest refusal, never a bad solid): a full-revolution
90/// cylinder/cone SIDE face whose neighbour across every non-seam boundary edge is
91/// PLANAR (its end caps). The pushed face's own periodic SEAM edge rides the
92/// offset carrier's meridian; each rim edge re-intersects its cap
93/// (`intersect_analytic_pair`, exact circle); the caps re-trim as planar faces.
94/// Ruled/curved neighbours and non-full-revolution ruled faces are deferred.
95pub fn offset_ruled_face(
96    solid: &BrepSolid,
97    face_id: u64,
98    distance: f64,
99) -> Result<BrepSolid, String> {
100    if !distance.is_finite() {
101        return Err("offset_ruled_face: distance must be finite".into());
102    }
103    let scale = solid_model_scale(solid);
104    let tolerance = (scale * 1e-7).max(1e-9);
105    let plane_tolerance = (scale * 1e-6).max(1e-7);
106
107    let (pshell, pface) =
108        find_face(solid, face_id).ok_or_else(|| format!("offset_ruled_face: no face {face_id}"))?;
109    let pushed = &solid.shells[pshell].faces[pface];
110    let Some(AnalyticSurface::RuledRevolution { frame, .. }) = pushed.surface.analytic() else {
111        return Err("offset_ruled_face: the pushed face is not a cylinder or cone".into());
112    };
113    let (frame_origin, frame_axis) = (frame.origin, frame.axis);
114
115    // Sign the offset: the carrier grows outward (radius +) along the face's
116    // OUTWARD normal. If that normal points radially inward (a hole wall), a
117    // positive push shrinks the radius.
118    let (mid_point, mid_normal) = outward_normal_mid(pushed)?;
119    let radial = {
120        let d = mid_point.sub(frame_origin);
121        d.sub(frame_axis.scale(d.dot(frame_axis)))
122    };
123    if radial.length() <= tolerance {
124        return Err("offset_ruled_face: degenerate radial direction (face on the axis)".into());
125    }
126    let outward_sign = if mid_normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
127    let signed_distance = distance * outward_sign;
128
129    let s_prime = offset_ruled_carrier(&pushed.surface, signed_distance, tolerance)?;
130
131    // Edge -> incident faces, to classify each of the pushed face's boundary
132    // edges as a SEAM (both incidences are the pushed face) or a RIM (the other
133    // face is the fixed neighbour cap).
134    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
135    for shell in &solid.shells {
136        for face in &shell.faces {
137            for loop_record in &face.loops {
138                for coedge in &loop_record.coedges {
139                    faces_of_edge
140                        .entry(coedge.edge_id)
141                        .or_default()
142                        .push(face.id);
143                }
144            }
145        }
146    }
147    let edge_by_id: HashMap<u64, &EdgeRecord> =
148        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
149
150    let mut new_curve: HashMap<u64, NurbsCurve> = HashMap::default();
151    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
152    let mut cap_faces: HashSet<u64> = HashSet::default();
153    // Coaxial ruled neighbours (a stepped/telescoping bore or boss: a cylinder
154    // meeting a coaxial cone, or two coaxial cones) — retrimmed on their own
155    // (axially-grown) carriers rather than as planar caps.
156    let mut ruled_faces: HashSet<u64> = HashSet::default();
157    // CURVED neighbours re-intersected through the shared generic lane — a
158    // sphere dome, a fillet torus, a general revolution, a non-coaxial
159    // cylinder. Their carriers do NOT move and need no growth (unlike a plane's
160    // finite patch or a ruled band's axial span, a sphere's and a torus's
161    // domains are already closed over their whole surface, and a re-intersected
162    // rim by construction lands on the carrier it was intersected with), so
163    // they are re-trimmed in place: pcurves rebuilt around the moved rim, and
164    // every OTHER edge they own re-trimmed by parameter on the curve it already
165    // carries — see `retrim_curved_neighbour_ranges`.
166    let mut curved_faces: HashSet<u64> = HashSet::default();
167    let mut seam_edges: Vec<u64> = Vec::new();
168    // Current vertex positions, for rebuilding a ruled neighbour's seam whose
169    // far endpoint (its unmoved rim vertex) is not relocated by any rim.
170    let vertex_pos: HashMap<u64, Vec3> =
171        solid.vertices.iter().map(|v| (v.id, v.point)).collect();
172
173    // OPEN rim arcs (a multi-loop pushed face: a window / slot cut through the
174    // wall). Collected here and trimmed in a second pass, once every corner
175    // vertex has been solved, so both arcs meeting at a corner agree on it.
176    let mut open_rims: Vec<OpenRim> = Vec::new();
177    // Every RIM edge rebuilt below, closed circles and open arcs alike: the
178    // pushed face's pcurves for these are refitted when a multi-loop rebuild
179    // ran, because the replacement conic carries its own parameterization.
180    let mut rim_edges: Vec<u64> = Vec::new();
181
182    // PRE-PASS — a HOLE cut clean through the wall by ONE crossing carrier.
183    //
184    // A window bored by a crossing cylinder, a dome or a torus leaves an
185    // interior loop every one of whose edges is shared with the SAME neighbour
186    // face, and whose corner vertices have valence two: no third face meets
187    // there, so no triple point determines them and `resolve_open_rim_end` —
188    // which solves a triple point against a PLANE — has nothing to solve. The
189    // loop is one closed section that the arrangement stored as arcs, and the
190    // right rebuild is to re-intersect once and cut the section at the samples
191    // nearest the vertices it replaces. Handled here, ahead of the per-edge
192    // loop, because the decision is a property of the whole loop.
193    let mut hole_edges: HashSet<u64> = HashSet::default();
194    for loop_record in &pushed.loops {
195        let Some(hole) = single_neighbour_hole(
196            loop_record,
197            face_id,
198            solid,
199            &faces_of_edge,
200            &edge_by_id,
201            frame_origin,
202            frame_axis,
203            scale,
204        )?
205        else {
206            continue;
207        };
208        rebuild_single_neighbour_hole(
209            solid,
210            &s_prime,
211            &hole,
212            &edge_by_id,
213            &vertex_pos,
214            tolerance,
215            scale,
216            &mut new_curve,
217            &mut new_vertex,
218        )?;
219        for edge_id in &hole.edge_ids {
220            hole_edges.insert(*edge_id);
221            if !rim_edges.contains(edge_id) {
222                rim_edges.push(*edge_id);
223            }
224        }
225        curved_faces.insert(hole.neighbour);
226    }
227
228    for loop_record in &pushed.loops {
229        let coedge_count = loop_record.coedges.len();
230        for coedge_index in 0..coedge_count {
231            let coedge = &loop_record.coedges[coedge_index];
232            let edge = *edge_by_id
233                .get(&coedge.edge_id)
234                .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
235            if hole_edges.contains(&edge.id) {
236                continue; // rebuilt whole, by the pre-pass above.
237            }
238            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
239            let is_seam = incident.iter().all(|f| *f == face_id);
240            if is_seam {
241                if !seam_edges.contains(&edge.id) {
242                    seam_edges.push(edge.id);
243                }
244                continue;
245            }
246            // RIM: the fixed neighbour is the other face. Three lanes, in
247            // decreasing exactness and increasing generality:
248            //
249            //   * a PLANE (an end cap) or a ruled revolution COAXIAL with the
250            //     pushed carrier (a stepped/telescoping bore or boss) — the two
251            //     original lanes, whose closed forms are reached by the exact
252            //     call this function has always made, unchanged;
253            //   * ANY OTHER carrier — sphere, torus, general revolution,
254            //     non-coaxial cylinder/cone, free-form — through the shared
255            //     re-intersection service (`offset/reintersect.rs`), which tries
256            //     the same closed forms first and marches the pair when they
257            //     decline. This is the lane the audit's §2.3 says push-face
258            //     lacks and offset-shell has.
259            //
260            // The generic lane is admitted only where THIS rebuild can express
261            // the answer: a CLOSED rim, whose single vertex is a bookkeeping
262            // seam point rather than a triple point. An open arc against a
263            // curved neighbour needs `resolve_open_rim_end` to solve a triple
264            // point against a curved `N'`, which it cannot (it takes a plane),
265            // so that stays a refusal with its own reason.
266            let neighbour = *incident
267                .iter()
268                .find(|f| **f != face_id)
269                .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", edge.id))?;
270            let (nshell, nface) = find_face(solid, neighbour)
271                .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
272            let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
273            let is_plane =
274                matches!(neighbour_surface.analytic(), Some(AnalyticSurface::Plane { .. }));
275            let is_coaxial_ruled =
276                ruled_neighbour_is_coaxial(neighbour_surface, frame_origin, frame_axis, scale);
277            let is_curved = !is_plane && !is_coaxial_ruled;
278            let separated = || {
279                "offset_ruled_face: the pushed carrier no longer meets a neighbour \
280                 (the push separated them, or the rim left the neighbour's domain) — refusing"
281                    .to_string()
282            };
283            // New rim = offset carrier ∩ neighbour carrier, the branch nearest
284            // the old edge.
285            let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
286            let mut marched = false;
287            let curves = if is_curved {
288                if edge.start_vertex_id != edge.end_vertex_id {
289                    return Err(format!(
290                        "offset_ruled_face: the rim against curved neighbour {neighbour} is an \
291                         OPEN arc (edge {}); an arc's corner is a triple point solved against a \
292                         PLANE only — deferred (refusing)",
293                        edge.id
294                    ));
295                }
296                let policy = MarchPolicy {
297                    tolerance,
298                    // The rebuilt rim must sit on BOTH carriers tightly enough
299                    // that its pcurves build and `validate` accepts them; the
300                    // free-form push's own residual gate (0.05% of model scale)
301                    // is the in-tree precedent for "how far an approximate
302                    // offset result may be off".
303                    residual_tolerance: (scale * 5e-4).max(5e-6),
304                    // The boundary being replaced is the best seed set for the
305                    // boundary replacing it.
306                    seeds: edge_seeds(edge, 9)?,
307                };
308                match reintersect_carriers(&s_prime, neighbour_surface, &policy) {
309                    Ok(found) => {
310                        marched = found.lane == RimLane::Marched;
311                        if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
312                            eprintln!(
313                                "RIM neighbour {neighbour}: lane {:?}, {} branch(es), \
314                                 residual {:.3e} (gate {:.3e})",
315                                found.lane,
316                                found.sections.len(),
317                                found.residual,
318                                policy.residual_tolerance
319                            );
320                        }
321                        found.curves()
322                    }
323                    Err(ReintersectRefusal::Separated) => return Err(separated()),
324                    Err(other) => {
325                        return Err(format!("offset_ruled_face: {}", other.describe()));
326                    }
327                }
328            } else {
329                // UNCHANGED: the exact call, with the same arguments in the same
330                // order, so every pair this function answered before is answered
331                // bit-identically now.
332                intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
333                    .filter(|curves| !curves.is_empty())
334                    .ok_or_else(separated)?
335            };
336            let rim = nearest_curve(&curves, old_mid)?;
337            if !rim_edges.contains(&edge.id) {
338                rim_edges.push(edge.id);
339            }
340            if edge.start_vertex_id == edge.end_vertex_id {
341                // A closed rim is a whole conic, so it must also run the way the
342                // edge it replaces ran — see `match_closed_rim_direction`.
343                let rim = if marched {
344                    // A MARCHED section begins wherever the trace was seeded,
345                    // not on the carrier's seam, so the start-tangent test would
346                    // compare two unrelated places. Ask the same question at the
347                    // old curve's nearest parameter instead.
348                    match_marched_rim_direction(rim, edge)?
349                } else {
350                    match_closed_rim_direction(rim, edge)?
351                };
352                // CLOSED rim (the canonical cap circle of a single-loop push):
353                // its seam-azimuth point is the relocated corner and matches the
354                // offset carrier's meridian. A recognized carrier's u = 0 IS its
355                // seam, and the shared rim's one seam vertex sits on both
356                // carriers' seams, so this placement keeps the coaxial
357                // neighbour's straight meridian on its carrier too. A marched
358                // section has no such convention, so its own domain start is
359                // where its single vertex goes — the vertex is a bookkeeping
360                // split of a closed curve, not a geometric corner.
361                let seam_point = if marched {
362                    let [d0, _] = rim.domain()?;
363                    rim.evaluate(d0)?
364                } else {
365                    rim.evaluate(0.0)?
366                };
367                new_vertex.insert(edge.start_vertex_id, seam_point);
368                new_vertex.insert(edge.end_vertex_id, seam_point);
369                new_curve.insert(edge.id, rim);
370            } else {
371                // OPEN rim: an arc / generatrix bounding a window cut through
372                // the wall. `rim` is the WHOLE conic the two carriers share, so
373                // each endpoint must be re-solved (it is the triple point
374                // `S' ∩ N ∩ N'` against the ADJACENT rim's neighbour, or the
375                // pushed carrier's own seam) and the conic trimmed between them.
376                // Collapsing both onto `rim.evaluate(0.0)` — the closed-rim rule
377                // — is what used to tear a multi-loop push apart.
378                let previous =
379                    &loop_record.coedges[(coedge_index + coedge_count - 1) % coedge_count];
380                let next = &loop_record.coedges[(coedge_index + 1) % coedge_count];
381                let (at_start, at_end) = if coedge.forward {
382                    (previous, next)
383                } else {
384                    (next, previous)
385                };
386                let mut resolve = |adjacent_coedge: &CoedgeRecord,
387                                   vertex_id: u64|
388                 -> Result<RimEnd, String> {
389                    let adjacent = *edge_by_id.get(&adjacent_coedge.edge_id).ok_or_else(|| {
390                        format!(
391                            "offset_ruled_face: missing edge {}",
392                            adjacent_coedge.edge_id
393                        )
394                    })?;
395                    let old_point = vertex_pos
396                        .get(&vertex_id)
397                        .copied()
398                        .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
399                    let (end, point) = resolve_open_rim_end(
400                        solid,
401                        face_id,
402                        &faces_of_edge,
403                        &rim,
404                        adjacent,
405                        old_point,
406                        plane_tolerance,
407                    )?;
408                    // Both arcs meeting at a corner solve the SAME triple point
409                    // from their own conic; a disagreement means the branch pick
410                    // went to different roots, so refuse rather than tear.
411                    match new_vertex.get(&vertex_id).copied() {
412                        Some(existing) if existing.sub(point).length() > plane_tolerance => {
413                            return Err(
414                                "offset_ruled_face: the two rims meeting at a multi-loop corner \
415                                 disagree on its new position — refusing"
416                                    .into(),
417                            )
418                        }
419                        Some(_) => {}
420                        None => {
421                            new_vertex.insert(vertex_id, point);
422                        }
423                    }
424                    Ok(end)
425                };
426                let start = resolve(at_start, edge.start_vertex_id)?;
427                let end = resolve(at_end, edge.end_vertex_id)?;
428                open_rims.push(OpenRim {
429                    edge_id: edge.id,
430                    conic: rim,
431                    start,
432                    end,
433                    old_mid,
434                    start_vertex_id: edge.start_vertex_id,
435                    end_vertex_id: edge.end_vertex_id,
436                });
437            }
438            if is_plane {
439                cap_faces.insert(neighbour);
440            } else if is_coaxial_ruled {
441                ruled_faces.insert(neighbour);
442            } else {
443                curved_faces.insert(neighbour);
444            }
445        }
446    }
447
448    // Second pass: trim each open rim's conic to the arc BETWEEN its two solved
449    // corners — the one that contains the old edge, picked by the old midpoint's
450    // parameter — and orient it start-vertex → end-vertex.
451    for rim in &open_rims {
452        let [d0, d1] = rim.conic.domain()?;
453        let middle = project_point_to_curve(&rim.conic, rim.old_mid)?.u;
454        let (from, to) = match (rim.start, rim.end) {
455            (RimEnd::Corner(a), RimEnd::Corner(b)) => {
456                let (low, high) = if a <= b { (a, b) } else { (b, a) };
457                if middle < low || middle > high {
458                    return Err(
459                        "offset_ruled_face: a multi-loop rim arc wraps the pushed carrier's \
460                         periodic seam — deferred (refusing)"
461                            .into(),
462                    );
463                }
464                (low, high)
465            }
466            (RimEnd::Seam, RimEnd::Corner(corner)) | (RimEnd::Corner(corner), RimEnd::Seam) => {
467                if middle < corner {
468                    (d0, corner)
469                } else {
470                    (corner, d1)
471                }
472            }
473            (RimEnd::Seam, RimEnd::Seam) => {
474                return Err(
475                    "offset_ruled_face: a multi-loop rim arc ends on the seam at BOTH ends — \
476                     refusing"
477                        .into(),
478                )
479            }
480        };
481        let mut trimmed = subcurve(&rim.conic, from, to)?;
482        let start_point = *new_vertex.get(&rim.start_vertex_id).ok_or_else(|| {
483            "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
484        })?;
485        let end_point = *new_vertex.get(&rim.end_vertex_id).ok_or_else(|| {
486            "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
487        })?;
488        let [t0, _] = trimmed.domain()?;
489        let head = trimmed.evaluate(t0)?;
490        if head.sub(start_point).length() > head.sub(end_point).length() {
491            trimmed = trimmed.reversed()?;
492        }
493        new_curve.insert(rim.edge_id, trimmed);
494    }
495
496    let pos = |vid: u64| -> Vec3 {
497        new_vertex
498            .get(&vid)
499            .copied()
500            .unwrap_or_else(|| vertex_pos[&vid])
501    };
502
503    // Seam edge(s): the straight generatrix segment of the offset carrier, from
504    // the relocated bottom seam vertex to the top one. Both endpoints were placed
505    // on S′'s seam meridian by the rim re-intersections above, so a line between
506    // them rides u = 0 of S′ over the SAME axial range the seam had — using the
507    // full `iso_curve_u` meridian would wrongly span the untrimmed carrier (a
508    // boolean-drilled wall's surface runs past the plate faces).
509    for seam_id in &seam_edges {
510        let seam = *edge_by_id
511            .get(seam_id)
512            .ok_or_else(|| format!("offset_ruled_face: missing seam edge {seam_id}"))?;
513        let start = *new_vertex.get(&seam.start_vertex_id).ok_or_else(|| {
514            "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
515        })?;
516        let end = *new_vertex.get(&seam.end_vertex_id).ok_or_else(|| {
517            "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
518        })?;
519        new_curve.insert(*seam_id, make_line(start, end)?);
520    }
521
522    // A COAXIAL ruled neighbour also has its own straight seam meridian ending
523    // at the shared rim's (now moved) seam vertex. Rebuild it as the chord
524    // between its endpoints — one relocated by the rim, the other its unmoved
525    // rim vertex — which, both lying on the neighbour's straight generatrix, is
526    // exactly the meridian segment.
527    for ruled in &ruled_faces {
528        let (nshell, nface) = find_face(solid, *ruled)
529            .ok_or_else(|| format!("offset_ruled_face: missing ruled neighbour {ruled}"))?;
530        for loop_record in &solid.shells[nshell].faces[nface].loops {
531            for coedge in &loop_record.coedges {
532                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
533                    format!("offset_ruled_face: missing edge {}", coedge.edge_id)
534                })?;
535                let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
536                let is_seam = incident.iter().all(|f| *f == *ruled);
537                let touches_moved = new_vertex.contains_key(&edge.start_vertex_id)
538                    || new_vertex.contains_key(&edge.end_vertex_id);
539                if is_seam && touches_moved && !new_curve.contains_key(&edge.id) {
540                    new_curve.insert(
541                        edge.id,
542                        make_line(pos(edge.start_vertex_id), pos(edge.end_vertex_id))?,
543                    );
544                }
545            }
546        }
547    }
548
549    // A CURVED neighbour keeps its surface, so it keeps every 3D CURVE it owns
550    // — a sphere's seam meridian is the same great-circle arc after the push as
551    // before it. What changes is where that curve is TRIMMED: the endpoint the
552    // moved rim carried with it slides along the curve to a new parameter.
553    // Re-solving it as a parameter on the existing curve is EXACT, and it is
554    // strictly better than rebuilding: the coaxial-ruled lane above can chord
555    // its seam only because a ruled revolution's meridian is straight, and a
556    // sphere's is not.
557    let mut new_range: HashMap<u64, (f64, f64)> = HashMap::default();
558    for curved in &curved_faces {
559        let (nshell, nface) = find_face(solid, *curved)
560            .ok_or_else(|| format!("offset_ruled_face: missing curved neighbour {curved}"))?;
561        for loop_record in &solid.shells[nshell].faces[nface].loops {
562            for coedge in &loop_record.coedges {
563                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
564                    format!("offset_ruled_face: missing edge {}", coedge.edge_id)
565                })?;
566                if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
567                    continue;
568                }
569                let start_moved = new_vertex.get(&edge.start_vertex_id).copied();
570                let end_moved = new_vertex.get(&edge.end_vertex_id).copied();
571                if start_moved.is_none() && end_moved.is_none() {
572                    continue;
573                }
574                if edge.start_vertex_id == edge.end_vertex_id {
575                    // A closed or degenerate edge of the neighbour (its own
576                    // opposite rim, or a pole) whose vertex a rim relocated:
577                    // the whole curve would have to move, which this lane does
578                    // not do.
579                    return Err(format!(
580                        "offset_ruled_face: the push moved the vertex of curved neighbour \
581                         {curved}'s CLOSED edge {} — deferred (refusing)",
582                        edge.id
583                    ));
584                }
585                let mut range = (edge.t0, edge.t1);
586                for (moved, slot) in [(start_moved, 0usize), (end_moved, 1usize)] {
587                    let Some(point) = moved else { continue };
588                    let projection = project_point_to_curve(&edge.curve, point)?;
589                    if projection.distance > plane_tolerance {
590                        return Err(format!(
591                            "offset_ruled_face: a relocated rim vertex left curved neighbour \
592                             {curved}'s edge {} (off by {:.3e}) — refusing",
593                            edge.id, projection.distance
594                        ));
595                    }
596                    if slot == 0 {
597                        range.0 = projection.u;
598                    } else {
599                        range.1 = projection.u;
600                    }
601                }
602                new_range.insert(edge.id, range);
603            }
604        }
605    }
606    // Fail-safe: every relocated vertex must belong only to edges this push
607    // rebuilt or re-trimmed. A vertex that also ends an edge of some other face
608    // would leave that face's boundary behind, which is exactly the silent
609    // tear the ruled lane's own corner guard refuses.
610    if !curved_faces.is_empty() {
611        for edge in &solid.edges {
612            if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
613                continue;
614            }
615            if new_vertex.contains_key(&edge.start_vertex_id)
616                || new_vertex.contains_key(&edge.end_vertex_id)
617            {
618                return Err(format!(
619                    "offset_ruled_face: relocating a curved neighbour's rim moved the end of \
620                     edge {}, which this push does not rebuild — refusing",
621                    edge.id
622                ));
623            }
624        }
625    }
626
627    // A multi-loop window's corner is a TRIPLE point, so it is also the end of a
628    // third edge that belongs to neither the pushed face nor a rim: the two
629    // fixed neighbour planes' own shared edge (a slot floor meeting a slot
630    // side wall). Both carriers are fixed, so that edge stays on their
631    // intersection line and the chord between its (possibly relocated)
632    // endpoints IS the rebuilt edge. Skipped entirely when no open rim was
633    // rebuilt, so the single-loop paths are untouched.
634    if !open_rims.is_empty() {
635        let mut corner_edges: Vec<(u64, NurbsCurve)> = Vec::new();
636        for edge in &solid.edges {
637            if new_curve.contains_key(&edge.id) {
638                continue;
639            }
640            if !new_vertex.contains_key(&edge.start_vertex_id)
641                && !new_vertex.contains_key(&edge.end_vertex_id)
642            {
643                continue;
644            }
645            if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
646                return Err(
647                    "offset_ruled_face: the push moved the end of a CURVED edge that is not a \
648                     rebuilt rim — refusing"
649                        .into(),
650                );
651            }
652            let start = pos(edge.start_vertex_id);
653            let end = pos(edge.end_vertex_id);
654            for neighbour in faces_of_edge.get(&edge.id).cloned().unwrap_or_default() {
655                if !cap_faces.contains(&neighbour) {
656                    return Err(
657                        "offset_ruled_face: a relocated corner borders a face this push does not \
658                         re-trim — refusing"
659                            .into(),
660                    );
661                }
662                let (nshell, nface) = find_face(solid, neighbour).ok_or_else(|| {
663                    format!("offset_ruled_face: missing neighbour {neighbour}")
664                })?;
665                let plane = plane_of_surface(
666                    &solid.shells[nshell].faces[nface].surface,
667                    plane_tolerance,
668                    "offset_ruled_face",
669                )?;
670                for point in [start, end] {
671                    if point.sub(plane.origin).dot(plane.normal).abs() > plane_tolerance {
672                        return Err(
673                            "offset_ruled_face: a relocated corner left one of its fixed \
674                             neighbour planes — refusing"
675                                .into(),
676                        );
677                    }
678                }
679            }
680            corner_edges.push((edge.id, make_line(start, end)?));
681        }
682        for (edge_id, curve) in corner_edges {
683            new_curve.insert(edge_id, curve);
684        }
685    }
686
687    // Every edge this push touched, by curve or by trim range — the selective
688    // re-fit's work list.
689    let changed_edges: HashSet<u64> = new_curve
690        .keys()
691        .chain(new_range.keys())
692        .copied()
693        .collect();
694
695    // --- Apply to a fresh clone (the input is never mutated) ---------------
696    let mut result = solid.clone();
697    for edge in &mut result.edges {
698        if let Some(curve) = new_curve.get(&edge.id) {
699            // A trimmed rim arc carries its PARENT conic's parameter range
700            // (`NurbsCurve::split` preserves knot values), so the edge range
701            // comes from the curve. Every whole-curve rebuild — the closed rims,
702            // the seam lines — still lands on [0, 1] exactly as before.
703            let [d0, d1] = curve.domain()?;
704            edge.curve = curve.clone();
705            edge.t0 = d0;
706            edge.t1 = d1;
707        } else if let Some((t0, t1)) = new_range.get(&edge.id) {
708            // A curved neighbour's own edge: same curve, new trim.
709            edge.t0 = *t0;
710            edge.t1 = *t1;
711        }
712    }
713    for vertex in &mut result.vertices {
714        if let Some(point) = new_vertex.get(&vertex.id) {
715            vertex.point = *point;
716        }
717    }
718    // The pushed face rides the offset carrier. S′ shares the source's parameter
719    // domain + seam azimuth (same make_revolution frame/span), so the face's
720    // existing (u, v) pcurves stay valid when every rim stayed at its axial
721    // station (the planar-cap push) — only the surface swaps.
722    result.shells[pshell].faces[pface].surface = s_prime;
723
724    let final_edges: HashMap<u64, EdgeRecord> =
725        result.edges.iter().map(|e| (e.id, e.clone())).collect();
726
727    // A COAXIAL ruled neighbour moves the shared rim ALONG the axis, so the
728    // pushed face's pcurve for that rim (and the seam whose endpoint moved) is
729    // no longer at its old v — refit both the pushed face and every ruled
730    // neighbour on their (axially-grown-if-needed) carriers. The planar-only
731    // push skips this and keeps the exact pcurve reuse above.
732    //
733    // A CURVED neighbour moves the shared rim just as much (a dome's rim climbs
734    // the sphere as the rod it caps grows), so it joins the same pass — but its
735    // own carrier neither moves nor grows: a sphere, a torus and a full
736    // revolution are already closed over their whole surface, and the rim was
737    // intersected WITH that surface, so it is on it by construction. Its retrim
738    // is therefore the same driver with a growth that does nothing.
739    if !ruled_faces.is_empty() {
740        retrim_offset_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
741        for ruled in &ruled_faces {
742            retrim_offset_ruled_face(&mut result, *ruled, &final_edges, tolerance)?;
743        }
744        for curved in &curved_faces {
745            refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
746        }
747    } else if !curved_faces.is_empty() {
748        // The pushed carrier still has to GROW where the rim climbed past its
749        // axial span — the same exact prolongation the ruled lane uses — but its
750        // pcurves are re-fitted selectively, not wholesale.
751        let (gshell, gface) = find_face(&result, face_id)
752            .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
753        let samples = boundary_samples(
754            &result.shells[gshell].faces[gface],
755            &final_edges,
756            "offset_ruled_face",
757        )?;
758        extend_ruled_neighbour_over(&mut result, face_id, &samples, tolerance)?;
759        refit_changed_pcurves(&mut result, face_id, &final_edges, &changed_edges, tolerance)?;
760        for curved in &curved_faces {
761            refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
762        }
763    } else if !open_rims.is_empty() {
764        // Every rebuilt RIM needs a fresh pcurve on S'. An open rim because its
765        // azimuth span (an arc) or station (a generatrix) genuinely moved; a
766        // CLOSED rim because the replacement circle carries the INTERSECTOR's
767        // parameterization, which need not be the one the incoming curve had —
768        // a boolean-cut cylinder's cap circle comes back re-parameterized, and
769        // `validate` pairs pcurve to curve by FRACTION of their domains, so a
770        // pointwise-identical circle with a different parameter distribution
771        // still reads as a gross deviation (measured 5.96 and 12.0 on the slot
772        // fixture, against a 0.394 limit).
773        //
774        // The SEAM edges are deliberately left alone: each is rebuilt as a
775        // straight chord over the same axial range, so fraction ↦ height is
776        // unchanged and their exact (u, v) — including the two coedges sitting
777        // on OPPOSITE sides of the periodic seam — survives untouched.
778        let rebuilt: HashSet<u64> = rim_edges.iter().copied().collect();
779        let surface = result.shells[pshell].faces[pface].surface.clone();
780        let [u_start, u_end] = surface.domain_u()?;
781        let u_period = u_end - u_start;
782        for loop_record in &mut result.shells[pshell].faces[pface].loops {
783            for coedge in &mut loop_record.coedges {
784                if !rebuilt.contains(&coedge.edge_id) {
785                    continue;
786                }
787                let edge = final_edges
788                    .get(&coedge.edge_id)
789                    .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
790                let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
791                if !coedge.forward {
792                    pcurve = pcurve.reversed()?;
793                }
794                coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
795            }
796        }
797    }
798
799    // The fixed caps re-trim as planar faces around their grown rim circles.
800    for cap in &cap_faces {
801        let (cshell, cface) = find_face(&result, *cap)
802            .ok_or_else(|| format!("offset_ruled_face: missing cap {cap}"))?;
803        let plane = plane_of_surface(
804            &result.shells[cshell].faces[cface].surface,
805            plane_tolerance,
806            "offset_ruled_face",
807        )?;
808        retrim_planar_face(
809            &mut result.shells[cshell].faces[cface],
810            &plane,
811            &final_edges,
812            scale,
813            "offset_ruled_face",
814        )?;
815    }
816
817    let issues = result.validate();
818    if !issues.is_empty() {
819        return Err(format!(
820            "offset_ruled_face: pushed solid failed validation: {issues:?}"
821        ));
822    }
823    if let (Ok(before), Ok(after)) =
824        (solid_signed_volume(solid), solid_signed_volume(&result))
825    {
826        if before * after <= 0.0 {
827            return Err("offset_ruled_face: the push inverts the solid — refusing".into());
828        }
829    }
830    Ok(result)
831}
832
833/// Where an OPEN rim arc's endpoint sits on the rebuilt conic.
834#[derive(Clone, Copy)]
835enum RimEnd {
836    /// A genuine corner: the TRIPLE point `offset carrier ∩ this rim's
837    /// neighbour ∩ the ADJACENT rim's neighbour`, carried as the conic
838    /// parameter that lands on it.
839    Corner(f64),
840    /// The pushed carrier's periodic SEAM split this rim in two, so the
841    /// endpoint rides the offset carrier's seam meridian — which is exactly
842    /// parameter 0 (== 1) of the conic, because `S'` is revolved about the
843    /// SAME frame and seam azimuth as the source carrier.
844    Seam,
845}
846
847/// One open rim arc, held between the two passes of the rebuild.
848struct OpenRim {
849    edge_id: u64,
850    /// The WHOLE conic `S' ∩ N`, before trimming.
851    conic: NurbsCurve,
852    start: RimEnd,
853    end: RimEnd,
854    /// The OLD edge's midpoint: picks which of the two complementary arcs
855    /// between the corners is the one this edge actually was.
856    old_mid: Vec3,
857    start_vertex_id: u64,
858    end_vertex_id: u64,
859}
860
861/// Resolve ONE endpoint of an open rim arc against the edge that follows it
862/// around the loop.
863///
864/// Two cases, and nothing else is admitted:
865/// * the adjacent edge is the pushed face's own SEAM — the endpoint rides the
866///   offset carrier's seam meridian;
867/// * the adjacent edge's fixed neighbour `N'` is a PLANE — the endpoint is the
868///   triple point `S' ∩ N ∩ N'`, i.e. where this rim's conic (already the
869///   `S' ∩ N` curve) crosses `N'`. The crossing nearest the old vertex is the
870///   branch, so a conic that meets `N'` twice picks the right corner.
871///
872/// A conic that LIES IN `N'` means the boolean merely split one rim in two
873/// (`N == N'`); there is no triple point, and the split simply keeps its
874/// azimuth on the rebuilt conic. Any other adjacent neighbour (a curved
875/// carrier) makes `plane_of_surface` refuse, which is the fail-safe.
876fn resolve_open_rim_end(
877    solid: &BrepSolid,
878    face_id: u64,
879    faces_of_edge: &HashMap<u64, Vec<u64>>,
880    conic: &NurbsCurve,
881    adjacent: &EdgeRecord,
882    old_point: Vec3,
883    plane_tolerance: f64,
884) -> Result<(RimEnd, Vec3), String> {
885    let incident = faces_of_edge.get(&adjacent.id).cloned().unwrap_or_default();
886    if incident.iter().all(|f| *f == face_id) {
887        let [d0, _] = conic.domain()?;
888        return Ok((RimEnd::Seam, conic.evaluate(d0)?));
889    }
890    let other = *incident
891        .iter()
892        .find(|f| **f != face_id)
893        .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", adjacent.id))?;
894    let (nshell, nface) = find_face(solid, other)
895        .ok_or_else(|| format!("offset_ruled_face: missing neighbour {other}"))?;
896    let plane = plane_of_surface(
897        &solid.shells[nshell].faces[nface].surface,
898        plane_tolerance,
899        "offset_ruled_face",
900    )?;
901    if curve_lies_in_plane(conic, &plane, plane_tolerance)? {
902        let projection = project_point_to_curve(conic, old_point)?;
903        return Ok((RimEnd::Corner(projection.u), conic.evaluate(projection.u)?));
904    }
905    let mut best: Option<(f64, f64)> = None;
906    for parameter in plane_crossing_params(conic, &plane)? {
907        let distance = conic.evaluate(parameter)?.sub(old_point).length();
908        if best.map(|(best, _)| distance < best).unwrap_or(true) {
909            best = Some((distance, parameter));
910        }
911    }
912    let (_, parameter) = best.ok_or_else(|| {
913        "offset_ruled_face: a multi-loop rim corner no longer meets its adjacent neighbour \
914         (the push pulled the window off it) — refusing"
915            .to_string()
916    })?;
917    Ok((RimEnd::Corner(parameter), conic.evaluate(parameter)?))
918}
919
920/// TRUE when the whole curve lies in the plane (so it cannot cross it): the
921/// adjacent rim shares this rim's carrier plane and the shared vertex is a
922/// SPLIT point, not a triple point.
923fn curve_lies_in_plane(
924    curve: &NurbsCurve,
925    plane: &Plane,
926    plane_tolerance: f64,
927) -> Result<bool, String> {
928    let [d0, d1] = curve.domain()?;
929    for step in 0..=16 {
930        let t = d0 + (d1 - d0) * step as f64 / 16.0;
931        if curve
932            .evaluate(t)?
933            .sub(plane.origin)
934            .dot(plane.normal)
935            .abs()
936            > plane_tolerance
937        {
938            return Ok(false);
939        }
940    }
941    Ok(true)
942}
943
944/// Every parameter at which `curve` crosses `plane`, by dense sampling of the
945/// signed plane distance plus bisection on each sign change. The curves here
946/// are conics (a circle crosses a slot wall twice, a generatrix line crosses a
947/// slot floor once), so a sampled sign-change sweep finds every root; bisection
948/// then drives it to the last bit rather than to a fit tolerance.
949fn plane_crossing_params(curve: &NurbsCurve, plane: &Plane) -> Result<Vec<f64>, String> {
950    let [d0, d1] = curve.domain()?;
951    let signed = |t: f64| -> Result<f64, String> {
952        Ok(curve.evaluate(t)?.sub(plane.origin).dot(plane.normal))
953    };
954    const SAMPLES: usize = 512;
955    let mut roots = Vec::new();
956    let mut previous = (d0, signed(d0)?);
957    for index in 1..=SAMPLES {
958        let t = d0 + (d1 - d0) * index as f64 / SAMPLES as f64;
959        let value = signed(t)?;
960        if previous.1 == 0.0 {
961            roots.push(previous.0);
962        } else if (previous.1 < 0.0) != (value < 0.0) {
963            let (mut low, mut high) = (previous.0, t);
964            let mut low_value = previous.1;
965            for _ in 0..100 {
966                let middle = 0.5 * (low + high);
967                if middle <= low || middle >= high {
968                    break;
969                }
970                let middle_value = signed(middle)?;
971                if (low_value < 0.0) != (middle_value < 0.0) {
972                    high = middle;
973                } else {
974                    low = middle;
975                    low_value = middle_value;
976                }
977            }
978            roots.push(0.5 * (low + high));
979        }
980        previous = (t, value);
981    }
982    if previous.1 == 0.0 {
983        roots.push(previous.0);
984    }
985    Ok(roots)
986}
987
988/// The piece of `curve` over `[from, to]`. `NurbsCurve::split` keeps the parent
989/// parameterization, so the result's own domain IS `[from, to]` — which is what
990/// the edge's `t0`/`t1` are then set from.
991fn subcurve(curve: &NurbsCurve, from: f64, to: f64) -> Result<NurbsCurve, String> {
992    let [d0, d1] = curve.domain()?;
993    let span = (d1 - d0).max(1e-12);
994    if to - from <= 1e-9 * span {
995        return Err("offset_ruled_face: a rebuilt rim arc collapsed to a point — refusing".into());
996    }
997    let mut trimmed = curve.clone();
998    if to < d1 - 1e-9 * span {
999        trimmed = trimmed.split(to)?.0;
1000    }
1001    if from > d0 + 1e-9 * span {
1002        trimmed = trimmed.split(from)?.1;
1003    }
1004    Ok(trimmed)
1005}
1006
1007/// Orient a rebuilt CLOSED rim the way the edge it replaces ran.
1008///
1009/// `intersect_analytic_pair` always emits its conics in its OWN direction
1010/// (increasing azimuth about the carrier frame). The edge being replaced need
1011/// not run that way: a wall's top and bottom cap circles are traversed in
1012/// OPPOSITE directions around the face loop, so one of them arrives decreasing.
1013/// Both are the same point set and a closed rim's two endpoints are the same
1014/// vertex, so `validate` cannot tell them apart — but the face's pcurve for the
1015/// reversed one runs `u: 1 → 0`, and handing the loop an increasing rim tears it
1016/// open in parameter space, which the Green's-theorem volume integral reads as a
1017/// completely different solid (measured 138.18 against a true 1024.11).
1018///
1019/// Decided on the START TANGENT, which is local and exact: a closed rim starts
1020/// on the carrier's seam and so does the rebuilt conic, so the two tangents
1021/// there either agree or oppose. OPEN rims are not orientated here — they are
1022/// trimmed to their solved corners first and then turned to run
1023/// start-vertex → end-vertex.
1024fn match_closed_rim_direction(
1025    rim: NurbsCurve,
1026    previous: &EdgeRecord,
1027) -> Result<NurbsCurve, String> {
1028    let [d0, _] = rim.domain()?;
1029    let incoming = previous.curve.derivatives(previous.t0, 1)?;
1030    let rebuilt = rim.derivatives(d0, 1)?;
1031    if incoming[1].dot(rebuilt[1]) < 0.0 {
1032        return rim.reversed();
1033    }
1034    Ok(rim)
1035}
1036
1037/// Put a freshly fitted pcurve back on the periodic BRANCH of `u` that the
1038/// pcurve it replaces used, by the whole-period shift that aligns their starts.
1039///
1040/// This is not cosmetic. `build_pcurve_on_surface` CLAMPS an analytic carrier's
1041/// parameters into `[u0, u1]`, but a face loop on a full revolution legitimately
1042/// carries `u` one period PAST the domain: the two coedges of the periodic seam
1043/// sit on opposite sides of it, so the coedge that closes the loop across the
1044/// seam runs (say) `u: 1 → 2`. `validate` accepts either branch — it evaluates
1045/// the surface with `evaluate_extended`, which wraps — but the Green's-theorem
1046/// area/volume integral does NOT: it integrates the wire as drawn in parameter
1047/// space, and a rim dropped back to `u: 0 → 1` tears the loop open and yields a
1048/// nonsense volume (measured 138.18 for a solid whose true volume is 1024.11).
1049///
1050/// The replacement rim traverses the same path in the same direction as the edge
1051/// it replaces — only its parameter DISTRIBUTION and (for an open arc) its
1052/// endpoints change — so aligning the starts fixes the branch, and the endpoint
1053/// check refuses anything that is not merely re-anchored.
1054fn reanchor_pcurve_u(
1055    pcurve: &NurbsCurve,
1056    previous: &NurbsCurve,
1057    u_period: f64,
1058) -> Result<NurbsCurve, String> {
1059    if !(u_period.is_finite() && u_period > 0.0) {
1060        return Ok(pcurve.clone());
1061    }
1062    let [a0, a1] = pcurve.domain()?;
1063    let [b0, b1] = previous.domain()?;
1064    let shift = ((previous.evaluate(b0)?.x - pcurve.evaluate(a0)?.x) / u_period).round() * u_period;
1065    let shifted = if shift == 0.0 {
1066        pcurve.clone()
1067    } else {
1068        let controls = pcurve
1069            .control_points
1070            .iter()
1071            .map(|control| {
1072                let mut point = control.point()?;
1073                point.x += shift;
1074                Ok(crate::Vec4::from_point(point, control.w))
1075            })
1076            .collect::<Result<Vec<_>, String>>()?;
1077        NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), controls)?
1078    };
1079    // Both ends must land within a quarter period of the ones they replace: a
1080    // rim that reversed direction, or that jumped a branch mid-curve, is not a
1081    // re-anchoring and must not be silently accepted.
1082    let end_drift = (shifted.evaluate(a1)?.x - previous.evaluate(b1)?.x).abs();
1083    if end_drift > 0.25 * u_period {
1084        return Err(format!(
1085            "offset_ruled_face: a rebuilt rim traverses the carrier's periodic parameter \
1086             differently from the edge it replaces (end drift {end_drift}) — refusing"
1087        ));
1088    }
1089    Ok(shifted)
1090}
1091
1092/// TRUE when `neighbour` is a ruled revolution (cylinder or cone) sharing the
1093/// pushed carrier's axis LINE — same axis direction (up to sign) and the axes
1094/// coincident. Only such a neighbour has a closed-form re-intersection with the
1095/// offset carrier (`intersect_coaxial_revolutions`); the tolerances mirror that
1096/// intersector so an accepted neighbour is one it can actually solve.
1097pub(super) fn ruled_neighbour_is_coaxial(
1098    neighbour: &NurbsSurface,
1099    axis_origin: Vec3,
1100    axis_dir: Vec3,
1101    scale: f64,
1102) -> bool {
1103    let Some(AnalyticSurface::RuledRevolution { frame, .. }) = neighbour.analytic() else {
1104        return false;
1105    };
1106    if frame.axis.dot(axis_dir).abs() < 1.0 - 1e-9 {
1107        return false;
1108    }
1109    let offset = frame.origin.sub(axis_origin);
1110    let perpendicular = offset.sub(axis_dir.scale(offset.dot(axis_dir)));
1111    perpendicular.length() <= 1e-9 * scale.max(1.0)
1112}
1113
1114/// Re-trim a ruled-revolution face (the pushed carrier itself, now S′, or a
1115/// coaxial ruled neighbour) whose boundary moved axially: grow the carrier along
1116/// its axis to cover the updated boundary (`extend_ruled_neighbour_over`, exact),
1117/// then rebuild every pcurve from the already-updated edge curves. The offset
1118/// analogue of `retrim_planar_face` — all loops are visited, so holes carry.
1119///
1120/// The three-phase body is `crate::offset_retrim::retrim_face_in_solid`; this is
1121/// the ruled growth strategy and the `offset_ruled_face` refusal prefix.
1122pub(super) fn retrim_offset_ruled_face(
1123    result: &mut BrepSolid,
1124    face_id: u64,
1125    final_edges: &HashMap<u64, EdgeRecord>,
1126    tolerance: f64,
1127) -> Result<(), String> {
1128    let (shell, face_pos) = find_face(result, face_id)
1129        .ok_or_else(|| format!("offset_ruled_face: missing ruled face {face_id}"))?;
1130    retrim_face_in_solid(
1131        result,
1132        shell,
1133        face_pos,
1134        final_edges,
1135        |solid, points| extend_ruled_neighbour_over(solid, face_id, points, tolerance),
1136        PcurveFit::SubrangeAware { tolerance },
1137        "offset_ruled_face",
1138    )
1139}
1140
1141/// A window through the wall bounded entirely by ONE crossing carrier.
1142struct SingleNeighbourHole {
1143    neighbour: u64,
1144    /// Every edge of the loop, in loop order, de-duplicated.
1145    edge_ids: Vec<u64>,
1146    /// Corner vertices that are ALSO the end of one of the neighbour's own
1147    /// edges — its seam meridian, in every case the corpus reaches. Such a
1148    /// corner is NOT a free bookkeeping split: it is pinned to that meridian,
1149    /// so it moves along it rather than to the nearest point of the new
1150    /// section. `(vertex id, the neighbour edge it is pinned to)`.
1151    pinned: Vec<(u64, u64)>,
1152}
1153
1154/// The axial half-plane a revolution carrier's SEAM meridian lies in.
1155///
1156/// Every seam of every revolution carrier this lane admits — a cylinder, a
1157/// cone, a sphere, a torus, a general revolution — is a meridian, and a
1158/// meridian lies in the half-plane spanned by the axis and its own radial
1159/// direction. So "where does the new section cross the neighbour's seam" is a
1160/// curve × plane crossing, in closed form, for all five at once. Returns `None`
1161/// for a carrier that is not a revolution or a seam that runs ON the axis.
1162fn seam_axial_plane(surface: &NurbsSurface, seam: &EdgeRecord) -> Option<Plane> {
1163    let structure = crate::revolution_structure(surface)?;
1164    let midpoint = seam
1165        .curve
1166        .evaluate(0.5 * (seam.t0 + seam.t1))
1167        .ok()?;
1168    let offset = midpoint.sub(structure.frame.origin);
1169    let radial = offset
1170        .sub(structure.frame.axis.scale(offset.dot(structure.frame.axis)))
1171        .normalized()
1172        .ok()?;
1173    let normal = structure.frame.axis.cross(radial).normalized().ok()?;
1174    Some(Plane {
1175        origin: structure.frame.origin,
1176        u_dir: structure.frame.axis,
1177        v_dir: radial,
1178        normal,
1179    })
1180}
1181
1182/// Recognise a loop that is a hole cut by ONE crossing carrier, or say why not.
1183///
1184/// Four conditions, each of which the rebuild depends on and none of which it
1185/// can check afterwards:
1186///
1187/// * every coedge's other face is the SAME neighbour — otherwise a corner IS a
1188///   triple point and belongs to `resolve_open_rim_end`;
1189/// * that neighbour is neither planar nor coaxial-ruled — the two lanes with
1190///   their own exact rebuilds, which must keep them (bit-identity);
1191/// * every edge is OPEN (a closed edge is the single-rim lane's business);
1192/// * every corner vertex has valence two across the WHOLE solid, so relocating
1193///   it cannot strand a third face's boundary. This is what rules out the hole
1194///   that straddles the pushed carrier's periodic seam: its arcs are stitched
1195///   into the outer loop, so the loop fails the first condition long before the
1196///   valence test — and either way it is refused rather than torn.
1197///
1198/// Returns `Ok(None)` for a loop that is simply not this shape (the outer loop
1199/// of any ordinary push), so the caller falls through to the lanes it always
1200/// used.
1201#[allow(clippy::too_many_arguments)]
1202fn single_neighbour_hole(
1203    loop_record: &crate::topology::LoopRecord,
1204    face_id: u64,
1205    solid: &BrepSolid,
1206    faces_of_edge: &HashMap<u64, Vec<u64>>,
1207    edge_by_id: &HashMap<u64, &EdgeRecord>,
1208    frame_origin: Vec3,
1209    frame_axis: Vec3,
1210    scale: f64,
1211) -> Result<Option<SingleNeighbourHole>, String> {
1212    let debug = std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok();
1213    let mut neighbour: Option<u64> = None;
1214    let mut edge_ids: Vec<u64> = Vec::new();
1215    for coedge in &loop_record.coedges {
1216        let edge = *edge_by_id
1217            .get(&coedge.edge_id)
1218            .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1219        if edge.start_vertex_id == edge.end_vertex_id {
1220            if debug { eprintln!("HOLE reject: edge {} is closed", edge.id); }
1221            return Ok(None); // a closed rim: the single-rim lane owns it.
1222        }
1223        let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
1224        let others: Vec<u64> = incident.into_iter().filter(|f| *f != face_id).collect();
1225        if others.len() != 1 {
1226            if debug { eprintln!("HOLE reject: edge {} has {} others", edge.id, others.len()); }
1227            return Ok(None); // a seam, or a non-manifold edge.
1228        }
1229        match neighbour {
1230            Some(known) if known != others[0] => {
1231                if debug { eprintln!("HOLE reject: mixed neighbours {known} / {}", others[0]); }
1232                return Ok(None);
1233            }
1234            Some(_) => {}
1235            None => neighbour = Some(others[0]),
1236        }
1237        if !edge_ids.contains(&edge.id) {
1238            edge_ids.push(edge.id);
1239        }
1240    }
1241    let Some(neighbour) = neighbour else {
1242        return Ok(None);
1243    };
1244    if edge_ids.len() < 2 {
1245        if debug { eprintln!("HOLE reject: only {} edges", edge_ids.len()); }
1246        return Ok(None);
1247    }
1248    let (nshell, nface) = find_face(solid, neighbour)
1249        .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
1250    let surface = &solid.shells[nshell].faces[nface].surface;
1251    if matches!(surface.analytic(), Some(AnalyticSurface::Plane { .. }))
1252        || ruled_neighbour_is_coaxial(surface, frame_origin, frame_axis, scale)
1253    {
1254        if debug { eprintln!("HOLE reject: neighbour {neighbour} is planar/coaxial"); }
1255        return Ok(None); // the two lanes that already have exact rebuilds.
1256    }
1257    // Every corner is either a free bookkeeping split (nothing else ends there)
1258    // or PINNED to one edge of the neighbour — its seam. Anything else ending
1259    // at a corner makes it a genuine junction of three or more faces, which
1260    // this lane does not solve.
1261    let neighbour_edges: HashSet<u64> = solid.shells[nshell].faces[nface]
1262        .loops
1263        .iter()
1264        .flat_map(|loop_record| &loop_record.coedges)
1265        .map(|coedge| coedge.edge_id)
1266        .collect();
1267    let mut pinned: Vec<(u64, u64)> = Vec::new();
1268    for edge_id in &edge_ids {
1269        let edge = *edge_by_id
1270            .get(edge_id)
1271            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1272        for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1273            for other in &solid.edges {
1274                if other.start_vertex_id != vertex_id && other.end_vertex_id != vertex_id {
1275                    continue;
1276                }
1277                if edge_ids.contains(&other.id) {
1278                    continue;
1279                }
1280                if !neighbour_edges.contains(&other.id) {
1281                    if debug {
1282                        eprintln!("HOLE reject: vertex {vertex_id} also on foreign edge {}", other.id);
1283                    }
1284                    return Ok(None);
1285                }
1286                if pinned
1287                    .iter()
1288                    .any(|(known, pin)| *known == vertex_id && *pin != other.id)
1289                {
1290                    if debug {
1291                        eprintln!("HOLE reject: vertex {vertex_id} pinned by two neighbour edges");
1292                    }
1293                    return Ok(None);
1294                }
1295                if !pinned.iter().any(|(known, _)| *known == vertex_id) {
1296                    pinned.push((vertex_id, other.id));
1297                }
1298            }
1299        }
1300    }
1301    if debug {
1302        eprintln!("HOLE accept: neighbour {neighbour} edges {edge_ids:?} pinned {pinned:?}");
1303    }
1304    Ok(Some(SingleNeighbourHole {
1305        neighbour,
1306        edge_ids,
1307        pinned,
1308    }))
1309}
1310
1311/// Rebuild a single-neighbour hole: re-intersect once, then cut the section.
1312#[allow(clippy::too_many_arguments)]
1313fn rebuild_single_neighbour_hole(
1314    solid: &BrepSolid,
1315    s_prime: &NurbsSurface,
1316    hole: &SingleNeighbourHole,
1317    edge_by_id: &HashMap<u64, &EdgeRecord>,
1318    vertex_pos: &HashMap<u64, Vec3>,
1319    tolerance: f64,
1320    scale: f64,
1321    new_curve: &mut HashMap<u64, NurbsCurve>,
1322    new_vertex: &mut HashMap<u64, Vec3>,
1323) -> Result<(), String> {
1324    let (nshell, nface) = find_face(solid, hole.neighbour)
1325        .ok_or_else(|| format!("offset_ruled_face: missing neighbour {}", hole.neighbour))?;
1326    let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
1327
1328    // Seed the march with the WHOLE old loop: the section replacing it runs
1329    // near it for any push small against the feature, and a blind seed grid on
1330    // two large carriers can miss a small window entirely.
1331    let mut seeds: Vec<Vec3> = Vec::new();
1332    let mut reference: Vec<Vec3> = Vec::new();
1333    for edge_id in &hole.edge_ids {
1334        let edge = *edge_by_id
1335            .get(edge_id)
1336            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1337        let samples = edge_seeds(edge, 9)?;
1338        reference.extend(samples.iter().copied());
1339        seeds.extend(samples);
1340    }
1341    let policy = MarchPolicy {
1342        tolerance,
1343        residual_tolerance: (scale * 5e-4).max(5e-6),
1344        seeds,
1345    };
1346    let found = match reintersect_carriers(s_prime, neighbour_surface, &policy) {
1347        Ok(found) => found,
1348        Err(ReintersectRefusal::Separated) => {
1349            return Err(
1350                "offset_ruled_face: the pushed carrier no longer meets a neighbour \
1351                 (the push separated them, or the rim left the neighbour's domain) — refusing"
1352                    .into(),
1353            )
1354        }
1355        Err(other) => return Err(format!("offset_ruled_face: {}", other.describe())),
1356    };
1357    if found.lane != RimLane::Marched {
1358        // An analytic section has no polyline to cut, and the exact lanes that
1359        // produce one already have their own arc rebuild. Refusing here keeps
1360        // this lane from re-deciding a case the closed forms own.
1361        return Err(format!(
1362            "offset_ruled_face: the window bounded by face {} re-intersects in CLOSED FORM, \
1363             whose arc rebuild is the analytic lane's — deferred (refusing)",
1364            hole.neighbour
1365        ));
1366    }
1367    if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
1368        eprintln!(
1369            "HOLE rebuild neighbour {}: lane {:?}, {} branch(es), residual {:.3e} \
1370             (gate {:.3e})",
1371            hole.neighbour,
1372            found.lane,
1373            found.sections.len(),
1374            found.residual,
1375            policy.residual_tolerance
1376        );
1377    }
1378    // Which branch is THIS window: two windows cut by one crossing carrier
1379    // share a surface and so come back as two branches of one section set.
1380    let section = found
1381        .nearest_section(&reference)
1382        .map_err(|error| format!("offset_ruled_face: {error}"))?;
1383
1384    // Corners first, so every arc is cut against the same relocated vertices.
1385    //
1386    // A corner PINNED to the neighbour's seam is not free to go to the nearest
1387    // sample: it must stay on that meridian, so it goes where the new section
1388    // CROSSES the meridian's axial half-plane. Placing it at the nearest sample
1389    // instead leaves it off the seam by the amount the section drifted, which
1390    // the neighbour's own re-trim then reports as "a relocated rim vertex left
1391    // curved neighbour N's edge" — a refusal where a correct answer exists.
1392    for edge_id in &hole.edge_ids {
1393        let edge = *edge_by_id
1394            .get(edge_id)
1395            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1396        for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1397            if new_vertex.contains_key(&vertex_id) {
1398                continue;
1399            }
1400            let old = *vertex_pos
1401                .get(&vertex_id)
1402                .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
1403            let point = match hole
1404                .pinned
1405                .iter()
1406                .find(|(known, _)| *known == vertex_id)
1407                .map(|(_, pin)| *pin)
1408            {
1409                Some(pin) => {
1410                    let seam = *edge_by_id
1411                        .get(&pin)
1412                        .ok_or_else(|| format!("offset_ruled_face: missing edge {pin}"))?;
1413                    let plane = seam_axial_plane(neighbour_surface, seam).ok_or_else(|| {
1414                        format!(
1415                            "offset_ruled_face: the window's corner is pinned to edge {pin} of a \
1416                             neighbour that is not a surface of revolution — refusing"
1417                        )
1418                    })?;
1419                    // The correct crossing is inside the window itself, so the
1420                    // search reach is the window's own extent about this corner
1421                    // — the opposite meridian cannot win.
1422                    let reach = reference
1423                        .iter()
1424                        .map(|point| point.sub(old).length())
1425                        .fold(0.0f64, f64::max)
1426                        .max(tolerance * 100.0);
1427                    curve_plane_crossing_near(&section.curve, &plane, old, reach)
1428                        .map(|(_, point)| point)
1429                        .ok_or_else(|| {
1430                            format!(
1431                                "offset_ruled_face: the rebuilt window never crosses the seam \
1432                                 (edge {pin}) its corner is pinned to — refusing"
1433                            )
1434                        })?
1435                }
1436                None => section_corner(section, old)
1437                    .map_err(|error| format!("offset_ruled_face: {error}"))?,
1438            };
1439            new_vertex.insert(vertex_id, point);
1440        }
1441    }
1442    for edge_id in &hole.edge_ids {
1443        let edge = *edge_by_id
1444            .get(edge_id)
1445            .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1446        let from = new_vertex[&edge.start_vertex_id];
1447        let to = new_vertex[&edge.end_vertex_id];
1448        let through = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
1449        let arc = arc_of_section(section, from, to, through, tolerance)
1450            .map_err(|error| format!("offset_ruled_face: {error}"))?;
1451        new_curve.insert(edge.id, arc);
1452    }
1453    Ok(())
1454}
1455
1456/// Re-fit ONLY the pcurves whose edge actually changed, each back onto the
1457/// periodic branch of `u` the pcurve it replaces was on.
1458///
1459/// This is the CURVED-neighbour lane's re-trim, and it is deliberately not
1460/// [`crate::offset_retrim::retrim_face_in_solid`]. That driver rebuilds *every*
1461/// pcurve of the face, which is right for a ruled revolution (whose seam
1462/// coedges are straight generatrices whose rebuilt pcurves land back on their
1463/// own sides by luck of the parameterization) and **measurably wrong** for a
1464/// sphere or a torus. Measured on the ball-capped rod, `d = +0.5`:
1465///
1466/// | face | rebuilt-all | correct (independently built r = 3.5 solid) |
1467/// |---|---|---|
1468/// | wall seam coedge | `u: 0 → 0` | `u: 1 → 1` |
1469/// | dome seam coedge | `u: 0 → 0` | `u: 1 → 1` |
1470/// | dome pole coedge | `(0,1) → (0,1)` | `(1,1) → (0,1)` |
1471///
1472/// with a resulting solid that `validate()` accepts and whose Green's-theorem
1473/// volume reads **207.86 against the true 534.10** — the dome's parameter-space
1474/// loop collapsed to zero area because both of its seam sides ended up on the
1475/// same branch. Exactly the failure [`reanchor_pcurve_u`] was written for.
1476///
1477/// Two rules together fix it, and both are needed:
1478/// * **touch only what moved.** A neighbour that keeps its surface keeps every
1479///   pcurve whose edge did not change; rebuilding one is a chance to land on
1480///   the wrong branch for no gain.
1481/// * **re-anchor what is rebuilt**, by the whole-period shift that aligns its
1482///   start with the pcurve it replaces — the rebuilt rim traverses the same path
1483///   in the same direction, so aligning the starts fixes the branch, and
1484///   [`reanchor_pcurve_u`]'s end-drift check refuses anything that is not
1485///   merely re-anchored.
1486fn refit_changed_pcurves(
1487    result: &mut BrepSolid,
1488    face_id: u64,
1489    final_edges: &HashMap<u64, EdgeRecord>,
1490    changed: &HashSet<u64>,
1491    tolerance: f64,
1492) -> Result<(), String> {
1493    let (shell, face_pos) = find_face(result, face_id)
1494        .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
1495    let surface = result.shells[shell].faces[face_pos].surface.clone();
1496    let [u_start, u_end] = surface.domain_u()?;
1497    let u_period = u_end - u_start;
1498    for loop_record in &mut result.shells[shell].faces[face_pos].loops {
1499        for coedge in &mut loop_record.coedges {
1500            if !changed.contains(&coedge.edge_id) {
1501                continue;
1502            }
1503            let edge = final_edges
1504                .get(&coedge.edge_id)
1505                .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1506            // Same subrange rule as `PcurveFit::SubrangeAware`, so a rim that is
1507            // a strict piece of a full-domain curve keeps the range-aware fit it
1508            // has always had.
1509            let [d0, d1] = edge.curve.domain()?;
1510            let span = (d1 - d0).max(1e-12);
1511            let is_subrange =
1512                (edge.t0 - d0).abs() > 1e-9 * span || (edge.t1 - d1).abs() > 1e-9 * span;
1513            let pcurve = if is_subrange {
1514                build_pcurve_on_surface_range(
1515                    &surface,
1516                    &edge.curve,
1517                    edge.t0,
1518                    edge.t1,
1519                    coedge.forward,
1520                    tolerance,
1521                )?
1522            } else {
1523                let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
1524                if !coedge.forward {
1525                    pcurve = pcurve.reversed()?;
1526                }
1527                pcurve
1528            };
1529            coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
1530        }
1531    }
1532    Ok(())
1533}
1534
1535/// The curve in `curves` whose midpoint is nearest `reference` (branch selection
1536/// for a surface∩surface intersection that returns more than one component).
1537fn nearest_curve(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
1538    let mut best: Option<(f64, &NurbsCurve)> = None;
1539    for curve in curves {
1540        let mid = curve.evaluate(0.5)?;
1541        let d = mid.sub(reference).length();
1542        if best.map(|(best_d, _)| d < best_d).unwrap_or(true) {
1543            best = Some((d, curve));
1544        }
1545    }
1546    best.map(|(_, curve)| curve.clone())
1547        .ok_or_else(|| "offset_ruled_face: empty intersection".into())
1548}
1549
1550#[cfg(test)]
1551mod probe_tests {
1552    use super::*;
1553    use crate::{make_cone_brep, make_cylinder_brep};
1554
1555    /// The radial distance of `point` from the `axis` through `origin`.
1556    fn radial(point: Vec3, origin: Vec3, axis: Vec3) -> f64 {
1557        let d = point.sub(origin);
1558        d.sub(axis.scale(d.dot(axis))).length()
1559    }
1560
1561    fn side_surface(solid: &BrepSolid) -> NurbsSurface {
1562        solid
1563            .shells
1564            .iter()
1565            .flat_map(|shell| &shell.faces)
1566            .find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })))
1567            .expect("a ruled side face")
1568            .surface
1569            .clone()
1570    }
1571
1572    fn side_face_id(solid: &BrepSolid) -> u64 {
1573        solid
1574            .shells
1575            .iter()
1576            .flat_map(|shell| &shell.faces)
1577            .find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })))
1578            .expect("a ruled side face")
1579            .id
1580    }
1581
1582    // Push a solid cylinder's side face OUT (radius grows) and IN (radius
1583    // shrinks): the carrier offsets, the two caps re-trim to the new circle, and
1584    // the volume is exactly π r'² h.
1585    #[test]
1586    fn push_solid_cylinder_side_changes_radius() {
1587        let axis = Vec3::new(0.0, 0.0, 1.0);
1588        for (d, r_new) in [(2.0_f64, 7.0_f64), (-2.0, 3.0)] {
1589            let cyl = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
1590            let side = side_face_id(&cyl);
1591            let pushed = offset_ruled_face(&cyl, side, d)
1592                .unwrap_or_else(|e| panic!("push cylinder side by {d}: {e}"));
1593            assert!(pushed.validate().is_empty(), "validate {d}: {:?}", pushed.validate());
1594            let got = solid_signed_volume(&pushed).unwrap().abs();
1595            let expected = std::f64::consts::PI * r_new * r_new * 10.0;
1596            assert!((got - expected).abs() < 1e-3, "push {d}: vol {got}, want {expected}");
1597        }
1598    }
1599
1600    // A DRILLED HOLE's cylindrical wall: pushing it grows/shrinks the hole. The
1601    // wall's outward normal points radially INWARD (into the void), so a positive
1602    // push shrinks the hole (more material). The plate faces (planar, with the
1603    // hole as an internal loop) re-trim around the new circle.
1604    #[test]
1605    fn push_drilled_hole_wall_resizes_the_hole() {
1606        let plate = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 4.0).unwrap();
1607        let cutter = make_cylinder_brep(Vec3::new(10.0, 10.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 6.0)
1608            .unwrap();
1609        let options = crate::BooleanOptions {
1610            merge_coplanar_faces: true,
1611            ..crate::BooleanOptions::default()
1612        };
1613        let drilled =
1614            crate::boolean_operation(&plate, &cutter, crate::BooleanOperation::Subtract, &options)
1615                .unwrap();
1616        let v0 = solid_signed_volume(&drilled).unwrap().abs();
1617        let wall = side_face_id(&drilled);
1618        // Push the wall +1 → hole radius 3 → 2 (outward normal points inward).
1619        let pushed = offset_ruled_face(&drilled, wall, 1.0)
1620            .unwrap_or_else(|e| panic!("push drilled-hole wall: {e}"));
1621        assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
1622        let got = solid_signed_volume(&pushed).unwrap().abs();
1623        // Material grows by the annulus π(3² − 2²)·4.
1624        let expected = std::f64::consts::PI * (9.0 - 4.0) * 4.0;
1625        assert!(
1626            (got - v0 - expected).abs() < 1e-3,
1627            "hole-shrink volume delta {}, expected {expected}",
1628            got - v0
1629        );
1630    }
1631
1632    // Pushing the side IN by the full radius (or past it) must refuse, not emit a
1633    // degenerate solid.
1634    #[test]
1635    fn push_cylinder_side_through_axis_refuses() {
1636        let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
1637        let side = side_face_id(&cyl);
1638        let err = offset_ruled_face(&cyl, side, -5.0).expect_err("collapse must refuse");
1639        assert!(err.contains("axis") || err.contains("refus"), "unexpected: {err}");
1640    }
1641
1642    /// A cylinder r=5 h=10 carrying a rectangular THROUGH-SLOT: a box
1643    /// x ∈ [−1.5, 1.5], z ∈ [3.5, 6.5] driven all the way through in y. Its
1644    /// four bounding faces are PLANES, so every rim neighbour is of the
1645    /// supported kind (unlike a round radial bore, whose wall is a NON-coaxial
1646    /// ruled surface and refuses earlier — see
1647    /// `push_cylinder_wall_with_a_radial_bore_still_refuses`).
1648    fn slotted_cylinder() -> BrepSolid {
1649        let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
1650        let slot = crate::make_box_brep(Vec3::new(-1.5, -9.0, 3.5), 3.0, 18.0, 3.0).unwrap();
1651        let options = crate::BooleanOptions {
1652            merge_coplanar_faces: true,
1653            ..crate::BooleanOptions::default()
1654        };
1655        crate::boolean_operation(&cyl, &slot, crate::BooleanOperation::Subtract, &options).unwrap()
1656    }
1657
1658    /// The multi-loop cylindrical wall of [`slotted_cylinder`].
1659    fn slotted_wall_id(solid: &BrepSolid) -> u64 {
1660        solid
1661            .shells
1662            .iter()
1663            .flat_map(|shell| &shell.faces)
1664            .filter(|face| {
1665                matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. }))
1666            })
1667            .max_by_key(|face| face.loops.len())
1668            .expect("a ruled side face")
1669            .id
1670    }
1671
1672    /// The EXACT volume of the slotted cylinder at wall radius `r` — DERIVED,
1673    /// not pinned. The solid is the cylinder π r² h minus the slot, and the
1674    /// slot is the prism of height 3 over the strip |x| ≤ a = 1.5 of the disc
1675    /// of radius r. That strip's area is
1676    /// `∫₋ₐ^ₐ 2√(r²−x²) dx = 2(a√(r²−a²) + r² asin(a/r))`.
1677    fn slotted_cylinder_volume(radius: f64) -> f64 {
1678        let a = 1.5_f64;
1679        let strip =
1680            2.0 * (a * (radius * radius - a * a).sqrt() + radius * radius * (a / radius).asin());
1681        std::f64::consts::PI * radius * radius * 10.0 - 3.0 * strip
1682    }
1683
1684    /// Push the MULTI-LOOP cylindrical wall of a through-slotted cylinder in and
1685    /// out (backlog #7). The wall's loops are 12 + 4 coedges: the slot's +y
1686    /// window is crossed by the carrier's periodic seam, so it is a notch in the
1687    /// outer loop, while the −y window is a clean interior loop. Every rim
1688    /// neighbour is a plane.
1689    ///
1690    /// The rebuild has to do three things the single-loop path never needed: use
1691    /// the arcs' OWN endpoints instead of collapsing both onto the seam, re-solve
1692    /// each slot corner as the triple point `S′ ∩ slot z-plane ∩ slot x-plane`,
1693    /// and trim each rim conic to the arc between them. Correctness (not just
1694    /// validity) is checked against the closed-form volume and against the
1695    /// corner positions: the x = ±1.5 walls hold, so each corner slides to
1696    /// `y = ±√(r′² − 1.5²)`.
1697    #[test]
1698    fn push_multiloop_cylinder_wall_resizes() {
1699        let slotted = slotted_cylinder();
1700        assert!(slotted.validate().is_empty(), "fixture: {:?}", slotted.validate());
1701        let wall_id = slotted_wall_id(&slotted);
1702        let before: Vec<usize> = slotted
1703            .shells
1704            .iter()
1705            .flat_map(|shell| &shell.faces)
1706            .find(|face| face.id == wall_id)
1707            .expect("the wall")
1708            .loops
1709            .iter()
1710            .map(|loop_record| loop_record.coedges.len())
1711            .collect();
1712        assert!(
1713            before.len() >= 2,
1714            "fixture must give the wall multiple loops, got {before:?}"
1715        );
1716        let base = solid_signed_volume(&slotted).unwrap().abs();
1717        assert!(
1718            (base - slotted_cylinder_volume(5.0)).abs() < 1e-3,
1719            "fixture volume {base}, want {}",
1720            slotted_cylinder_volume(5.0)
1721        );
1722
1723        for (d, r_new) in [(1.0_f64, 6.0_f64), (-1.0, 4.0)] {
1724            let pushed = offset_ruled_face(&slotted, wall_id, d)
1725                .unwrap_or_else(|e| panic!("multi-loop wall push {d}: {e}"));
1726            assert!(pushed.validate().is_empty(), "validate {d}: {:?}", pushed.validate());
1727
1728            let wall = pushed
1729                .shells
1730                .iter()
1731                .flat_map(|shell| &shell.faces)
1732                .find(|face| face.id == wall_id)
1733                .expect("the pushed wall");
1734            let Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) =
1735                wall.surface.analytic()
1736            else {
1737                panic!("the pushed wall is no longer a ruled revolution");
1738            };
1739            assert!(
1740                (rho0 - r_new).abs() < 1e-9 && (rho1 - r_new).abs() < 1e-9,
1741                "push {d}: wall radii {rho0}/{rho1}, want {r_new}"
1742            );
1743            let after: Vec<usize> = wall
1744                .loops
1745                .iter()
1746                .map(|loop_record| loop_record.coedges.len())
1747                .collect();
1748            assert_eq!(after, before, "push {d} must preserve the wall's loop structure");
1749
1750            // Each slot corner is the triple point of the offset wall, a slot
1751            // z-plane and a slot x-plane: it stays on z ∈ {3.5, 6.5} and
1752            // x = ±1.5, and slides along them to |y| = √(r′² − 1.5²).
1753            let corner_y = (r_new * r_new - 2.25_f64).sqrt();
1754            let mut corners = 0;
1755            for vertex in &pushed.vertices {
1756                let on_slot_plane =
1757                    (vertex.point.z - 3.5).abs() < 1e-9 || (vertex.point.z - 6.5).abs() < 1e-9;
1758                if !on_slot_plane || (vertex.point.x.abs() - 1.5).abs() > 1e-9 {
1759                    continue;
1760                }
1761                assert!(
1762                    (vertex.point.y.abs() - corner_y).abs() < 1e-6,
1763                    "push {d}: slot corner {:?} should sit at |y| = {corner_y}",
1764                    vertex.point
1765                );
1766                corners += 1;
1767            }
1768            assert_eq!(corners, 8, "push {d}: the slot has eight corner vertices");
1769
1770            let expected = slotted_cylinder_volume(r_new);
1771            let got = solid_signed_volume(&pushed).unwrap().abs();
1772            assert!(
1773                (got - expected).abs() < 1e-3,
1774                "push {d}: volume {got}, want {expected} (delta {}, want {})",
1775                got - base,
1776                expected - base
1777            );
1778        }
1779        // The fail-safe contract: the input is never mutated.
1780        assert!(slotted.validate().is_empty());
1781    }
1782
1783    /// The negative half of the same fixture: pushing the wall far enough INWARD
1784    /// COLLAPSES the slot. At r = 1.4 the wall no longer reaches the slot's
1785    /// x = ±1.5 side walls, so the window's corners cease to exist and the
1786    /// rebuild would need a topology change this edit cannot make. It must
1787    /// refuse cleanly and leave the input alone.
1788    #[test]
1789    fn push_multiloop_cylinder_wall_collapsing_the_slot_refuses() {
1790        let slotted = slotted_cylinder();
1791        let wall_id = slotted_wall_id(&slotted);
1792        for d in [-3.6_f64, -5.0] {
1793            let err = offset_ruled_face(&slotted, wall_id, d)
1794                .expect_err("a push that collapses the slot must refuse");
1795            assert!(
1796                err.contains("refus") || err.contains("validation"),
1797                "push {d} must refuse with a typed error, got: {err}"
1798            );
1799        }
1800        assert!(slotted.validate().is_empty());
1801    }
1802
1803    /// The CONE analogue of the same fixture, which does NOT work: a frustum
1804    /// minus the same through-slot. The rim rebuild is carrier-agnostic, but
1805    /// the slot's x = ±1.5 walls are parallel to the cone axis, so their
1806    /// section of the cone is a HYPERBOLA — `intersect_plane_quadric` refuses
1807    /// the weight-sign flip and `intersect_analytic_pair` returns nothing. So
1808    /// multi-loop CONE pushes stay fail-safe-but-unsupported (backlog #7b); this
1809    /// pins that, and would have to be revisited before the matrix may claim
1810    /// the cone multi-loop cell.
1811    #[test]
1812    fn push_multiloop_cone_wall_is_fail_safe() {
1813        let frustum =
1814            make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 3.0, 10.0).unwrap();
1815        let slot = crate::make_box_brep(Vec3::new(-1.5, -9.0, 3.5), 3.0, 18.0, 3.0).unwrap();
1816        let options = crate::BooleanOptions {
1817            merge_coplanar_faces: true,
1818            ..crate::BooleanOptions::default()
1819        };
1820        let slotted =
1821            crate::boolean_operation(&frustum, &slot, crate::BooleanOperation::Subtract, &options)
1822                .unwrap();
1823        assert!(slotted.validate().is_empty(), "fixture: {:?}", slotted.validate());
1824        let wall = slotted_wall_id(&slotted);
1825        for d in [1.0_f64, -1.0] {
1826            match offset_ruled_face(&slotted, wall, d) {
1827                Err(error) => assert!(
1828                    error.contains("refus"),
1829                    "push {d} must refuse with a typed error, got: {error}"
1830                ),
1831                Ok(good) => assert!(
1832                    good.validate().is_empty(),
1833                    "if the cone multi-loop push is accepted it must be valid: {:?}",
1834                    good.validate()
1835                ),
1836            }
1837        }
1838        assert!(slotted.validate().is_empty());
1839    }
1840
1841    /// A ROUND radial bore through the same wall is a DIFFERENT and still
1842    /// unimplemented problem: the bore's wall is a NON-coaxial ruled neighbour
1843    /// (backlog #3b), so the multi-loop rim rebuild must never be reached. The
1844    /// push still refuses at neighbour classification.
1845    #[test]
1846    fn push_cylinder_wall_with_a_radial_bore_still_refuses() {
1847        let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
1848        let bore =
1849            make_cylinder_brep(Vec3::new(0.0, -9.0, 5.0), Vec3::new(0.0, 1.0, 0.0), 1.5, 18.0)
1850                .unwrap();
1851        let options = crate::BooleanOptions {
1852            merge_coplanar_faces: true,
1853            ..crate::BooleanOptions::default()
1854        };
1855        let bored =
1856            crate::boolean_operation(&cyl, &bore, crate::BooleanOperation::Subtract, &options)
1857                .unwrap();
1858        assert!(bored.validate().is_empty(), "fixture: {:?}", bored.validate());
1859        let wall = cylinder_side_id(&bored, Vec3::new(0.0, 0.0, 1.0));
1860        let err = offset_ruled_face(&bored, wall, 1.0)
1861            .expect_err("a radial bore's seam-straddling hole must still refuse");
1862        // The reason MOVED when the curved-neighbour lane landed, and the move
1863        // is the finding: the bore's wall is no longer refused for being
1864        // non-coaxial (the shared re-intersection marches that pair fine), it is
1865        // refused because the hole it cuts STRADDLES the pushed carrier's
1866        // periodic seam, so one of its arcs is stitched into the outer loop and
1867        // the loop is not a single-neighbour ring. `tee_junction` below is the
1868        // same pair with the hole clear of the seam, and it now SUCCEEDS.
1869        assert!(
1870            err.contains("seam") || err.contains("OPEN arc"),
1871            "expected the seam-straddling refusal, got: {err}"
1872        );
1873        assert!(bored.validate().is_empty());
1874    }
1875
1876    // Push a frustum's side face OUT: both cap radii grow by δ·√(1+m²) and the
1877    // volume matches the frustum formula on the grown radii.
1878    #[test]
1879    fn push_frustum_side_grows_both_radii() {
1880        let axis = Vec3::new(0.0, 0.0, 1.0);
1881        let (r_b, r_t, h, d) = (4.0_f64, 2.0_f64, 6.0_f64, 1.0_f64);
1882        let frustum = make_cone_brep(Vec3::default(), axis, r_b, r_t, h).unwrap();
1883        let side = side_face_id(&frustum);
1884        let pushed = offset_ruled_face(&frustum, side, d)
1885            .unwrap_or_else(|e| panic!("push frustum side: {e}"));
1886        assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
1887        let slope = (r_t - r_b) / h;
1888        let grow = d * (1.0 + slope * slope).sqrt();
1889        let (rb, rt) = (r_b + grow, r_t + grow);
1890        let expected = std::f64::consts::PI * h / 3.0 * (rb * rb + rb * rt + rt * rt);
1891        let got = solid_signed_volume(&pushed).unwrap().abs();
1892        assert!((got - expected).abs() < 1e-3, "frustum push vol {got}, want {expected}");
1893    }
1894
1895    fn cap_surface_at(solid: &BrepSolid, axis: Vec3, target_axial: f64) -> NurbsSurface {
1896        solid
1897            .shells
1898            .iter()
1899            .flat_map(|shell| &shell.faces)
1900            .filter(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. })))
1901            .find(|face| {
1902                let (u, v) = (0.5, 0.5);
1903                let p = face.surface.evaluate(u, v).unwrap();
1904                (p.dot(axis) - target_axial).abs() < 1e-6
1905            })
1906            .expect("a planar cap at the target height")
1907            .surface
1908            .clone()
1909    }
1910
1911    // Probe (advisor step 1): S′ = offset carrier RE-RECOGNIZES as a ruled
1912    // revolution, and S′ ∩ cap is one circle at the expected radius / height —
1913    // for both a cylinder (radial scale) and a cone frustum (axis translation).
1914    #[test]
1915    fn offset_carrier_recognizes_and_reintersects_the_caps() {
1916        let axis = Vec3::new(0.0, 0.0, 1.0);
1917        let tol = 1e-9;
1918
1919        // --- Cylinder r=5, h=10: push out +2 → coaxial cylinder r=7 ---
1920        let cyl = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
1921        let s_prime = offset_ruled_carrier(&side_surface(&cyl), 2.0, tol).unwrap();
1922        match s_prime.analytic() {
1923            Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
1924                assert!((rho0 - 7.0).abs() < 1e-6 && (rho1 - 7.0).abs() < 1e-6, "cyl rho {rho0},{rho1}");
1925            }
1926            other => panic!("cylinder offset must re-recognize as ruled, got {other:?}"),
1927        }
1928        for (z, want_r) in [(0.0, 7.0), (10.0, 7.0)] {
1929            let cap = cap_surface_at(&cyl, axis, z);
1930            let curves = intersect_analytic_pair(&s_prime, &cap, tol)
1931                .unwrap_or_else(|| panic!("no analytic intersection at z={z}"));
1932            assert_eq!(curves.len(), 1, "one rim circle at z={z}");
1933            for step in 0..=8 {
1934                let p = curves[0].evaluate(step as f64 / 8.0).unwrap();
1935                assert!((radial(p, Vec3::default(), axis) - want_r).abs() < 1e-6, "cyl rim r at z={z}");
1936                assert!((p.z - z).abs() < 1e-6, "cyl rim z");
1937            }
1938        }
1939
1940        // --- Frustum r_bottom=4 @ z=0, r_top=2 @ z=6: push out +1 ---
1941        // slope m = (2−4)/6 = −1/3; radial growth δ·√(1+m²) = √(10)/3 ≈ 1.0541.
1942        let grow = (1.0 + (1.0f64 / 3.0).powi(2)).sqrt();
1943        let frustum = make_cone_brep(Vec3::default(), axis, 4.0, 2.0, 6.0).unwrap();
1944        let s_prime = offset_ruled_carrier(&side_surface(&frustum), 1.0, tol).unwrap();
1945        assert!(
1946            matches!(s_prime.analytic(), Some(AnalyticSurface::RuledRevolution { .. })),
1947            "frustum offset must re-recognize as ruled"
1948        );
1949        for (z, base_r) in [(0.0, 4.0), (6.0, 2.0)] {
1950            let cap = cap_surface_at(&frustum, axis, z);
1951            let curves = intersect_analytic_pair(&s_prime, &cap, tol)
1952                .unwrap_or_else(|| panic!("no frustum intersection at z={z}"));
1953            assert_eq!(curves.len(), 1, "one frustum rim circle at z={z}");
1954            let want_r = base_r + grow;
1955            for step in 0..=8 {
1956                let p = curves[0].evaluate(step as f64 / 8.0).unwrap();
1957                assert!(
1958                    (radial(p, Vec3::default(), axis) - want_r).abs() < 1e-6,
1959                    "frustum rim r at z={z}: got {}, want {want_r}",
1960                    radial(p, Vec3::default(), axis)
1961                );
1962                assert!((p.z - z).abs() < 1e-6, "frustum rim z");
1963            }
1964        }
1965    }
1966
1967    // --- Coaxial ruled × ruled neighbours (backlog #3, coaxial subset) ------
1968
1969    /// A stepped boss: a cone frustum (r 5→3 over z∈[0,4]) whose top circle
1970    /// coincides with a coaxial cylinder (r 3, z∈[4,10]). The union shares one
1971    /// circular rim at z=4 between the cone side and the cylinder side.
1972    fn coaxial_cone_cylinder_boss() -> BrepSolid {
1973        let axis = Vec3::new(0.0, 0.0, 1.0);
1974        let cone = make_cone_brep(Vec3::default(), axis, 5.0, 3.0, 4.0).unwrap();
1975        let cyl = make_cylinder_brep(Vec3::default(), axis, 3.0, 10.0).unwrap();
1976        let options = crate::BooleanOptions {
1977            merge_coplanar_faces: true,
1978            ..crate::BooleanOptions::default()
1979        };
1980        crate::boolean_operation(&cone, &cyl, crate::BooleanOperation::Union, &options).unwrap()
1981    }
1982
1983    /// The id of the CYLINDER side face (a ruled revolution with rho0 == rho1)
1984    /// whose axis is ~parallel to `axis`.
1985    fn cylinder_side_id(solid: &BrepSolid, axis: Vec3) -> u64 {
1986        solid
1987            .shells
1988            .iter()
1989            .flat_map(|shell| &shell.faces)
1990            .find(|face| match face.surface.analytic() {
1991                Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, .. }) => {
1992                    (rho0 - rho1).abs() < 1e-9 && frame.axis.dot(axis).abs() > 1.0 - 1e-9
1993                }
1994                _ => false,
1995            })
1996            .expect("a cylinder side face")
1997            .id
1998    }
1999
2000    fn counts(solid: &BrepSolid) -> (usize, usize, usize) {
2001        let faces = solid.shells.iter().map(|s| s.faces.len()).sum();
2002        (solid.vertices.len(), solid.edges.len(), faces)
2003    }
2004
2005    // Push the cylinder side of a coaxial cone+cylinder boss OUTWARD: the
2006    // shared rim must slide DOWN the FIXED cone (from z=4 to z=2, where the cone
2007    // has radius 4) and the cone neighbour re-intersects + re-trims instead of
2008    // refusing. Volume, topology counts, and volume sign are all checked.
2009    #[test]
2010    fn push_cylinder_side_against_coaxial_cone_reintersects() {
2011        let axis = Vec3::new(0.0, 0.0, 1.0);
2012        let solid = coaxial_cone_cylinder_boss();
2013        assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
2014        let before_counts = counts(&solid);
2015        let before_vol = solid_signed_volume(&solid).unwrap();
2016
2017        let cyl = cylinder_side_id(&solid, axis);
2018        let pushed = offset_ruled_face(&solid, cyl, 1.0)
2019            .unwrap_or_else(|e| panic!("push cylinder side against coaxial cone: {e}"));
2020        assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
2021
2022        // Topology preserved (no faces/edges added or lost).
2023        assert_eq!(counts(&pushed), before_counts, "topology counts changed");
2024
2025        // Volume: frustum z∈[0,2] (r 5→4) + cylinder r=4 z∈[2,10].
2026        let expected =
2027            std::f64::consts::PI * (2.0 / 3.0 * (25.0 + 20.0 + 16.0) + 16.0 * 8.0);
2028        let got = solid_signed_volume(&pushed).unwrap().abs();
2029        assert!((got - expected).abs() < 1e-2, "vol {got}, want {expected}");
2030
2031        // Volume sign preserved (no inversion).
2032        assert!(
2033            before_vol * solid_signed_volume(&pushed).unwrap() > 0.0,
2034            "volume sign flipped"
2035        );
2036
2037        // The cylinder grew to r=4, and the shared rim slid to z=2 / r=4.
2038        let cyl_face = pushed
2039            .shells
2040            .iter()
2041            .flat_map(|s| &s.faces)
2042            .find(|f| f.id == cyl)
2043            .unwrap();
2044        match cyl_face.surface.analytic() {
2045            Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
2046                assert!((rho0 - 4.0).abs() < 1e-6 && (rho1 - 4.0).abs() < 1e-6, "cyl r {rho0}");
2047            }
2048            other => panic!("pushed face must stay ruled, got {other:?}"),
2049        }
2050        // The shared rim (a full circle joining cone+cylinder) is now at z=2.
2051        let shared = pushed
2052            .edges
2053            .iter()
2054            .find(|e| {
2055                let m = e.curve.evaluate(0.5 * (e.t0 + e.t1)).unwrap();
2056                (radial(m, Vec3::default(), axis) - 4.0).abs() < 1e-6 && (m.z - 2.0).abs() < 1e-6
2057            })
2058            .expect("relocated shared rim at z=2, r=4");
2059        assert!(shared.start_vertex_id == shared.end_vertex_id, "shared rim is a closed circle");
2060    }
2061
2062    // A NON-coaxial ruled neighbour: a vertical pipe crossed by a horizontal
2063    // one. The pair has no closed form, so the rim is a genuine quartic and the
2064    // window it cuts is two open arcs — the class the audit's §2.3 puts in the
2065    // "push-face refuses, offset-shell handles" column. It is the headline
2066    // conversion of this slice: the shared re-intersection marches the pair and
2067    // the window is rebuilt from ONE closed section.
2068    #[test]
2069    fn push_cylinder_side_against_a_crossing_pipe_matches_the_rebuilt_tee() {
2070        let vertical =
2071            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
2072        let horizontal =
2073            make_cylinder_brep(Vec3::new(-6.0, 0.0, 5.0), Vec3::new(1.0, 0.0, 0.0), 1.5, 12.0)
2074                .unwrap();
2075        let options = crate::BooleanOptions {
2076            merge_coplanar_faces: true,
2077            ..crate::BooleanOptions::default()
2078        };
2079        let solid = crate::boolean_operation(
2080            &vertical,
2081            &horizontal,
2082            crate::BooleanOperation::Union,
2083            &options,
2084        )
2085        .unwrap();
2086        assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
2087        // The vertical wall (axis z) borders the horizontal wall (axis x).
2088        let wall = cylinder_side_id(&solid, Vec3::new(0.0, 0.0, 1.0));
2089        // CONVERTED. This was `push_cylinder_side_against_noncoaxial_ruled_refuses`
2090        // — "there is no closed-form coaxial re-intersection", which was true and
2091        // beside the point: the shared re-intersection MARCHES the pair, and the
2092        // window it cuts is one closed section stored as two arcs whose corners
2093        // are pinned to the crossing pipe's own seam. The oracle is the same tee
2094        // built at the pushed radius.
2095        let pushed = offset_ruled_face(&solid, wall, 1.0)
2096            .unwrap_or_else(|error| panic!("push a wall against a crossing pipe: {error}"));
2097        let issues = pushed.validate();
2098        assert!(issues.is_empty(), "validate: {issues:?}");
2099
2100        let oracle = {
2101            let grown =
2102                make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 10.0).unwrap();
2103            crate::boolean_operation(
2104                &grown,
2105                &horizontal,
2106                crate::BooleanOperation::Union,
2107                &options,
2108            )
2109            .unwrap()
2110        };
2111        let got = solid_signed_volume(&pushed).unwrap().abs();
2112        let want = solid_signed_volume(&oracle).unwrap().abs();
2113        // Both sides are MARCHED-and-fitted quartic rims — the push's and the
2114        // boolean's — so this compares two approximations of the same curve, not
2115        // an approximation against a closed form. Measured 2.6e-6 relative; the
2116        // bound is well above that and far below any real error.
2117        assert!(
2118            (got - want).abs() <= 1e-4 * want,
2119            "pushed volume {got} against the independently built {want} (relative {:.3e})",
2120            (got - want).abs() / want
2121        );
2122        assert_eq!(
2123            (
2124                pushed.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2125                pushed.edges.len(),
2126                pushed.vertices.len()
2127            ),
2128            (
2129                oracle.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2130                oracle.edges.len(),
2131                oracle.vertices.len()
2132            ),
2133            "the push must reproduce the rebuilt tee's topology"
2134        );
2135        // The wall really is at the new radius, and the crossing pipe kept its
2136        // own radius — only its trim moved.
2137        let radii: Vec<f64> = pushed
2138            .shells
2139            .iter()
2140            .flat_map(|shell| &shell.faces)
2141            .filter_map(|face| match face.surface.analytic() {
2142                Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
2143                    if (rho0 - rho1).abs() < 1e-9 =>
2144                {
2145                    Some(*rho0)
2146                }
2147                _ => None,
2148            })
2149            .collect();
2150        assert!(
2151            radii.iter().any(|r| (r - 4.0).abs() < 1e-9),
2152            "the pushed wall must be at radius 4, got {radii:?}"
2153        );
2154        assert_eq!(
2155            radii.iter().filter(|r| (**r - 1.5).abs() < 1e-9).count(),
2156            2,
2157            "both stubs of the crossing pipe keep radius 1.5, got {radii:?}"
2158        );
2159        // Every rebuilt window arc sits on BOTH carriers — the honest statement
2160        // of what a marched rim has to satisfy, and the thing a fit can lose.
2161        let mut window_samples = 0usize;
2162        for edge in &pushed.edges {
2163            for step in 0..=8 {
2164                let point = edge
2165                    .curve
2166                    .evaluate(edge.t0 + (edge.t1 - edge.t0) * step as f64 / 8.0)
2167                    .unwrap();
2168                let on_wall = ((point.x * point.x + point.y * point.y).sqrt() - 4.0).abs();
2169                let on_pipe = ((point.y * point.y + (point.z - 5.0) * (point.z - 5.0)).sqrt()
2170                    - 1.5)
2171                    .abs();
2172                if on_wall < 1e-3 && on_pipe < 1e-3 {
2173                    window_samples += 1;
2174                    assert!(
2175                        on_wall < 1e-5 && on_pipe < 1e-5,
2176                        "a window arc drifts off its carriers: {on_wall:.3e} / {on_pipe:.3e}"
2177                    );
2178                }
2179            }
2180        }
2181        assert!(
2182            window_samples >= 18,
2183            "the two window arcs must be present and sampled, got {window_samples}"
2184        );
2185    }
2186
2187    /// The SAME pair, with the window straddling the pushed carrier's periodic
2188    /// seam: the arrangement stitches its arcs into the OUTER loop, so the loop
2189    /// is no longer a single-neighbour ring and the window cannot be rebuilt as
2190    /// one section. It must refuse — and the refusal now names the arc, not
2191    /// coaxiality, because coaxiality stopped being the blocker.
2192    #[test]
2193    fn a_window_straddling_the_pushed_seam_still_refuses() {
2194        let axis = Vec3::new(0.0, 0.0, 1.0);
2195        let vertical = make_cylinder_brep(Vec3::default(), axis, 3.0, 10.0).unwrap();
2196        // Along Y, where `make_cylinder_brep` puts the seam.
2197        let crossing =
2198            make_cylinder_brep(Vec3::new(0.0, -6.0, 5.0), Vec3::new(0.0, 1.0, 0.0), 1.5, 12.0)
2199                .unwrap();
2200        let options = crate::BooleanOptions {
2201            merge_coplanar_faces: true,
2202            ..crate::BooleanOptions::default()
2203        };
2204        let solid = crate::boolean_operation(
2205            &vertical,
2206            &crossing,
2207            crate::BooleanOperation::Union,
2208            &options,
2209        )
2210        .unwrap();
2211        assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
2212        let wall = cylinder_side_id(&solid, axis);
2213        let error = offset_ruled_face(&solid, wall, 1.0)
2214            .expect_err("a seam-straddling window must refuse");
2215        assert!(
2216            error.contains("OPEN arc") || error.contains("seam"),
2217            "unexpected refusal: {error}"
2218        );
2219        assert!(solid.validate().is_empty(), "the source is never mutated");
2220    }
2221
2222    // Pushing the same boss INWARD drives the shared rim OFF the cone's domain
2223    // (a harder sub-case, deferred): it must refuse cleanly, not tear.
2224    #[test]
2225    fn push_cylinder_side_coaxial_inward_refuses_cleanly() {
2226        let axis = Vec3::new(0.0, 0.0, 1.0);
2227        let solid = coaxial_cone_cylinder_boss();
2228        let cyl = cylinder_side_id(&solid, axis);
2229        // r 3 → 2 puts the cone-crossing at z=6, past the cone patch [0,4].
2230        let result = offset_ruled_face(&solid, cyl, -1.0);
2231        match result {
2232            Err(_) => {}
2233            Ok(good) => assert!(
2234                good.validate().is_empty(),
2235                "if inward is accepted it must still be valid: {:?}",
2236                good.validate()
2237            ),
2238        }
2239    }
2240
2241    // ---------------------------------------------------------------------
2242    // The CURVED-neighbour lane (offset-unification: the generic neighbour
2243    // re-intersection). Each of these was a blanket refusal — "a boundary
2244    // neighbour is not planar (sphere/torus/free-form neighbours are deferred
2245    // in this slice)" — for a pair the kernel's own closed forms already solve
2246    // exactly. The oracle is not a hand-derived volume but the SAME FEATURE
2247    // BUILT AT THE PUSHED SIZE: pushing a wall from r to r+d must land on the
2248    // solid you get by building it at r+d from scratch.
2249    // ---------------------------------------------------------------------
2250
2251    /// A rod capped by a ball (a dome on a shaft): the wall's top rim neighbour
2252    /// is a SPHERE. Pushing the wall out slides the rim UP the dome, and the
2253    /// dome's own seam meridian re-trims to the new latitude on the curve it
2254    /// already carries.
2255    #[test]
2256    fn push_cylinder_wall_against_a_spherical_dome_matches_the_rebuilt_solid() {
2257        let ball_capped = |radius: f64| {
2258            let rod =
2259                make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), radius, 10.0)
2260                    .unwrap();
2261            let ball =
2262                crate::make_sphere_brep(Vec3::new(0.0, 0.0, 10.0), 4.0, Vec3::new(0.0, 0.0, 1.0))
2263                    .unwrap();
2264            crate::boolean_operation(
2265                &rod,
2266                &ball,
2267                crate::BooleanOperation::Union,
2268                &crate::BooleanOptions {
2269                    merge_coplanar_faces: true,
2270                    ..crate::BooleanOptions::default()
2271                },
2272            )
2273            .unwrap()
2274        };
2275        let source = ball_capped(3.0);
2276        let wall = cylinder_side_id(&source, Vec3::new(0.0, 0.0, 1.0));
2277        let pushed = offset_ruled_face(&source, wall, 0.5)
2278            .unwrap_or_else(|error| panic!("push a wall against a dome: {error}"));
2279        let issues = pushed.validate();
2280        assert!(issues.is_empty(), "validate: {issues:?}");
2281
2282        let oracle = ball_capped(3.5);
2283        let got = solid_signed_volume(&pushed).unwrap().abs();
2284        let want = solid_signed_volume(&oracle).unwrap().abs();
2285        // Every step of this push is exact — the offset carrier is an exact
2286        // cylinder, the rim is the closed-form coaxial section, and the dome's
2287        // seam re-trims by a parameter on its own curve — so the tolerance is
2288        // rounding, not a fit budget.
2289        assert!(
2290            (got - want).abs() <= 1e-9 * want,
2291            "pushed volume {got} against the independently built {want}"
2292        );
2293        assert_eq!(
2294            (
2295                pushed.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2296                pushed.edges.len(),
2297                pushed.vertices.len()
2298            ),
2299            (
2300                oracle.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2301                oracle.edges.len(),
2302                oracle.vertices.len()
2303            ),
2304            "the push must reproduce the rebuilt solid's topology, not merely its volume"
2305        );
2306    }
2307
2308    /// A shaft with a turned circumferential groove: the wall band's rim
2309    /// neighbour is a TORUS. The rim rides the tube to a new station and the
2310    /// torus's u-seam re-trims to it.
2311    #[test]
2312    fn push_cylinder_wall_against_a_toroidal_groove_moves_the_rim_onto_the_tube() {
2313        let axis = Vec3::new(0.0, 0.0, 1.0);
2314        let shaft = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
2315        let groove = crate::make_torus_brep(Vec3::new(0.0, 0.0, 5.0), axis, 5.0, 1.5).unwrap();
2316        let grooved = crate::boolean_operation(
2317            &shaft,
2318            &groove,
2319            crate::BooleanOperation::Subtract,
2320            &crate::BooleanOptions {
2321                merge_coplanar_faces: true,
2322                ..crate::BooleanOptions::default()
2323            },
2324        )
2325        .unwrap();
2326        assert!(grooved.validate().is_empty(), "fixture: {:?}", grooved.validate());
2327        let before = solid_signed_volume(&grooved).unwrap().abs();
2328        let band = cylinder_side_id(&grooved, axis);
2329        let pushed = offset_ruled_face(&grooved, band, 0.5)
2330            .unwrap_or_else(|error| panic!("push a wall band against a groove: {error}"));
2331        let issues = pushed.validate();
2332        assert!(issues.is_empty(), "validate: {issues:?}");
2333
2334        // The pushed band is the UPPER one (z ∈ [6.5, 10] before the push). Its
2335        // radius becomes 5.5, so its rim slides DOWN the tube to where the
2336        // r = 5.5 cylinder meets the torus: (5.5−5)² + (z−5)² = 1.5², i.e.
2337        // z = 5 + √2. That station is the exact statement of what the
2338        // re-intersection had to compute, so it is asserted directly rather
2339        // than through a volume the groove profile makes fiddly.
2340        let rim_z = 5.0 + 2.0_f64.sqrt();
2341        let after = solid_signed_volume(&pushed).unwrap().abs();
2342        assert!(after > before, "an outward push must grow the solid");
2343        let rim = pushed
2344            .edges
2345            .iter()
2346            .find(|edge| {
2347                let Ok(mid) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
2348                    return false;
2349                };
2350                (mid.z - rim_z).abs() < 1e-9
2351                    && ((mid.x * mid.x + mid.y * mid.y).sqrt() - 5.5).abs() < 1e-9
2352            })
2353            .expect("a rebuilt rim circle of radius 5.5 at z = 5 + √2");
2354        for step in 0..=8 {
2355            let point = rim
2356                .curve
2357                .evaluate(rim.t0 + (rim.t1 - rim.t0) * step as f64 / 8.0)
2358                .unwrap();
2359            assert!(
2360                ((point.x * point.x + point.y * point.y).sqrt() - 5.5).abs() < 1e-9
2361                    && (point.z - rim_z).abs() < 1e-9,
2362                "the rim must be the exact circle, not a fitted approximation: {point:?}"
2363            );
2364        }
2365        // The pushed band really is at the new radius, and the torus kept its
2366        // own geometry (only its trim moved).
2367        let radii: Vec<f64> = pushed
2368            .shells
2369            .iter()
2370            .flat_map(|shell| &shell.faces)
2371            .filter_map(|face| match face.surface.analytic() {
2372                Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
2373                    if (rho0 - rho1).abs() < 1e-9 =>
2374                {
2375                    Some(*rho0)
2376                }
2377                _ => None,
2378            })
2379            .collect();
2380        assert!(
2381            radii.iter().any(|r| (r - 5.5).abs() < 1e-9)
2382                && radii.iter().any(|r| (r - 5.0).abs() < 1e-9),
2383            "expected one band at 5.5 and one still at 5.0, got {radii:?}"
2384        );
2385        assert!(
2386            pushed
2387                .shells
2388                .iter()
2389                .flat_map(|shell| &shell.faces)
2390                .any(|face| matches!(
2391                    face.surface.analytic(),
2392                    Some(AnalyticSurface::Torus { major_radius, minor_radius, .. })
2393                        if (major_radius - 5.0).abs() < 1e-12
2394                            && (minor_radius - 1.5).abs() < 1e-12
2395                )),
2396            "the groove's torus must be untouched — only its trim moved"
2397        );
2398    }
2399
2400    /// A curved neighbour whose rim the push SEPARATES must refuse, not emit a
2401    /// solid with a stranded boundary. Pushing the dome's rod INWARD far enough
2402    /// takes the wall out of the ball entirely.
2403    #[test]
2404    fn push_that_pulls_a_wall_out_of_its_curved_neighbour_refuses() {
2405        let rod =
2406            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
2407        let ball =
2408            crate::make_sphere_brep(Vec3::new(0.0, 0.0, 10.0), 4.0, Vec3::new(0.0, 0.0, 1.0))
2409                .unwrap();
2410        let capped = crate::boolean_operation(
2411            &rod,
2412            &ball,
2413            crate::BooleanOperation::Union,
2414            &crate::BooleanOptions {
2415                merge_coplanar_faces: true,
2416                ..crate::BooleanOptions::default()
2417            },
2418        )
2419        .unwrap();
2420        let wall = cylinder_side_id(&capped, Vec3::new(0.0, 0.0, 1.0));
2421        // r 3 → 3.9 keeps a rim; r 3 → 4.5 puts the wall outside the ball, so
2422        // the two carriers no longer meet at all.
2423        let error = offset_ruled_face(&capped, wall, 1.5)
2424            .expect_err("a wall pushed clear of its dome must refuse");
2425        assert!(
2426            error.contains("no longer meets") || error.contains("refus"),
2427            "unexpected refusal: {error}"
2428        );
2429        assert!(capped.validate().is_empty(), "the source is never mutated");
2430    }
2431}