Skip to main content

brep_kernel/construction/sweep_topology/
stations.rs

1//! Error-driven station placement, and the MEASURED bound a station sweep
2//! reports (kinematic-sweep plan, item 2).
3//!
4//! A station sweep places rigid copies of the section at sampled stations and
5//! lofts through them; between stations the loft's cubic v-interpolant stands
6//! in for the path, and everything it gets wrong is a function of where the
7//! stations are. Two instruments here read that:
8//!
9//! - [`spine_deviations`]: the loft's own interpolant of the STATION POINTS
10//!   (the same fit, the same chord parameters) against the true path between
11//!   the stations, one number per span. The chain sampler refines the spans
12//!   that read over the requested tolerance ([`refine_parameters`]).
13//! - [`station_report`]: after the sections are placed, the same interpolants
14//!   of the section's centre track and frame axes — which, because the loft's
15//!   fit is linear in the data and every column shares one parameter vector,
16//!   ARE the built wall between stations — against the true path and the true
17//!   transported frame: the worst wall deviation, and the built solid's volume
18//!   by quadrature against the kinematic sweep's own volume. The built volume
19//!   is the loft's to quadrature precision (`the_station_report_reads_the_
20//!   built_volume` pins it against `solid_mass_properties` at 1e-10), so the
21//!   reported residual is the Pappus residual itself, not a bound on it.
22use crate::mass_properties::{GAUSS_W, GAUSS_X};
23use crate::{interpolate_curve, interpolate_curve_closed, NurbsCurve, Vec3};
24use crate::{Approximation, KernelRefusal, KernelStage, OrRefuse};
25
26/// The centre-track deviation a station sweep is refined to when its caller
27/// names none: 1e-4 mm. Read off the tolerance ladder in the record: the
28/// loosest value at which both closed rings the plan named sit under 1e-6
29/// relative on their closed forms (the rounded rectangle 4.8e-4 → 1.9e-7 at
30/// 211 stations from 81, the filleted skew quad 8.3e-4 → 7.4e-8 at 248 from
31/// 92), while the `R = 10` ring keeps its 64 stations and every single-curve
32/// fixture the landed geometry pins (a quarter arc at 32 stations reads
33/// 1.4e-7, a 350° arc 3.9e-5) stays under it and so bit-identical. One
34/// decade tighter costs half again as many stations for a residual already
35/// under 1e-7.
36pub const SWEEP_DEFAULT_TOLERANCE: f64 = 1e-4;
37
38/// The loft's station cap: its interpolation solve is per control column and
39/// a run past this looks like a hang. Refinement stops here and says so.
40pub const SWEEP_MAX_STATIONS: usize = 1024;
41
42/// How many refinement rounds the sampler runs before it stops and reports
43/// what it reached (`StationBudget::rounds_exhausted`). A round splits an
44/// offending span by up to 8, so six rounds cover a span 2.6e5× over under
45/// the `h²` law a G1 joint's span obeys.
46pub const SWEEP_REFINEMENT_ROUNDS: usize = 6;
47
48/// Where a station sits on the chain: the segment its NEXT span runs on, and
49/// its parameter there. A joint station belongs to the segment it departs
50/// into; the last station of an open run keeps the segment it arrived on.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct StationParam {
53    pub segment: usize,
54    pub t: f64,
55}
56
57/// What the sampler did to reach its stations.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct StationBudget {
60    /// The stations the floors gave before any refinement.
61    pub initial: usize,
62    /// The stations lofted through.
63    pub stations: usize,
64    /// Refinement rounds that inserted at least one station.
65    pub rounds: usize,
66    /// True when refinement wanted more stations than the cap allows.
67    pub at_cap: bool,
68    /// True when [`SWEEP_REFINEMENT_ROUNDS`] rounds ran and the last
69    /// measurement was still over the tolerance.
70    pub rounds_exhausted: bool,
71}
72
73/// The measured bound a station sweep carries.
74#[derive(Clone, Debug, PartialEq)]
75pub struct SweepStationReport {
76    pub budget: StationBudget,
77    /// The centre-track deviation the run was refined to, mm.
78    pub tolerance: f64,
79    /// The worst distance from the true path to the loft's centre track, mm —
80    /// the number read against `tolerance`, and the one refinement drives:
81    /// the cubic's error falls as `h⁴` inside a segment and as `h²` at a G1
82    /// joint's curvature jump.
83    pub spine_deviation: f64,
84    /// The worst deviation of the loft's frame axes from the transported
85    /// frame, per unit of section reach. Reported, not driven: at a G1 joint
86    /// the frame's rate of turn JUMPS, and a cubic through samples of a kinked
87    /// function overshoots by a FIRST-order term in the local spacing (the
88    /// rounded-rectangle ring reads 2.4e-3 at its floor budget where its
89    /// centre track reads 6.6e-4, and 1.1e-3 against 7.6e-6 refined to 1e-5),
90    /// so stations buy it down a decade a decade of track, not four; that is
91    /// the fitted route's own limit and the plan's item 3 bar.
92    pub frame_deviation: f64,
93    /// `spine + reach × frame`: the worst distance any wall point stands off
94    /// the kinematic sweep's wall, mm.
95    pub wall_deviation: f64,
96    /// The section's reach from its anchor, mm (a control-hull bound).
97    pub reach: f64,
98    /// The built solid's volume, by quadrature over the loft's own interpolants.
99    pub built_volume: f64,
100    /// The kinematic sweep's volume: `A·L` for a section centred on the path,
101    /// with the section's first-moment terms and the carried anchor's own
102    /// motion otherwise (Simpson over each span's quarter points).
103    pub ideal_volume: f64,
104    /// `|built − ideal|`, mm³: the Pappus residual, measured.
105    pub volume_residual: f64,
106    /// True when `volume_residual` is the crude `lateral area × wall
107    /// deviation` bound instead of the quadrature (a seam-shifted ring).
108    pub crude_bound: bool,
109}
110
111impl SweepStationReport {
112    /// The report as the typed approximation a feature result carries
113    /// (`sweep.stations`).
114    pub fn approximation(&self, body: impl Into<String>) -> Approximation {
115        let cap = if self.budget.at_cap {
116            format!(
117                "; refinement stopped at the {SWEEP_MAX_STATIONS}-station cap, so the run does \
118                 not meet the requested tolerance"
119            )
120        } else if self.budget.rounds_exhausted {
121            format!(
122                "; refinement stopped after its {SWEEP_REFINEMENT_ROUNDS} rounds still over the \
123                 requested tolerance"
124            )
125        } else {
126            String::new()
127        };
128        let volume = if self.crude_bound {
129            format!(
130                "the volume is within {:.3e} mm³ of the kinematic sweep's (lateral area × wall \
131                 deviation)",
132                self.volume_residual
133            )
134        } else {
135            format!(
136                "the volume by quadrature is {:.9} against the kinematic sweep's {:.9} \
137                 (residual {:.3e} mm³)",
138                self.built_volume, self.ideal_volume, self.volume_residual
139            )
140        };
141        Approximation {
142            code: "sweep.stations".to_string(),
143            body: body.into(),
144            measured: self.spine_deviation,
145            bar: self.tolerance,
146            volume_bound: Some(self.volume_residual),
147            edges: Vec::new(),
148            budget: None,
149            message: format!(
150                "the swept wall is lofted through {} stations ({} from the floors, {} refinement \
151                 round(s)); the loft's centre track stands within {:.3e} mm of the path against \
152                 the requested {:.1e}{cap}; its frame stands within {:.3e} per unit reach of the \
153                 transported frame (a reach of {:.3}), so the wall is within {:.3e} mm of the \
154                 kinematic sweep's; {volume}",
155                self.budget.stations,
156                self.budget.initial,
157                self.budget.rounds,
158                self.spine_deviation,
159                self.tolerance,
160                self.frame_deviation,
161                self.reach,
162                self.wall_deviation,
163            ),
164        }
165    }
166}
167
168thread_local! {
169    /// The bite-proof STUB for this thread: `true` switches refinement off
170    /// without touching the process environment, so a lib test can pull it
171    /// while the rest of the module runs beside it.
172    static REFINEMENT_STUBBED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
173}
174
175/// Whether refinement is switched off — by the environment
176/// (`BREP_SWEEP_STATION_REFINEMENT=0`, mirroring `BREP_SWEEP_BEND_GUARD`, the
177/// switch a probe or a whole-module run pulls) or by this thread's stub.
178pub(super) fn refinement_enabled() -> bool {
179    !REFINEMENT_STUBBED.with(|stub| stub.get())
180        && std::env::var("BREP_SWEEP_STATION_REFINEMENT").as_deref() != Ok("0")
181}
182
183
184/// Cumulative chord parameters over `points`, normalized to `[0, 1]`; a
185/// CLOSED run has one more entry for the wrap chord back to point 0.
186pub(super) fn chord_parameters(points: &[Vec3], closed: bool) -> Vec<f64> {
187    let count = points.len();
188    let mut parameters = Vec::with_capacity(count + usize::from(closed));
189    let mut total = 0.0;
190    parameters.push(0.0);
191    for index in 1..count {
192        total += points[index].sub(points[index - 1]).length();
193        parameters.push(total);
194    }
195    if closed {
196        total += points[0].sub(points[count - 1]).length();
197        parameters.push(total);
198    }
199    if total > 0.0 {
200        for parameter in parameters.iter_mut() {
201            *parameter /= total;
202        }
203    }
204    parameters
205}
206
207/// The loft's own v-parameters for a run of placed sections: the average over
208/// every control-point column of that column's cumulative chord fraction, the
209/// wrap chord included on a ring — `loft_topology/basic.rs` and `closed.rs`,
210/// formula for formula, so the interpolants built on them are the loft's.
211pub(super) fn loft_parameters(sections: &[Vec<NurbsCurve>], closed: bool) -> Result<Vec<f64>, KernelRefusal> {
212    let count = sections.len();
213    let spans = count - 1 + usize::from(closed);
214    let mut accumulated = vec![0.0; spans + 1];
215    let mut columns = 0usize;
216    for curve_index in 0..sections[0].len() {
217        for control_index in 0..sections[0][curve_index].control_points.len() {
218            let mut chords = vec![0.0; spans + 1];
219            let mut total = 0.0;
220            for station in 1..=spans {
221                let previous = sections[station - 1][curve_index].control_points[control_index]
222                    .point()
223                    .or_refuse(KernelStage::Refine, "control_point")?;
224                let current = sections[station % count][curve_index].control_points[control_index]
225                    .point()
226                    .or_refuse(KernelStage::Refine, "control_point")?;
227                total += current.sub(previous).length();
228                chords[station] = total;
229            }
230            if total <= 1e-6 {
231                continue;
232            }
233            for station in 0..=spans {
234                accumulated[station] += chords[station] / total;
235            }
236            columns += 1;
237        }
238    }
239    if columns == 0 {
240        return Err(KernelRefusal::input(
241            KernelStage::Classify,
242            "coincident_sections",
243            "sweepSolid: the placed sections coincide",
244        ));
245    }
246    let mut parameters: Vec<f64> = accumulated.iter().map(|sum| sum / columns as f64).collect();
247    parameters[0] = 0.0;
248    parameters[spans] = 1.0;
249    Ok(parameters)
250}
251
252/// The loft's v-interpolant through `points` at `parameters`: the open fit at
253/// the loft's degree (cubic, or fewer points minus one), or the periodic
254/// cubic on a ring (`parameters` then carries the wrap entry).
255pub(super) fn interpolant(points: &[Vec3], parameters: &[f64], closed: bool) -> Result<NurbsCurve, String> {
256    if closed {
257        interpolate_curve_closed(points, parameters)
258    } else {
259        interpolate_curve(points, 3usize.min(points.len() - 1), parameters)
260    }
261}
262
263/// The parameter on `curve` nearest `point`, from `guess`: Newton on the
264/// distance's derivative, held inside the curve's domain. The spans are
265/// short and the guess is the chord fraction, so a few steps converge; a step
266/// that does not improve is discarded.
267fn nearest_parameter(curve: &NurbsCurve, point: Vec3, guess: f64) -> Result<(f64, f64), String> {
268    let [lo, hi] = curve.domain()?;
269    let mut u = guess.clamp(lo, hi);
270    let mut distance = curve.evaluate(u)?.sub(point).length();
271    for _ in 0..6 {
272        let derivatives = curve.derivatives_small(u, 2)?;
273        let offset = derivatives[0].sub(point);
274        let numerator = offset.dot(derivatives[1]);
275        let denominator = derivatives[1].dot(derivatives[1]) + offset.dot(derivatives[2]);
276        if denominator.abs() <= 1e-300 {
277            break;
278        }
279        let next = (u - numerator / denominator).clamp(lo, hi);
280        let next_distance = curve.evaluate(next)?.sub(point).length();
281        if next_distance >= distance || (next - u).abs() <= 1e-15 * hi.abs().max(1.0) {
282            break;
283        }
284        u = next;
285        distance = next_distance;
286    }
287    Ok((u, distance))
288}
289
290/// The true path piece under span `k`: its segment and parameter interval.
291/// A span runs from station `k` to station `k + 1` on station `k`'s departing
292/// segment — to that segment's end when the next station is the joint (or
293/// station 0 of a ring).
294fn span_piece(chain: &[NurbsCurve], params: &[StationParam], k: usize, closed: bool) -> Result<(usize, f64, f64), String> {
295    let count = params.len();
296    let from = params[k];
297    let next = (k + 1) % count;
298    let end = if !closed && k + 1 == count {
299        return Err("no span past the last station of an open run".into());
300    } else if next != 0 && params[next].segment == from.segment {
301        params[next].t
302    } else {
303        chain[from.segment].domain()?[1]
304    };
305    Ok((from.segment, from.t, end))
306}
307
308/// The fractions of a span the instruments sample the true path at.
309const SPAN_FRACTIONS: [f64; 3] = [0.25, 0.5, 0.75];
310
311/// Per span, the worst distance from the true path to the loft's interpolant
312/// of the station points (at the centre-chord parameters the sampler has
313/// before any section is placed). An open run of `n` stations has `n − 1`
314/// spans, a ring `n`.
315pub(super) fn spine_deviations(
316    chain: &[NurbsCurve],
317    params: &[StationParam],
318    points: &[Vec3],
319    closed: bool,
320) -> Result<Vec<f64>, String> {
321    let parameters = chord_parameters(points, closed);
322    let spine = interpolant(points, &parameters, closed)?;
323    let spans = points.len() - 1 + usize::from(closed);
324    let mut deviations = Vec::with_capacity(spans);
325    for k in 0..spans {
326        let (segment, t_a, t_b) = span_piece(chain, params, k, closed)?;
327        let (u_a, u_b) = (parameters[k], parameters[k + 1]);
328        let mut worst: f64 = 0.0;
329        for fraction in SPAN_FRACTIONS {
330            let truth = chain[segment].evaluate(t_a + (t_b - t_a) * fraction)?;
331            let (_, distance) = nearest_parameter(&spine, truth, u_a + (u_b - u_a) * fraction)?;
332            worst = worst.max(distance);
333        }
334        deviations.push(worst);
335    }
336    Ok(deviations)
337}
338
339/// One refinement round: for every span whose deviation is over `tolerance`,
340/// split it into `ceil((deviation / tolerance)^(1/4))` equal parameter steps
341/// — the cubic interpolant's `h⁴` law — or `^(1/2)` for a span that starts or
342/// ends at a segment joint, where a curvature jump drops the cubic to `h²`
343/// (at least 2, at most 8 pieces), and insert the new parameters into that
344/// segment's list. Returns how many stations were inserted and whether the
345/// round wanted more than the cap allows, in which case the spans are refined
346/// worst-first until it is reached and the caller records `at_cap`.
347pub(super) fn refine_parameters(
348    chain: &[NurbsCurve],
349    per_segment: &mut [Vec<f64>],
350    params: &[StationParam],
351    deviations: &[f64],
352    tolerance: f64,
353    closed: bool,
354    emitted: usize,
355) -> Result<(usize, bool), String> {
356    let count = params.len();
357    let mut wanted: Vec<(usize, usize)> = Vec::new();
358    for (k, &deviation) in deviations.iter().enumerate() {
359        if deviation > tolerance {
360            let (segment, t_a, t_b) = span_piece(chain, params, k, closed)?;
361            let [t0, t1] = chain[segment].domain()?;
362            let next = (k + 1) % count;
363            let at_joint = (t_a == t0 && (k > 0 || closed))
364                || t_b == t1 && (next != 0 || closed) && (next == 0 || params[next].segment != segment);
365            let exponent = if at_joint { 0.5 } else { 0.25 };
366            let pieces = ((deviation / tolerance).powf(exponent).ceil() as usize).clamp(2, 8);
367            wanted.push((k, pieces));
368        }
369    }
370    if wanted.is_empty() {
371        return Ok((0, false));
372    }
373    let asked: usize = wanted.iter().map(|(_, pieces)| pieces - 1).sum();
374    let room = SWEEP_MAX_STATIONS.saturating_sub(emitted);
375    let at_cap = asked > room;
376    if at_cap {
377        // Worst first, as many as fit; a span that does not fit whole is
378        // bisected if one station fits.
379        wanted.sort_by(|a, b| deviations[b.0].total_cmp(&deviations[a.0]));
380    }
381    let mut inserted = 0usize;
382    for (k, pieces) in wanted {
383        let mut pieces = pieces;
384        while pieces > 1 && inserted + pieces - 1 > room {
385            pieces -= 1;
386        }
387        if pieces < 2 {
388            continue;
389        }
390        let (segment, t_a, t_b) = span_piece(chain, params, k, closed)?;
391        for step in 1..pieces {
392            per_segment[segment].push(t_a + (t_b - t_a) * step as f64 / pieces as f64);
393        }
394        inserted += pieces - 1;
395    }
396    for list in per_segment.iter_mut() {
397        list.sort_by(|a, b| a.total_cmp(b));
398        list.dedup();
399    }
400    Ok((inserted, at_cap))
401}
402
403/// The section's signed area and first moments about its anchor, in the
404/// anchor's own `(a, b) = ((x − origin)·pu, (x − origin)·pv)` coordinates, by
405/// Gauss quadrature on every knot span of every curve (Green's theorem:
406/// `A = ½∮(a·b' − b·a')`, `∫a dA = ½∮a²·b'`, `∫b dA = −½∮b²·a'`). Exact on
407/// polynomial curves; a rational arc reads to round-off at eight points a
408/// span.
409pub(super) fn section_moments(profile: &[NurbsCurve], origin: Vec3, pu: Vec3, pv: Vec3) -> Result<(f64, f64, f64), String> {
410    let (mut area, mut moment_a, mut moment_b) = (0.0, 0.0, 0.0);
411    for curve in profile {
412        for pair in curve.knots.windows(2) {
413            if pair[1] <= pair[0] {
414                continue;
415            }
416            let half = 0.5 * (pair[1] - pair[0]);
417            let middle = 0.5 * (pair[1] + pair[0]);
418            for index in 0..GAUSS_X.len() {
419                let derivatives = curve.derivatives_small(middle + half * GAUSS_X[index], 1)?;
420                let local = derivatives[0].sub(origin);
421                let (a, b) = (local.dot(pu), local.dot(pv));
422                let (da, db) = (derivatives[1].dot(pu), derivatives[1].dot(pv));
423                let weight = GAUSS_W[index] * half;
424                area += weight * 0.5 * (a * db - b * da);
425                moment_a += weight * 0.5 * a * a * db;
426                moment_b -= weight * 0.5 * b * b * da;
427            }
428        }
429    }
430    Ok((area, moment_a, moment_b))
431}
432
433/// How far the section reaches from its anchor: the farthest control point
434/// (the curves lie in their hulls, so this bounds every section point).
435pub(super) fn section_reach(profile: &[NurbsCurve], origin: Vec3) -> f64 {
436    profile
437        .iter()
438        .flat_map(|curve| curve.control_points.iter())
439        .filter_map(|point| point.point().ok())
440        .map(|point| point.sub(origin).length())
441        .fold(0.0, f64::max)
442}
443
444/// Rodrigues about a unit axis.
445fn rotated(x: Vec3, axis: Vec3, angle: f64) -> Vec3 {
446    let (sin, cos) = angle.sin_cos();
447    x.scale(cos).add(axis.cross(x).scale(sin)).add(axis.scale(axis.dot(x) * (1.0 - cos)))
448}
449
450/// The inputs [`station_report`] reads off a placed run.
451pub(super) struct PlacedRun<'a> {
452    pub chain: &'a [NurbsCurve],
453    pub params: &'a [StationParam],
454    pub points: &'a [Vec3],
455    pub tangents: &'a [Vec3],
456    /// The axes the sections were placed on, twist included.
457    pub axes: &'a [(Vec3, Vec3)],
458    /// The image of the anchor origin at every station (the centre track).
459    pub centres: &'a [Vec3],
460    /// The images of `pu` and `pv` — the section's affine axes — per station.
461    pub e1: &'a [Vec3],
462    pub e2: &'a [Vec3],
463    /// `(pu, pv)` in the placement basis `(t, r, s)`: the same coefficients
464    /// at every station, which is what makes the carry rigid.
465    pub pu_in_frame: [f64; 3],
466    pub pv_in_frame: [f64; 3],
467    pub closed: bool,
468    pub sections: &'a [Vec<NurbsCurve>],
469    /// Signed area and first moments of the section about its anchor.
470    pub moments: (f64, f64, f64),
471    pub reach: f64,
472    /// The seam shift of a twisted ring; the quadrature is over one lap only.
473    pub shift: usize,
474    pub budget: StationBudget,
475    pub tolerance: f64,
476}
477
478/// One RMF step (Wang et al. 2008), as `sweep.rs::rmf_step` takes it, for the
479/// sub-station transport below.
480fn rmf_step(r: Vec3, t: Vec3, t_next: Vec3, step: Vec3) -> Option<Vec3> {
481    let c1 = step.dot(step);
482    let candidate = if c1 <= 1e-18 {
483        r
484    } else {
485        let reflected_r = r.sub(step.scale(2.0 / c1 * step.dot(r)));
486        let reflected_t = t.sub(step.scale(2.0 / c1 * step.dot(t)));
487        let v2 = t_next.sub(reflected_t);
488        let c2 = v2.dot(v2);
489        if c2 <= 1e-18 {
490            reflected_r
491        } else {
492            reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
493        }
494    };
495    candidate.sub(t_next.scale(candidate.dot(t_next))).normalized().ok()
496}
497
498/// Sub-station fractions the report transports the frame through: the span's
499/// quarter points, then its end (where the residual roll is read).
500const SUB_FRACTIONS: [f64; 4] = [0.25, 0.5, 0.75, 1.0];
501
502/// The measured bound of a placed run — see the module header.
503pub(super) fn station_report(run: &PlacedRun<'_>) -> Result<SweepStationReport, KernelRefusal> {
504    let refuse = |what: &str, error: String| {
505        KernelRefusal::internal(KernelStage::Refine, "station_report", format!("sweepSolid: station report ({what}): {error}"))
506    };
507    let count = run.points.len();
508    let closed = run.closed;
509    let spans = count - 1 + usize::from(closed);
510    let (area, moment_a, moment_b) = run.moments;
511    // The section's anchor rides the path under `Transplant`; under `Rigid` it
512    // rides where it was drawn, `offset` from the path in the station-0 frame
513    // and carried rigidly — so the centre track is the path plus that offset
514    // turned with the frame, and the track's own motion enters the volume.
515    let t0 = run.tangents[0];
516    let (r0, s0) = run.axes[0];
517    let drawn = run.centres[0].sub(run.points[0]);
518    let offset = [drawn.dot(t0), drawn.dot(r0), drawn.dot(s0)];
519    let carried = offset.iter().any(|component| component.abs() > 1e-12 * run.reach.max(1e-9));
520    let centred = moment_a.abs().max(moment_b.abs()) <= 1e-9 * area.abs() * run.reach.max(1e-9);
521    let corrections = !centred || carried;
522
523    // --- The loft's interpolants of the centre track and the affine axes, on
524    //     the loft's own parameters.
525    let parameters = loft_parameters(run.sections, closed)?;
526    let centre = interpolant(run.centres, &parameters, closed).map_err(|e| refuse("centre", e))?;
527    let axis_1 = interpolant(run.e1, &parameters, closed).map_err(|e| refuse("axis 1", e))?;
528    let axis_2 = interpolant(run.e2, &parameters, closed).map_err(|e| refuse("axis 2", e))?;
529
530    // --- The built volume: ∫ det[X_v, X_a, X_b] over the section and one lap,
531    //     with X = C(v) + a·E1(v) + b·E2(v), by Gauss on every knot span of the
532    //     interpolants (the integrand is a polynomial of degree 8 there).
533    let mut built = 0.0;
534    let mut lateral_length = 0.0;
535    for pair in centre.knots.windows(2) {
536        if pair[1] <= pair[0] {
537            continue;
538        }
539        let half = 0.5 * (pair[1] - pair[0]);
540        let middle = 0.5 * (pair[1] + pair[0]);
541        for index in 0..GAUSS_X.len() {
542            let v = middle + half * GAUSS_X[index];
543            let c = centre.derivatives_small(v, 1).map_err(|e| refuse("centre", e))?;
544            let e1 = axis_1.derivatives_small(v, 1).map_err(|e| refuse("axis 1", e))?;
545            let e2 = axis_2.derivatives_small(v, 1).map_err(|e| refuse("axis 2", e))?;
546            let normal = e1[0].cross(e2[0]);
547            let weight = GAUSS_W[index] * half;
548            built += weight * (area * c[1].dot(normal) + moment_a * e1[1].dot(normal) + moment_b * e2[1].dot(normal));
549            lateral_length += weight * c[1].length();
550        }
551    }
552
553    // --- The true path and the transported frame between stations: the
554    //     deviations, the path length, and the kinematic sweep's own moment
555    //     terms (Simpson over each span's quarter points).
556    let [pu_t, pu_r, pu_s] = run.pu_in_frame;
557    let [pv_t, pv_r, pv_s] = run.pv_in_frame;
558    let axes_of = |t: Vec3, r: Vec3, s: Vec3| -> (Vec3, Vec3) {
559        (
560            t.scale(pu_t).add(r.scale(pu_r)).add(s.scale(pu_s)),
561            t.scale(pv_t).add(r.scale(pv_r)).add(s.scale(pv_s)),
562        )
563    };
564    let mut spine_deviation: f64 = 0.0;
565    let mut frame_deviation: f64 = 0.0;
566    let mut length = 0.0;
567    let mut moment_integral = 0.0;
568    for k in 0..spans {
569        let (segment, t_a, t_b) = span_piece(run.chain, run.params, k, closed).map_err(|e| refuse("span", e))?;
570        let curve = &run.chain[segment];
571        let next = (k + 1) % count;
572        let (u_a, u_b) = (parameters[k], parameters[k + 1]);
573        // Path length over the span, by Gauss in the path parameter.
574        let (half, middle) = (0.5 * (t_b - t_a), 0.5 * (t_b + t_a));
575        for index in 0..GAUSS_X.len() {
576            let derivatives = curve.derivatives_small(middle + half * GAUSS_X[index], 1).map_err(|e| refuse("path", e))?;
577            length += GAUSS_W[index] * half * derivatives[1].length();
578        }
579        // Transport station k's placed frame through the quarter points to
580        // station k + 1, read the roll it arrives short of the placed frame
581        // there, and lay that roll down linearly in chord over the span.
582        let (r_k, s_k) = run.axes[k];
583        let mut r = r_k;
584        let mut t_prev = run.tangents[k];
585        let mut p_prev = run.points[k];
586        let mut transported: Vec<(Vec3, Vec3, Vec3, Vec3)> = Vec::with_capacity(SUB_FRACTIONS.len());
587        let [d0, d1] = curve.domain().map_err(|e| refuse("domain", e))?;
588        for fraction in SUB_FRACTIONS {
589            let t = t_a + (t_b - t_a) * fraction;
590            let derivatives = curve.derivatives_small(t, 2).map_err(|e| refuse("path", e))?;
591            let point = derivatives[0];
592            // A stationary parameter (a cusp in the parameterization) still has
593            // a direction of travel: the one-sided limit `unit_tangent` reads.
594            let tangent = curve.unit_tangent(t, d0, d1).map_err(|e| refuse("tangent", e))?;
595            let kappa_n = super::sweep::curvature_vector(derivatives[1], derivatives[2]);
596            r = rmf_step(r, t_prev, tangent, point.sub(p_prev)).ok_or_else(|| refuse("transport", "the frame degenerated".into()))?;
597            transported.push((point, tangent, kappa_n, r));
598            t_prev = tangent;
599            p_prev = point;
600        }
601        let (r_end, _) = run.axes[next];
602        let t_end = run.tangents[next];
603        let r_arrived = transported[SUB_FRACTIONS.len() - 1].3;
604        let roll = r_arrived.cross(r_end).dot(t_end).atan2(r_arrived.dot(r_end));
605        let chord: f64 = {
606            let mut sum = 0.0;
607            let mut prev = run.points[k];
608            for entry in &transported {
609                sum += entry.0.sub(prev).length();
610                prev = entry.0;
611            }
612            sum
613        };
614        let _ = s_k;
615        // Simpson over the five points of the span (station k, the quarter
616        // points, station k + 1) for the moment terms.
617        let mut integrand: Vec<(f64, f64)> = Vec::with_capacity(5);
618        let mut chord_so_far = 0.0;
619        let mut prev = run.points[k];
620        for (which, fraction) in std::iter::once(0.0).chain(SUB_FRACTIONS).enumerate() {
621            let (point, tangent, kappa_n, r_raw) = if which == 0 {
622                let derivatives = curve.derivatives_small(t_a, 2).map_err(|e| refuse("path", e))?;
623                (run.points[k], run.tangents[k], super::sweep::curvature_vector(derivatives[1], derivatives[2]), r_k)
624            } else {
625                transported[which - 1]
626            };
627            chord_so_far += point.sub(prev).length();
628            prev = point;
629            let phi = if chord > 0.0 { roll * chord_so_far / chord } else { 0.0 };
630            let r_true = if which == 0 { r_k } else { rotated(r_raw, tangent, phi) };
631            let s_true = tangent.cross(r_true);
632            let (e1_true, e2_true) = axes_of(tangent, r_true, s_true);
633            let centre_true = point
634                .add(tangent.scale(offset[0]))
635                .add(r_true.scale(offset[1]))
636                .add(s_true.scale(offset[2]));
637            let speed = curve.derivatives_small(t_a + (t_b - t_a) * fraction, 1).map_err(|e| refuse("path", e))?[1].length();
638            if corrections {
639                // d/ds of the carried axes: T' = κN, and the RMF's r' = −κ(r·N)T
640                // plus the roll laid down, ω = roll / chord, about T. The
641                // centre's own motion beyond T is the offset turned with them.
642                let omega = if chord > 0.0 { roll / chord } else { 0.0 };
643                let t_dot = kappa_n;
644                let r_dot = tangent.scale(-kappa_n.dot(r_true)).add(s_true.scale(omega));
645                let s_dot = tangent.scale(-kappa_n.dot(s_true)).sub(r_true.scale(omega));
646                let e1_dot = t_dot.scale(pu_t).add(r_dot.scale(pu_r)).add(s_dot.scale(pu_s));
647                let e2_dot = t_dot.scale(pv_t).add(r_dot.scale(pv_r)).add(s_dot.scale(pv_s));
648                let c_dot = t_dot.scale(offset[0]).add(r_dot.scale(offset[1])).add(s_dot.scale(offset[2]));
649                let normal = e1_true.cross(e2_true);
650                integrand.push((
651                    fraction,
652                    (area * c_dot.dot(normal) + moment_a * e1_dot.dot(normal) + moment_b * e2_dot.dot(normal)) * speed,
653                ));
654            }
655            if which == 0 || which == SUB_FRACTIONS.len() {
656                continue;
657            }
658            // The deviations, at the interior quarter points.
659            let guess = u_a + (u_b - u_a) * fraction;
660            let (u, distance) = nearest_parameter(&centre, centre_true, guess).map_err(|e| refuse("projection", e))?;
661            spine_deviation = spine_deviation.max(distance);
662            let e1_built = axis_1.evaluate(u).map_err(|e| refuse("axis 1", e))?;
663            let e2_built = axis_2.evaluate(u).map_err(|e| refuse("axis 2", e))?;
664            let frame = (e1_built.sub(e1_true).length_squared() + e2_built.sub(e2_true).length_squared()).sqrt();
665            frame_deviation = frame_deviation.max(frame);
666        }
667        if corrections {
668            // Composite Simpson on four equal steps of the path parameter.
669            let h = (t_b - t_a) / 4.0;
670            let weights = [1.0, 4.0, 2.0, 4.0, 1.0];
671            moment_integral += integrand.iter().zip(weights).map(|((_, g), w)| g * w).sum::<f64>() * h / 3.0;
672        }
673    }
674    let (e1_0, e2_0) = (run.e1[0], run.e2[0]);
675    let advance = run.tangents[0].dot(e1_0.cross(e2_0));
676    let ideal = area * length * advance + moment_integral;
677    let wall_deviation = spine_deviation + run.reach * frame_deviation;
678    let (built_volume, ideal_volume, residual, crude) = if run.shift == 0 {
679        (built.abs(), ideal.abs(), (built.abs() - ideal.abs()).abs(), false)
680    } else {
681        // A seam-shifted ring's columns run several laps; the one-lap
682        // quadrature does not describe it. Bound it instead.
683        let perimeter = section_perimeter(run.sections.first().map(|s| s.as_slice()).unwrap_or(&[]))?;
684        (f64::NAN, ideal.abs(), perimeter * lateral_length * wall_deviation, true)
685    };
686    Ok(SweepStationReport {
687        budget: run.budget,
688        tolerance: run.tolerance,
689        spine_deviation,
690        frame_deviation,
691        wall_deviation,
692        reach: run.reach,
693        built_volume,
694        ideal_volume,
695        volume_residual: residual,
696        crude_bound: crude,
697    })
698}
699
700/// A section's perimeter by chord sums (64 per curve) — only the crude bound
701/// reads it.
702fn section_perimeter(section: &[NurbsCurve]) -> Result<f64, KernelRefusal> {
703    let mut total = 0.0;
704    for curve in section {
705        let [start, end] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
706        let mut previous = curve.evaluate(start).or_refuse(KernelStage::Refine, "evaluate")?;
707        for index in 1..=64 {
708            let point = curve.evaluate(start + (end - start) * index as f64 / 64.0).or_refuse(KernelStage::Refine, "evaluate")?;
709            total += point.sub(previous).length();
710            previous = point;
711        }
712    }
713    Ok(total)
714}