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
168/// Whether refinement is switched off — by the environment
169/// (`BREP_SWEEP_STATION_REFINEMENT=0`, mirroring `BREP_SWEEP_BEND_GUARD`, the
170/// switch a probe or a whole-module run pulls) or by this thread's stub.
171pub(super) fn refinement_enabled() -> bool {
172    switch!(live "BREP_SWEEP_STATION_REFINEMENT", true)
173}
174
175
176/// Cumulative chord parameters over `points`, normalized to `[0, 1]`; a
177/// CLOSED run has one more entry for the wrap chord back to point 0.
178pub(super) fn chord_parameters(points: &[Vec3], closed: bool) -> Vec<f64> {
179    let count = points.len();
180    let mut parameters = Vec::with_capacity(count + usize::from(closed));
181    let mut total = 0.0;
182    parameters.push(0.0);
183    for index in 1..count {
184        total += points[index].sub(points[index - 1]).length();
185        parameters.push(total);
186    }
187    if closed {
188        total += points[0].sub(points[count - 1]).length();
189        parameters.push(total);
190    }
191    if total > 0.0 {
192        for parameter in parameters.iter_mut() {
193            *parameter /= total;
194        }
195    }
196    parameters
197}
198
199/// The loft's own v-parameters for a run of placed sections: the average over
200/// every control-point column of that column's cumulative chord fraction, the
201/// wrap chord included on a ring — `loft_topology/basic.rs` and `closed.rs`,
202/// formula for formula, so the interpolants built on them are the loft's.
203pub(super) fn loft_parameters(sections: &[Vec<NurbsCurve>], closed: bool) -> Result<Vec<f64>, KernelRefusal> {
204    let count = sections.len();
205    let spans = count - 1 + usize::from(closed);
206    let mut accumulated = vec![0.0; spans + 1];
207    let mut columns = 0usize;
208    for curve_index in 0..sections[0].len() {
209        for control_index in 0..sections[0][curve_index].control_points.len() {
210            let mut chords = vec![0.0; spans + 1];
211            let mut total = 0.0;
212            for station in 1..=spans {
213                let previous = sections[station - 1][curve_index].control_points[control_index]
214                    .point()
215                    .or_refuse(KernelStage::Refine, "control_point")?;
216                let current = sections[station % count][curve_index].control_points[control_index]
217                    .point()
218                    .or_refuse(KernelStage::Refine, "control_point")?;
219                total += current.sub(previous).length();
220                chords[station] = total;
221            }
222            if total <= 1e-6 {
223                continue;
224            }
225            for station in 0..=spans {
226                accumulated[station] += chords[station] / total;
227            }
228            columns += 1;
229        }
230    }
231    if columns == 0 {
232        return Err(KernelRefusal::input(
233            KernelStage::Classify,
234            "coincident_sections",
235            "sweepSolid: the placed sections coincide",
236        ));
237    }
238    let mut parameters: Vec<f64> = accumulated.iter().map(|sum| sum / columns as f64).collect();
239    parameters[0] = 0.0;
240    parameters[spans] = 1.0;
241    Ok(parameters)
242}
243
244/// The loft's v-interpolant through `points` at `parameters`: the open fit at
245/// the loft's degree (cubic, or fewer points minus one), or the periodic
246/// cubic on a ring (`parameters` then carries the wrap entry).
247pub(super) fn interpolant(points: &[Vec3], parameters: &[f64], closed: bool) -> Result<NurbsCurve, String> {
248    if closed {
249        interpolate_curve_closed(points, parameters)
250    } else {
251        interpolate_curve(points, 3usize.min(points.len() - 1), parameters)
252    }
253}
254
255/// The parameter on `curve` nearest `point`, from `guess`: Newton on the
256/// distance's derivative, held inside the curve's domain. The spans are
257/// short and the guess is the chord fraction, so a few steps converge; a step
258/// that does not improve is discarded.
259fn nearest_parameter(curve: &NurbsCurve, point: Vec3, guess: f64) -> Result<(f64, f64), String> {
260    let [lo, hi] = curve.domain()?;
261    let (u, _) = crate::projection::curve_foot_newton(curve, point, guess, [lo, hi],
262        crate::projection::CurveFootPolicy {
263            domain: crate::projection::FootDomain::Clamp,
264            iterations: 6,
265            slope_guard: 1e-300,
266            step_limit: f64::INFINITY,
267            parameter_tolerance: 1e-15 * hi.abs().max(1.0),
268            mode: crate::projection::FootMode::Monotone,
269        })?;
270    Ok((u, curve.evaluate(u)?.sub(point).length()))
271}
272
273/// The true path piece under span `k`: its segment and parameter interval.
274/// A span runs from station `k` to station `k + 1` on station `k`'s departing
275/// segment — to that segment's end when the next station is the joint (or
276/// station 0 of a ring).
277fn span_piece(chain: &[NurbsCurve], params: &[StationParam], k: usize, closed: bool) -> Result<(usize, f64, f64), String> {
278    let count = params.len();
279    let from = params[k];
280    let next = (k + 1) % count;
281    let end = if !closed && k + 1 == count {
282        return Err("no span past the last station of an open run".into());
283    } else if next != 0 && params[next].segment == from.segment {
284        params[next].t
285    } else {
286        chain[from.segment].domain()?[1]
287    };
288    Ok((from.segment, from.t, end))
289}
290
291/// The fractions of a span the instruments sample the true path at.
292const SPAN_FRACTIONS: [f64; 3] = [0.25, 0.5, 0.75];
293
294/// Per span, the worst distance from the true path to the loft's interpolant
295/// of the station points (at the centre-chord parameters the sampler has
296/// before any section is placed). An open run of `n` stations has `n − 1`
297/// spans, a ring `n`.
298pub(super) fn spine_deviations(
299    chain: &[NurbsCurve],
300    params: &[StationParam],
301    points: &[Vec3],
302    closed: bool,
303) -> Result<Vec<f64>, String> {
304    let parameters = chord_parameters(points, closed);
305    let spine = interpolant(points, &parameters, closed)?;
306    let spans = points.len() - 1 + usize::from(closed);
307    let mut deviations = Vec::with_capacity(spans);
308    for k in 0..spans {
309        let (segment, t_a, t_b) = span_piece(chain, params, k, closed)?;
310        let (u_a, u_b) = (parameters[k], parameters[k + 1]);
311        let mut worst: f64 = 0.0;
312        for fraction in SPAN_FRACTIONS {
313            let truth = chain[segment].evaluate(t_a + (t_b - t_a) * fraction)?;
314            let (_, distance) = nearest_parameter(&spine, truth, u_a + (u_b - u_a) * fraction)?;
315            worst = worst.max(distance);
316        }
317        deviations.push(worst);
318    }
319    Ok(deviations)
320}
321
322/// One refinement round: for every span whose deviation is over `tolerance`,
323/// split it into `ceil((deviation / tolerance)^(1/4))` equal parameter steps
324/// — the cubic interpolant's `h⁴` law — or `^(1/2)` for a span that starts or
325/// ends at a segment joint, where a curvature jump drops the cubic to `h²`
326/// (at least 2, at most 8 pieces), and insert the new parameters into that
327/// segment's list. Returns how many stations were inserted and whether the
328/// round wanted more than the cap allows, in which case the spans are refined
329/// worst-first until it is reached and the caller records `at_cap`.
330pub(super) fn refine_parameters(
331    chain: &[NurbsCurve],
332    per_segment: &mut [Vec<f64>],
333    params: &[StationParam],
334    deviations: &[f64],
335    tolerance: f64,
336    closed: bool,
337    emitted: usize,
338) -> Result<(usize, bool), String> {
339    let count = params.len();
340    let mut wanted: Vec<(usize, usize)> = Vec::new();
341    for (k, &deviation) in deviations.iter().enumerate() {
342        if deviation > tolerance {
343            let (segment, t_a, t_b) = span_piece(chain, params, k, closed)?;
344            let [t0, t1] = chain[segment].domain()?;
345            let next = (k + 1) % count;
346            let at_joint = (t_a == t0 && (k > 0 || closed))
347                || t_b == t1 && (next != 0 || closed) && (next == 0 || params[next].segment != segment);
348            let exponent = if at_joint { 0.5 } else { 0.25 };
349            let pieces = ((deviation / tolerance).powf(exponent).ceil() as usize).clamp(2, 8);
350            wanted.push((k, pieces));
351        }
352    }
353    if wanted.is_empty() {
354        return Ok((0, false));
355    }
356    let asked: usize = wanted.iter().map(|(_, pieces)| pieces - 1).sum();
357    let room = SWEEP_MAX_STATIONS.saturating_sub(emitted);
358    let at_cap = asked > room;
359    if at_cap {
360        // Worst first, as many as fit; a span that does not fit whole is
361        // bisected if one station fits.
362        wanted.sort_by(|a, b| deviations[b.0].total_cmp(&deviations[a.0]));
363    }
364    let mut inserted = 0usize;
365    for (k, pieces) in wanted {
366        let mut pieces = pieces;
367        while pieces > 1 && inserted + pieces - 1 > room {
368            pieces -= 1;
369        }
370        if pieces < 2 {
371            continue;
372        }
373        let (segment, t_a, t_b) = span_piece(chain, params, k, closed)?;
374        for step in 1..pieces {
375            per_segment[segment].push(t_a + (t_b - t_a) * step as f64 / pieces as f64);
376        }
377        inserted += pieces - 1;
378    }
379    for list in per_segment.iter_mut() {
380        list.sort_by(|a, b| a.total_cmp(b));
381        list.dedup();
382    }
383    Ok((inserted, at_cap))
384}
385
386/// The section's signed area and first moments about its anchor, in the
387/// anchor's own `(a, b) = ((x − origin)·pu, (x − origin)·pv)` coordinates, by
388/// Gauss quadrature on every knot span of every curve (Green's theorem:
389/// `A = ½∮(a·b' − b·a')`, `∫a dA = ½∮a²·b'`, `∫b dA = −½∮b²·a'`). Exact on
390/// polynomial curves; a rational arc reads to round-off at eight points a
391/// span.
392pub(super) fn section_moments(profile: &[NurbsCurve], origin: Vec3, pu: Vec3, pv: Vec3) -> Result<(f64, f64, f64), String> {
393    let (mut area, mut moment_a, mut moment_b) = (0.0, 0.0, 0.0);
394    for curve in profile {
395        for pair in curve.knots.windows(2) {
396            if pair[1] <= pair[0] {
397                continue;
398            }
399            let half = 0.5 * (pair[1] - pair[0]);
400            let middle = 0.5 * (pair[1] + pair[0]);
401            for index in 0..GAUSS_X.len() {
402                let derivatives = curve.derivatives_small(middle + half * GAUSS_X[index], 1)?;
403                let local = derivatives[0].sub(origin);
404                let (a, b) = (local.dot(pu), local.dot(pv));
405                let (da, db) = (derivatives[1].dot(pu), derivatives[1].dot(pv));
406                let weight = GAUSS_W[index] * half;
407                area += weight * 0.5 * (a * db - b * da);
408                moment_a += weight * 0.5 * a * a * db;
409                moment_b -= weight * 0.5 * b * b * da;
410            }
411        }
412    }
413    Ok((area, moment_a, moment_b))
414}
415
416/// How far the section reaches from its anchor: the farthest control point
417/// (the curves lie in their hulls, so this bounds every section point).
418pub(super) fn section_reach(profile: &[NurbsCurve], origin: Vec3) -> f64 {
419    profile
420        .iter()
421        .flat_map(|curve| curve.control_points.iter())
422        .filter_map(|point| point.point().ok())
423        .map(|point| point.sub(origin).length())
424        .fold(0.0, f64::max)
425}
426
427/// Rodrigues about a unit axis.
428fn rotated(x: Vec3, axis: Vec3, angle: f64) -> Vec3 {
429    x.rotate_about(axis, angle)
430}
431
432/// The inputs [`station_report`] reads off a placed run.
433pub(super) struct PlacedRun<'a> {
434    pub chain: &'a [NurbsCurve],
435    pub params: &'a [StationParam],
436    pub points: &'a [Vec3],
437    pub tangents: &'a [Vec3],
438    /// The axes the sections were placed on, twist included.
439    pub axes: &'a [(Vec3, Vec3)],
440    /// The image of the anchor origin at every station (the centre track).
441    pub centres: &'a [Vec3],
442    /// The images of `pu` and `pv` — the section's affine axes — per station.
443    pub e1: &'a [Vec3],
444    pub e2: &'a [Vec3],
445    /// `(pu, pv)` in the placement basis `(t, r, s)`: the same coefficients
446    /// at every station, which is what makes the carry rigid.
447    pub pu_in_frame: [f64; 3],
448    pub pv_in_frame: [f64; 3],
449    pub closed: bool,
450    pub sections: &'a [Vec<NurbsCurve>],
451    /// Signed area and first moments of the section about its anchor.
452    pub moments: (f64, f64, f64),
453    pub reach: f64,
454    /// The seam shift of a twisted ring; the quadrature is over one lap only.
455    pub shift: usize,
456    pub budget: StationBudget,
457    pub tolerance: f64,
458}
459
460/// One RMF step (Wang et al. 2008), as `sweep.rs::rmf_step` takes it, for the
461/// sub-station transport below.
462fn rmf_step(r: Vec3, t: Vec3, t_next: Vec3, step: Vec3) -> Option<Vec3> {
463    let c1 = step.dot(step);
464    let candidate = if c1 <= 1e-18 {
465        r
466    } else {
467        let reflected_r = r.sub(step.scale(2.0 / c1 * step.dot(r)));
468        let reflected_t = t.sub(step.scale(2.0 / c1 * step.dot(t)));
469        let v2 = t_next.sub(reflected_t);
470        let c2 = v2.dot(v2);
471        if c2 <= 1e-18 {
472            reflected_r
473        } else {
474            reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
475        }
476    };
477    candidate.sub(t_next.scale(candidate.dot(t_next))).normalized().ok()
478}
479
480/// Sub-station fractions the report transports the frame through: the span's
481/// quarter points, then its end (where the residual roll is read).
482const SUB_FRACTIONS: [f64; 4] = [0.25, 0.5, 0.75, 1.0];
483
484/// The measured bound of a placed run — see the module header.
485pub(super) fn station_report(run: &PlacedRun<'_>) -> Result<SweepStationReport, KernelRefusal> {
486    let refuse = |what: &str, error: String| {
487        KernelRefusal::internal(KernelStage::Refine, "station_report", format!("sweepSolid: station report ({what}): {error}"))
488    };
489    let count = run.points.len();
490    let closed = run.closed;
491    let spans = count - 1 + usize::from(closed);
492    let (area, moment_a, moment_b) = run.moments;
493    // The section's anchor rides the path under `Transplant`; under `Rigid` it
494    // rides where it was drawn, `offset` from the path in the station-0 frame
495    // and carried rigidly — so the centre track is the path plus that offset
496    // turned with the frame, and the track's own motion enters the volume.
497    let t0 = run.tangents[0];
498    let (r0, s0) = run.axes[0];
499    let drawn = run.centres[0].sub(run.points[0]);
500    let offset = [drawn.dot(t0), drawn.dot(r0), drawn.dot(s0)];
501    let carried = offset.iter().any(|component| component.abs() > 1e-12 * run.reach.max(1e-9));
502    let centred = moment_a.abs().max(moment_b.abs()) <= 1e-9 * area.abs() * run.reach.max(1e-9);
503    let corrections = !centred || carried;
504
505    // --- The loft's interpolants of the centre track and the affine axes, on
506    //     the loft's own parameters.
507    let parameters = loft_parameters(run.sections, closed)?;
508    let centre = interpolant(run.centres, &parameters, closed).map_err(|e| refuse("centre", e))?;
509    let axis_1 = interpolant(run.e1, &parameters, closed).map_err(|e| refuse("axis 1", e))?;
510    let axis_2 = interpolant(run.e2, &parameters, closed).map_err(|e| refuse("axis 2", e))?;
511
512    // --- The built volume: ∫ det[X_v, X_a, X_b] over the section and one lap,
513    //     with X = C(v) + a·E1(v) + b·E2(v), by Gauss on every knot span of the
514    //     interpolants (the integrand is a polynomial of degree 8 there).
515    let mut built = 0.0;
516    let mut lateral_length = 0.0;
517    for pair in centre.knots.windows(2) {
518        if pair[1] <= pair[0] {
519            continue;
520        }
521        let half = 0.5 * (pair[1] - pair[0]);
522        let middle = 0.5 * (pair[1] + pair[0]);
523        for index in 0..GAUSS_X.len() {
524            let v = middle + half * GAUSS_X[index];
525            let c = centre.derivatives_small(v, 1).map_err(|e| refuse("centre", e))?;
526            let e1 = axis_1.derivatives_small(v, 1).map_err(|e| refuse("axis 1", e))?;
527            let e2 = axis_2.derivatives_small(v, 1).map_err(|e| refuse("axis 2", e))?;
528            let normal = e1[0].cross(e2[0]);
529            let weight = GAUSS_W[index] * half;
530            built += weight * (area * c[1].dot(normal) + moment_a * e1[1].dot(normal) + moment_b * e2[1].dot(normal));
531            lateral_length += weight * c[1].length();
532        }
533    }
534
535    // --- The true path and the transported frame between stations: the
536    //     deviations, the path length, and the kinematic sweep's own moment
537    //     terms (Simpson over each span's quarter points).
538    let [pu_t, pu_r, pu_s] = run.pu_in_frame;
539    let [pv_t, pv_r, pv_s] = run.pv_in_frame;
540    let axes_of = |t: Vec3, r: Vec3, s: Vec3| -> (Vec3, Vec3) {
541        (
542            t.scale(pu_t).add(r.scale(pu_r)).add(s.scale(pu_s)),
543            t.scale(pv_t).add(r.scale(pv_r)).add(s.scale(pv_s)),
544        )
545    };
546    let mut spine_deviation: f64 = 0.0;
547    let mut frame_deviation: f64 = 0.0;
548    let mut length = 0.0;
549    let mut moment_integral = 0.0;
550    for k in 0..spans {
551        let (segment, t_a, t_b) = span_piece(run.chain, run.params, k, closed).map_err(|e| refuse("span", e))?;
552        let curve = &run.chain[segment];
553        let next = (k + 1) % count;
554        let (u_a, u_b) = (parameters[k], parameters[k + 1]);
555        // Path length over the span, by Gauss in the path parameter.
556        let (half, middle) = (0.5 * (t_b - t_a), 0.5 * (t_b + t_a));
557        for index in 0..GAUSS_X.len() {
558            let derivatives = curve.derivatives_small(middle + half * GAUSS_X[index], 1).map_err(|e| refuse("path", e))?;
559            length += GAUSS_W[index] * half * derivatives[1].length();
560        }
561        // Transport station k's placed frame through the quarter points to
562        // station k + 1, read the roll it arrives short of the placed frame
563        // there, and lay that roll down linearly in chord over the span.
564        let (r_k, s_k) = run.axes[k];
565        let mut r = r_k;
566        let mut t_prev = run.tangents[k];
567        let mut p_prev = run.points[k];
568        let mut transported: Vec<(Vec3, Vec3, Vec3, Vec3)> = Vec::with_capacity(SUB_FRACTIONS.len());
569        let [d0, d1] = curve.domain().map_err(|e| refuse("domain", e))?;
570        for fraction in SUB_FRACTIONS {
571            let t = t_a + (t_b - t_a) * fraction;
572            let derivatives = curve.derivatives_small(t, 2).map_err(|e| refuse("path", e))?;
573            let point = derivatives[0];
574            // A stationary parameter (a cusp in the parameterization) still has
575            // a direction of travel: the one-sided limit `unit_tangent` reads.
576            let tangent = curve.unit_tangent(t, d0, d1).map_err(|e| refuse("tangent", e))?;
577            let kappa_n = super::sweep::curvature_vector(derivatives[1], derivatives[2]);
578            r = rmf_step(r, t_prev, tangent, point.sub(p_prev)).ok_or_else(|| refuse("transport", "the frame degenerated".into()))?;
579            transported.push((point, tangent, kappa_n, r));
580            t_prev = tangent;
581            p_prev = point;
582        }
583        let (r_end, _) = run.axes[next];
584        let t_end = run.tangents[next];
585        let r_arrived = transported[SUB_FRACTIONS.len() - 1].3;
586        let roll = r_arrived.cross(r_end).dot(t_end).atan2(r_arrived.dot(r_end));
587        let chord: f64 = {
588            let mut sum = 0.0;
589            let mut prev = run.points[k];
590            for entry in &transported {
591                sum += entry.0.sub(prev).length();
592                prev = entry.0;
593            }
594            sum
595        };
596        let _ = s_k;
597        // Simpson over the five points of the span (station k, the quarter
598        // points, station k + 1) for the moment terms.
599        let mut integrand: Vec<(f64, f64)> = Vec::with_capacity(5);
600        let mut chord_so_far = 0.0;
601        let mut prev = run.points[k];
602        for (which, fraction) in std::iter::once(0.0).chain(SUB_FRACTIONS).enumerate() {
603            let (point, tangent, kappa_n, r_raw) = if which == 0 {
604                let derivatives = curve.derivatives_small(t_a, 2).map_err(|e| refuse("path", e))?;
605                (run.points[k], run.tangents[k], super::sweep::curvature_vector(derivatives[1], derivatives[2]), r_k)
606            } else {
607                transported[which - 1]
608            };
609            chord_so_far += point.sub(prev).length();
610            prev = point;
611            let phi = if chord > 0.0 { roll * chord_so_far / chord } else { 0.0 };
612            let r_true = if which == 0 { r_k } else { rotated(r_raw, tangent, phi) };
613            let s_true = tangent.cross(r_true);
614            let (e1_true, e2_true) = axes_of(tangent, r_true, s_true);
615            let centre_true = point
616                .add(tangent.scale(offset[0]))
617                .add(r_true.scale(offset[1]))
618                .add(s_true.scale(offset[2]));
619            let speed = curve.derivatives_small(t_a + (t_b - t_a) * fraction, 1).map_err(|e| refuse("path", e))?[1].length();
620            if corrections {
621                // d/ds of the carried axes: T' = κN, and the RMF's r' = −κ(r·N)T
622                // plus the roll laid down, ω = roll / chord, about T. The
623                // centre's own motion beyond T is the offset turned with them.
624                let omega = if chord > 0.0 { roll / chord } else { 0.0 };
625                let t_dot = kappa_n;
626                let r_dot = tangent.scale(-kappa_n.dot(r_true)).add(s_true.scale(omega));
627                let s_dot = tangent.scale(-kappa_n.dot(s_true)).sub(r_true.scale(omega));
628                let e1_dot = t_dot.scale(pu_t).add(r_dot.scale(pu_r)).add(s_dot.scale(pu_s));
629                let e2_dot = t_dot.scale(pv_t).add(r_dot.scale(pv_r)).add(s_dot.scale(pv_s));
630                let c_dot = t_dot.scale(offset[0]).add(r_dot.scale(offset[1])).add(s_dot.scale(offset[2]));
631                let normal = e1_true.cross(e2_true);
632                integrand.push((
633                    fraction,
634                    (area * c_dot.dot(normal) + moment_a * e1_dot.dot(normal) + moment_b * e2_dot.dot(normal)) * speed,
635                ));
636            }
637            if which == 0 || which == SUB_FRACTIONS.len() {
638                continue;
639            }
640            // The deviations, at the interior quarter points.
641            let guess = u_a + (u_b - u_a) * fraction;
642            let (u, distance) = nearest_parameter(&centre, centre_true, guess).map_err(|e| refuse("projection", e))?;
643            spine_deviation = spine_deviation.max(distance);
644            let e1_built = axis_1.evaluate(u).map_err(|e| refuse("axis 1", e))?;
645            let e2_built = axis_2.evaluate(u).map_err(|e| refuse("axis 2", e))?;
646            let frame = (e1_built.sub(e1_true).length_squared() + e2_built.sub(e2_true).length_squared()).sqrt();
647            frame_deviation = frame_deviation.max(frame);
648        }
649        if corrections {
650            // Composite Simpson on four equal steps of the path parameter.
651            let h = (t_b - t_a) / 4.0;
652            let weights = [1.0, 4.0, 2.0, 4.0, 1.0];
653            moment_integral += integrand.iter().zip(weights).map(|((_, g), w)| g * w).sum::<f64>() * h / 3.0;
654        }
655    }
656    let (e1_0, e2_0) = (run.e1[0], run.e2[0]);
657    let advance = run.tangents[0].dot(e1_0.cross(e2_0));
658    let ideal = area * length * advance + moment_integral;
659    let wall_deviation = spine_deviation + run.reach * frame_deviation;
660    let (built_volume, ideal_volume, residual, crude) = if run.shift == 0 {
661        (built.abs(), ideal.abs(), (built.abs() - ideal.abs()).abs(), false)
662    } else {
663        // A seam-shifted ring's columns run several laps; the one-lap
664        // quadrature does not describe it. Bound it instead.
665        let perimeter = section_perimeter(run.sections.first().map(|s| s.as_slice()).unwrap_or(&[]))?;
666        (f64::NAN, ideal.abs(), perimeter * lateral_length * wall_deviation, true)
667    };
668    Ok(SweepStationReport {
669        budget: run.budget,
670        tolerance: run.tolerance,
671        spine_deviation,
672        frame_deviation,
673        wall_deviation,
674        reach: run.reach,
675        built_volume,
676        ideal_volume,
677        volume_residual: residual,
678        crude_bound: crude,
679    })
680}
681
682/// A section's perimeter by chord sums (64 per curve) — only the crude bound
683/// reads it.
684fn section_perimeter(section: &[NurbsCurve]) -> Result<f64, KernelRefusal> {
685    let mut total = 0.0;
686    for curve in section {
687        let [start, end] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
688        let mut previous = curve.evaluate(start).or_refuse(KernelStage::Refine, "evaluate")?;
689        for index in 1..=64 {
690            let point = curve.evaluate(start + (end - start) * index as f64 / 64.0).or_refuse(KernelStage::Refine, "evaluate")?;
691            total += point.sub(previous).length();
692            previous = point;
693        }
694    }
695    Ok(total)
696}