Skip to main content

brep_kernel/edit/direct_edit/
face_offset_revolution.rs

1use super::*;
2
3/// Push a general surface of revolution with planar neighbours.
4///
5/// The meridian is densely offset and interpolated, then revolved through the
6/// original full or partial sweep. A dense normal-distance residual check
7/// bounds that approximation. Endpoint rims and start/end meridians are rebuilt,
8/// and planar axial/radial neighbours are re-trimmed around the new boundary.
9/// The offset meridian runs between the source parameters at which it reaches
10/// each rim's cap plane, so a rim is the offset carrier's intersection with its
11/// cap rather than the offset of the old rim (see
12/// [`offset_general_revolution_face`]).
13///
14/// A PARTIAL CYLINDER (an extruded sketch arc, a fillet band between planes)
15/// does not take the general lane: its meridian edges usually meet planes that
16/// are parallel to the axis but do not contain it, where keeping a corner at
17/// its old azimuth is wrong. [`offset_partial_cylinder_face`] re-intersects
18/// those corners exactly first, and only a face outside its scope falls
19/// through to the general lane below.
20///
21/// A boundary whose pcurve is NEITHER a constant-u nor a constant-v line is no
22/// longer refused outright: its 3D image on the offset carrier comes from
23/// [`crate::image_curve`]. That transfer is only valid where the boundary is
24/// definitional, so a general image — or a constant-u meridian — against a
25/// FIXED planar neighbour is measured against that neighbour's plane and
26/// refused, with the number, when it leaves it, because the honest answer
27/// there is a re-intersection this site does not do.
28pub fn offset_revolution_face(
29    solid: &BrepSolid,
30    face_id: u64,
31    distance: f64,
32) -> Result<BrepSolid, String> {
33    if !distance.is_finite() {
34        return Err("offset_revolution_face: distance must be finite".into());
35    }
36    let scale = solid_model_scale(solid);
37    let tolerance = (scale * 1e-7).max(1e-9);
38    let residual_tolerance = (scale * 1e-4).max(1e-6);
39    // Bar for the general 3D-image ladder: a CURVE-fit accuracy target, which
40    // is a different question from `residual_tolerance`'s bound on the offset
41    // SURFACE, so it takes the kernel's single named fit-accuracy field.
42    let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
43    let (shell_index, face_index) = find_face(solid, face_id)
44        .ok_or_else(|| format!("offset_revolution_face: no face {face_id}"))?;
45    let face = &solid.shells[shell_index].faces[face_index];
46    if !matches!(face.surface.analytic(), Some(AnalyticSurface::Revolution { .. })) {
47        return Err("offset_revolution_face: the pushed face is not a general revolution".into());
48    }
49    if let Some(result) = offset_partial_cylinder_face(
50        solid,
51        face_id,
52        distance,
53        tolerance,
54        residual_tolerance,
55        scale,
56    )? {
57        return Ok(result);
58    }
59    let trace = BoundaryTrace::open(solid, face_id, distance);
60    let outcome = offset_general_revolution_face(
61        solid,
62        face_id,
63        distance,
64        tolerance,
65        residual_tolerance,
66        fit_tolerance,
67        scale,
68        &trace,
69    );
70    trace.outcome(&outcome);
71    outcome
72}
73
74/// The general lane of [`offset_revolution_face`]: every face the partial
75/// cylinder lane does not take.
76///
77/// Its constant-v RIMS are solved, not transferred. A rim is the circle a
78/// point of the generatrix sweeps, and against a FIXED cap the new rim is the
79/// offset carrier's intersection with that cap's plane. The offset of the
80/// source rim's own generatrix point is not in that plane wherever the
81/// generatrix meets the cap at an angle — the offset normal there has an axial
82/// component `d·n_axis` — and rebuilding the rim at that point left the lib
83/// barrel's rims 8.602e-2 and 6.155e-2 off their caps at d = 0.15 and a solid
84/// that validated 1.757 above the exact offset's volume. So each rim's source
85/// parameter is solved where the offset meridian reaches its cap's plane
86/// ([`solve_rim_span`]), the offset generatrix is sampled between those two
87/// parameters — through the source carrier's natural extension where a solved
88/// parameter leaves its domain — and every rebuilt rim is measured against its
89/// plane and refused, with the number, past the pcurve floor.
90#[allow(clippy::too_many_arguments)]
91fn offset_general_revolution_face(
92    solid: &BrepSolid,
93    face_id: u64,
94    distance: f64,
95    tolerance: f64,
96    residual_tolerance: f64,
97    fit_tolerance: f64,
98    scale: f64,
99    trace: &BoundaryTrace,
100) -> Result<BrepSolid, String> {
101    let (shell_index, face_index) = find_face(solid, face_id)
102        .ok_or_else(|| format!("offset_revolution_face: no face {face_id}"))?;
103    let face = &solid.shells[shell_index].faces[face_index];
104    let Some(AnalyticSurface::Revolution { sweep, .. }) = face.surface.analytic() else {
105        return Err("offset_revolution_face: the pushed face is not a general revolution".into());
106    };
107    let source_structure = crate::revolution_structure(&face.surface).ok_or_else(|| {
108        "offset_revolution_face: source carrier lost its revolution structure".to_string()
109    })?;
110    let [u0, u1] = face.surface.domain_u()?;
111    let [v0, v1] = face.surface.domain_v()?;
112    if (v0.abs() + (v1 - 1.0).abs()) > 1e-9 {
113        return Err(
114            "offset_revolution_face: a non-normalized generatrix domain is deferred".into(),
115        );
116    }
117    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
118    for shell in &solid.shells {
119        for candidate in &shell.faces {
120            for loop_record in &candidate.loops {
121                for coedge in &loop_record.coedges {
122                    faces_of_edge
123                        .entry(coedge.edge_id)
124                        .or_default()
125                        .push(candidate.id);
126                }
127            }
128        }
129    }
130    let edge_by_id: HashMap<u64, &EdgeRecord> =
131        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
132    // Offset the meridian densely, then revolve it. A general curve offset is
133    // not exactly rational; using 33 cubic interpolation stations materially
134    // lowers the residual compared with fitting the whole surface back onto its
135    // original sparse control net, while retaining the [0,1] pcurve contract.
136    let u_probe = u0 + (u1 - u0) * 0.137;
137    let span = solve_rim_span(
138        solid,
139        face,
140        face_id,
141        &faces_of_edge,
142        &source_structure.frame,
143        u_probe,
144        distance,
145        residual_tolerance,
146    )?;
147    let source_surface = span.surface.as_ref().unwrap_or(&face.surface);
148    // The dense meridian sample is a pointwise offset — the shared evaluator's
149    // `Face` lane, whose distance is signed ALONG the oriented normal exactly
150    // like direct-edit's own convention (audit §4.1: this side of the kernel
151    // grows the solid on a positive distance).
152    let offsets = OffsetEvaluator::new(
153        "push_revolution",
154        source_surface,
155        OffsetNormal::Face {
156            same_sense: face.same_sense,
157        },
158    );
159    // The carrier's generatrix parameter `w` runs over [0, 1] while the source
160    // parameter it offsets runs over the solved span.
161    let [s0, s1] = span.new;
162    let source_parameter = |w: f64| s0 + (s1 - s0) * w;
163    let mut meridian_points = Vec::with_capacity(33);
164    let mut meridian_parameters = Vec::with_capacity(33);
165    for index in 0..=32 {
166        let parameter = index as f64 / 32.0;
167        let v = source_parameter(v0 + (v1 - v0) * parameter);
168        let target_point = offsets.at(u_probe, v, distance)?.point;
169        let relative = target_point.sub(source_structure.frame.origin);
170        let axial = relative.dot(source_structure.frame.axis);
171        let radial = relative
172            .sub(source_structure.frame.axis.scale(axial))
173            .length();
174        meridian_points.push(
175            source_structure
176                .frame
177                .origin
178                .add(source_structure.frame.x_axis.scale(radial))
179                .add(source_structure.frame.axis.scale(axial)),
180        );
181        meridian_parameters.push(parameter);
182    }
183    let offset_generatrix = crate::interpolate_curve(&meridian_points, 3, &meridian_parameters)?;
184    let offset = crate::make_revolution(
185        source_structure.frame.origin,
186        source_structure.frame.axis,
187        &offset_generatrix,
188        *sweep,
189    )?;
190    let structure = crate::revolution_structure(&offset).ok_or_else(|| {
191        "offset_revolution_face: offset carrier lost its revolution structure — refusing"
192            .to_string()
193    })?;
194    if !matches!(offset.analytic(), Some(AnalyticSurface::Revolution { .. })) {
195        return Err(
196            "offset_revolution_face: offset carrier changed analytic kind — refusing".into(),
197        );
198    }
199
200    // A general interpolated offset is approximate. Admit it only when its
201    // same-parameter displacement stays normal and at the requested distance.
202    let target = distance.abs();
203    let (mut worst_distance, mut worst_normal) = (0.0f64, 0.0f64);
204    // Stagger away from repeated knots: derivatives at an exact full-period
205    // seam or a multiple internal knot are side-dependent and may be zero in
206    // the generic evaluator even though the carrier is regular there.
207    for iu in 0..13 {
208        for iv in 0..13 {
209            let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 13.0;
210            let w = v0 + (v1 - v0) * (iv as f64 + 0.41) / 13.0;
211            let v = source_parameter(w);
212            let source_point = source_surface.evaluate(u, v)?;
213            let offset_point = offset.evaluate(u, w)?;
214            let delta = offset_point.sub(source_point);
215            let normal = offsets
216                .normal(u, v)
217                .map_err(|error| format!("offset_revolution_face: sample normal: {error}"))?;
218            let distance_error = (delta.length() - target).abs();
219            let normal_error = if delta.length() > tolerance {
220                1.0 - delta
221                    .normalized()
222                    .map_err(|error| format!("offset_revolution_face: sample delta: {error}"))?
223                    .dot(normal)
224                    .abs()
225            } else {
226                0.0
227            };
228            if distance_error > residual_tolerance || normal_error > 5e-4 {
229                return Err(format!(
230                    "offset_revolution_face: offset fit exceeds tolerance \
231                     (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
232                ));
233            }
234            worst_distance = worst_distance.max(distance_error);
235            worst_normal = worst_normal.max(normal_error);
236        }
237    }
238    trace.write(format!(
239        "carrier\trims_old={:?}\trims_new={:?}\textended={}\t\
240         fit_distance_error={worst_distance:.3e}\tfit_normal_error={worst_normal:.3e}",
241        span.old,
242        span.new,
243        span.surface.is_some()
244    ));
245
246    // The offset generatrix endpoints revolve into the only two possible rims:
247    // the low rim at w = 0, the high rim at w = 1.
248    let [g0, g1] = structure.generatrix.domain()?;
249    let mut rim_candidates = Vec::new();
250    for parameter in [g0, g1] {
251        let point = structure.generatrix.evaluate(parameter)?;
252        let relative = point.sub(structure.frame.origin);
253        let axial = relative.dot(structure.frame.axis);
254        let radial = relative.sub(structure.frame.axis.scale(axial));
255        let radius = radial.length();
256        if radius <= tolerance {
257            return Err(format!(
258                "offset_revolution_face: an offset rim collapses onto the axis \
259                 (point {point:?}, frame {:?}) — refusing",
260                structure.frame
261            ));
262        }
263        rim_candidates.push(crate::make_arc(
264            structure
265                .frame
266                .origin
267                .add(structure.frame.axis.scale(axial)),
268            structure.frame.x_axis,
269            structure.frame.y_axis,
270            radius,
271            0.0,
272            structure.sweep,
273        )?);
274    }
275
276    // Each boundary's pcurve on the new carrier. A rim that is not at a
277    // generatrix end — a face trimmed across its carrier — moves to w = 0 or 1,
278    // so the ISO boundaries are carried by the affine map that takes the two
279    // rims' old parameters there. A GENERAL boundary is a transfer of the old
280    // one, so it keeps its source parameter: w = (s − s₀)/(s₁ − s₀).
281    let uv_tolerance = 1e-7;
282    let pcurves = face
283        .loops
284        .iter()
285        .map(|loop_record| {
286            loop_record
287                .coedges
288                .iter()
289                .map(|coedge| match iso_kind(&coedge.pcurve, uv_tolerance)? {
290                    Some(_) => span.carry_pcurve(&coedge.pcurve),
291                    None => span.transfer_pcurve(&coedge.pcurve),
292                })
293                .collect::<Result<Vec<_>, String>>()
294        })
295        .collect::<Result<Vec<_>, String>>()?;
296
297    let floor = crate::pcurve::PCURVE_REFINEMENT_TOLERANCE;
298    let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
299    let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
300    let mut planar_neighbours: HashSet<u64> = HashSet::default();
301    for (loop_record, loop_pcurves) in face.loops.iter().zip(&pcurves) {
302        for (coedge, pcurve) in loop_record.coedges.iter().zip(loop_pcurves) {
303            if new_curves.contains_key(&coedge.edge_id) {
304                continue;
305            }
306            let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
307                format!("offset_revolution_face: missing edge {}", coedge.edge_id)
308            })?;
309            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
310            let neighbour = incident.iter().find(|candidate| **candidate != face_id);
311            // Kept for the gates below: an edge shared with a FIXED planar
312            // cap has to stay in that cap's plane, and only this loop knows
313            // which plane that is.
314            let mut neighbour_plane = None;
315            if let Some(neighbour) = neighbour {
316                let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
317                    format!("offset_revolution_face: missing neighbour {neighbour}")
318                })?;
319                match plane_of_surface(
320                    &solid.shells[ns].faces[nf].surface,
321                    residual_tolerance,
322                    "offset_revolution_face",
323                ) {
324                    Err(_) => {
325                        return Err(
326                            "offset_revolution_face: only planar neighbours are supported \
327                                for a general revolution — refusing"
328                                .into(),
329                        );
330                    }
331                    Ok(plane) => neighbour_plane = Some(plane),
332                }
333                planar_neighbours.insert(*neighbour);
334            }
335
336            // Constant-v pcurve → a revolved endpoint rim. Constant-u → a
337            // start/end meridian (or the intrinsic full-sweep seam).
338            let mut general_image = false;
339            let mut meridian = false;
340            let mut curve = match iso_kind(&coedge.pcurve, uv_tolerance)? {
341                Some(IsoKind::ConstantV) => {
342                    let [q0, _] = pcurve.domain()?;
343                    let w = pcurve.evaluate(q0)?.y;
344                    if w.abs() <= uv_tolerance {
345                        rim_candidates[0].clone()
346                    } else if (w - 1.0).abs() <= uv_tolerance {
347                        rim_candidates[1].clone()
348                    } else {
349                        return Err(format!(
350                            "offset_revolution_face: constant-v boundary edge {} sits at v = {w} \
351                             between the face's two rims — refusing",
352                            edge.id
353                        ));
354                    }
355                }
356                Some(IsoKind::ConstantU(u)) => {
357                    meridian = true;
358                    offset.iso_curve_u(u)?
359                }
360                None => {
361                    // A GENERAL boundary pcurve — what this site used to refuse.
362                    // Its image on the offset carrier is built by the shared
363                    // ladder, oriented off `coedge.forward` (the authoritative
364                    // pcurve↔edge relation) rather than the iso lanes' geometric
365                    // nearest-endpoint match, because a general image need not
366                    // share an endpoint with the old curve at all.
367                    general_image = true;
368                    let image = crate::image_curve(
369                        &offset,
370                        pcurve,
371                        fit_tolerance,
372                        "offset_revolution_face",
373                    )?;
374                    if coedge.forward {
375                        image.curve
376                    } else {
377                        image.curve.reversed()?
378                    }
379                }
380            };
381            let kind = if general_image {
382                "general"
383            } else if meridian {
384                "meridian"
385            } else {
386                "rim"
387            };
388            trace.edge(kind, edge, neighbour, neighbour_plane.as_ref(), &curve);
389            // Every rebuilt boundary is measured against the FIXED planar
390            // neighbour it must lie in; the validator's `pcurve_acceptance`
391            // band (2.5% of the model diagonal) is far too loose to catch one
392            // that leaves it.
393            //
394            // A RIM is built in its cap's plane by `solve_rim_span`, so past
395            // the pcurve floor it is a cap this lane cannot meet — one not
396            // normal to the axis, or two coplanar caps of one rim that
397            // disagree — and it is refused with the number.
398            //
399            // A general image, or a constant-u MERIDIAN, is a transfer, which
400            // is only legitimate where the boundary is DEFINITIONAL. Keeping a
401            // meridian's azimuth is exact only when the neighbour plane contains
402            // the axis (a partial revolve's radial end face); a plane beside the
403            // axis — the straight sketch segment next to an extruded arc —
404            // needs the corner re-intersected, and the kept azimuth left it
405            // 0.33 off that plane on a reported extrude, where re-trimming the
406            // neighbour around it tilted the wall and still validated.
407            if let Some(plane) = &neighbour_plane {
408                let [g0, g1] = curve.domain()?;
409                let mut worst = 0.0f64;
410                for index in 0..=32 {
411                    let t = g0 + (g1 - g0) * index as f64 / 32.0;
412                    let point = curve.evaluate(t)?;
413                    worst = worst.max(point.sub(plane.origin).dot(plane.normal).abs());
414                }
415                if !(general_image || meridian) && !(worst <= floor) {
416                    return Err(format!(
417                        "offset_revolution_face: the rebuilt rim of boundary edge {} leaves the \
418                         plane of neighbour face {} by {worst:.3e} (pcurve floor {floor:.0e}) — \
419                         refusing",
420                        edge.id,
421                        neighbour.copied().unwrap_or_default()
422                    ));
423                }
424                if (general_image || meridian) && worst > residual_tolerance {
425                    let lane = if meridian {
426                        "meridian at its old azimuth"
427                    } else {
428                        "general image"
429                    };
430                    return Err(format!(
431                        "offset_revolution_face: the {lane} of boundary edge {} \
432                         leaves its fixed planar neighbour by {worst:.3e} (limit \
433                         {residual_tolerance:.3e}) — re-intersecting the offset carrier \
434                         with a fixed neighbour is a separate capability; refusing",
435                        edge.id
436                    ));
437                }
438            }
439            if !general_image {
440                let [c0, c1] = curve.domain()?;
441                let old_start = edge.curve.evaluate(edge.t0)?;
442                if curve.evaluate(c0)?.sub(old_start).length()
443                    > curve.evaluate(c1)?.sub(old_start).length()
444                {
445                    curve = curve.reversed()?;
446                }
447            }
448            // A corner is rebuilt by each boundary edge that ends on it, and
449            // those must agree: a rim and a meridian meeting at one vertex, or
450            // two rim arcs of one split circle. One that does not is a rim this
451            // lane built across a vertex it does not end at.
452            let [d0, d1] = curve.domain()?;
453            let ends = [
454                (edge.start_vertex_id, curve.evaluate(d0)?),
455                (edge.end_vertex_id, curve.evaluate(d1)?),
456            ];
457            for (vertex_id, point) in ends {
458                if let Some(previous) = new_vertices.insert(vertex_id, point) {
459                    let gap = previous.sub(point).length();
460                    if !(gap <= floor) {
461                        return Err(format!(
462                            "offset_revolution_face: boundary edge {} rebuilds vertex {vertex_id} \
463                             {gap:.3e} from where another boundary edge put it (pcurve floor \
464                             {floor:.0e}) — refusing",
465                            edge.id
466                        ));
467                    }
468                }
469            }
470            new_curves.insert(edge.id, curve);
471        }
472    }
473
474    let mut result = solid.clone();
475    let pushed = &mut result.shells[shell_index].faces[face_index];
476    if span.old != [0.0, 1.0] || span.new != [0.0, 1.0] {
477        for (loop_record, loop_pcurves) in pushed.loops.iter_mut().zip(pcurves) {
478            for (coedge, pcurve) in loop_record.coedges.iter_mut().zip(loop_pcurves) {
479                coedge.pcurve = pcurve;
480            }
481        }
482    }
483    pushed.surface = offset;
484    retrim_planar_neighbours(
485        solid,
486        result,
487        &edge_by_id,
488        &new_curves,
489        &new_vertices,
490        planar_neighbours,
491        residual_tolerance,
492        scale,
493    )
494}
495
496/// Where the offset carrier's generatrix starts and ends.
497struct RimSpan {
498    /// The face's low and high rim parameters on the SOURCE generatrix — the
499    /// domain end where the face has no rim on that side.
500    old: [f64; 2],
501    /// The source parameters whose offset meridian points lie in those rims'
502    /// cap planes: the new carrier's w = 0 and w = 1.
503    new: [f64; 2],
504    /// The source carrier extended past its domain by its own terminal span
505    /// ([`NurbsSurface::extend_natural`]), when a solved parameter leaves it.
506    surface: Option<NurbsSurface>,
507}
508
509impl RimSpan {
510    /// An iso boundary's pcurve on the new carrier: `v ↦ (v − old₀)/(old₁ −
511    /// old₀)`, which puts the two rims at w = 0 and 1.
512    fn carry_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, String> {
513        Self::map_v(pcurve, self.old)
514    }
515
516    /// A general boundary's pcurve on the new carrier: `v ↦ (v − new₀)/(new₁ −
517    /// new₀)`, the same source parameter and so the same offset point.
518    fn transfer_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, String> {
519        Self::map_v(pcurve, self.new)
520    }
521
522    /// `pcurve` with `v ↦ (v − a₀)/(a₁ − a₀)` applied to its control net (an
523    /// affine map, so exact), or the pcurve itself when that is the identity.
524    fn map_v(pcurve: &NurbsCurve, [a0, a1]: [f64; 2]) -> Result<NurbsCurve, String> {
525        if [a0, a1] == [0.0, 1.0] {
526            return Ok(pcurve.clone());
527        }
528        let mut carried = pcurve.clone();
529        for control in &mut carried.control_points {
530            let point = control.point()?;
531            *control = crate::Vec4::from_point(
532                Vec3::new(point.x, (point.y - a0) / (a1 - a0), point.z),
533                control.w,
534            );
535        }
536        Ok(carried)
537    }
538}
539
540/// Solve the source generatrix parameters between which the offset carrier
541/// must run so that each constant-v rim of `face` lies in the plane of the
542/// fixed cap across it.
543///
544/// A rim at source parameter `a` against a cap whose plane the axis pierces at
545/// station `h` moves to the root of `f(s) = axial(S(u, s) + d·N(u, s)) − h`
546/// nearest `a`. The root is bracketed by stepping from `a` along the source
547/// meridian's own axial rate (doubling the step up to four times), extending
548/// the source carrier by its natural continuation where the bracket leaves the
549/// domain, and bisected to exhaustion. A rim whose generatrix leaves its cap
550/// tangentially (no axial rate to step along, and a crossing on either side of
551/// the rim), and one whose root cannot be bracketed, are refused by name with
552/// the drift.
553#[allow(clippy::too_many_arguments)]
554fn solve_rim_span(
555    solid: &BrepSolid,
556    face: &FaceRecord,
557    face_id: u64,
558    faces_of_edge: &HashMap<u64, Vec<u64>>,
559    frame: &crate::RevolutionFrame,
560    u: f64,
561    distance: f64,
562    residual_tolerance: f64,
563) -> Result<RimSpan, String> {
564    let op = "offset_revolution_face";
565    let uv_tolerance = 1e-7;
566    // (source parameter, cap station, cap face) of every constant-v boundary
567    // with a planar neighbour, and the face's parameter extent.
568    let mut rims: Vec<(f64, f64, u64)> = Vec::new();
569    let (mut v_min, mut v_max) = (f64::INFINITY, f64::NEG_INFINITY);
570    for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
571        let [q0, q1] = coedge.pcurve.domain()?;
572        let uv0 = coedge.pcurve.evaluate(q0)?;
573        let uv1 = coedge.pcurve.evaluate(q1)?;
574        let uvm = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
575        for uv in [uv0, uv1, uvm] {
576            v_min = v_min.min(uv.y);
577            v_max = v_max.max(uv.y);
578        }
579        if !matches!(iso_kind(&coedge.pcurve, uv_tolerance)?, Some(IsoKind::ConstantV)) {
580            continue;
581        }
582        let incident = faces_of_edge.get(&coedge.edge_id).cloned().unwrap_or_default();
583        let Some(neighbour) = incident.iter().find(|candidate| **candidate != face_id) else {
584            continue;
585        };
586        let (ns, nf) = find_face(solid, *neighbour)
587            .ok_or_else(|| format!("{op}: missing neighbour {neighbour}"))?;
588        // A curved cap is refused by the boundary loop, ahead of any rim.
589        let Ok(plane) =
590            plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
591        else {
592            continue;
593        };
594        // Where the axis pierces the cap's plane.
595        let station =
596            plane.origin.sub(frame.origin).dot(plane.normal) / frame.axis.dot(plane.normal);
597        rims.push((uv0.y, station, *neighbour));
598    }
599    if rims.is_empty() {
600        return Ok(RimSpan {
601            old: [0.0, 1.0],
602            new: [0.0, 1.0],
603            surface: None,
604        });
605    }
606    // One rim per end: every constant-v boundary sits at the face's lowest or
607    // highest parameter.
608    let mut ends: [Option<(f64, f64, u64)>; 2] = [None, None];
609    for rim in &rims {
610        let slot = if (rim.0 - v_min).abs() <= (rim.0 - v_max).abs() { 0 } else { 1 };
611        match ends[slot] {
612            None => ends[slot] = Some(*rim),
613            Some(first) if (first.0 - rim.0).abs() > uv_tolerance => {
614                return Err(format!(
615                    "{op}: face {face_id} has constant-v boundaries at v = {} and v = {} on one \
616                     side of its trim — refusing",
617                    first.0, rim.0
618                ));
619            }
620            Some(_) => {}
621        }
622    }
623
624    let same_sense = face.same_sense;
625    let mut surface: Option<NurbsSurface> = None;
626    let mut old = [0.0, 1.0];
627    let mut new = [0.0, 1.0];
628    for (slot, end) in ends.iter().enumerate() {
629        let Some((start, station, neighbour)) = *end else {
630            continue;
631        };
632        old[slot] = start;
633        let axial_miss = |carrier: &NurbsSurface, s: f64| -> Result<f64, String> {
634            let convention = OffsetNormal::Face { same_sense };
635            let point = OffsetEvaluator::new("push_revolution", carrier, convention)
636                .at(u, s, distance)?
637                .point;
638            Ok(point.sub(frame.origin).dot(frame.axis) - station)
639        };
640        let base = surface.clone().unwrap_or_else(|| face.surface.clone());
641        let drift = axial_miss(&base, start)?;
642        if drift == 0.0 {
643            new[slot] = start;
644            continue;
645        }
646        let rate = face.surface.derivatives(u, start, 1)?[0][1].dot(frame.axis);
647        let step = -drift / rate;
648        if !step.is_finite() {
649            // The generatrix leaves its cap along the cap: the offset meridian
650            // dips through the plane on BOTH sides of the rim, and which
651            // crossing is the rim is not the solve's to pick.
652            return Err(format!(
653                "{op}: the generatrix of face {face_id} meets the plane of neighbour face \
654                 {neighbour} tangentially at its rim, which the push leaves {drift:.3e} off \
655                 that plane — refusing"
656            ));
657        }
658        let mut bracket = None;
659        let mut reach = 2.0 * step;
660        for _ in 0..4 {
661            if !reach.is_finite() {
662                break;
663            }
664            let far = start + reach;
665            let carrier = extend_to(&base, far)
666                .map_err(|refusal| {
667                    format!(
668                        "{op}: the rim of face {face_id} against neighbour face {neighbour} lies \
669                         {drift:.3e} off that cap, and the carrier cannot be extended to reach \
670                         it: {refusal} — refusing"
671                    )
672                })?;
673            let far_miss = axial_miss(carrier.as_ref().unwrap_or(&base), far)?;
674            if far_miss == 0.0 || far_miss.signum() != drift.signum() {
675                bracket = Some((far, carrier));
676                break;
677            }
678            reach *= 2.0;
679        }
680        let Some((far, carrier)) = bracket else {
681            return Err(format!(
682                "{op}: the offset meridian of face {face_id} does not reach the plane of \
683                 neighbour face {neighbour} near its rim, which the push leaves {drift:.3e} off \
684                 that plane — refusing"
685            ));
686        };
687        let carrier_ref = carrier.as_ref().unwrap_or(&base);
688        let (mut near, mut far) = (start, far);
689        for _ in 0..200 {
690            let middle = 0.5 * (near + far);
691            if middle == near || middle == far {
692                break;
693            }
694            if axial_miss(carrier_ref, middle)?.signum() == drift.signum() {
695                near = middle;
696            } else {
697                far = middle;
698            }
699        }
700        new[slot] = 0.5 * (near + far);
701        if carrier.is_some() {
702            surface = carrier;
703        }
704    }
705    if !(new[1] > new[0]) {
706        return Err(format!(
707            "{op}: pushing face {face_id} by {distance} crosses its two rims over (source \
708             parameters {} and {}) — refusing",
709            new[0], new[1]
710        ));
711    }
712    Ok(RimSpan { old, new, surface })
713}
714
715/// Which iso line of its carrier a boundary pcurve is, by the same three-sample
716/// test the lane has always used; `None` for a general pcurve.
717enum IsoKind {
718    ConstantV,
719    ConstantU(f64),
720}
721
722fn iso_kind(pcurve: &NurbsCurve, uv_tolerance: f64) -> Result<Option<IsoKind>, String> {
723    let [q0, q1] = pcurve.domain()?;
724    let uv0 = pcurve.evaluate(q0)?;
725    let uv1 = pcurve.evaluate(q1)?;
726    let uvm = pcurve.evaluate(0.5 * (q0 + q1))?;
727    Ok(
728        if (uv0.y - uv1.y).abs() <= uv_tolerance && (uv0.y - uvm.y).abs() <= uv_tolerance {
729            Some(IsoKind::ConstantV)
730        } else if (uv0.x - uv1.x).abs() <= uv_tolerance && (uv0.x - uvm.x).abs() <= uv_tolerance {
731            Some(IsoKind::ConstantU(uv0.x))
732        } else {
733            None
734        },
735    )
736}
737
738/// `surface` naturally extended in v so that its domain contains `parameter`,
739/// or `None` when it already does.
740fn extend_to(surface: &NurbsSurface, parameter: f64) -> Result<Option<NurbsSurface>, String> {
741    let [v0, v1] = surface.domain_v()?;
742    let (side, delta) = if parameter < v0 {
743        (crate::SurfaceSide::VMin, v0 - parameter)
744    } else if parameter > v1 {
745        (crate::SurfaceSide::VMax, parameter - v1)
746    } else {
747        return Ok(None);
748    };
749    // The solve bisects inside the extension, so its length only has to
750    // clear the knot identity bar `extend_natural` refuses an increment under.
751    surface
752        .extend_natural(side, delta.max(2.0 * crate::curve::KNOT_IDENTITY_TOL))
753        .map(Some)
754        .map_err(|refusal| refusal.describe())
755}
756
757/// A meridian boundary of a partial cylinder and the fixed plane across it.
758struct Meridian<'a> {
759    edge: &'a EdgeRecord,
760    neighbour: u64,
761    /// Azimuth of the meridian in the carrier's frame.
762    azimuth: f64,
763    plane_origin: Vec3,
764    plane_normal: Vec3,
765}
766
767/// Push a PARTIAL CYLINDER exactly: a straight generatrix parallel to the axis,
768/// swept through less than a full turn, bounded by two meridians and two rims,
769/// with a planar neighbour across each.
770///
771/// The offset carrier is the coaxial cylinder of radius ρ′ = ρ ± d, and every
772/// boundary is re-derived as that carrier's intersection with the fixed
773/// neighbour's carrier:
774/// * a rim against an axis-normal cap stays at its axial station;
775/// * a meridian against a plane PARALLEL to the axis moves to where the new
776///   circle crosses that plane, `ρ′·A·cos(θ − φ) = D` in the carrier frame. A
777///   radial end face (D = 0) keeps its azimuth — the one case the general
778///   lane's kept azimuth is right for.
779///
780/// Of the two crossings, the corner is the one from which the new arc leaves
781/// into the side of the plane the old arc left into: the old arc's tangent side
782/// at a transversal corner, the centre's side at a tangent one (a fillet band,
783/// a sketch arc tangent to its segment), where "nearest crossing" is a tie.
784///
785/// `Ok(None)` means the face is not this lane's shape and goes to the general
786/// lane; an `Err` is a push this lane recognised and refuses by name.
787fn offset_partial_cylinder_face(
788    solid: &BrepSolid,
789    face_id: u64,
790    distance: f64,
791    tolerance: f64,
792    residual_tolerance: f64,
793    scale: f64,
794) -> Result<Option<BrepSolid>, String> {
795    use std::f64::consts::TAU;
796    let op = "offset_revolution_face";
797    let (shell_index, face_index) =
798        find_face(solid, face_id).ok_or_else(|| format!("{op}: no face {face_id}"))?;
799    let face = &solid.shells[shell_index].faces[face_index];
800    let Some(AnalyticSurface::Revolution {
801        frame, generatrix, ..
802    }) = face.surface.analytic()
803    else {
804        return Ok(None);
805    };
806    if generatrix.degree != 1 || generatrix.control_points.len() != 2 {
807        return Ok(None);
808    }
809    let (origin, axis, x_axis, y_axis) = (frame.origin, frame.axis, frame.x_axis, frame.y_axis);
810    // (axial coordinate, radial vector) of a point about the carrier axis.
811    let cylindrical = |point: Vec3| {
812        let relative = point.sub(origin);
813        let axial = relative.dot(axis);
814        (axial, relative.sub(axis.scale(axial)))
815    };
816    let azimuth_of = |radial: Vec3| radial.dot(y_axis).atan2(radial.dot(x_axis));
817    let (generatrix_z0, generatrix_radial0) = cylindrical(generatrix.control_points[0].point()?);
818    let (generatrix_z1, generatrix_radial1) = cylindrical(generatrix.control_points[1].point()?);
819    if generatrix_radial0.sub(generatrix_radial1).length() > residual_tolerance {
820        return Ok(None); // a cone
821    }
822    let radius = generatrix_radial0.length();
823    let span = (generatrix_z1 - generatrix_z0).abs();
824
825    if face.loops.len() != 1 || face.loops[0].coedges.len() != 4 {
826        return Ok(None);
827    }
828    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
829    for shell in &solid.shells {
830        for candidate in &shell.faces {
831            for loop_record in &candidate.loops {
832                for coedge in &loop_record.coedges {
833                    faces_of_edge
834                        .entry(coedge.edge_id)
835                        .or_default()
836                        .push(candidate.id);
837                }
838            }
839        }
840    }
841    let edge_by_id: HashMap<u64, &EdgeRecord> =
842        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
843
844    // Classify the four boundary edges by their 3D geometry.
845    let mut meridians: Vec<Meridian> = Vec::new();
846    let mut rims: Vec<(&EdgeRecord, f64)> = Vec::new();
847    let mut planar_neighbours: HashSet<u64> = HashSet::default();
848    for coedge in &face.loops[0].coedges {
849        let edge = *edge_by_id
850            .get(&coedge.edge_id)
851            .ok_or_else(|| format!("{op}: missing edge {}", coedge.edge_id))?;
852        let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
853        if incident.len() != 2 || incident.iter().filter(|f| **f == face_id).count() != 1 {
854            return Ok(None);
855        }
856        let neighbour = *incident.iter().find(|f| **f != face_id).expect("two incidences");
857        let (ns, nf) =
858            find_face(solid, neighbour).ok_or_else(|| format!("{op}: missing neighbour {neighbour}"))?;
859        let Ok(plane) =
860            plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
861        else {
862            return Ok(None);
863        };
864        let (z_start, radial_start) = cylindrical(edge.curve.evaluate(edge.t0)?);
865        let (z_mid, radial_mid) = cylindrical(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?);
866        let (z_end, radial_end) = cylindrical(edge.curve.evaluate(edge.t1)?);
867        if radial_start.sub(radial_mid).length() <= residual_tolerance
868            && radial_start.sub(radial_end).length() <= residual_tolerance
869        {
870            if plane.normal.dot(axis).abs() * span > residual_tolerance {
871                return Ok(None); // a plane that is not parallel to the axis
872            }
873            meridians.push(Meridian {
874                edge,
875                neighbour,
876                azimuth: azimuth_of(radial_start),
877                plane_origin: plane.origin,
878                plane_normal: plane.normal,
879            });
880        } else if (z_start - z_mid).abs() <= residual_tolerance
881            && (z_start - z_end).abs() <= residual_tolerance
882        {
883            if plane.normal.cross(axis).length() * radius > residual_tolerance {
884                return Ok(None); // a cap that is not normal to the axis
885            }
886            rims.push((edge, z_start));
887        } else {
888            return Ok(None);
889        }
890        planar_neighbours.insert(neighbour);
891    }
892    if meridians.len() != 2 || rims.len() != 2 || (rims[0].1 - rims[1].1).abs() <= residual_tolerance
893    {
894        return Ok(None);
895    }
896    // Every corner is a meridian end at one of the two rim stations, each rim
897    // runs between two corners, and every other edge ending at a corner is a
898    // straight edge between two of the planes this push re-trims — so the chord
899    // the re-trim rebuilds stays on both.
900    let mut stations: HashMap<u64, f64> = HashMap::default();
901    for meridian in &meridians {
902        for vertex_id in [meridian.edge.start_vertex_id, meridian.edge.end_vertex_id] {
903            let vertex = solid
904                .vertices
905                .iter()
906                .find(|vertex| vertex.id == vertex_id)
907                .ok_or_else(|| format!("{op}: missing vertex {vertex_id}"))?;
908            let (axial, _) = cylindrical(vertex.point);
909            let Some(station) = rims
910                .iter()
911                .map(|(_, station)| *station)
912                .find(|station| (axial - station).abs() <= residual_tolerance)
913            else {
914                return Ok(None);
915            };
916            stations.insert(vertex_id, station);
917        }
918    }
919    let boundary: HashSet<u64> =
920        face.loops[0].coedges.iter().map(|coedge| coedge.edge_id).collect();
921    for edge in &solid.edges {
922        let at_corner = stations.contains_key(&edge.start_vertex_id)
923            || stations.contains_key(&edge.end_vertex_id);
924        if !at_corner {
925            continue;
926        }
927        if boundary.contains(&edge.id) {
928            if !(stations.contains_key(&edge.start_vertex_id)
929                && stations.contains_key(&edge.end_vertex_id))
930            {
931                return Ok(None);
932            }
933            continue;
934        }
935        let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
936        if edge.degenerate
937            || edge.curve.degree != 1
938            || !incident.iter().all(|f| planar_neighbours.contains(f))
939        {
940            return Ok(None);
941        }
942    }
943
944    // The pushed radius, signed along the face's own outward normal.
945    let [u0, u1] = face.surface.domain_u()?;
946    let [v0, v1] = face.surface.domain_v()?;
947    let (u_mid, v_mid) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
948    let offsets = OffsetEvaluator::new(
949        "push_revolution",
950        &face.surface,
951        OffsetNormal::Face {
952            same_sense: face.same_sense,
953        },
954    );
955    let sample = offsets.at(u_mid, v_mid, distance)?;
956    let outward = cylindrical(sample.source).1.normalized()?;
957    let new_radius = cylindrical(sample.point).1.dot(outward);
958    if new_radius <= tolerance {
959        return Err(format!(
960            "{op}: pushing partial cylinder face {face_id} by {distance} collapses its radius \
961             {radius:.6} onto the axis — refusing"
962        ));
963    }
964
965    // Which meridian starts the sweep: the counter-clockwise run from start to
966    // end is the one that passes through a rim's midpoint.
967    let ccw = |from: f64, to: f64| (to - from).rem_euclid(TAU);
968    let rim_mid = rims[0].0.curve.evaluate(0.5 * (rims[0].0.t0 + rims[0].0.t1))?;
969    let rim_azimuth = azimuth_of(cylindrical(rim_mid).1);
970    let (start, end) = if ccw(meridians[0].azimuth, rim_azimuth)
971        < ccw(meridians[0].azimuth, meridians[1].azimuth)
972    {
973        (&meridians[0], &meridians[1])
974    } else {
975        (&meridians[1], &meridians[0])
976    };
977
978    // Solve one corner; `sense` is +1 where the arc leaves its corner
979    // counter-clockwise (the start) and -1 where it leaves clockwise (the end).
980    // Returns the new azimuth and the plane's two crossings of the new circle.
981    let solve = |meridian: &Meridian, sense: f64| -> Result<(f64, [f64; 2]), String> {
982        let normal = meridian.plane_normal;
983        let (nx, ny) = (normal.dot(x_axis), normal.dot(y_axis));
984        let reach = nx.hypot(ny);
985        let phi = ny.atan2(nx);
986        let offset = meridian.plane_origin.sub(origin).dot(normal);
987        let lean = (phi - meridian.azimuth).sin();
988        let side = if radius * reach * lean.abs() > residual_tolerance {
989            sense * lean
990        } else {
991            -offset
992        };
993        let shortfall = offset.abs() - new_radius * reach;
994        if shortfall > tolerance {
995            return Err(format!(
996                "{op}: pushing partial cylinder face {face_id} by {distance} separates it from \
997                 neighbour face {} — the radius {new_radius:.6} carrier stops {shortfall:.3e} \
998                 short of that plane (refusing)",
999                meridian.neighbour
1000            ));
1001        }
1002        let half = (offset / (new_radius * reach)).clamp(-1.0, 1.0).acos();
1003        let azimuth = if sense * side > 0.0 { phi - half } else { phi + half };
1004        Ok((azimuth, [phi - half, phi + half]))
1005    };
1006    let (start_azimuth, start_crossings) = solve(start, 1.0)?;
1007    let (end_azimuth, end_crossings) = solve(end, -1.0)?;
1008    let new_sweep = ccw(start_azimuth, end_azimuth);
1009    let angular_tolerance = tolerance / new_radius;
1010    if new_sweep <= angular_tolerance || new_sweep >= TAU - angular_tolerance {
1011        return Err(format!(
1012            "{op}: pushing partial cylinder face {face_id} by {distance} closes its arc between \
1013             neighbour faces {} and {} — refusing",
1014            start.neighbour, end.neighbour
1015        ));
1016    }
1017    // A crossing strictly inside the new arc means the arc passes through a
1018    // neighbour's plane before reaching its own corner — a convex round grown
1019    // past the two walls it was tangent to has no corner on either. Both
1020    // corners of an arc cut from one sketch line solve against two faces whose
1021    // planes agree only to the sketch solver's precision, so a crossing within
1022    // the residual bar of a corner is that corner.
1023    let crossing_margin = residual_tolerance / new_radius;
1024    for (meridian, crossings) in [(start, start_crossings), (end, end_crossings)] {
1025        for crossing in crossings {
1026            let along = ccw(start_azimuth, crossing);
1027            if along > crossing_margin && along < new_sweep - crossing_margin {
1028                return Err(format!(
1029                    "{op}: pushing partial cylinder face {face_id} by {distance} carries its arc \
1030                     through the plane of neighbour face {} — refusing",
1031                    meridian.neighbour
1032                ));
1033            }
1034        }
1035    }
1036
1037    // New corner vertices, meridians and rims.
1038    let direction = |azimuth: f64| x_axis.scale(azimuth.cos()).add(y_axis.scale(azimuth.sin()));
1039    let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
1040    let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
1041    for (meridian, azimuth) in [(start, start_azimuth), (end, end_azimuth)] {
1042        let edge = meridian.edge;
1043        let mut ends = [Vec3::default(); 2];
1044        for (slot, vertex_id) in [edge.start_vertex_id, edge.end_vertex_id].into_iter().enumerate()
1045        {
1046            ends[slot] = origin
1047                .add(axis.scale(stations[&vertex_id]))
1048                .add(direction(azimuth).scale(new_radius));
1049            new_vertices.insert(vertex_id, ends[slot]);
1050        }
1051        new_curves.insert(edge.id, make_line(ends[0], ends[1])?);
1052    }
1053    let new_x = direction(start_azimuth);
1054    let new_y = axis.cross(new_x);
1055    for (edge, station) in &rims {
1056        let (head, tail) = (new_vertices[&edge.start_vertex_id], new_vertices[&edge.end_vertex_id]);
1057        let mut arc = crate::make_arc(
1058            origin.add(axis.scale(*station)),
1059            new_x,
1060            new_y,
1061            new_radius,
1062            0.0,
1063            new_sweep,
1064        )?;
1065        let [a0, _] = arc.domain()?;
1066        let first = arc.evaluate(a0)?;
1067        if first.sub(head).length() > first.sub(tail).length() {
1068            arc = arc.reversed()?;
1069        }
1070        new_curves.insert(edge.id, arc);
1071    }
1072
1073    // The straight neighbour edges ending at a moved corner must still run the
1074    // way they ran; a corner carried past an edge's far end folds that face.
1075    for edge in &solid.edges {
1076        if new_curves.contains_key(&edge.id)
1077            || !(new_vertices.contains_key(&edge.start_vertex_id)
1078                || new_vertices.contains_key(&edge.end_vertex_id))
1079        {
1080            continue;
1081        }
1082        let position = |vertex_id: u64| {
1083            solid
1084                .vertices
1085                .iter()
1086                .find(|vertex| vertex.id == vertex_id)
1087                .map(|vertex| vertex.point)
1088                .ok_or_else(|| format!("{op}: missing vertex {vertex_id}"))
1089        };
1090        let old_run = position(edge.end_vertex_id)?.sub(position(edge.start_vertex_id)?);
1091        let new_start = match new_vertices.get(&edge.start_vertex_id) {
1092            Some(point) => *point,
1093            None => position(edge.start_vertex_id)?,
1094        };
1095        let new_end = match new_vertices.get(&edge.end_vertex_id) {
1096            Some(point) => *point,
1097            None => position(edge.end_vertex_id)?,
1098        };
1099        let new_run = new_end.sub(new_start);
1100        if new_run.length() <= tolerance || new_run.dot(old_run) <= 0.0 {
1101            return Err(format!(
1102                "{op}: pushing partial cylinder face {face_id} by {distance} runs a corner past \
1103                 the far end of edge {} — refusing",
1104                edge.id
1105            ));
1106        }
1107    }
1108
1109    // The offset carrier: the same axis, the new radius, the new sweep, with
1110    // the generatrix kept in its own direction so the normal keeps its sense.
1111    let carrier = make_revolution(
1112        origin,
1113        axis,
1114        &make_line(
1115            origin
1116                .add(axis.scale(generatrix_z0))
1117                .add(new_x.scale(new_radius)),
1118            origin
1119                .add(axis.scale(generatrix_z1))
1120                .add(new_x.scale(new_radius)),
1121        )?,
1122        new_sweep,
1123    )?;
1124    let [c0, c1] = carrier.domain_u()?;
1125    let [w0, w1] = carrier.domain_v()?;
1126    let old_normal = face.surface.normal(u_mid, v_mid)?;
1127    let old_point = face.surface.evaluate(u_mid, v_mid)?;
1128    let new_normal = carrier.normal(0.5 * (c0 + c1), 0.5 * (w0 + w1))?;
1129    let new_point = carrier.evaluate(0.5 * (c0 + c1), 0.5 * (w0 + w1))?;
1130    if old_normal.dot(cylindrical(old_point).1) * new_normal.dot(cylindrical(new_point).1) <= 0.0 {
1131        return Err(format!(
1132            "{op}: the offset carrier of partial cylinder face {face_id} flipped its normal — \
1133             refusing"
1134        ));
1135    }
1136
1137    let mut result = solid.clone();
1138    let pushed = &mut result.shells[shell_index].faces[face_index];
1139    for coedge in &mut pushed.loops[0].coedges {
1140        let mut pcurve = build_pcurve_on_surface(&carrier, &new_curves[&coedge.edge_id])?;
1141        if !coedge.forward {
1142            pcurve = pcurve.reversed()?;
1143        }
1144        coedge.pcurve = pcurve;
1145    }
1146    pushed.surface = carrier;
1147    retrim_planar_neighbours(
1148        solid,
1149        result,
1150        &edge_by_id,
1151        &new_curves,
1152        &new_vertices,
1153        planar_neighbours,
1154        residual_tolerance,
1155        scale,
1156    )
1157    .map(Some)
1158}
1159
1160/// Land a push's rebuilt boundary on `result` (whose pushed face already
1161/// carries its new surface and pcurves): swap in the new edge curves and vertex
1162/// positions, rebuild every straight edge of a planar neighbour that ends at a
1163/// moved vertex, re-trim those neighbours, and accept the solid only if it
1164/// validates and keeps its orientation.
1165#[allow(clippy::too_many_arguments)]
1166fn retrim_planar_neighbours(
1167    solid: &BrepSolid,
1168    mut result: BrepSolid,
1169    edge_by_id: &HashMap<u64, &EdgeRecord>,
1170    new_curves: &HashMap<u64, NurbsCurve>,
1171    new_vertices: &HashMap<u64, Vec3>,
1172    planar_neighbours: HashSet<u64>,
1173    residual_tolerance: f64,
1174    scale: f64,
1175) -> Result<BrepSolid, String> {
1176    for edge in &mut result.edges {
1177        if let Some(curve) = new_curves.get(&edge.id) {
1178            let [d0, d1] = curve.domain()?;
1179            edge.curve = curve.clone();
1180            edge.t0 = d0;
1181            edge.t1 = d1;
1182        }
1183    }
1184    for vertex in &mut result.vertices {
1185        if let Some(point) = new_vertices.get(&vertex.id) {
1186            vertex.point = *point;
1187        }
1188    }
1189    // A full-revolve planar disk/annulus may carry its own radial seam from an
1190    // axis vertex to the shared circular rim. Moving the rim relocates one end
1191    // of that seam, so rebuild the straight edge before re-trimming the cap.
1192    let result_vertices: HashMap<u64, Vec3> = result
1193        .vertices
1194        .iter()
1195        .map(|vertex| (vertex.id, vertex.point))
1196        .collect();
1197    let mut cap_seams = HashSet::default();
1198    for neighbour in &planar_neighbours {
1199        let (ns, nf) = find_face(&result, *neighbour)
1200            .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
1201        for loop_record in &result.shells[ns].faces[nf].loops {
1202            for coedge in &loop_record.coedges {
1203                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
1204                    format!(
1205                        "offset_revolution_face: missing cap edge {}",
1206                        coedge.edge_id
1207                    )
1208                })?;
1209                let touches_moved = new_vertices.contains_key(&edge.start_vertex_id)
1210                    || new_vertices.contains_key(&edge.end_vertex_id);
1211                if touches_moved
1212                    && !new_curves.contains_key(&edge.id)
1213                    && !edge.degenerate
1214                    && edge.curve.degree == 1
1215                {
1216                    cap_seams.insert(edge.id);
1217                }
1218            }
1219        }
1220    }
1221    for seam_id in cap_seams {
1222        let source = edge_by_id[&seam_id];
1223        let curve = make_line(
1224            result_vertices[&source.start_vertex_id],
1225            result_vertices[&source.end_vertex_id],
1226        )?;
1227        let [d0, d1] = curve.domain()?;
1228        if let Some(edge) = result.edges.iter_mut().find(|edge| edge.id == seam_id) {
1229            edge.curve = curve;
1230            edge.t0 = d0;
1231            edge.t1 = d1;
1232        }
1233    }
1234    let final_edges: HashMap<u64, EdgeRecord> = result
1235        .edges
1236        .iter()
1237        .map(|edge| (edge.id, edge.clone()))
1238        .collect();
1239    for neighbour in planar_neighbours {
1240        let (ns, nf) = find_face(&result, neighbour)
1241            .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
1242        let plane = plane_of_surface(
1243            &result.shells[ns].faces[nf].surface,
1244            residual_tolerance,
1245            "offset_revolution_face",
1246        )?;
1247        retrim_planar_face(
1248            &mut result.shells[ns].faces[nf],
1249            &plane,
1250            &final_edges,
1251            scale,
1252            "offset_revolution_face",
1253        )
1254        .map_err(|error| format!("offset_revolution_face: cap retrim: {error}"))?;
1255    }
1256    let issues = result.validate();
1257    if !issues.is_empty() {
1258        return Err(format!(
1259            "offset_revolution_face: pushed solid failed validation: {issues:?}"
1260        ));
1261    }
1262    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
1263        if before * after <= 0.0 {
1264            return Err("offset_revolution_face: the push inverts the solid — refusing".into());
1265        }
1266    }
1267    Ok(result)
1268}
1269
1270/// `BREP_OFFSET_REVOLUTION_TRACE=<file>` appends one record per general-lane
1271/// push to `<file>`: the call, each rebuilt boundary edge against the fixed
1272/// planar neighbour it must lie in — before the push (the source edge) and
1273/// after (the rebuilt curve) — and the outcome with the volume. It writes to a
1274/// file, not stderr, because the case gate replays each document in a child
1275/// process whose stderr it discards.
1276struct BoundaryTrace {
1277    path: Option<std::path::PathBuf>,
1278}
1279
1280impl BoundaryTrace {
1281    fn open(solid: &BrepSolid, face_id: u64, distance: f64) -> Self {
1282        let path = std::env::var_os("BREP_OFFSET_REVOLUTION_TRACE")
1283            .filter(|value| !value.is_empty() && value != "0")
1284            .map(std::path::PathBuf::from);
1285        let trace = Self { path };
1286        if trace.path.is_some() {
1287            let volume = solid_signed_volume(solid).map_or(f64::NAN, f64::abs);
1288            trace.write(format!(
1289                "call\tface={face_id}\tdistance={distance}\tscale={}\tvolume_before={volume:.15}",
1290                solid_model_scale(solid)
1291            ));
1292        }
1293        trace
1294    }
1295
1296    /// One whole line per `write` call on an append-mode file, prefixed with
1297    /// the thread's name, so parallel test threads (named after their tests)
1298    /// neither interleave nor lose which test a line belongs to.
1299    fn write(&self, line: String) {
1300        use std::io::Write;
1301        let Some(path) = &self.path else { return };
1302        let thread = std::thread::current();
1303        let record = format!("{}\t{line}\n", thread.name().unwrap_or("?"));
1304        if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
1305            let _ = file.write_all(record.as_bytes());
1306        }
1307    }
1308
1309    /// Largest distance from `plane` over `samples + 1` points of `curve`
1310    /// between `t0` and `t1`, and over its two end points.
1311    fn off_plane(
1312        curve: &NurbsCurve,
1313        t0: f64,
1314        t1: f64,
1315        plane: &crate::offset_retrim::Plane,
1316    ) -> (f64, f64) {
1317        let distance = |t: f64| {
1318            curve
1319                .evaluate(t)
1320                .map_or(f64::NAN, |point| point.sub(plane.origin).dot(plane.normal).abs())
1321        };
1322        let ends = distance(t0).max(distance(t1));
1323        let samples = (0..=32)
1324            .map(|index| distance(t0 + (t1 - t0) * index as f64 / 32.0))
1325            .fold(0.0f64, f64::max);
1326        (ends, samples)
1327    }
1328
1329    fn edge(
1330        &self,
1331        kind: &str,
1332        edge: &EdgeRecord,
1333        neighbour: Option<&u64>,
1334        plane: Option<&crate::offset_retrim::Plane>,
1335        rebuilt: &NurbsCurve,
1336    ) {
1337        if self.path.is_none() {
1338            return;
1339        }
1340        let Some(plane) = plane else {
1341            self.write(format!("edge\t{}\t{kind}\tneighbour=none", edge.id));
1342            return;
1343        };
1344        let (before_ends, before_samples) = Self::off_plane(&edge.curve, edge.t0, edge.t1, plane);
1345        let (after_ends, after_samples) = match rebuilt.domain() {
1346            Ok([r0, r1]) => Self::off_plane(rebuilt, r0, r1, plane),
1347            Err(_) => (f64::NAN, f64::NAN),
1348        };
1349        self.write(format!(
1350            "edge\t{}\t{kind}\tneighbour={}\tbefore_vertices={before_ends:.3e}\t\
1351             before_samples={before_samples:.3e}\tafter_vertices={after_ends:.3e}\t\
1352             after_samples={after_samples:.3e}",
1353            edge.id,
1354            neighbour.map_or("none".to_string(), u64::to_string)
1355        ));
1356    }
1357
1358    fn outcome(&self, outcome: &Result<BrepSolid, String>) {
1359        if self.path.is_none() {
1360            return;
1361        }
1362        match outcome {
1363            Ok(result) => {
1364                let volume = solid_signed_volume(result).map_or(f64::NAN, f64::abs);
1365                self.write(format!("ok\tvolume_after={volume:.15}"));
1366            }
1367            Err(error) => self.write(format!("refused\t{error}")),
1368        }
1369    }
1370}
1371