Skip to main content

brep_kernel/construction/sweep_topology/
sweep.rs

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