Skip to main content

brep_kernel/construction/sweep_topology/
sweep.rs

1use super::*;
2
3/// Sweep a CLOSED PLANAR profile loop along a path curve (§3.3/§5.7),
4/// TRANSPLANTING the profile onto the path — see [`SectionPlacement`] for the
5/// other reading, and for why the choice changes the solid.
6///
7/// Reuses the loft builder rather than a bespoke swept surface: the path is
8/// sampled at `STATIONS` uniform stations, a rotation-minimizing frame is
9/// propagated along it (double-reflection RMF, Wang et al. 2008 — the frame
10/// does NOT spin at inflections the way raw Frenet does), a rigidly
11/// transformed copy of the profile is placed at each station (identical
12/// degree/knots/weights, only the control points moved — which guarantees
13/// loft's per-section compatibility), and the stations are lofted through
14/// to produce the tube plus planar end caps.
15///
16/// Guards return a clear `Err` on an open/non-planar profile, a degenerate
17/// path tangent, or a loft failure. A path that bends TIGHTER than the section
18/// reaches is refused by name before the loft (`ρκ ≥ 1`, §4c of
19/// [`sweep_sections_through_samples`]); it used to be out of scope and the
20/// caller's problem, and what the caller got instead was a wall that passed
21/// through itself.
22pub fn sweep_profile_along_path(
23    profile: &[NurbsCurve],
24    path: &NurbsCurve,
25    name: Option<&str>,
26) -> Result<BrepSolid, KernelRefusal> {
27    // 32 stations is the original fixed sampling — golden parity pins the
28    // emitted geometry to it — and it stays the count for every path whose
29    // tangent turns through at most a full turn. A path that turns further
30    // (a coil) gets the helix builder's density; see [`path_turning_stations`].
31    sweep_profile_along_path_stations(
32        profile,
33        path,
34        name,
35        path_turning_stations(path),
36        0.0,
37        None,
38        SectionPlacement::Transplant,
39    )
40}
41
42/// Twisted path sweep (§5.7): identical to [`sweep_profile_along_path`], but
43/// the profile additionally ROTATES about the path tangent, linearly in ARC
44/// LENGTH, from 0 at the sweep start to `twist_angle` radians (right-handed
45/// about the tangent) at the end.  The arc-length fraction comes from the
46/// sampled station polyline, not the raw path parameter, so a non-uniformly
47/// parameterized path still twists uniformly in space.
48///
49/// STATION LAW: 16 stations per quarter turn of twist, floored at the path
50/// sweep's 32 and capped at 1024 (the loft's dense interpolation solve is
51/// O(stations³) per control column — the same cap the helix uses).  At
52/// 16/quarter-turn the inter-station twist step is Δφ ≈ 5.6°, so the cubic
53/// v-interpolation error on a profile point circling at radius r is
54/// ≈ r·Δφ⁴/384 ≈ 2.4·10⁻¹⁰·r — far below any geometric tolerance.  The cap
55/// holds that density up to 16 full turns (|twist| = 32π = 1024/16 quarter
56/// turns); a larger twist would silently alias under the cap, so it is
57/// REJECTED with an honest error instead.  Everything else the path sweep
58/// documents (profile validity, the tight-bend refusal) applies unchanged, and
59/// the tight-bend guard measures the TWISTED section's reach because it reads
60/// the axes the sections were actually placed on; twisting about the profile's
61/// own centroid adds no new radial extent of its own.
62pub fn sweep_profile_twisted(
63    profile: &[NurbsCurve],
64    path: &NurbsCurve,
65    twist_angle: f64,
66    name: Option<&str>,
67) -> Result<BrepSolid, KernelRefusal> {
68    use std::f64::consts::{FRAC_PI_2, TAU};
69
70    if !twist_angle.is_finite() {
71        return Err(KernelRefusal::input(
72            KernelStage::Collect,
73            "twist_finite",
74            "sweep_profile_twisted: twist angle must be finite",
75        ));
76    }
77    // 16 turns is where the 1024-station cap meets 16 stations/quarter-turn;
78    // beyond it the cap would degrade the twist sampling density silently.
79    const MAX_TURNS: f64 = 16.0;
80    if twist_angle.abs() > MAX_TURNS * TAU {
81        return Err(KernelRefusal::unsupported(
82            KernelStage::Collect,
83            "twist_cap",
84            format!(
85            "sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
86             limit the 1024-station cap can resolve at 16 stations per quarter turn; \
87             split the sweep or reduce the twist",
88            twist_angle.abs() / TAU
89            ),
90        ));
91    }
92    let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
93    let stations = (quarter_turns * 16).clamp(32, 1024).max(path_turning_stations(path));
94    sweep_profile_along_path_stations(
95        profile,
96        path,
97        name,
98        stations,
99        twist_angle,
100        None,
101        SectionPlacement::Transplant,
102    )
103    .map_err(|error| error.with_message(|error| format!("sweep_profile_twisted: {error}")))
104}
105
106/// The station budget of a single-curve path sweep, from how far the path's
107/// TANGENT turns: 8 stations per quarter turn of tangent — the 32 per turn
108/// [`sweep_profile_helix`] lofts a coil with — floored at the original 32 and
109/// capped at the loft's 1024.
110///
111/// A fixed 32 was a budget for a path that bends once. Along a coil it is
112/// FOUR stations a turn (an 8-turn helix edge, 2026-09-25), and a loft through
113/// sections a quarter turn apart does not follow the path between them: the
114/// wall cut its own corner and folded through itself, and the Tube and Path
115/// Sweep features refused the edge. The floor keeps every path that turns
116/// through at most a full turn on exactly the geometry it always had.
117///
118/// The turning is [`segment_turning`]'s — the chain sampler's own reading —
119/// independent of the station count it decides. A path it cannot read is
120/// reported by the sampler itself, in its own words, after the profile has been
121/// checked; here it only leaves the budget on the floor.
122fn path_turning_stations(path: &NurbsCurve) -> usize {
123    use std::f64::consts::FRAC_PI_2;
124    let turning = path
125        .domain()
126        .and_then(|domain| segment_turning(path, domain))
127        .unwrap_or(0.0);
128    // The slack keeps a path of EXACTLY a full turn (a circle) on the floor
129    // rather than one quarter over it from sampling round-off.
130    let quarter_turns = (turning / FRAC_PI_2 - 1e-3).ceil().max(0.0) as usize;
131    (quarter_turns * 8).clamp(32, 1024)
132}
133
134/// HOW a section is placed at each station — the two things "sweep a profile
135/// along a path" can mean, and the reason they are not the same shape.
136///
137/// `Transplant` MOVES the profile onto the path: the anchor origin lands on the
138/// station point and the profile plane becomes the station's NORMAL plane. The
139/// profile's own position and its angle to the path are both discarded — every
140/// sweep comes out centred on the path and square to it. That is the classic
141/// swept-surface reading, and it is what `SWP` builds.
142///
143/// `Rigid` CARRIES the profile: the section at station `k` is the profile moved
144/// by the same rigid motion the PATH undergoes between its start and station
145/// `k`,
146///
147/// ```text
148///     section_k(x) = P_k + R_k · (x − P_0)
149/// ```
150///
151/// where `R_k` is the rotation taking the start frame `(T_0, r_0, s_0)` to the
152/// station's `(T_k, r_k, s_k)`. The profile keeps its drawn POSITION and its
153/// drawn ANGLE to the path; what changes along the sweep is only what the path
154/// itself does. Two consequences are the whole point of the mode:
155///   - a STRAIGHT path makes every `R_k` the identity, so the section merely
156///     translates and the result is the oblique prism `extrude_profile_brep`
157///     builds from the same profile and vector — `pathAlign` and `translate`
158///     agree on a straight path rather than merely having the same volume;
159///   - a circular ARC makes `R_k` the rotation about the ARC'S OWN CENTRE AXIS
160///     (for a planar path the RMF's transport rotation IS that rotation), so
161///     the profile is carried round the pivot and the end cap arrives at the
162///     angle the start cap had to the tangent. A full-circle path degenerates
163///     to exactly a revolve of the profile about that axis;
164///   - a HELIX makes `R_k` the SCREW about the helix's own axis — but only when
165///     the path says it is one ([`SweepPath::screw_axes`]), because the frame
166///     that realizes it is not the RMF. A helix is carried into itself by the
167///     screw, which spins the tangent frame at the helix's torsion; the RMF has
168///     no spin about the tangent by construction, so it rolls away from the
169///     screw by `∫τ ds` (169.75° over three turns of a radius-5, pitch-5 coil).
170///     A helix segment's stations are therefore framed off its axis
171///     (`screw_reference`), and every other segment keeps the RMF — which on a
172///     chain of lines and arcs in ANY planes is still exactly each arc's turn
173///     about its own centre, composed. The two hand over at a joint without a
174///     roll.
175///
176/// Why not one frame for every curve: the Frenet frame is the screw on a helix
177/// but spins where a spatial curve nearly straightens (a planar S-cubic lifted
178/// 1e-3 out of its plane carries a 180° roll through the inflection, which 32
179/// stations read as 0.4°), and one axis fitted to the whole run is the screw on
180/// a helix but rolls a two-arc spatial chain by up to 256° against its arcs.
181///
182/// `R_k = F_k · F_0ᵀ` does NOT depend on which perpendicular `r_0` the RMF was
183/// seeded with: re-seeding rolls `F_0` and every `F_k` by the same angle about
184/// the tangent, and the two rolls cancel in the product. That matters because
185/// `Transplant` maps `pu → r_k` directly, so under IT the profile's roll about
186/// the path is whatever `tangents[0].perpendicular()` happened to return — an
187/// arbitrary orientation no caller can predict. `Rigid` has no such freedom.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum SectionPlacement {
190    /// Move the profile onto the path, square to it (the classic sweep).
191    Transplant,
192    /// Carry the profile by the path's own rigid motion (see above).
193    Rigid,
194}
195
196/// The placement anchor a path sweep transplants its profile with: the plane
197/// frame `(origin = boundary-sample centroid, normal, pu, pv)` the station
198/// loop maps profile points through (`local = p − origin` → `station + ri·(local
199/// ·pu) + si·(local·pv)`). Extracted as data so a HOLE loop can sweep with its
200/// OUTER loop's anchor — sweeping each loop with its OWN centroid would
201/// re-center every loop onto the path and lose the hole's in-plane offset.
202#[derive(Debug, Clone, Copy)]
203pub struct ProfileAnchor {
204    pub origin: Vec3,
205    pub normal: Vec3,
206    pub pu: Vec3,
207    pub pv: Vec3,
208}
209
210/// [`sweep_profile_along_path`] with an explicit placement anchor (see
211/// [`ProfileAnchor`]) — the hole-loop cutter path: the swept loop is validated
212/// as usual but PLACED in its outer loop's frame.
213pub fn sweep_profile_along_path_anchored(
214    profile: &[NurbsCurve],
215    path: &NurbsCurve,
216    name: Option<&str>,
217    anchor: ProfileAnchor,
218) -> Result<BrepSolid, KernelRefusal> {
219    sweep_profile_along_path_stations(
220        profile,
221        path,
222        name,
223        path_turning_stations(path),
224        0.0,
225        Some(anchor),
226        SectionPlacement::Transplant,
227    )
228}
229
230/// [`sweep_profile_twisted`] with an explicit placement anchor: the hole loop
231/// twists about the SAME path axis as its outer loop (shared anchor), so the
232/// cutter stays registered with the outer wall through the whole twist.
233pub fn sweep_profile_twisted_anchored(
234    profile: &[NurbsCurve],
235    path: &NurbsCurve,
236    twist_angle: f64,
237    name: Option<&str>,
238    anchor: ProfileAnchor,
239) -> Result<BrepSolid, KernelRefusal> {
240    use std::f64::consts::{FRAC_PI_2, TAU};
241
242    if !twist_angle.is_finite() {
243        return Err(KernelRefusal::input(
244            KernelStage::Collect,
245            "twist_finite",
246            "sweep_profile_twisted: twist angle must be finite",
247        ));
248    }
249    const MAX_TURNS: f64 = 16.0;
250    if twist_angle.abs() > MAX_TURNS * TAU {
251        return Err(KernelRefusal::unsupported(
252            KernelStage::Collect,
253            "twist_cap",
254            format!(
255            "sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
256             limit the 1024-station cap can resolve at 16 stations per quarter turn; \
257             split the sweep or reduce the twist",
258            twist_angle.abs() / TAU
259            ),
260        ));
261    }
262    let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
263    let stations = (quarter_turns * 16).clamp(32, 1024).max(path_turning_stations(path));
264    sweep_profile_along_path_stations(
265        profile,
266        path,
267        name,
268        stations,
269        twist_angle,
270        Some(anchor),
271        SectionPlacement::Transplant,
272    )
273    .map_err(|error| error.with_message(|error| format!("sweep_profile_twisted: {error}")))
274}
275
276/// Sweep a CLOSED PLANAR profile along a CHAIN of path curves joined
277/// head-to-tail — the multi-segment entry point, with the twist and anchor
278/// options the single-curve family exposes as separate functions folded into
279/// one signature (the feature layer picks all three per loop, so splitting them
280/// four ways here would only push the same match into the caller).
281///
282/// `path` carries the chain AND its classification ([`SweepPath`]): the segment
283/// names for the joint refusal (a short or empty name list degrades to
284/// `<unnamed>` rather than failing), whether the run is CLOSED, the plane it lies
285/// in if it lies in one, and the continuity of every joint. The refusals below
286/// READ that classification rather than each re-deriving it — the corner gate no
287/// longer measures its own tangent break while sampling, and closure is a
288/// property of the path rather than something the sampler notices about its first
289/// and last station.
290///
291/// `corner_advice` is appended to the cornered-joint refusal and is the CALLER's
292/// to write, because the builder has two callers with different answers: path
293/// sweep sends the user to SW (a different feature), while SW's own `pathAlign`
294/// sends them to its `translate` mode. A builder that named one feature would be
295/// telling half its users to switch to the feature they are already in.
296///
297/// A ONE-segment OPEN chain delegates to the single-curve builders unchanged, so
298/// the geometry every existing path sweep emits is untouched by this entry point
299/// existing — the multi-segment sampler is reached only by a path that actually
300/// has a joint. A one-segment CLOSED chain (a resident circular edge) does NOT
301/// delegate: the single-curve samplers cap both ends, which is exactly what a
302/// ring must not do.
303///
304/// A CORNER (a joint past [`MAX_JOINT_TANGENT_BREAK`]) selects the MITRE lane
305/// when every segment is straight — see `sweep_topology/miter.rs` for the
306/// construction and [`CornerPolicy`] for why this entry mitres while
307/// [`sweep_profile_along_chain_with_stations`] refuses. A CLOSED cornered
308/// polyline is a picture FRAME: its closing joint is a mitre like the rest and
309/// the result has no caps. A corner at a CURVED segment is refused by name.
310pub fn sweep_profile_along_chain(
311    profile: &[NurbsCurve],
312    path: &SweepPath,
313    twist_angle: f64,
314    name: Option<&str>,
315    anchor: Option<ProfileAnchor>,
316    placement_mode: SectionPlacement,
317    corner_advice: &str,
318) -> Result<BrepSolid, KernelRefusal> {
319    use std::f64::consts::{FRAC_PI_2, TAU};
320
321    if path.is_empty() {
322        return Err(KernelRefusal::input(
323            KernelStage::Collect,
324            "chain_empty",
325            "sweepSolid: path chain is empty",
326        ));
327    }
328    // A bend TIGHTER than the section is the envelope lane's, not the loft's, and
329    // it is asked BEFORE the single-curve shortcut below — that shortcut lands in
330    // the same skinning builder, so taking it first would hide the one path shape
331    // this lane exists for (one circular arc) behind it.
332    let envelope_lane = super::envelope::recognize_swept_envelope(profile, path, placement_mode)?
333        .is_ok();
334
335    // Single segment under `Transplant`: the existing builders, byte for byte.
336    // `Rigid` has no single-curve wrapper to delegate to and falls through to the
337    // chain sampler, which samples a 1-element chain over exactly the domain the
338    // single-curve sampler would (no joints, the whole budget on one segment).
339    // A CLOSED single segment falls through too — the single-curve samplers end
340    // in a capped loft, and a ring has no caps.
341    if let ([single], SectionPlacement::Transplant, false, false) =
342        (path.curves.as_slice(), placement_mode, path.closed, envelope_lane)
343    {
344        return match (anchor, twist_angle == 0.0) {
345            (None, true) => sweep_profile_along_path(profile, single, name),
346            (Some(anchor), true) => {
347                sweep_profile_along_path_anchored(profile, single, name, anchor)
348            }
349            (None, false) => sweep_profile_twisted(profile, single, twist_angle, name),
350            (Some(anchor), false) => {
351                sweep_profile_twisted_anchored(profile, single, twist_angle, name, anchor)
352            }
353        };
354    }
355
356    // Everything else — any multi-segment chain, and a single segment under
357    // `Rigid` — takes the same station law the single-curve twisted builder
358    // uses, so density scales with twist identically on a chained path.
359    if !twist_angle.is_finite() {
360        return Err(KernelRefusal::input(
361            KernelStage::Collect,
362            "twist_finite",
363            "sweep_profile_along_chain: twist angle must be finite",
364        ));
365    }
366    const MAX_TURNS: f64 = 16.0;
367    if twist_angle.abs() > MAX_TURNS * TAU {
368        return Err(KernelRefusal::unsupported(
369            KernelStage::Collect,
370            "twist_cap",
371            format!(
372            "sweep_profile_along_chain: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
373             limit the 1024-station cap can resolve at 16 stations per quarter turn; \
374             split the sweep or reduce the twist",
375            twist_angle.abs() / TAU
376            ),
377        ));
378    }
379    // A CLOSED SPATIAL path lays its counter-twist down on top of the requested
380    // twist, which adds up to half a turn, so the density is set for the most the
381    // lap can roll.
382    let stations = if twist_angle == 0.0 {
383        32
384    } else {
385        let counter = if path.closed && path.planar.is_none() {
386            std::f64::consts::PI
387        } else {
388            0.0
389        };
390        let quarter_turns = ((twist_angle.abs() + counter) / FRAC_PI_2).ceil() as usize;
391        (quarter_turns * 16).clamp(32, 1024)
392    };
393    let _ = name;
394    sweep_profile_along_chain_stations(
395        profile,
396        path,
397        stations,
398        twist_angle,
399        anchor,
400        placement_mode,
401        corner_advice,
402        CornerPolicy::Mitre,
403    )
404}
405
406/// [`sweep_profile_along_chain`] with an explicit STATION budget and no twist
407/// — the entry a caller with a long, smooth chain uses when the default 32
408/// stations would under-sample it (a wire-harness bundle along a many-anchor
409/// spline: three pieces per span, so a handful of spans already starves each
410/// piece of stations). The budget is spread over the chain by arc length
411/// exactly as the twisted builder's law spreads its own; the 1024-station cap
412/// and the tangent-continuity refusal apply unchanged.
413pub fn sweep_profile_along_chain_with_stations(
414    profile: &[NurbsCurve],
415    path: &SweepPath,
416    stations: usize,
417    corner_advice: &str,
418) -> Result<BrepSolid, KernelRefusal> {
419    sweep_profile_along_chain_stations(
420        profile,
421        path,
422        stations.clamp(2, 1024),
423        0.0,
424        None,
425        SectionPlacement::Transplant,
426        corner_advice,
427        CornerPolicy::Refuse,
428    )
429}
430
431/// Validate a closed planar profile loop and derive its placement anchor —
432/// the path sweep's §1 block, extracted bit-identically: 16 samples per curve,
433/// closure at `tolerance`, Newell normal, boundary-sample-mean origin,
434/// planarity at `tolerance * 100`, `pu = np.perpendicular()`, `pv = np × pu`.
435pub fn profile_anchor(profile: &[NurbsCurve]) -> Result<ProfileAnchor, KernelRefusal> {
436    let tolerance = 1e-6;
437    if profile.len() < 2 {
438        return Err(KernelRefusal::input(
439            KernelStage::Collect,
440            "profile_count",
441            "sweepSolid: profile needs at least 2 curves forming a closed loop",
442        ));
443    }
444    let mut samples = Vec::new();
445    for (index, curve) in profile.iter().enumerate() {
446        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
447        let next = &profile[(index + 1) % profile.len()];
448        let next_start = next.domain().or_refuse(KernelStage::Collect, "domain")?[0];
449        if curve
450            .evaluate(end).or_refuse(KernelStage::Collect, "evaluate")?
451            .sub(next.evaluate(next_start).or_refuse(KernelStage::Collect, "evaluate")?)
452            .length()
453            > tolerance
454        {
455            return Err(KernelRefusal::input(
456                KernelStage::Collect,
457                "profile_closed",
458                format!("sweepSolid: profile is not closed at curve {index}"),
459            ));
460        }
461        for sample in 0..16 {
462            samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0).or_refuse(KernelStage::Collect, "evaluate")?);
463        }
464    }
465    let normal = crate::polygon::newell_normal(&samples);
466    let centroid = samples.iter().fold(Vec3::default(), |sum, &point| sum.add(point));
467    let np = normal.normalized().map_err(|_| {
468        KernelRefusal::input(
469            KernelStage::Collect,
470            "profile_area",
471            "sweepSolid: profile is degenerate (zero enclosed area)",
472        )
473    })?;
474    let origin = centroid.scale(1.0 / samples.len() as f64);
475    if samples
476        .iter()
477        .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
478    {
479        return Err(KernelRefusal::input(
480            KernelStage::Collect,
481            "profile_planar",
482            "sweepSolid: profile is not planar",
483        ));
484    }
485    let pu = np.perpendicular().or_refuse(KernelStage::Collect, "perpendicular")?;
486    let pv = np.cross(pu).normalized().or_refuse(KernelStage::Collect, "normalized")?;
487    Ok(ProfileAnchor {
488        origin,
489        normal: np,
490        pu,
491        pv,
492    })
493}
494
495/// Core of the path sweep with an explicit station count.  Every consumer of
496/// the station count (path sampling, RMF propagation, section placement)
497/// derives from the one `stations` argument so the density scales as a unit.
498/// `twist_angle` (radians; 0 for the untwisted variants) rotates the placed
499/// profile about the path tangent linearly in sampled arc length — the
500/// `twist_angle == 0.0` fast path leaves the RMF axes bit-identical, so the
501/// untwisted callers keep golden parity.
502fn sweep_profile_along_path_stations(
503    profile: &[NurbsCurve],
504    path: &NurbsCurve,
505    name: Option<&str>,
506    stations: usize,
507    twist_angle: f64,
508    anchor: Option<ProfileAnchor>,
509    placement_mode: SectionPlacement,
510) -> Result<BrepSolid, KernelRefusal> {
511    // Loft carries no face names; the app stamps them onto the emitted face
512    // order.  Accept `name` for ABI symmetry with the other builders.
513    let _ = name;
514    let tolerance = 1e-6;
515    if stations < 2 {
516        return Err(KernelRefusal::input(
517            KernelStage::Collect,
518            "stations",
519            "sweepSolid: need at least 2 stations",
520        ));
521    }
522
523    // --- 1. Validate the profile: closed + planar; derive (origin, np, pu, pv).
524    //        A caller-supplied anchor OVERRIDES the placement frame (the swept
525    //        loop is still validated against its own plane), so a hole loop
526    //        rides the path in its outer loop's frame instead of re-centering.
527    //        Resolved HERE, before the path is touched, so a bad profile is
528    //        still reported ahead of a bad path exactly as it always was.
529    let placement = resolve_placement(profile, anchor, placement_mode)?;
530
531    // --- 2. Sample the path; require a non-degenerate tangent at every station.
532    let [t0, t1] = path.domain().or_refuse(KernelStage::Collect, "domain")?;
533    if (t1 - t0).abs() <= tolerance {
534        return Err(KernelRefusal::input(
535            KernelStage::Collect,
536            "path_domain",
537            "sweepSolid: path domain is degenerate",
538        ));
539    }
540    let mut points = Vec::with_capacity(stations);
541    let mut tangents = Vec::with_capacity(stations);
542    let mut curvatures = Vec::with_capacity(stations);
543    for index in 0..stations {
544        let t = t0 + (t1 - t0) * index as f64 / (stations - 1) as f64;
545        let derivatives = path.derivatives_small(t, 2).or_refuse(KernelStage::Collect, "derivatives")?;
546        let tangent = derivatives[1].normalized().map_err(|_| {
547            KernelRefusal::input(
548                KernelStage::Classify,
549                "path_tangent",
550                format!("sweepSolid: path tangent is degenerate at station {index}"),
551            )
552        })?;
553        points.push(derivatives[0]);
554        tangents.push(tangent);
555        curvatures.push(curvature_vector(derivatives[1], derivatives[2]));
556    }
557
558    sweep_sections_through_samples(
559        profile,
560        &points,
561        &tangents,
562        &curvatures,
563        twist_angle,
564        placement,
565        placement_mode,
566        RingFrame::Open,
567        None,
568        None,
569    )
570}
571
572/// What a CHAIN builder does with a CORNER (a `G0` joint).
573///
574/// Two of this module's entry points sweep chains, and they answer the question
575/// differently — not because the geometry differs but because their callers'
576/// corner POLICIES do, and a builder that guessed would be overriding one of
577/// them:
578///   - [`sweep_profile_along_chain`] MITRES a polyline's corners, a closed
579///     polyline's closing joint included (`sweep_topology/miter.rs`). Its callers
580///     are the two sweep FEATURES, and a mitred corner is the shape they are
581///     asked for.
582///   - [`sweep_profile_along_chain_with_stations`] REFUSES every corner. Its
583///     callers build their own chains and have already decided what a corner
584///     means: the tube ROUNDS one into a tangent bend or joins it with a ball,
585///     and the wire harness routes tangent-continuous splines. A corner
586///     reaching either of them means their own construction did not come out,
587///     and mitring it instead would silently ship a shape neither feature
588///     offers.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590enum CornerPolicy {
591    /// A polyline's corners are mitred, open or closed; anything else is refused.
592    Mitre,
593    /// Every corner is refused by name.
594    Refuse,
595}
596
597/// The SECTION a mitred sweep carries, placed in world, with its plane normal —
598/// the two things `sweep_topology/miter.rs` needs, and all it needs.
599///
600/// The placement modes are resolved HERE rather than in the mitre builder, on
601/// the same frame the skinning lane's station 0 uses (`û₀.perpendicular()`), so
602/// the two lanes place a section identically and a path that crosses the
603/// continuity gate does not also jump in space:
604///   - `Transplant` maps the profile's plane frame onto the path's first station
605///     (anchor origin onto the path, profile square to it), so the section's
606///     normal is the first tangent;
607///   - `Rigid` takes the profile WHERE IT IS, keeping the offset from the path
608///     and the angle to it that it was drawn with, so the section's normal is
609///     the profile's own.
610fn place_section(
611    profile: &[NurbsCurve],
612    placement: ProfileAnchor,
613    placement_mode: SectionPlacement,
614    start: Vec3,
615    tangent: Vec3,
616) -> Result<(Vec<NurbsCurve>, Vec3), KernelRefusal> {
617    if placement_mode == SectionPlacement::Rigid {
618        return Ok((profile.to_vec(), placement.normal));
619    }
620    let ProfileAnchor { origin, pu, pv, .. } = placement;
621    let r0 = tangent.perpendicular().or_refuse(KernelStage::Fragment, "perpendicular")?;
622    let s0 = tangent.cross(r0).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
623    let mut placed = Vec::with_capacity(profile.len());
624    for curve in profile {
625        let control_points = curve
626            .control_points
627            .iter()
628            .map(|control| {
629                let point = control.point()?;
630                let local = point.sub(origin);
631                let world = start
632                    .add(r0.scale(local.dot(pu)))
633                    .add(s0.scale(local.dot(pv)));
634                Ok(Vec4::from_point(world, control.w))
635            })
636            .collect::<Result<Vec<_>, String>>()
637            .or_refuse(KernelStage::Fragment, "control_point")?;
638        placed.push(
639            NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
640                .or_refuse(KernelStage::Fragment, "curve_new")?,
641        );
642    }
643    Ok((placed, tangent))
644}
645
646/// The placement frame a sweep transplants its profile with: the caller's
647/// `anchor` when it supplied one (a hole loop riding its outer loop's frame),
648/// otherwise the profile's own. Either way the profile is VALIDATED — closed and
649/// planar — because `profile_anchor` is what does that validating, and skipping
650/// it for an anchored loop would let an open hole loop through.
651///
652/// Called by each SAMPLER before it touches the path, so the "bad profile" error
653/// still precedes the "bad path" one, as it did when the two lived in one
654/// function.
655fn resolve_placement(
656    profile: &[NurbsCurve],
657    anchor: Option<ProfileAnchor>,
658    placement_mode: SectionPlacement,
659) -> Result<ProfileAnchor, KernelRefusal> {
660    let computed = profile_anchor(profile)?;
661    Ok(match placement_mode {
662        // RIGID never transplants, so there is no frame to borrow: every loop
663        // keeps its own position already, which is the whole reason the anchor
664        // exists under `Transplant`. Taking a caller's anchor here would silently
665        // swap in another loop's NORMAL for the fold-back guard below.
666        SectionPlacement::Rigid => computed,
667        SectionPlacement::Transplant => anchor.unwrap_or(computed),
668    })
669}
670
671
672/// Sweep a CLOSED PLANAR profile along a CHAIN of path curves, joined
673/// head-to-tail, as one continuous tube — or, for a CLOSED path, as a capless
674/// ring.
675///
676/// The chain is sampled as a single trajectory and handed to the shared core,
677/// so ONE rotation-minimizing frame is propagated across the whole path: a joint
678/// is just another pair of adjacent stations to the RMF, which is what makes the
679/// profile arrive at the far end in the orientation the near end implies rather
680/// than snapping at each segment.
681///
682/// STATION BUDGET. `stations` are distributed across the segments in proportion
683/// to estimated ARC LENGTH (chord sums over 16 samples per curve), floored at 2
684/// per segment so a short fillet between two long lines still carries its
685/// curvature. The shared endpoint of two adjacent segments is sampled ONCE, not
686/// twice — a duplicated station would land in the RMF's coincident-step branch
687/// and waste a section on zero advance. A CLOSED path drops one more station for
688/// the same reason: its last station IS its first.
689///
690/// JOINTS decide the LANE. A break within [`MAX_JOINT_TANGENT_BREAK`] is skinned
691/// here; a break past it is a CORNER, which under [`CornerPolicy::Mitre`] hands an
692/// open all-straight run to the mitre builder (`sweep_topology/miter.rs`) and is
693/// otherwise refused by name, with the angle and the two segments. The
694/// classification carried on the path is what says so — this sampler no longer
695/// measures its own.
696#[allow(clippy::too_many_arguments)]
697fn sweep_profile_along_chain_stations(
698    profile: &[NurbsCurve],
699    path: &SweepPath,
700    stations: usize,
701    twist_angle: f64,
702    anchor: Option<ProfileAnchor>,
703    placement_mode: SectionPlacement,
704    corner_advice: &str,
705    corners: CornerPolicy,
706) -> Result<BrepSolid, KernelRefusal> {
707    let chain = path.curves.as_slice();
708    if chain.is_empty() {
709        return Err(KernelRefusal::input(
710            KernelStage::Collect,
711            "chain_empty",
712            "sweepSolid: path chain is empty",
713        ));
714    }
715    if stations < 2 {
716        return Err(KernelRefusal::input(
717            KernelStage::Collect,
718            "stations",
719            "sweepSolid: need at least 2 stations",
720        ));
721    }
722    // Profile first, matching the single-curve sampler's order of complaint.
723    let placement = resolve_placement(profile, anchor, placement_mode)?;
724
725    // --- The swept ENVELOPE, trimmed at its own self-intersection.
726    //
727    //     Past `ρκ = 1` the sections CROSS and the loft below skins a wall that
728    //     passes through itself. For a CIRCLE carried along one circular planar
729    //     arc the correct solid is a REVOLVE — the section disc truncated at the
730    //     axis, plus the reflection of the part that crossed it, each revolved
731    //     through the path's own turn (`sweep_topology/envelope.rs`) — and this
732    //     is where that lane is taken instead of the loft.
733    //
734    //     Anything else tight is left to the §4c refusal below, which now carries
735    //     WHICH half of the configuration this sweep missed. Nothing under
736    //     `ρκ = 1` reaches the lane at all: the recognition refuses a bend that
737    //     is not tighter than its section, so every shape that builds today keeps
738    //     the builder it has.
739    let envelope_gap =
740        match super::envelope::recognize_swept_envelope(profile, path, placement_mode)? {
741            Ok(plan) => {
742                let mut bodies = super::envelope::build_swept_envelope(&plan)?;
743                if bodies.len() == 1 {
744                    return Ok(bodies.remove(0));
745                }
746                return Err(KernelRefusal::unsupported(
747                    KernelStage::Fragment,
748                    "envelope_two_bodies",
749                    format!(
750                    "{}: a section that crosses the path's axis (ρ·κ = {:.6}) sweeps TWO \
751                     bodies — the near lobe over the path's own azimuths and the far lobe half \
752                     a turn from them, meeting only along the axis — and this entry returns one \
753                     solid. {} were built; ask `sweep_envelope_bodies` for the pair",
754                    SWEEP_ENVELOPE_REFUSAL,
755                    plan.ratio(),
756                    bodies.len()
757                    ),
758                ));
759            }
760            Err(reason) => reason,
761        };
762
763    // --- A CORNER, read off the path's classification. Every joint was measured
764    //     once when the path was resolved, the CLOSING joint of a closed path
765    //     included — which is how a ring's seam gets the same gate as its
766    //     interior joints instead of being the one joint nobody checked.
767    //
768    //     A corner now selects a LANE rather than ending the build. A POLYLINE —
769    //     every segment straight, at least one corner — is MITRED: the section is
770    //     swept along each segment and the two sweeps are trimmed to the joint's
771    //     bisector plane, sharing one face loop there
772    //     (`sweep_topology/miter.rs`). A CLOSED one is a picture FRAME, whose
773    //     closing joint is mitred like the rest and which has no caps; the frame
774    //     lane's own refusals (a spatial loop, by its holonomy; a self-crossing
775    //     loop; overlapping mitres) live with the construction. What remains
776    //     refused HERE is a corner on a path that is not all straight — there is
777    //     no bisector plane for a curve to be trimmed to, and no lane that mixes
778    //     the two.
779    if let Some(corner) = path.corner() {
780        if corners == CornerPolicy::Mitre && path.cornered_polyline() {
781            // A twist is laid down in arc length over the stations, and a mitre has
782            // none. A CLOSED frame rolls it along the middle of each side instead,
783            // where a roll keeps the shared joint loops (`sweep_topology/miter.rs`),
784            // exactly as it rolls its own counter-twist. An OPEN cornered run does
785            // not have that construction and is refused.
786            if twist_angle != 0.0 && !path.closed {
787                return Err(KernelRefusal::unsupported(
788                    KernelStage::Classify,
789                    "cornered_twist",
790                    "sweepSolid: a CORNERED path cannot carry a twist — its corners are mitred \
791                     from exact pieces that meet on the joint's bisector plane, and a twist laid \
792                     down along the path would roll the two sides of that plane apart, so they \
793                     would no longer share one loop there. Only a CLOSED frame rolls a twist along \
794                     the middle of its sides. Sweep the run without the twist; a \
795                     tangent-continuous (smooth) path is skinned through stations and carries one",
796                ));
797            }
798            let (section, section_normal, _) =
799                mitre_section(profile, placement, placement_mode, path)?;
800            return super::miter::sweep_section_mitered(
801                &section,
802                section_normal,
803                path,
804                twist_angle,
805            );
806        }
807        // A path with BOTH a corner and a curved segment is in NEITHER lane, and
808        // this is the refusal that says so: the mitre lane needs every segment
809        // straight (one direction per side for the bisector plane to bisect) and
810        // the skinning lane needs every joint tangent-continuous (it interpolates
811        // stations, so it would round the corner off). A rounded rectangle with
812        // ONE corner left sharp is exactly that path — three G0 joints the mitre
813        // lane could carry and two G1 joints at an arc it cannot — and mitring
814        // the sharp corners while skinning the arc is a MIXED construction this
815        // builder does not have. Refused with both halves named rather than built
816        // as whichever lane happens to be asked first.
817        let curved = (0..path.len())
818            .find(|index| {
819                let curve = &path.curves[*index];
820                curve.degree != 1 || curve.control_points.len() != 2
821            })
822            .map(|index| format!(" — segment '{}' is CURVED", path.name(index)))
823            .unwrap_or_default();
824        // The MIXED-LANE sentence belongs only to a path that actually has a
825        // curved segment. Under [`CornerPolicy::Refuse`] an ALL-STRAIGHT cornered
826        // chain reaches this same refusal (the tube's and the wire harness's
827        // corners are refused whatever their segments are), and telling those
828        // callers that a straight cornered run is mitred would name a lane they
829        // deliberately do not have — their own `corner_advice` says what to do
830        // instead.
831        let mixed = if curved.is_empty() {
832            String::new()
833        } else {
834            " A path that MIXES the two — corners at some joints and curved segments elsewhere              — is in neither lane: every segment straight is mitred (open or closed), every              joint tangent-continuous is skinned, and nothing builds one construction for some              joints and the other for the rest."
835                .to_string()
836        };
837        return Err(KernelRefusal::unsupported(
838            KernelStage::Classify,
839            "corner",
840            format!(
841            "the path must be tangent-continuous: segments '{}' and '{}' meet at a {:.1}° \
842             corner{curved}. The profile is skinned between sampled stations along the path, so a \
843             corner would be ROUNDED OFF rather than mitred, and a mitre needs two STRAIGHT \
844             segments: it trims both sweeps to the plane that bisects their directions, and a \
845             curve has no single direction to bisect.{mixed} {corner_advice}",
846            path.name(corner.before),
847            path.name(corner.after),
848            corner.tangent_break.to_degrees()
849            ),
850        ));
851    }
852
853    // --- A CLOSED path takes a REQUESTED twist only where it closes. After one lap
854    //     the section comes back rolled by the whole twist, so the twist must be a
855    //     rotational symmetry of the section about the path. The ring lane reads
856    //     that off the section and refuses by name a twist that does not close
857    //     ([`closing_twist`]).
858
859    // --- Sample the chain as ONE trajectory (budget, domains, stations).
860    let StationSamples {
861        points,
862        tangents,
863        curvatures,
864        segments,
865    } = chain_stations(path, stations)?;
866
867    // --- CLOSED: the ring needs a frame that comes back to itself. A PLANAR path
868    //     has one for free (see `ring_normal`); a SPATIAL one comes back rolled by
869    //     its own holonomy and is closed by the counter-twist policy
870    //     ([`RingFrame::Spatial`]).
871    let closure = ring_frame(path, &tangents)?;
872
873    sweep_sections_through_samples(
874        profile,
875        &points,
876        &tangents,
877        &curvatures,
878        twist_angle,
879        placement,
880        placement_mode,
881        closure,
882        Some(envelope_gap.as_str()),
883        Some((segments.as_slice(), path)),
884    )
885}
886
887/// How a station run CLOSES — which frame a ring's sections are placed on.
888///
889/// Three states, because "is this a ring" and "is its frame written down in
890/// closed form" are different questions and only a planar ring answers yes to
891/// both:
892///   - `Open`: the transported frame and two end caps;
893///   - `Planar(normal)`: a ring whose reference axis is the path plane's normal
894///     at every station, which returns to itself EXACTLY (see [`ring_normal`]);
895///   - `Spatial`: a ring on the TRANSPORTED frame, which returns rolled by the
896///     path's own holonomy and is closed by the counter-twist policy — see
897///     [`ring_closure_frames`].
898#[derive(Debug, Clone, Copy)]
899enum RingFrame {
900    Open,
901    Planar(Vec3),
902    Spatial,
903}
904
905impl RingFrame {
906    fn is_ring(self) -> bool {
907        !matches!(self, RingFrame::Open)
908    }
909}
910
911/// The ring frame a path's stations close on, read off its classification.
912fn ring_frame(path: &SweepPath, tangents: &[Vec3]) -> Result<RingFrame, KernelRefusal> {
913    Ok(match (path.closed, path.planar) {
914        (false, _) => RingFrame::Open,
915        (true, Some(normal)) => RingFrame::Planar(ring_normal(normal, tangents)?),
916        (true, None) => RingFrame::Spatial,
917    })
918}
919
920/// What a CLOSED sweep did to make its section meet itself after one lap — the
921/// applied twist the frame-closure policy REPORTS.
922///
923/// THE POLICY. Carried once round a closed path, the section frame comes back
924/// ROLLED about the start tangent by the path's HOLONOMY. That angle is not a
925/// defect of the sampling: the frame is parallel transport of the section's
926/// axes in the path's normal plane, which is parallel transport on the unit
927/// sphere along the path's TANGENT INDICATRIX, so by Gauss–Bonnet the roll is
928/// the solid angle that indicatrix encloses, modulo `2π`. A planar path's
929/// indicatrix runs along one great circle and encloses a hemisphere — `2π`,
930/// the identity — which is why a planar ring closes on its own; a spatial one
931/// generally does not. (A curve lying ON a sphere reads zero too: the radial
932/// direction is itself rotation-minimizing along any such curve. Its enclosed
933/// solid angle is not its holonomy; its indicatrix's is.)
934///
935/// The sweep closes the ring with the SMALLEST roll that does it: the
936/// counter-twist `−holonomy`, wrapped to `(−π, π]`.
937///   - SMOOTH lane: laid down LINEARLY IN ARC LENGTH over the stations, the wrap
938///     step from the last station back to station 0 included, so every span —
939///     the seam span too — rolls at the same rate and the closed loft sees no
940///     seam at all. A uniform rate is the distribution with the smallest peak
941///     rate and the smallest `∫(roll rate)² ds`.
942///   - CORNERED lane (`sweep_topology/miter.rs`): shared over the SEGMENTS in
943///     proportion to their length, each share laid down over its segment's
944///     untrimmed middle — a roll AT a mitre would pull the shared loop apart —
945///     and nothing at all when the untwisted lap already closes within the
946///     lane's own weld floor.
947///
948/// A REQUESTED twist rides on top of that counter-twist and is laid down the same
949/// way, so `applied_twist = requested_twist − holonomy` (or `requested_twist` alone
950/// where no counter-twist is needed). It closes only as a rotational symmetry of
951/// the section about the path, `2π·n/k` for a section with `k` of them, and is
952/// refused by name otherwise ([`crate::SWEEP_TWIST_CLOSURE_REFUSAL`]). A
953/// FRACTIONAL turn on a symmetric section lands each wall on a different section
954/// curve than it left: `curve_shift`.
955///
956/// `holonomy` is what the untwisted frame measures; `applied_twist` is what was
957/// laid down. All three angles are radians, right-handed about the path tangent.
958#[derive(Debug, Clone, Copy, PartialEq)]
959pub struct SweepClosure {
960    /// The roll the UNTWISTED frame returns with after one lap, right-handed
961    /// about the start tangent, wrapped to `(−π, π]`. Zero for a planar path.
962    pub holonomy: f64,
963    /// The twist the caller asked for, snapped to the section symmetry it closes
964    /// on (it matched at round-off). Zero for an untwisted sweep.
965    pub requested_twist: f64,
966    /// The twist laid down over the lap: the requested twist plus the
967    /// counter-twist `−holonomy`, which is zero where the lane closes without one
968    /// (a planar path, or a frame whose untwisted lap already closes within the
969    /// weld floor).
970    pub applied_twist: f64,
971    /// How far along the section's curve list one lap carries each curve: the
972    /// wall leaving section curve `j` arrives on curve `(j + curve_shift) mod m`.
973    /// Zero for no twist and for a whole number of turns. The curves are counted
974    /// in the order the builder carries them, which on the MITRE lane is the
975    /// section's order after its winding is made counter-clockwise about the path.
976    pub curve_shift: usize,
977}
978
979/// The CLOSURE a sweep of `profile` along `path` applies — the frame-closure
980/// policy's report, exposed so a caller can read the twist a closed sweep laid
981/// down (see [`SweepClosure`]). `None` for an OPEN path, which has no lap to
982/// close.
983///
984/// Reads the very construction the sweep builds, not a second measurement of
985/// it: a cornered polyline asks the mitre lane's own frame plan (its placed
986/// section, its rings, its weld floor), and any other closed path asks the
987/// smooth lane's own stations and frames at `stations` — the budget the sweep
988/// was given (32 for [`sweep_profile_along_chain`] untwisted, the caller's for
989/// [`sweep_profile_along_chain_with_stations`]). Whatever the builder would
990/// refuse, this refuses with the same words, a requested `twist_angle` that does
991/// not close included.
992pub fn sweep_closure(
993    profile: &[NurbsCurve],
994    path: &SweepPath,
995    placement_mode: SectionPlacement,
996    stations: usize,
997    twist_angle: f64,
998) -> Result<Option<SweepClosure>, KernelRefusal> {
999    if !path.closed {
1000        return Ok(None);
1001    }
1002    let placement = resolve_placement(profile, None, placement_mode)?;
1003    if path.cornered_polyline() {
1004        let (section, section_normal, _) = mitre_section(profile, placement, placement_mode, path)?;
1005        return super::miter::mitred_frame_closure(&section, section_normal, path, twist_angle)
1006            .map(Some);
1007    }
1008    let StationSamples {
1009        points,
1010        tangents,
1011        segments,
1012        ..
1013    } = chain_stations(path, stations.clamp(2, 1024))?;
1014    let screw = Some((segments.as_slice(), path)).filter(|_| placement_mode == SectionPlacement::Rigid);
1015    let closure = ring_frame(path, &tangents)?;
1016    let (section, _) = place_section(profile, placement, placement_mode, points[0], tangents[0])?;
1017    let requested = ring_closing_twist(&section, &points, &tangents, twist_angle)?;
1018    Ok(station_frames(&points, &tangents, closure, screw, requested)?.2)
1019}
1020
1021/// The requested twist a RING closes on ([`closing_twist`]), read off the
1022/// section as placed at station 0, about the path's first tangent, against the
1023/// ring's own position floor: `1e-6` of the lap's chord length, the floor the
1024/// mitred frame measures its own closure against.
1025fn ring_closing_twist(
1026    section: &[NurbsCurve],
1027    points: &[Vec3],
1028    tangents: &[Vec3],
1029    twist_angle: f64,
1030) -> Result<ClosingTwist, KernelRefusal> {
1031    let lap: f64 = (0..points.len())
1032        .map(|index| points[(index + 1) % points.len()].sub(points[index]).length())
1033        .sum();
1034    closing_twist(section, points[0], tangents[0], twist_angle, 1e-6 * lap)
1035}
1036
1037/// The placed section a MITRED sweep carries and its plane normal, with the
1038/// first segment's chord direction — shared by the builder and by
1039/// [`sweep_closure`], so the instrument reads the section the frame is built
1040/// from.
1041fn mitre_section(
1042    profile: &[NurbsCurve],
1043    placement: ProfileAnchor,
1044    placement_mode: SectionPlacement,
1045    path: &SweepPath,
1046) -> Result<(Vec<NurbsCurve>, Vec3, Vec3), KernelRefusal> {
1047    let [t0, t1] = path.curves[0].domain().or_refuse(KernelStage::Collect, "domain")?;
1048    let start = path.curves[0].evaluate(t0).or_refuse(KernelStage::Collect, "evaluate")?;
1049    // The CHORD direction, which is both the first station's tangent (a
1050    // straight segment's derivative is its chord) and the direction the
1051    // mitre builder sweeps along, so the placed section is square to the
1052    // same vector the construction uses.
1053    let tangent = path.curves[0]
1054        .evaluate(t1).or_refuse(KernelStage::Collect, "evaluate")?
1055        .sub(start)
1056        .normalized()
1057        .map_err(|_| {
1058            KernelRefusal::input(
1059                KernelStage::Collect,
1060                "segment_length",
1061                "sweepSolid: path segment 0 has zero length",
1062            )
1063        })?;
1064    let (section, section_normal) =
1065        place_section(profile, placement, placement_mode, start, tangent)?;
1066    Ok((section, section_normal, tangent))
1067}
1068
1069/// The station frames §3 places its sections on, extracted so the tight-bend
1070/// INSTRUMENT ([`sweep_bend_profile`]) reads the frame the sweep will use — the
1071/// section's reach into a bend depends on how the section is ROLLED about the
1072/// path, so a second frame that merely agreed on a circle would be a different
1073/// measurement on anything else.
1074///
1075/// `screw` is the chain's segment-of-station map and the path whose
1076/// [`SweepPath::screw_axes`] it indexes, passed only by the `Rigid` placement.
1077/// A station on a HELIX segment is framed off the helix's own axis instead of
1078/// transported (see [`screw_reference`]); every other station takes the RMF step
1079/// from its predecessor, so the two compose across a joint in either direction.
1080///
1081/// The third value is the ring's CLOSURE report: `None` for an open run, zero
1082/// for a planar ring, and the measured holonomy with the counter-twist that
1083/// closes it for a spatial one — whose axes are returned already twisted.
1084///
1085/// `requested` is a RING's requested twist, already known to close
1086/// ([`closing_twist`]). It is laid down in the ring's frames over the whole lap,
1087/// the wrap chord included, together with any counter-twist, so the seam span
1088/// rolls at the rate every other span does. An OPEN run ignores it: its twist is
1089/// laid down by the placement, over the stations it has.
1090fn station_frames(
1091    points: &[Vec3],
1092    tangents: &[Vec3],
1093    closure: RingFrame,
1094    screw: Option<(&[usize], &SweepPath)>,
1095    requested: ClosingTwist,
1096) -> Result<(Vec<Vec3>, Vec<Vec3>, Option<SweepClosure>), KernelRefusal> {
1097    match closure {
1098        RingFrame::Open => {
1099            let (r_axes, s_axes) = transported_frames(points, tangents, screw)?;
1100            Ok((r_axes, s_axes, None))
1101        }
1102        RingFrame::Planar(normal) => {
1103            let mut r_axes = Vec::with_capacity(points.len());
1104            let mut s_axes = Vec::with_capacity(points.len());
1105            for tangent in tangents {
1106                // Project out any tangent component and re-normalize, so the frame is
1107                // orthonormal even where the fitted plane and the sampled tangent
1108                // disagree in the last bits.
1109                let r = normal
1110                    .sub(tangent.scale(normal.dot(*tangent)))
1111                    .normalized()
1112                    .map_err(|_| {
1113                        KernelRefusal::internal(
1114                            KernelStage::Classify,
1115                            "planar_frame",
1116                            "sweepSolid: the closed path's plane normal is parallel to its tangent",
1117                        )
1118                    })?;
1119                s_axes.push(tangent.cross(r).normalized().or_refuse(KernelStage::Classify, "normalized")?);
1120                r_axes.push(r);
1121            }
1122            if requested.twist != 0.0 {
1123                roll_over_the_lap(points, &mut r_axes, &mut s_axes, requested.twist)?;
1124            }
1125            Ok((
1126                r_axes,
1127                s_axes,
1128                Some(SweepClosure {
1129                    holonomy: 0.0,
1130                    requested_twist: requested.twist,
1131                    applied_twist: requested.twist,
1132                    curve_shift: requested.shift,
1133                }),
1134            ))
1135        }
1136        RingFrame::Spatial => {
1137            let (r_axes, s_axes, closure) =
1138                ring_closure_frames(points, tangents, screw, requested)?;
1139            Ok((r_axes, s_axes, Some(closure)))
1140        }
1141    }
1142}
1143
1144/// The frames of a closed SPATIAL ring: the transported frame, counter-twisted
1145/// by its own holonomy so the section meets itself (see [`SweepClosure`]).
1146///
1147/// MEASURED, not assumed. The run is transported ONE STEP FURTHER than an open
1148/// run goes — from the last station back to station 0, by the very rule every
1149/// other step takes (the RMF, or a helix segment's screw frame under `Rigid`) —
1150/// and the returned axis is compared with station 0's: the signed angle between
1151/// them, right-handed about the start tangent, is the holonomy `h`.
1152///
1153/// Then station `k`, at chord-length `s_k` round a lap of chord length `L`
1154/// (the wrap chord included), is rolled about its tangent by `φ_k = −h·s_k/L`.
1155/// Rolling about the tangent commutes with the transport step (each step is a
1156/// rotation carrying one tangent onto the next, and it carries a roll about the
1157/// first onto the same roll about the second), so the frame transported from
1158/// the last twisted station across the wrap arrives rolled by
1159/// `h − h·s_{n−1}/L`, and station 0 sits `−h·(L − s_{n−1})/L` further on — the
1160/// same rate as every other span. The ring closes with no seam to skin.
1161///
1162/// No new station law: the closed lane already floors its budget at 16 stations
1163/// per quarter turn of the path's TURNING, and a closed curve turns at least
1164/// `2π` (Fenchel), so a lap carries at least 64 stations. The largest roll this
1165/// lays down is `π`, under 3° a station — finer than the 16 per quarter turn a
1166/// requested twist gets.
1167///
1168/// A REQUESTED twist `T` that closes ([`closing_twist`]) rides on the same rate:
1169/// each station rolls by `(T − h)·s_k/L`, so the section comes back from the lap
1170/// rolled by `T` alone, onto itself. [`sweep_profile_along_chain`] sizes the
1171/// station budget for `|T| + π`.
1172fn ring_closure_frames(
1173    points: &[Vec3],
1174    tangents: &[Vec3],
1175    screw: Option<(&[usize], &SweepPath)>,
1176    requested: ClosingTwist,
1177) -> Result<(Vec<Vec3>, Vec<Vec3>, SweepClosure), KernelRefusal> {
1178    let stations = points.len();
1179    if stations < 2 {
1180        return Err(KernelRefusal::internal(
1181            KernelStage::Collect,
1182            "station_run",
1183            "sweepSolid: need at least 2 stations",
1184        ));
1185    }
1186    // The lap with its wrap: station 0 again, arriving as the LAST segment's end.
1187    let mut lap_points = points.to_vec();
1188    lap_points.push(points[0]);
1189    let mut lap_tangents = tangents.to_vec();
1190    lap_tangents.push(tangents[0]);
1191    let lap_segments: Option<Vec<usize>> = screw.map(|(segments, _)| {
1192        let mut extended = segments.to_vec();
1193        extended.push(segments[stations - 1]);
1194        extended
1195    });
1196    let lap_screw = match (&lap_segments, screw) {
1197        (Some(segments), Some((_, path))) => Some((segments.as_slice(), path)),
1198        _ => None,
1199    };
1200    let (mut r_axes, mut s_axes) = transported_frames(&lap_points, &lap_tangents, lap_screw)?;
1201    let returned = r_axes.pop().expect("a lap has a wrap station");
1202    s_axes.pop();
1203    let holonomy = returned.dot(s_axes[0]).atan2(returned.dot(r_axes[0]));
1204
1205    let applied = if requested.twist == 0.0 {
1206        -holonomy
1207    } else {
1208        requested.twist - holonomy
1209    };
1210    roll_over_the_lap(points, &mut r_axes, &mut s_axes, applied)?;
1211    Ok((
1212        r_axes,
1213        s_axes,
1214        SweepClosure {
1215            holonomy,
1216            requested_twist: requested.twist,
1217            applied_twist: applied,
1218            curve_shift: requested.shift,
1219        },
1220    ))
1221}
1222
1223/// Roll a ring's station frames about their tangents by `roll` over the lap,
1224/// linearly in chord length with the WRAP chord included: station `k` at `s_k`
1225/// of a lap of length `L` rolls by `roll·s_k/L`.
1226fn roll_over_the_lap(
1227    points: &[Vec3],
1228    r_axes: &mut [Vec3],
1229    s_axes: &mut [Vec3],
1230    roll: f64,
1231) -> Result<(), KernelRefusal> {
1232    let stations = points.len();
1233    let mut travelled = Vec::with_capacity(stations);
1234    let mut length = 0.0;
1235    for index in 0..stations {
1236        travelled.push(length);
1237        length += points[(index + 1) % stations].sub(points[index]).length();
1238    }
1239    if length <= 0.0 {
1240        return Err(KernelRefusal::internal(
1241            KernelStage::Classify,
1242            "lap_length",
1243            "sweepSolid: the closed path has zero length",
1244        ));
1245    }
1246    for station in 0..stations {
1247        let phi = roll * travelled[station] / length;
1248        let (sin_phi, cos_phi) = phi.sin_cos();
1249        let (r, s) = (r_axes[station], s_axes[station]);
1250        r_axes[station] = r.scale(cos_phi).add(s.scale(sin_phi));
1251        s_axes[station] = s.scale(cos_phi).sub(r.scale(sin_phi));
1252    }
1253    Ok(())
1254}
1255
1256/// The TRANSPORTED frames of a station run: seeded on an arbitrary perpendicular
1257/// to the first tangent and carried station to station — by the RMF, or by a
1258/// helix segment's screw frame under `Rigid` (see [`station_frames`]).
1259fn transported_frames(
1260    points: &[Vec3],
1261    tangents: &[Vec3],
1262    screw: Option<(&[usize], &SweepPath)>,
1263) -> Result<(Vec<Vec3>, Vec<Vec3>), KernelRefusal> {
1264    let stations = points.len();
1265    let mut r_axes = Vec::with_capacity(stations);
1266    let mut s_axes = Vec::with_capacity(stations);
1267    {
1268        let r0 = tangents[0].perpendicular().or_refuse(KernelStage::Classify, "perpendicular")?; // any unit vector ⟂ T0
1269        s_axes.push(tangents[0].cross(r0).normalized().or_refuse(KernelStage::Classify, "normalized")?);
1270        r_axes.push(r0);
1271        // The helix segment the running roll was fixed for, and that roll.
1272        let mut roll: Option<(usize, f64)> = None;
1273        for index in 0..stations - 1 {
1274            let t_next = tangents[index + 1];
1275            let helix = screw.and_then(|(segments, path)| {
1276                let segment = segments[index + 1];
1277                path.screw_axes[segment].map(|axis| (segment, axis, path))
1278            });
1279            let r_next = match helix {
1280                None => rmf_step(
1281                    r_axes[index],
1282                    tangents[index],
1283                    t_next,
1284                    points[index + 1].sub(points[index]),
1285                )
1286                .ok_or_else(|| {
1287                    KernelRefusal::internal(
1288                        KernelStage::Classify,
1289                        "frame_step",
1290                        format!("sweepSolid: frame degenerated at station {index}"),
1291                    )
1292                })?,
1293                Some((segment, axis, path)) => {
1294                    // The roll is fixed ONCE per helix segment, at the station it
1295                    // departs from — station 0, or the joint the previous segment
1296                    // arrived at — so the incoming frame continues without a snap.
1297                    let phi = match roll {
1298                        Some((fixed, phi)) if fixed == segment => phi,
1299                        _ => {
1300                            let tangent = tangents[index];
1301                            let reference =
1302                                screw_reference(axis, tangent, path.name(segment), index)?;
1303                            let r = r_axes[index];
1304                            let phi = tangent.dot(reference.cross(r)).atan2(reference.dot(r));
1305                            roll = Some((segment, phi));
1306                            phi
1307                        }
1308                    };
1309                    let reference = screw_reference(axis, t_next, path.name(segment), index + 1)?;
1310                    let (sin_phi, cos_phi) = phi.sin_cos();
1311                    reference
1312                        .scale(cos_phi)
1313                        .add(t_next.cross(reference).scale(sin_phi))
1314                }
1315            };
1316            s_axes.push(t_next.cross(r_next).normalized().or_refuse(KernelStage::Classify, "normalized")?);
1317            r_axes.push(r_next);
1318        }
1319    }
1320    Ok((r_axes, s_axes))
1321}
1322
1323/// A HELIX station's reference axis: the screw axis with its tangent component
1324/// removed, `normalize(W − (W·T)T)`.
1325///
1326/// Why this and not the transported frame: a helix is carried into itself by
1327/// ONE rigid motion — the screw about its axis — and that motion fixes both the
1328/// axis and the tangent's angle to it, so the axis' projection onto the normal
1329/// plane is the same body-fixed direction at every station. A frame built on it
1330/// IS the screw, so `R_k = F_k·F_0ᵀ` carries the profile about the coil's axis
1331/// and up its pitch. A rotation-minimizing frame has no spin about the tangent,
1332/// while the screw's frame spins at the helix's torsion `τ`, so the RMF rolls
1333/// away from it by `∫τ ds` — the rotation the user saw. On a TAPERED helix the
1334/// tangent's angle to the axis drifts with the radius, and this frame keeps the
1335/// section square to the axis all the same, which is what a tapered coil is.
1336///
1337/// Undefined only where the path runs ALONG its own axis, which a helix of
1338/// positive radius never does; refused by name there rather than guessed.
1339fn screw_reference(axis: Vec3, tangent: Vec3, segment: &str, station: usize) -> Result<Vec3, KernelRefusal> {
1340    axis.sub(tangent.scale(axis.dot(tangent)))
1341        .normalized()
1342        .map_err(|_| {
1343            KernelRefusal::ill_posed(
1344                KernelStage::Classify,
1345                "screw_reference",
1346                format!(
1347                "sweepSolid: path segment '{segment}' runs along its own helix axis at station \
1348                 {station}, so the screw motion that carries the section has no direction \
1349                 about the path there"
1350                ),
1351            )
1352        })
1353}
1354
1355/// The stations a path is sampled at, and what each one carries: the point, the
1356/// unit tangent, and the CURVATURE VECTOR `κ·N̂` — the path's own bend there,
1357/// measured from the curve rather than differenced off the station polyline,
1358/// because it is the number the tight-bend refusal quotes.
1359pub(crate) struct StationSamples {
1360    pub(crate) points: Vec<Vec3>,
1361    pub(crate) tangents: Vec<Vec3>,
1362    /// `κ·N̂` per station: its length is the curvature and its direction points
1363    /// at the centre of the osculating circle. Zero on a straight piece.
1364    pub(crate) curvatures: Vec<Vec3>,
1365    /// The path SEGMENT each station belongs to. A joint station is emitted by
1366    /// the segment ARRIVING at it, so it carries that segment's index.
1367    pub(crate) segments: Vec<usize>,
1368}
1369
1370/// The CURVATURE VECTOR of a curve from its first two derivatives:
1371///
1372/// ```text
1373///     κ·N̂ = (r̈ − (r̈·T̂)T̂) / |ṙ|²,      T̂ = ṙ/|ṙ|
1374/// ```
1375///
1376/// the component of the second derivative perpendicular to the tangent over the
1377/// squared speed — the reparameterization-invariant form, so a non-uniformly
1378/// parameterized path reads its own geometric bend rather than its
1379/// parameterization's. A degree-1 piece has a zero second derivative
1380/// (`derivatives` pads past the degree), so a straight segment reads exactly
1381/// zero and needs no special case.
1382pub(crate) fn curvature_vector(first: Vec3, second: Vec3) -> Vec3 {
1383    let speed_squared = first.dot(first);
1384    if speed_squared <= 0.0 {
1385        return Vec3::default();
1386    }
1387    let tangent = first.scale(1.0 / speed_squared.sqrt());
1388    second
1389        .sub(tangent.scale(second.dot(tangent)))
1390        .scale(1.0 / speed_squared)
1391}
1392
1393/// Sample a CHAIN as ONE trajectory: the per-segment arc-length estimate, the
1394/// station budget it implies, and the stations themselves.
1395///
1396/// Extracted from the chain sweep so the tight-bend INSTRUMENT
1397/// ([`sweep_bend_profile`]) measures the very stations the sweep lofts through,
1398/// rather than a second sampling that agrees with it today.
1399fn chain_stations(path: &SweepPath, stations: usize) -> Result<StationSamples, KernelRefusal> {
1400    let tolerance = 1e-6;
1401    let chain = path.curves.as_slice();
1402    // --- Per-segment arc-length estimate, and the domain each one is sampled
1403    //     over. A degenerate domain is refused here rather than producing a
1404    //     silently collapsed station block.
1405    //
1406    //     Each segment's TURNING — the total angle its tangent sweeps — is measured
1407    //     too, because that, not its length, is what decides how many stations it
1408    //     needs (see the floor below).
1409    let mut domains = Vec::with_capacity(chain.len());
1410    let mut lengths = Vec::with_capacity(chain.len());
1411    let mut turning = Vec::with_capacity(chain.len());
1412    for (index, curve) in chain.iter().enumerate() {
1413        let [t0, t1] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
1414        if (t1 - t0).abs() <= tolerance {
1415            return Err(KernelRefusal::input(
1416                KernelStage::Collect,
1417                "path_domain",
1418                format!("sweepSolid: path segment {index} has a degenerate domain"),
1419            ));
1420        }
1421        let mut length = 0.0;
1422        let mut previous = curve.evaluate(t0).or_refuse(KernelStage::Collect, "evaluate")?;
1423        for sample in 1..=16 {
1424            let point = curve.evaluate(t0 + (t1 - t0) * sample as f64 / 16.0).or_refuse(KernelStage::Collect, "evaluate")?;
1425            length += point.sub(previous).length();
1426            previous = point;
1427        }
1428        domains.push([t0, t1]);
1429        lengths.push(length);
1430        // `segment_turning` is a stringly reading of the curve's unit tangents;
1431        // a failure there is a degenerate tangent, which is the path's fault.
1432        turning.push(segment_turning(curve, [t0, t1]).map_err(|error| {
1433            KernelRefusal::input(
1434                KernelStage::Classify,
1435                "path_tangent",
1436                format!("sweepSolid: path tangent is degenerate on segment {index}: {error}"),
1437            )
1438        })?);
1439    }
1440    let total_length: f64 = lengths.iter().sum();
1441    if total_length <= tolerance {
1442        return Err(KernelRefusal::input(
1443            KernelStage::Collect,
1444            "path_length",
1445            "sweepSolid: path chain has zero length",
1446        ));
1447    }
1448
1449    // --- Station budget per segment: proportional to arc length, floored at 2
1450    //     (a segment needs its two ends), and the remainder handed to the
1451    //     longest segment so the totals land exactly on `stations`.
1452    //
1453    //     The floor is raised to the segment's own TURNING, at 16
1454    //     stations per quarter turn — the density the twisted builder already
1455    //     uses, and the one that puts the cubic v-interpolant's own error around
1456    //     1e-7 relative (it falls as the fourth power of the angular spacing,
1457    //     `Δθ⁴/384`, which is ~2.4e-7 at 16 per quarter turn).
1458    //
1459    //     WHY THE CLOSED LANE NEEDS IT AND THE OPEN LANE DOES NOT, stated as the
1460    //     measurement that forced it: a rounded rectangle's four quarter arcs are
1461    //     SHORT next to its four straight runs, so a length-proportional share of
1462    //     32 hands each arc the 2-station floor — its two ends and nothing in
1463    //     between — and the interpolant cuts the corner. Measured on a 30 x 18
1464    //     path with r = 4: the ring came out 6.2 % under `A · perimeter`, a
1465    //     watertight, validating, WRONG solid. The floor takes the same case to
1466    //     1e-5.
1467    //
1468    //     An OPEN path is under-sampled the same way and now takes the same floor.
1469    //     It did not until 2026-09-25, and a coil showed what that cost: an 8-turn
1470    //     helix got 32 stations, four a turn, and the loft between sections a
1471    //     quarter turn apart folded its own wall — a `Rigid` sweep along the coil,
1472    //     or a coil with a straight lead, was refused outright. (A curvature-JUMP
1473    //     law at G1 joints is still this plan's remaining item 2; the floor is
1474    //     about how far a segment turns, not how its curvature changes.)
1475    //
1476    //     Once the floors lift the turning segments past their share, the SHARES
1477    //     are taken of the lifted total, not of the `stations` asked for: the
1478    //     segments that do not turn are sampled at the density the turning ones
1479    //     were raised to. Measured on the coil above with a tangent straight lead:
1480    //     the floor gave the coil ~500 stations and the lead its share of 32 — its
1481    //     two ends — and the interpolant, stiff from the coil, bowed through the
1482    //     lead: 2.7e-5 off Pappus on a lead of 10, 5.0e-4 on a lead of 100, and
1483    //     1.5e-7 / 2.1e-7 at the density the coil was given.
1484    let floors: Vec<usize> = turning
1485        .iter()
1486        .map(|turn| ((turn / std::f64::consts::FRAC_PI_2) * 16.0).ceil() as usize)
1487        .collect();
1488    let floored_length: f64 = lengths
1489        .iter()
1490        .zip(&floors)
1491        .filter(|(_, floor)| **floor > 2)
1492        .map(|(length, _)| length)
1493        .sum();
1494    let floor_total: usize = floors.iter().sum();
1495    //     A CLOSED path is not lifted — its shares stay those of the `stations`
1496    //     asked for (its floors can still move where a segment's turning is
1497    //     re-read densely, see `segment_turning`): its loft
1498    //     runs every skin through as many laps as its section curves take to
1499    //     cycle, so a lifted total multiplies against the cap there, and the
1500    //     closed lane's measured residuals are pinned at its own budget.
1501    let lifted = if !path.closed && floored_length > tolerance {
1502        let straight = floor_total as f64 * (total_length - floored_length) / floored_length;
1503        ((floor_total as f64 + straight).ceil() as usize).min(1024)
1504    } else {
1505        0
1506    };
1507    let stations = stations.max(lifted);
1508    let mut budget: Vec<usize> = lengths
1509        .iter()
1510        .zip(&floors)
1511        .map(|(length, floor)| {
1512            let share = (stations as f64 * length / total_length).round() as usize;
1513            share.max(*floor).max(2)
1514        })
1515        .collect();
1516    // Each interior joint shares one station between its two segments, so the
1517    // emitted total is sum(budget) - (segments - 1). A CLOSED path drops its very
1518    // last station too — it is the first one — so it emits one fewer again.
1519    let shared = chain.len() - 1 + usize::from(path.closed);
1520    let emitted: usize = budget.iter().sum::<usize>() - shared;
1521    // The 2-per-segment floor means a chain of n segments emits at least n+1
1522    // stations, so a long enough chain cannot be held under the loft's station
1523    // cap by any distribution. The loft's interpolation solve is O(stations³)
1524    // per control column, so silently accepting such a path would look like a
1525    // hang. Refuse with the count instead — the same honesty the twist cap uses.
1526    const MAX_STATIONS: usize = 1024;
1527    if emitted > MAX_STATIONS {
1528        return Err(KernelRefusal::unsupported(
1529            KernelStage::Collect,
1530            "station_cap",
1531            format!(
1532            "sweepSolid: a {}-segment path needs at least {emitted} stations, past the \
1533             {MAX_STATIONS}-station cap the loft solve can carry; sweep it in fewer, \
1534             longer pieces",
1535            chain.len()
1536            ),
1537        ));
1538    }
1539    if emitted < stations {
1540        let longest = lengths
1541            .iter()
1542            .enumerate()
1543            .max_by(|a, b| a.1.total_cmp(b.1))
1544            .map(|(index, _)| index)
1545            .unwrap_or(0);
1546        budget[longest] += stations - emitted;
1547    }
1548
1549    // --- Sample the chain as ONE trajectory.
1550    let mut points: Vec<Vec3> = Vec::new();
1551    let mut tangents: Vec<Vec3> = Vec::new();
1552    let mut curvatures: Vec<Vec3> = Vec::new();
1553    let mut segments: Vec<usize> = Vec::new();
1554    for (index, curve) in chain.iter().enumerate() {
1555        let [t0, t1] = domains[index];
1556        let count = budget[index];
1557        // Skip the first sample of every segment after the first: that station
1558        // is the joint, already emitted as the previous segment's last. On a
1559        // CLOSED path the LAST segment's last sample is skipped too — it is
1560        // station 0, and a ring that emitted it twice would interpolate a
1561        // zero-length span across its own seam.
1562        let first = usize::from(index > 0);
1563        if first == 1 {
1564            // THE JOINT reads the TIGHTER of the two pieces that meet there. A
1565            // G1 joint is tangent-continuous, not curvature-continuous — a line
1566            // running into a cubic hands over at κ = 0 and the cubic starts at
1567            // its own spike — and the station is emitted once, by the piece that
1568            // ARRIVES. Left as that, a path whose tightest bend sits at the
1569            // START of a curved piece would quote the straight piece's κ = 0 and
1570            // the bend would go unnamed; on the wire harness's own span the two
1571            // ends are mirror images and only ONE of them was being read.
1572            let derivatives = curve.derivatives_small(t0, 2).or_refuse(KernelStage::Collect, "derivatives")?;
1573            let arriving = curvature_vector(derivatives[1], derivatives[2]);
1574            if let Some(departing) = curvatures.last_mut() {
1575                if arriving.length() > departing.length() {
1576                    *departing = arriving;
1577                }
1578            }
1579        }
1580        let last = count - usize::from(path.closed && index + 1 == chain.len());
1581        for sample in first..last {
1582            let t = t0 + (t1 - t0) * sample as f64 / (count - 1) as f64;
1583            let derivatives = curve.derivatives_small(t, 2).or_refuse(KernelStage::Collect, "derivatives")?;
1584            let tangent = derivatives[1].normalized().map_err(|_| {
1585                KernelRefusal::input(
1586                    KernelStage::Classify,
1587                    "path_tangent",
1588                    format!("sweepSolid: path tangent is degenerate on segment {index}"),
1589                )
1590            })?;
1591            points.push(derivatives[0]);
1592            tangents.push(tangent);
1593            curvatures.push(curvature_vector(derivatives[1], derivatives[2]));
1594            segments.push(index);
1595        }
1596    }
1597    // A CLOSED path's seam is a joint like any other, and station 0 is the only
1598    // one whose two sides are sampled at opposite ends of the loop: the last
1599    // segment's end is station 0, and it was dropped rather than emitted.
1600    if path.closed {
1601        if let (Some(curve), Some(domain)) = (chain.last(), domains.last()) {
1602            let derivatives = curve.derivatives_small(domain[1], 2).or_refuse(KernelStage::Collect, "derivatives")?;
1603            let arriving = curvature_vector(derivatives[1], derivatives[2]);
1604            if let Some(departing) = curvatures.first_mut() {
1605                if arriving.length() > departing.length() {
1606                    *departing = arriving;
1607                }
1608            }
1609        }
1610    }
1611    Ok(StationSamples {
1612        points,
1613        tangents,
1614        curvatures,
1615        segments,
1616    })
1617}
1618
1619/// The ring frame's fixed reference axis: the path plane's own normal, oriented
1620/// once and reused at every station.
1621///
1622/// A planar path's rotation-minimizing frame IS the planar frame — the transport
1623/// has no component about the tangent, so the axis perpendicular to the plane is
1624/// carried unchanged the whole way round and comes back to itself EXACTLY. That
1625/// is what makes a closed planar sweep need no twist policy: the holonomy is
1626/// zero by construction, not by cancellation. Taking the plane normal as the
1627/// reference axis says so in the construction rather than hoping a
1628/// station-by-station transport returns to where it started.
1629///
1630/// The guard is the one case a fixed axis cannot serve: a tangent parallel to the
1631/// normal, which a planar path cannot have and a mis-fitted plane could.
1632fn ring_normal(normal: Vec3, tangents: &[Vec3]) -> Result<Vec3, KernelRefusal> {
1633    for (index, tangent) in tangents.iter().enumerate() {
1634        if tangent.cross(normal).length() < 0.5 {
1635            return Err(KernelRefusal::internal(
1636                KernelStage::Classify,
1637                "ring_normal",
1638                format!(
1639                "sweepSolid: the closed path's own plane normal is within 30° of its tangent \
1640                 at station {index}, so the path does not lie in that plane after all"
1641                ),
1642            ));
1643        }
1644    }
1645    normal.normalized().map_err(|_| {
1646        KernelRefusal::internal(
1647            KernelStage::Classify,
1648            "ring_normal_degenerate",
1649            "sweepSolid: the closed path's plane normal is degenerate",
1650        )
1651    })
1652}
1653
1654/// The total TURNING of one path segment: the angle its unit tangent sweeps over
1655/// its own domain, summed over 16 uniform steps — or, where a step turns more
1656/// than [`TURNING_ALIAS_STEP`], over 16 steps per knot span.
1657///
1658/// Not the same question as its length, and the one a station budget has to
1659/// answer: a straight segment of any length is carried exactly by two stations,
1660/// while a quarter arc of any radius needs enough stations that the cubic
1661/// interpolant through them does not cut the corner. 16 samples is the density
1662/// every other estimate in this builder uses — and it ALIASES a segment that
1663/// turns more than a few times: on an 8-turn coil each step is half a turn, and
1664/// the angle between two tangents cannot read more than π however far the curve
1665/// turned between them. A step that large is the sign, so only then is the
1666/// segment re-read densely; everything that reads calmly at 16 keeps exactly the
1667/// reading, and so exactly the floor, it always had (a half circle reads a hair
1668/// over π there, and a floor of 33 — the budget the closed lane is pinned at).
1669fn segment_turning(curve: &NurbsCurve, domain: [f64; 2]) -> Result<f64, String> {
1670    let (total, widest) = turning_at(curve, domain, 16)?;
1671    if widest <= TURNING_ALIAS_STEP {
1672        return Ok(total);
1673    }
1674    let spans = curve.knots.windows(2).filter(|pair| pair[1] > pair[0]).count();
1675    Ok(turning_at(curve, domain, (spans * 16).clamp(64, 8192))?.0)
1676}
1677
1678/// The widest tangent step [`segment_turning`] trusts at 16 samples: π/8, twice
1679/// the step of a quarter arc read at that density.
1680const TURNING_ALIAS_STEP: f64 = std::f64::consts::PI / 8.0;
1681
1682/// `(total turning, widest single step)` over `steps` uniform steps.
1683fn turning_at(curve: &NurbsCurve, domain: [f64; 2], steps: usize) -> Result<(f64, f64), String> {
1684    let [t0, t1] = domain;
1685    let mut total = 0.0;
1686    let mut widest: f64 = 0.0;
1687    // A stationary sample — a cusp in the parameterization, which an involute
1688    // flank's Hermite fit carries by construction — still has a direction of
1689    // travel, and the turning is a question about directions.
1690    let mut previous = curve.unit_tangent(t0, t0, t1)?;
1691    for sample in 1..=steps {
1692        let tangent = curve.unit_tangent(t0 + (t1 - t0) * sample as f64 / steps as f64, t0, t1)?;
1693        let step = previous.dot(tangent).clamp(-1.0, 1.0).acos();
1694        total += step;
1695        widest = widest.max(step);
1696        previous = tangent;
1697    }
1698    Ok((total, widest))
1699}
1700
1701/// ONE double-reflection step (Wang et al. 2008) of a rotation-minimizing frame:
1702/// carry the reference axis `r` from a station with tangent `t` to the next with
1703/// tangent `t_next` across the step vector `step`, then re-orthogonalize against
1704/// the new tangent to shed floating drift. `None` when the result degenerates.
1705///
1706/// Extracted so the frame the sections are placed on and the frame the holonomy
1707/// is measured with are the SAME transport rather than two copies of it.
1708fn rmf_step(r: Vec3, t: Vec3, t_next: Vec3, step: Vec3) -> Option<Vec3> {
1709    let c1 = step.dot(step);
1710    let candidate = if c1 <= 1e-18 {
1711        // Coincident stations: carry the reference axis forward unchanged.
1712        r
1713    } else {
1714        // First reflection across the plane bisecting the step vector.
1715        let reflected_r = r.sub(step.scale(2.0 / c1 * step.dot(r)));
1716        let reflected_t = t.sub(step.scale(2.0 / c1 * step.dot(t)));
1717        // Second reflection across the plane bisecting the tangents.
1718        let v2 = t_next.sub(reflected_t);
1719        let c2 = v2.dot(v2);
1720        if c2 <= 1e-18 {
1721            reflected_r
1722        } else {
1723            reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
1724        }
1725    };
1726    candidate
1727        .sub(t_next.scale(candidate.dot(t_next)))
1728        .normalized()
1729        .ok()
1730}
1731
1732// ===========================================================================
1733// The TIGHT BEND: a section carried through a bend tighter than its own reach
1734// ===========================================================================
1735
1736/// How many points each profile curve contributes to the section's REACH.
1737///
1738/// The reach has to be a WITNESS — a point of the section that really does turn
1739/// back — so it is read off the curve itself and never off the control hull:
1740/// `make_arc` places a half-circle's middle controls at `r√2`, so a hull bound
1741/// would refuse a round section 41 % early. Sampling under-reads instead, which
1742/// is the safe direction: at 64 samples per curve a circle's support is short by
1743/// `r(1 − cos(π/128))` = 3.0e-4·r, so the guard can only ever refuse LATE, and a
1744/// bend that slips past it still meets the fold acceptance at the loft's exit.
1745const BEND_SECTION_SAMPLES: usize = 64;
1746
1747/// One station's tight-bend reading: what the path does there and how far the
1748/// section reaches into it.
1749///
1750/// The quantity that decides the sweep is [`BendStation::ratio`] — `reach·κ`,
1751/// the same `ρκ` the fillet's canal surface folds at. A section point offset `d`
1752/// towards the centre of the osculating circle advances `(1 − d·κ)` per unit of
1753/// path, so at `d·κ = 1` it stops and past it it goes BACKWARDS: the swept
1754/// envelope has turned back through itself and the solid is the envelope trimmed
1755/// at that self-intersection, not the loft through the crossing sections.
1756#[derive(Debug, Clone, Copy)]
1757pub struct BendStation {
1758    /// The station's index in the sweep's own station list.
1759    pub station: usize,
1760    /// Where it is on the path.
1761    pub point: Vec3,
1762    /// The path's curvature `κ` there (zero on a straight piece).
1763    pub curvature: f64,
1764    /// The path's radius of curvature `ρ = 1/κ`, `f64::INFINITY` where straight.
1765    pub radius: f64,
1766    /// How far the placed section reaches towards the centre of the bend —
1767    /// its support in the curvature-normal direction. Negative when the whole
1768    /// section sits on the OUTSIDE of the bend, zero where the path is straight.
1769    pub reach: f64,
1770    /// `reach·κ`. `< 1` builds; `≥ 1` folds.
1771    pub ratio: f64,
1772}
1773
1774/// The profile's sample points in the placement frame's own 2D coordinates
1775/// `(a, b) = ((x − origin)·pu, (x − origin)·pv)`.
1776///
1777/// Sampled ONCE: `Transplant` places the same 2D shape at every station
1778/// (`world = station + rᵢ·a + sᵢ·b`), so a station's reach is a pair of dot
1779/// products against the precomputed cloud rather than a re-evaluation of the
1780/// placed curves.
1781fn section_offsets(
1782    profile: &[NurbsCurve],
1783    placement: &ProfileAnchor,
1784) -> Result<Vec<(f64, f64)>, KernelRefusal> {
1785    let ProfileAnchor { origin, pu, pv, .. } = *placement;
1786    let mut offsets = Vec::with_capacity(profile.len() * BEND_SECTION_SAMPLES);
1787    for curve in profile {
1788        let [start, end] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
1789        for sample in 0..BEND_SECTION_SAMPLES {
1790            let t = start + (end - start) * sample as f64 / BEND_SECTION_SAMPLES as f64;
1791            let local = curve.evaluate(t).or_refuse(KernelStage::Classify, "evaluate")?.sub(origin);
1792            offsets.push((local.dot(pu), local.dot(pv)));
1793        }
1794    }
1795    Ok(offsets)
1796}
1797
1798/// The `ρκ` profile: one [`BendStation`] per station, in station order.
1799///
1800/// `axes` are the frames the sections are PLACED on (twist included), because
1801/// the reach is the section's support in the curvature-normal direction and a
1802/// non-round section rolled about the path presents a different support there.
1803fn bend_stations(
1804    points: &[Vec3],
1805    curvatures: &[Vec3],
1806    axes: &[(Vec3, Vec3)],
1807    offsets: &[(f64, f64)],
1808) -> Vec<BendStation> {
1809    (0..points.len())
1810        .map(|station| {
1811            let curvature = curvatures[station].length();
1812            if curvature <= 0.0 {
1813                return BendStation {
1814                    station,
1815                    point: points[station],
1816                    curvature: 0.0,
1817                    radius: f64::INFINITY,
1818                    reach: 0.0,
1819                    ratio: 0.0,
1820                };
1821            }
1822            // The curvature vector is perpendicular to the tangent by
1823            // construction, so its components on (r, s) are the whole of it.
1824            let normal = curvatures[station].scale(1.0 / curvature);
1825            let (r_axis, s_axis) = axes[station];
1826            let (nu, nv) = (normal.dot(r_axis), normal.dot(s_axis));
1827            let reach = offsets
1828                .iter()
1829                .map(|(a, b)| a * nu + b * nv)
1830                .fold(f64::NEG_INFINITY, f64::max);
1831            BendStation {
1832                station,
1833                point: points[station],
1834                curvature,
1835                radius: 1.0 / curvature,
1836                reach,
1837                ratio: reach * curvature,
1838            }
1839        })
1840        .collect()
1841}
1842
1843/// The `ρκ` profile a TRANSPLANT sweep of `profile` along `path` would carry at
1844/// `stations` stations — the instrument behind the refusal below, exposed so a
1845/// caller (or a probe) can measure a path BEFORE asking for the solid.
1846///
1847/// Reads the very stations the sweep lofts through: the same budget, the same
1848/// sampling, the same rotation-minimizing frame. Untwisted, which is the lane
1849/// every caller of [`sweep_profile_along_chain_with_stations`] is in.
1850pub fn sweep_bend_profile(
1851    profile: &[NurbsCurve],
1852    path: &SweepPath,
1853    stations: usize,
1854) -> Result<Vec<BendStation>, KernelRefusal> {
1855    let placement = resolve_placement(profile, None, SectionPlacement::Transplant)?;
1856    let StationSamples {
1857        points,
1858        tangents,
1859        curvatures,
1860        ..
1861    } = chain_stations(path, stations.clamp(2, 1024))?;
1862    let closure = ring_frame(path, &tangents)?;
1863    let untwisted = ClosingTwist {
1864        twist: 0.0,
1865        shift: 0,
1866    };
1867    let (r_axes, s_axes, _) = station_frames(&points, &tangents, closure, None, untwisted)?;
1868    let axes: Vec<(Vec3, Vec3)> = r_axes.into_iter().zip(s_axes).collect();
1869    let offsets = section_offsets(profile, &placement)?;
1870    Ok(bend_stations(&points, &curvatures, &axes, &offsets))
1871}
1872
1873/// The prefix every tight-bend refusal starts with, so a caller can CLASSIFY one
1874/// (the wire harness reports it as a bundle status) without matching free text.
1875pub const SWEEP_TIGHT_BEND_REFUSAL: &str = "sweepSolid: the path bends TIGHTER than the section";
1876
1877/// The slug of the tight-bend refusal (`UnsupportedGeometry`), shared by its
1878/// one mint ([`tight_bend_refusal`]) and [`is_sweep_tight_bend`].
1879pub(crate) const SWEEP_TIGHT_BEND_WHAT: &str = "tight_bend";
1880
1881/// Is this the sweep's tight-bend refusal ([`SWEEP_TIGHT_BEND_REFUSAL`])? Read
1882/// off the class and its slug, so a caller that decides on it (the wire
1883/// harness's bundle status) follows the refusal, not its wording.
1884pub(crate) fn is_sweep_tight_bend(refusal: &KernelRefusal) -> bool {
1885    matches!(
1886        &refusal.class,
1887        crate::RefusalClass::UnsupportedGeometry { what } if what == SWEEP_TIGHT_BEND_WHAT
1888    )
1889}
1890
1891/// The refusal for a path that bends tighter than the section reaches, or `None`
1892/// when every station builds.
1893///
1894/// Quotes the WORST station rather than the first one scanned — the tightest
1895/// bend is the one the user has to ease, and a path can cross the bar in several
1896/// places (both ends of one Hermite span, typically).
1897fn tight_bend_refusal(profile: &[BendStation], stations: usize) -> Option<KernelRefusal> {
1898    let folded = profile.iter().filter(|station| station.ratio >= 1.0).count();
1899    if folded == 0 {
1900        return None;
1901    }
1902    let worst = profile
1903        .iter()
1904        .max_by(|a, b| a.ratio.total_cmp(&b.ratio))
1905        .expect("a folded station exists");
1906    // The solid exists — the envelope trimmed at its self-intersection — and
1907    // this lane does not build it: a named deferral.
1908    Some(KernelRefusal::unsupported(
1909        KernelStage::Classify,
1910        SWEEP_TIGHT_BEND_WHAT,
1911        format!(
1912        "{SWEEP_TIGHT_BEND_REFUSAL} at station {} of {stations} ({:.4}, {:.4}, {:.4}): the path's \
1913         radius of curvature there is {:.6} and the section reaches {:.6} into the bend, so \
1914         ρ·κ = {:.3} ≥ 1 ({folded} of {stations} stations do). A section carried through a bend \
1915         tighter than its own reach sweeps an envelope that passes through ITSELF — the solid \
1916         there is that envelope trimmed at its own self-intersection, which this lane does not \
1917         build: it skins the section between stations, and past ρ·κ = 1 the sections CROSS and \
1918         the wall folds. Ease the bend to a radius above {:.6}, or narrow the section until it \
1919         reaches no further than {:.6} (a round section of diameter {:.6})",
1920        worst.station,
1921        worst.point.x,
1922        worst.point.y,
1923        worst.point.z,
1924        worst.radius,
1925        worst.reach,
1926        worst.ratio,
1927        worst.reach,
1928        worst.radius,
1929        2.0 * worst.radius,
1930        ),
1931    ))
1932}
1933
1934/// The part of the sweep that does not care HOW the stations were sampled:
1935/// validate the profile, distribute the twist over the sampled polyline,
1936/// propagate the rotation-minimizing frame, place a profile copy per station
1937/// and loft through them.
1938///
1939/// Split out so a MULTI-SEGMENT path can share it
1940/// ([`sweep_profile_along_chain_stations`]). The single-curve sampler above is
1941/// deliberately left calling this with its OWN sampling rather than being
1942/// re-expressed as a one-element chain: two code paths that agree today are not
1943/// the same thing as one code path, and the untwisted single-curve geometry is
1944/// pinned by volume tests that must not move.
1945///
1946/// The RMF is propagated over the station sequence as a whole, so a chain's
1947/// frame carries across a joint exactly as it carries across any other pair of
1948/// adjacent stations — there is no per-segment restart to reconcile.
1949///
1950/// `closure` turns the run into a RING: anything but [`RingFrame::Open`] says the
1951/// stations come back round to station 0 (which is therefore NOT repeated at the
1952/// end). `Planar(normal)` says the path lies in the plane of that normal, so the
1953/// reference axis is fixed rather than transported — a planar frame, whose
1954/// holonomy round the loop is zero by construction. `Spatial` transports the
1955/// frame and closes it with the counter-twist policy ([`SweepClosure`]). Either
1956/// way the sections skin through a CLOSED loft: one face per section curve, no
1957/// end caps, `V − E + F = 0`. `Open` is the open run: transported frame, two end
1958/// caps, and the refusal below for a trajectory that closes anyway.
1959///
1960/// `chain` is the station-to-segment map and the classified path, from the chain
1961/// sampler; the single-curve sampler has none. Only `Rigid` reads it, to frame a
1962/// HELIX segment's stations off its screw axis ([`screw_reference`]).
1963#[allow(clippy::too_many_arguments)]
1964fn sweep_sections_through_samples(
1965    profile: &[NurbsCurve],
1966    points: &[Vec3],
1967    tangents: &[Vec3],
1968    curvatures: &[Vec3],
1969    twist_angle: f64,
1970    placement: ProfileAnchor,
1971    placement_mode: SectionPlacement,
1972    closure: RingFrame,
1973    envelope_gap: Option<&str>,
1974    chain: Option<(&[usize], &SweepPath)>,
1975) -> Result<BrepSolid, KernelRefusal> {
1976    let tolerance = 1e-6;
1977    let stations = points.len();
1978    if stations < 2 {
1979        return Err(KernelRefusal::internal(
1980            KernelStage::Collect,
1981            "station_run",
1982            "sweepSolid: need at least 2 stations",
1983        ));
1984    }
1985
1986    let ProfileAnchor { origin, pu, pv, .. } = placement;
1987
1988    // --- 2b. Twist distribution: cumulative ARC-LENGTH fractions over the
1989    //         sampled station polyline (chord sums), so the twist advances
1990    //         uniformly in space even on a non-uniformly parameterized path.
1991    //         Only computed when a twist is actually requested — the
1992    //         `twist_angle == 0.0` path must stay bit-identical to the
1993    //         pre-twist builder — and only on an OPEN run. These fractions stop at
1994    //         the last station, so a RING laid down by them would roll its wrap
1995    //         span at a different rate from every other; a ring rolls its twist
1996    //         in its frames instead, over the whole lap (§3).
1997    let twist_fractions: Option<Vec<f64>> = if twist_angle != 0.0 && !closure.is_ring() {
1998        let mut cumulative = vec![0.0; stations];
1999        let mut total = 0.0;
2000        for index in 1..stations {
2001            total += points[index].sub(points[index - 1]).length();
2002            cumulative[index] = total;
2003        }
2004        if total <= tolerance {
2005            return Err(KernelRefusal::input(
2006                KernelStage::Collect,
2007                "path_length",
2008                "sweepSolid: path has zero length; cannot distribute the twist",
2009            ));
2010        }
2011        for length in &mut cumulative {
2012            *length /= total;
2013        }
2014        Some(cumulative)
2015    } else {
2016        None
2017    };
2018
2019    // --- 3. The station frames.
2020    //
2021    //   OPEN: rotation-minimizing frames via the double-reflection method, seeded
2022    //   on an arbitrary perpendicular to T0 — arbitrary because `Rigid` cancels
2023    //   the seed out (`R_k = F_k·F_0ᵀ`) and `Transplant` has always taken whatever
2024    //   roll `perpendicular()` returned.
2025    //
2026    //   RING: the plane normal is the reference axis AT EVERY STATION. A planar
2027    //   path's RMF is that frame already — the transport has no component about
2028    //   the tangent — so this is not a different frame from the open lane's, it is
2029    //   the same frame written down in closed form instead of accumulated. Which
2030    //   matters for exactly one reason: written down, it returns to itself at the
2031    //   seam EXACTLY, so the ring's last section meets its first with no residual
2032    //   roll to skin away.
2033    //
2034    //   SPATIAL RING: the open lane's transported frame, carried one step further
2035    //   round the wrap to MEASURE the roll it returns with (the holonomy), and
2036    //   counter-twisted by that roll linearly in arc length so the lap's last span
2037    //   rolls at the same rate as every other and the ring meets itself
2038    //   (`ring_closure_frames`).
2039    //
2040    //   A REQUESTED twist on a RING is laid down in those frames too, over the
2041    //   whole lap with the wrap, on top of the counter-twist — but only a twist
2042    //   that closes: after the lap the section comes back rolled by it, so it must
2043    //   carry the section onto itself ([`closing_twist`], refused by name
2044    //   otherwise). A fractional turn on a symmetric section closes onto a
2045    //   DIFFERENT curve than it left, `shift` along, and the loft below is told.
2046    //
2047    //   RIGID on a HELIX segment: the helix's own screw frame (see
2048    //   `screw_reference`), which the RMF rolls away from by the helix's
2049    //   integrated torsion. `Transplant` keeps the RMF everywhere — it squares
2050    //   the section to the path and centres it there, and its callers (SWP, the
2051    //   tube, the harness) are not this contract.
2052    let screw = chain.filter(|_| placement_mode == SectionPlacement::Rigid);
2053    let requested = if closure.is_ring() && twist_angle != 0.0 {
2054        let (section, _) = place_section(profile, placement, placement_mode, points[0], tangents[0])?;
2055        ring_closing_twist(&section, points, tangents, twist_angle)?
2056    } else {
2057        ClosingTwist {
2058            twist: 0.0,
2059            shift: 0,
2060        }
2061    };
2062    let (r_axes, s_axes, _) = station_frames(points, tangents, closure, screw, requested)?;
2063
2064    // --- 3b. An OPEN trajectory that comes back to where it started cannot be
2065    //         capped: the first and last sections land on top of each other, and
2066    //         the loft's own complaint about that ("end sections coincide") says
2067    //         nothing about the path. Name it here instead, with the two things
2068    //         that do work. The check is on the PATH's first and last station
2069    //         POINTS, which is enough: a path that comes back to where it started
2070    //         puts the two caps in the same place whatever direction it arrives
2071    //         from — coincident when it also arrives pointing the same way,
2072    //         interpenetrating when it does not, and neither is a solid worth
2073    //         building.
2074    //
2075    //         TWO CLOSURE NOTIONS COEXIST, and deliberately. A path whose ends
2076    //         meet within the chainer's join band is CLASSIFIED closed and swept
2077    //         as a ring above — that is the shape a user drew as a loop. This band
2078    //         is a thousandth of the distance TRAVELLED, a hundred times looser,
2079    //         and it catches the run that is nearly a loop without being one: a
2080    //         359.7° arc whose caps would interpenetrate rather than coincide. The
2081    //         first is a shape with a construction; the second is an accident, and
2082    //         it is still refused. On a circular arc the band is the last ~0.36°
2083    //         before closure, so a 359° sweep still builds.
2084    if !closure.is_ring() {
2085        let travel: f64 = points
2086            .windows(2)
2087            .map(|pair| pair[1].sub(pair[0]).length())
2088            .sum();
2089        let gap = points[stations - 1].sub(points[0]).length();
2090        if travel > tolerance && gap <= 1e-3 * travel {
2091            // A run that is nearly a loop without being one: the input does not
2092            // say whether it is a ring or an open sweep, so neither is chosen.
2093            return Err(KernelRefusal::ill_posed(
2094                KernelStage::Classify,
2095                "near_closed_path",
2096                format!(
2097                "sweepSolid: the path returns to where it started ({gap:.3e} apart after \
2098                 travelling {travel:.3e}), so the sweep's two end caps would land on top of each \
2099                 other. A closed path swept this way IS a revolution — build it with Revolve \
2100                 about the same axis, or close the path exactly so it sweeps as a capless ring, \
2101                 or sweep the run in two halves and union them"
2102                ),
2103            ));
2104        }
2105    }
2106
2107    // --- 4. Place a transformed copy of the profile at each station, by the mode
2108    //        the caller asked for (see `SectionPlacement`).
2109    //
2110    //   TRANSPLANT maps the profile's own plane frame onto the station frame,
2111    //       world = P_k + r_k·(l·pu) + s_k·(l·pv),   l = x − origin
2112    //   which lands the anchor origin ON the path and the profile square to it.
2113    //   The profile's distance from the path and its angle to the path are both
2114    //   discarded — there is no term along the tangent to carry them.
2115    //
2116    //   RIGID applies the path's own motion to the profile where it stands,
2117    //       world = P_k + a·T_k + b·r_k + c·s_k,     (a,b,c) = (l·T_0, l·r_0, l·s_0)
2118    //                                                l = x − P_0
2119    //   which is `P_k + R_k·(x − P_0)` for `R_k = F_k·F_0ᵀ`, written in the frame
2120    //   basis rather than as a matrix. The tangent term `a·T_k` is exactly what
2121    //   TRANSPLANT drops, and carrying it is what preserves both the profile's
2122    //   offset from the path and its angle to it. At station 0 the frames
2123    //   coincide and the section IS the profile, so the START cap lies in the
2124    //   plane it was drawn in.
2125    let path_start = points[0];
2126    let (t0, r0, s0) = (tangents[0], r_axes[0], s_axes[0]);
2127    let rigid = placement_mode == SectionPlacement::Rigid;
2128    let mut sections: Vec<Vec<NurbsCurve>> = Vec::with_capacity(stations);
2129    // Only the RIGID guard below reads these; TRANSPLANT leaves them empty.
2130    let mut station_points: Vec<Vec<Vec3>> = Vec::with_capacity(if rigid { stations } else { 0 });
2131    let mut section_normals: Vec<Vec3> = Vec::with_capacity(if rigid { stations } else { 0 });
2132    let mut anchor_track: Vec<Vec3> = Vec::with_capacity(if rigid { stations } else { 0 });
2133    // The axes the section is actually PLACED on, twist included — what the
2134    // tight-bend guard below measures the section's reach in.
2135    let mut placed_axes: Vec<(Vec3, Vec3)> = Vec::with_capacity(stations);
2136    for station in 0..stations {
2137        let station_origin = points[station];
2138        // Rotate the RMF axes about the tangent by the station's twist angle
2139        // (Rodrigues on vectors ⟂ the tangent: r' = r·cosφ + s·sinφ,
2140        // s' = s·cosφ − r·sinφ, since s = t × r and t × s = −r).
2141        let (ri, si) = match &twist_fractions {
2142            Some(fractions) => {
2143                let phi = twist_angle * fractions[station];
2144                let (sin_phi, cos_phi) = phi.sin_cos();
2145                let r = r_axes[station];
2146                let s = s_axes[station];
2147                (
2148                    r.scale(cos_phi).add(s.scale(sin_phi)),
2149                    s.scale(cos_phi).sub(r.scale(sin_phi)),
2150                )
2151            }
2152            None => (r_axes[station], s_axes[station]),
2153        };
2154        placed_axes.push((ri, si));
2155        let tk = tangents[station];
2156        let place = |x: Vec3| -> Vec3 {
2157            if rigid {
2158                let local = x.sub(path_start);
2159                station_origin
2160                    .add(tk.scale(local.dot(t0)))
2161                    .add(ri.scale(local.dot(r0)))
2162                    .add(si.scale(local.dot(s0)))
2163            } else {
2164                let local = x.sub(origin);
2165                station_origin
2166                    .add(ri.scale(local.dot(pu)))
2167                    .add(si.scale(local.dot(pv)))
2168            }
2169        };
2170        if rigid {
2171            // The section's own normal takes the same rotation (a direction, so
2172            // `R_k` without the translation), and the anchor origin's track is
2173            // the profile's bulk motion for the near-parallel guard.
2174            let n0 = placement.normal;
2175            section_normals.push(
2176                tk.scale(n0.dot(t0))
2177                    .add(ri.scale(n0.dot(r0)))
2178                    .add(si.scale(n0.dot(s0))),
2179            );
2180            anchor_track.push(place(origin));
2181            station_points.push(Vec::with_capacity(
2182                profile.iter().map(|c| c.control_points.len()).sum(),
2183            ));
2184        }
2185        let mut section = Vec::with_capacity(profile.len());
2186        for curve in profile {
2187            let control_points = curve
2188                .control_points
2189                .iter()
2190                .map(|point| {
2191                    let weight = point.w;
2192                    let euclidean = Vec3::new(point.x / weight, point.y / weight, point.z / weight);
2193                    let world = place(euclidean);
2194                    if rigid {
2195                        station_points[station].push(world);
2196                    }
2197                    Vec4 {
2198                        x: world.x * weight,
2199                        y: world.y * weight,
2200                        z: world.z * weight,
2201                        w: weight,
2202                    }
2203                })
2204                .collect();
2205            section.push(NurbsCurve::new(
2206                curve.degree,
2207                curve.knots.clone(),
2208                control_points,
2209            ).or_refuse(KernelStage::Fragment, "curve_new")?);
2210        }
2211        sections.push(section);
2212    }
2213
2214    // --- 4b. RIGID only: refuse a sweep whose sections fold through each other.
2215    //
2216    // TRANSPLANT keeps every section square to the path, so it can only fold
2217    // where the path's curvature radius drops below the profile's extent — the
2218    // case this builder has always documented as the caller's to avoid. RIGID
2219    // can fold for a second, much more reachable reason: a turning path carries
2220    // the profile around the TURN'S OWN AXIS, so a profile straddling that axis
2221    // has one half advancing while the other retreats. That is not a tolerance
2222    // question, it is a sign question, so it is answered exactly.
2223    //
2224    // The placement map is AFFINE in `x`, so the advance is affine in `x` too and
2225    // its extremes over the section lie on the control points. Checking the hull
2226    // therefore bounds the advance over the curves AND over the region they
2227    // enclose — this is a proof, not a sampling.
2228    //
2229    // ON A RING the guard walks one step FURTHER: the wrap from the last station
2230    // back to station 0 is a step the sections take like any other, and it is the
2231    // one step an open run does not have. A ring whose wrap step folded while
2232    // every interior step advanced would otherwise pass. On a ring whose twist
2233    // lands each curve `shift` along, the wrap step's control points arrive on
2234    // that curve's: the flat control-point list of station 0, turned left by the
2235    // points of the `shift` curves before it (curves `j` and `j + shift` carry the
2236    // same number, which is what makes the turn a relabelling).
2237    let step_count = if closure.is_ring() {
2238        stations
2239    } else {
2240        stations - 1
2241    };
2242    let next_station = |station: usize| (station + 1) % stations;
2243    if rigid {
2244        let landing_offset: usize = profile[..requested.shift]
2245            .iter()
2246            .map(|curve| curve.control_points.len())
2247            .sum();
2248        let mut most_positive = 0.0f64;
2249        let mut most_negative = 0.0f64;
2250        let mut longest_step = 0.0f64;
2251        for station in 0..step_count {
2252            let normal = section_normals[station];
2253            let arriving = &station_points[next_station(station)];
2254            let turn = if next_station(station) == 0 { landing_offset } else { 0 };
2255            for (before, after) in station_points[station]
2256                .iter()
2257                .zip(arriving[turn..].iter().chain(&arriving[..turn]))
2258            {
2259                let step = after.sub(*before);
2260                longest_step = longest_step.max(step.length());
2261                let advance = step.dot(normal);
2262                most_positive = most_positive.max(advance);
2263                most_negative = most_negative.min(advance);
2264            }
2265        }
2266        // Scale-relative, and far below any geometric tolerance: this band only
2267        // has to separate a sign from floating noise, never a small motion from
2268        // a large one.
2269        let noise = 1e-9 * longest_step.max(tolerance);
2270        if most_positive > noise && most_negative < -noise {
2271            return Err(KernelRefusal::unsupported(
2272                KernelStage::Classify,
2273                "straddles_axis",
2274                "sweepSolid: the profile sweeps back through itself — part of it advances along \
2275                 the path while part of it retreats. A turning path carries the profile around \
2276                 the turn's own axis, so a profile that STRADDLES that axis folds into itself; \
2277                 move the profile clear of the axis, or sweep the run in pieces",
2278            ));
2279        }
2280        if most_positive <= noise && most_negative >= -noise {
2281            return Err(KernelRefusal::input(
2282                KernelStage::Classify,
2283                "no_advance",
2284                "sweepSolid: the path does not advance through the profile — it runs inside the \
2285                 profile plane, so the sweep encloses no volume",
2286            ));
2287        }
2288        // The near-parallel case, on the anchor origin's own track: the same 0.1
2289        // threshold `extrude_profile_brep` refuses a sliver at, so a STRAIGHT
2290        // path is accepted here exactly when the translational sweep of the same
2291        // profile and vector is. A station whose origin barely moves is skipped —
2292        // an origin sitting on the turn axis means the profile straddles it, and
2293        // the fold-back guard above has already spoken.
2294        for station in 0..step_count {
2295            let step = anchor_track[next_station(station)].sub(anchor_track[station]);
2296            let length = step.length();
2297            if length <= noise {
2298                continue;
2299            }
2300            if step.dot(section_normals[station]).abs() / length < 0.1 {
2301                return Err(KernelRefusal::input(
2302                    KernelStage::Classify,
2303                    "sliver",
2304                    "sweepSolid: the path is nearly parallel to the profile plane, which sweeps a \
2305                     sliver rather than a solid",
2306                ));
2307            }
2308        }
2309    }
2310
2311    // --- 4c. TRANSPLANT: refuse a path that bends TIGHTER than the section
2312    //         reaches, before the loft rather than after it.
2313    //
2314    // A section square to the path, offset `d` towards the centre of the
2315    // osculating circle, advances `(1 − d·κ)` per unit of path: at `d·κ = 1` it
2316    // stands still and past it it RETREATS, so consecutive sections cross and
2317    // the wall the loft skins through them passes through itself. That is the
2318    // same `ρκ > 1` the fillet's canal surface folds at, and the same defect the
2319    // soundness acceptance catches at the loft's exit — but caught THERE it can
2320    // only name a face and two parameters, where here it can name the station,
2321    // the bend and the section that does not fit through it.
2322    //
2323    // RIGID is not measured here: it does not keep the section square to the
2324    // path, so `ρκ` is not its criterion, and §4b above is its guard.
2325    //
2326    // The reach is a WITNESS and the curvature is the PATH's own, so this refuses
2327    // only where a real section point really does turn back. What it does not see
2328    // is a bend that peaks BETWEEN two stations — the loft interpolates those,
2329    // and the acceptance at the exit remains the backstop.
2330    if !rigid && std::env::var("BREP_SWEEP_BEND_GUARD").as_deref() != Ok("0") {
2331        let offsets = section_offsets(profile, &placement)?;
2332        let profile_stations = bend_stations(points, curvatures, &placed_axes, &offsets);
2333        if let Some(refusal) = tight_bend_refusal(&profile_stations, stations) {
2334            // The ENVELOPE lane builds this shape exactly for ONE configuration
2335            // (a circle on one circular planar arc), and a caller that lands here
2336            // has missed it. `envelope_gap` is that lane's own measurement of
2337            // WHICH half was missed, carried down from the entry that has the
2338            // path to measure — so the refusal names the boundary of what is
2339            // built rather than only the bend that is refused.
2340            return Err(match envelope_gap {
2341                Some(gap) if !gap.is_empty() => refusal.with_message(|refusal| {
2342                    format!(
2343                    "{refusal}. The swept envelope trimmed at its own self-intersection is built \
2344                     for a CIRCLE carried along one circular planar arc, and this sweep is not \
2345                     that: {gap}"
2346                    )
2347                }),
2348                _ => refusal,
2349            });
2350        }
2351    }
2352
2353    // --- 5. Loft through the swept sections.
2354    //
2355    //   OPEN: side walls plus two planar end caps.
2356    //
2357    //   RING: the CLOSED loft — the sections form a cycle, the last flowing back
2358    //   into the first through the same C² cyclic interpolation every interior
2359    //   span gets, so the seam is not a weld. One face per section curve and NO
2360    //   CAPS, which is the whole point: a ring has no ends to cap, and capping it
2361    //   would be two faces inside the material. The topology it emits is
2362    //   `c` corner vertices, `c` corner rings, `c` seam edges and `c` faces, so
2363    //   `V − E + F = c − 2c + c = 0` — the Euler characteristic of a torus, which
2364    //   is what a section carried once round a loop encloses.
2365    //   A RING whose requested twist lands each curve `shift` along skins each
2366    //   wall from its own curve into that one (`loft_profile_brep_closed_shifted`),
2367    //   so there is still one wall per section curve.
2368    //   The loft mints its own refusals; the wrap below keeps that class and
2369    //   prefixes the text.
2370    match closure {
2371        RingFrame::Planar(_) | RingFrame::Spatial => {
2372            // The loft mints its own refusals; its class rides through the
2373            // sweep's wrap (a station cap stays a deferral, the acceptance's
2374            // fold stays an UnsoundResult).
2375            crate::loft_profile_brep_closed_shifted(&sections, requested.shift).map_err(|error| {
2376                error.with_message(|error| {
2377                    format!("sweepSolid: closed loft through swept sections failed: {error}")
2378                })
2379            })
2380        }
2381        RingFrame::Open => loft_profile_brep(&sections).map_err(|error| {
2382            error.with_message(|error| {
2383                format!("sweepSolid: loft through swept sections failed: {error}")
2384            })
2385        }),
2386    }
2387}
2388
2389/// Helical sweep (§5.7): sweep a CLOSED PLANAR profile loop along a helix of
2390/// `helix_radius` about the axis through `axis_origin` with direction
2391/// `axis_direction`, rising `pitch` per revolution for `turns` revolutions.
2392///
2393/// Sample a helix — `turns` revolutions about `axis_direction` from
2394/// `axis_origin`, starting at angle `start_angle` (radians, measured from the
2395/// axis frame's `u`) and rising `pitch` per turn, the radius running linearly
2396/// from `start_radius` to `end_radius` — uniformly in angle: 64 stations per
2397/// turn (at least 9, at most 4097 in total, so beyond 64 turns the per-turn
2398/// density thins). Returns the points and their chord parameters `s ∈ [0,1]`
2399/// (uniform-in-angle IS chord-length for a constant-radius helix, which is
2400/// what the averaged-knot interpolation assumes). `left_handed` winds the
2401/// angle the other way. The axis frame is `w = axis`, `u` = the component of
2402/// `reference` perpendicular to the axis (so angle zero points AT the
2403/// reference — the helix feature's local +X, or its start point), falling
2404/// back to `w.perpendicular()` when there is no usable reference, and
2405/// `v = w × u`. Shared by [`sweep_profile_helix`] and the HX feature, so the
2406/// coil a sweep builds and the edge a helix feature publishes agree exactly.
2407///
2408/// Errors on a degenerate axis, non-finite inputs, `turns ≤ 0`, more than 256
2409/// turns, a negative radius or pitch, or a helix that is a single point (both
2410/// radii AND the pitch zero).
2411#[allow(clippy::too_many_arguments)]
2412pub fn helix_sample_points(
2413    axis_origin: Vec3,
2414    axis_direction: Vec3,
2415    reference: Option<Vec3>,
2416    start_radius: f64,
2417    end_radius: f64,
2418    pitch: f64,
2419    turns: f64,
2420    start_angle: f64,
2421    left_handed: bool,
2422) -> Result<(Vec<Vec3>, Vec<f64>), KernelRefusal> {
2423    use std::f64::consts::TAU;
2424    let w = axis_direction.normalized().map_err(|_| {
2425        KernelRefusal::input(
2426            KernelStage::Collect,
2427            "helix_axis",
2428            "helix: axis direction is degenerate",
2429        )
2430    })?;
2431    for (name, value) in [
2432        ("radius", start_radius),
2433        ("end radius", end_radius),
2434        ("pitch", pitch),
2435        ("turns", turns),
2436        ("start angle", start_angle),
2437    ] {
2438        if !value.is_finite() {
2439            return Err(KernelRefusal::input(
2440                KernelStage::Collect,
2441                "helix_finite",
2442                format!("helix: {name} must be a finite number"),
2443            ));
2444        }
2445    }
2446    if start_radius < 0.0 || end_radius < 0.0 {
2447        return Err(KernelRefusal::input(
2448            KernelStage::Collect,
2449            "helix_radius",
2450            "helix: radius must not be negative",
2451        ));
2452    }
2453    if pitch < 0.0 {
2454        return Err(KernelRefusal::input(
2455            KernelStage::Collect,
2456            "helix_pitch",
2457            "helix: pitch must not be negative",
2458        ));
2459    }
2460    if turns <= 0.0 {
2461        return Err(KernelRefusal::input(
2462            KernelStage::Collect,
2463            "helix_turns",
2464            "helix: turns must be positive",
2465        ));
2466    }
2467    if turns > 256.0 {
2468        return Err(KernelRefusal::input(
2469            KernelStage::Collect,
2470            "helix_turns_limit",
2471            "helix: turns must be at most 256",
2472        ));
2473    }
2474    if start_radius == 0.0 && end_radius == 0.0 && pitch == 0.0 {
2475        return Err(KernelRefusal::input(
2476            KernelStage::Collect,
2477            "helix_point",
2478            "helix: zero radius and zero pitch describe a single point",
2479        ));
2480    }
2481    let u = match reference {
2482        Some(reference) => {
2483            let radial = reference.sub(w.scale(reference.dot(w)));
2484            radial
2485                .normalized()
2486                .or_else(|_| w.perpendicular())
2487                .or_refuse(KernelStage::Collect, "perpendicular")?
2488        }
2489        None => w.perpendicular().or_refuse(KernelStage::Collect, "perpendicular")?,
2490    };
2491    let v = w.cross(u).normalized().or_refuse(KernelStage::Collect, "normalized")?;
2492    let total_angle = turns * TAU;
2493    let height = pitch * turns;
2494    let sign = if left_handed { -1.0 } else { 1.0 };
2495    let count = ((turns * 64.0).ceil() as usize + 1).clamp(9, 4097);
2496    let mut points = Vec::with_capacity(count);
2497    let mut parameters = Vec::with_capacity(count);
2498    for index in 0..count {
2499        let s = index as f64 / (count - 1) as f64;
2500        let theta = start_angle + sign * total_angle * s;
2501        let radius = start_radius + (end_radius - start_radius) * s;
2502        points.push(
2503            axis_origin
2504                .add(u.scale(radius * theta.cos()))
2505                .add(v.scale(radius * theta.sin()))
2506                .add(w.scale(height * s)),
2507        );
2508        parameters.push(s);
2509    }
2510    Ok((points, parameters))
2511}
2512
2513/// [`helix_sample_points`] fitted as ONE cubic curve (global interpolation —
2514/// `interpolate_curve`, the machinery every fitted path here uses). Cubic
2515/// interpolation error on a circle of radius R with node spacing Δθ is
2516/// ≈ R·Δθ⁴/384: ~2·10⁻⁷·R at 64 stations per turn.
2517#[allow(clippy::too_many_arguments)]
2518pub fn fit_helix_curve(
2519    axis_origin: Vec3,
2520    axis_direction: Vec3,
2521    reference: Option<Vec3>,
2522    start_radius: f64,
2523    end_radius: f64,
2524    pitch: f64,
2525    turns: f64,
2526    start_angle: f64,
2527    left_handed: bool,
2528) -> Result<NurbsCurve, KernelRefusal> {
2529    let (points, parameters) = helix_sample_points(
2530        axis_origin,
2531        axis_direction,
2532        reference,
2533        start_radius,
2534        end_radius,
2535        pitch,
2536        turns,
2537        start_angle,
2538        left_handed,
2539    )?;
2540    interpolate_curve(&points, 3, &parameters).or_refuse(KernelStage::Refine, "interpolate")
2541}
2542
2543/// The helix is transcendental, not exactly NURBS-representable, so the path
2544/// is FIT: points (R·cosθ, R·sinθ, pitch·θ/2π) in the axis frame are sampled
2545/// uniformly in θ and globally interpolated with a cubic (`interpolate_curve`
2546/// — the same machinery every other fitted path here uses).  DENSITY: 64
2547/// samples per turn, capped at 1025 total nodes.  Cubic interpolation error
2548/// on a circle of radius R with node spacing Δθ is ≈ R·Δθ⁴/384: ~2·10⁻⁷·R at
2549/// 64/turn, and still ~6·10⁻⁵·R at the cap's worst case (16/turn at the
2550/// 64-turn limit) — orders below any profile a caller could sweep without
2551/// self-intersecting.  Uniform-in-θ parameters are exact chord-length for a
2552/// helix (constant speed), which is what the averaged-knot interpolation
2553/// assumes.
2554///
2555/// The fitted path then drives the EXISTING path-sweep core with 32 stations
2556/// per turn (min 32, capped at 1024 — the loft's dense interpolation solve is
2557/// O(stations³) per control column, so the cap trades per-turn density, never
2558/// correctness, at high turn counts).
2559///
2560/// GUARDS beyond the path sweep's own: the path sweep documents
2561/// self-intersection as caller responsibility, so the helix variant — which
2562/// knows its curvature analytically — rejects the two garbage modes itself:
2563///   • fold-over: the helix curvature radius (R² + c²)/R (c = pitch/2π) must
2564///     exceed the profile's max extent about its centroid, or the tube folds
2565///     through itself on the inner side (the torus tube-radius > major-radius
2566///     failure, pitch-relaxed);
2567///   • coil collision (turns ≥ 1): the normal gap between consecutive coils,
2568///     pitch·2πR/√((2πR)² + pitch²), must exceed the profile diameter.
2569/// Both use the profile's max sample distance from its centroid — conservative
2570/// for asymmetric profiles (extent in a harmless direction still counts), but
2571/// a false reject beats silent garbage.
2572///
2573/// SHALLOW COILS BUILD. They did not until 2026-09-08: the loft measured its
2574/// advance by the END-TO-END chord, which on a whole-turn helix is purely
2575/// AXIAL while both caps face nearly tangentially, so `|n·chord|` fell under
2576/// the loft's 0.1 sliver band and everything with pitch ≲ 0.63·R — most
2577/// springs — was refused. The loft measures the advance at each END now
2578/// (`loft_topology::basic::advance_from`), which is the direction the tube
2579/// actually grows through the cap it is orienting, so the band only ever
2580/// catches a real sliver.
2581pub fn sweep_profile_helix(
2582    profile: &[NurbsCurve],
2583    axis_origin: Vec3,
2584    axis_direction: Vec3,
2585    helix_radius: f64,
2586    pitch: f64,
2587    turns: f64,
2588    name: Option<&str>,
2589) -> Result<BrepSolid, KernelRefusal> {
2590    use std::f64::consts::TAU;
2591
2592    // --- 1. Validate the helix parameters with honest errors.
2593    let w = axis_direction.normalized().map_err(|_| {
2594        KernelRefusal::input(
2595            KernelStage::Collect,
2596            "helix_axis",
2597            "sweep_profile_helix: axis direction is degenerate",
2598        )
2599    })?;
2600    if !(helix_radius.is_finite() && helix_radius > 0.0) {
2601        return Err(KernelRefusal::input(
2602            KernelStage::Collect,
2603            "helix_radius",
2604            "sweep_profile_helix: helix radius must be positive",
2605        ));
2606    }
2607    if !(pitch.is_finite() && pitch > 0.0) {
2608        return Err(KernelRefusal::input(
2609            KernelStage::Collect,
2610            "helix_pitch",
2611            "sweep_profile_helix: pitch must be positive",
2612        ));
2613    }
2614    if !(turns.is_finite() && turns > 0.0) {
2615        return Err(KernelRefusal::input(
2616            KernelStage::Collect,
2617            "helix_turns",
2618            "sweep_profile_helix: turns must be positive",
2619        ));
2620    }
2621    // 64 turns bounds the loft's O(stations³) interpolation solve; beyond it
2622    // the station cap would silently degrade per-turn density anyway.
2623    if turns > 64.0 {
2624        return Err(KernelRefusal::unsupported(
2625            KernelStage::Collect,
2626            "helix_turns_cap",
2627            "sweep_profile_helix: turns must be at most 64",
2628        ));
2629    }
2630
2631    // --- 2. Profile extent about its centroid, sampled exactly like the path
2632    //        sweep derives its placement origin (boundary-sample mean), so the
2633    //        extent is measured about the point that actually rides the path.
2634    let mut samples = Vec::new();
2635    for curve in profile {
2636        let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
2637        for sample in 0..16 {
2638            samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0).or_refuse(KernelStage::Collect, "evaluate")?);
2639        }
2640    }
2641    if !samples.is_empty() {
2642        let mut centroid = Vec3::default();
2643        for point in &samples {
2644            centroid = centroid.add(*point);
2645        }
2646        let origin = centroid.scale(1.0 / samples.len() as f64);
2647        let extent = samples
2648            .iter()
2649            .map(|point| point.sub(origin).length())
2650            .fold(0.0, f64::max);
2651        let c = pitch / TAU; // axial rise per radian
2652                             // Fold-over: profile reaches past the helix's center of curvature.
2653        let curvature_radius = (helix_radius * helix_radius + c * c) / helix_radius;
2654        if extent >= curvature_radius {
2655            return Err(KernelRefusal::unsupported(
2656                KernelStage::Classify,
2657                "helix_fold",
2658                format!(
2659                "sweep_profile_helix: profile extent {extent:.6} reaches the helix \
2660                 curvature radius {curvature_radius:.6}; the tube would fold through \
2661                 itself — increase the helix radius or pitch, or shrink the profile"
2662                ),
2663            ));
2664        }
2665        // Coil collision: only possible once the sweep spans a full revolution.
2666        if turns >= 1.0 {
2667            let circumference = TAU * helix_radius;
2668            let turn_length = (circumference * circumference + pitch * pitch).sqrt();
2669            let coil_gap = pitch * circumference / turn_length;
2670            if coil_gap <= 2.0 * extent {
2671                return Err(KernelRefusal::unsupported(
2672                    KernelStage::Classify,
2673                    "coil_collision",
2674                    format!(
2675                    "sweep_profile_helix: consecutive turns would self-intersect — \
2676                     coil gap {coil_gap:.6} does not clear the profile diameter {:.6}; \
2677                     increase the pitch or shrink the profile",
2678                    2.0 * extent
2679                    ),
2680                ));
2681            }
2682        }
2683    }
2684
2685    // --- 3. Fit the helical path (see the density rationale in the fn docs) —
2686    //        the SAME sampler + fit the HX feature publishes as its edge.
2687    let path = fit_helix_curve(
2688        axis_origin,
2689        w,
2690        None,
2691        helix_radius,
2692        helix_radius,
2693        pitch,
2694        turns,
2695        0.0,
2696        false,
2697    )
2698    .map_err(|error| {
2699        error.with_message(|error| format!("sweep_profile_helix: helix path fit failed: {error}"))
2700    })?;
2701
2702    // --- 4. Drive the existing sweep core; its (or the loft's) failures
2703    //        propagate with helix context prepended.
2704    let stations = ((turns * 32.0).ceil() as usize).clamp(32, 1024);
2705    // The helix builder places the profile ON the fitted helix (the coil's
2706    // section is drawn once and carried round), which is `Transplant` by
2707    // construction — the radius comes from the helix argument, not the profile's
2708    // own position.
2709    sweep_profile_along_path_stations(
2710        profile,
2711        &path,
2712        name,
2713        stations,
2714        0.0,
2715        None,
2716        SectionPlacement::Transplant,
2717    )
2718        .map_err(|error| error.with_message(|error| format!("sweep_profile_helix: {error}")))
2719}