Skip to main content

brep_kernel/construction/sweep_topology/
sweep.rs

1use super::*;
2
3/// Sweep a CLOSED PLANAR profile loop along a path curve (§3.3/§5.7),
4/// TRANSPLANTING the profile onto the path — see [`SectionPlacement`] for the
5/// other reading, and for why the choice changes the solid.
6///
7/// Reuses the loft builder rather than a bespoke swept surface: the path is
8/// sampled at `STATIONS` uniform stations, a rotation-minimizing frame is
9/// propagated along it (double-reflection RMF, Wang et al. 2008 — the frame
10/// does NOT spin at inflections the way raw Frenet does), a rigidly
11/// transformed copy of the profile is placed at each station (identical
12/// degree/knots/weights, only the control points moved — which guarantees
13/// loft's per-section compatibility), and the stations are lofted through
14/// to produce the tube plus planar end caps.
15///
16/// Guards return a clear `Err` on an open/non-planar profile, a degenerate
17/// path tangent, or a loft failure. A self-intersecting result (path
18/// curvature radius smaller than the profile extent) is OUT OF SCOPE — the
19/// caller is responsible for keeping the tube from folding onto itself.
20pub fn sweep_profile_along_path(
21    profile: &[NurbsCurve],
22    path: &NurbsCurve,
23    name: Option<&str>,
24) -> Result<BrepSolid, String> {
25    // 32 stations is the original fixed sampling — golden parity pins the
26    // emitted geometry to it.  The helix variant raises the count with the
27    // turn count instead, hence the shared `_stations` core.
28    sweep_profile_along_path_stations(
29        profile,
30        path,
31        name,
32        32,
33        0.0,
34        None,
35        SectionPlacement::Transplant,
36    )
37}
38
39/// Twisted path sweep (§5.7): identical to [`sweep_profile_along_path`], but
40/// the profile additionally ROTATES about the path tangent, linearly in ARC
41/// LENGTH, from 0 at the sweep start to `twist_angle` radians (right-handed
42/// about the tangent) at the end.  The arc-length fraction comes from the
43/// sampled station polyline, not the raw path parameter, so a non-uniformly
44/// parameterized path still twists uniformly in space.
45///
46/// STATION LAW: 16 stations per quarter turn of twist, floored at the path
47/// sweep's 32 and capped at 1024 (the loft's dense interpolation solve is
48/// O(stations³) per control column — the same cap the helix uses).  At
49/// 16/quarter-turn the inter-station twist step is Δφ ≈ 5.6°, so the cubic
50/// v-interpolation error on a profile point circling at radius r is
51/// ≈ r·Δφ⁴/384 ≈ 2.4·10⁻¹⁰·r — far below any geometric tolerance.  The cap
52/// holds that density up to 16 full turns (|twist| = 32π = 1024/16 quarter
53/// turns); a larger twist would silently alias under the cap, so it is
54/// REJECTED with an honest error instead.  Everything else the path sweep
55/// documents (profile validity, self-intersection being the caller's
56/// responsibility) applies unchanged; twisting about the profile's own
57/// centroid adds no new radial extent, so no extra collision guard exists
58/// to compute here.
59pub fn sweep_profile_twisted(
60    profile: &[NurbsCurve],
61    path: &NurbsCurve,
62    twist_angle: f64,
63    name: Option<&str>,
64) -> Result<BrepSolid, String> {
65    use std::f64::consts::{FRAC_PI_2, TAU};
66
67    if !twist_angle.is_finite() {
68        return Err("sweep_profile_twisted: twist angle must be finite".into());
69    }
70    // 16 turns is where the 1024-station cap meets 16 stations/quarter-turn;
71    // beyond it the cap would degrade the twist sampling density silently.
72    const MAX_TURNS: f64 = 16.0;
73    if twist_angle.abs() > MAX_TURNS * TAU {
74        return Err(format!(
75            "sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
76             limit the 1024-station cap can resolve at 16 stations per quarter turn; \
77             split the sweep or reduce the twist",
78            twist_angle.abs() / TAU
79        ));
80    }
81    let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
82    let stations = (quarter_turns * 16).clamp(32, 1024);
83    sweep_profile_along_path_stations(
84        profile,
85        path,
86        name,
87        stations,
88        twist_angle,
89        None,
90        SectionPlacement::Transplant,
91    )
92    .map_err(|error| format!("sweep_profile_twisted: {error}"))
93}
94
95/// HOW a section is placed at each station — the two things "sweep a profile
96/// along a path" can mean, and the reason they are not the same shape.
97///
98/// `Transplant` MOVES the profile onto the path: the anchor origin lands on the
99/// station point and the profile plane becomes the station's NORMAL plane. The
100/// profile's own position and its angle to the path are both discarded — every
101/// sweep comes out centred on the path and square to it. That is the classic
102/// swept-surface reading, and it is what `SWP` builds.
103///
104/// `Rigid` CARRIES the profile: the section at station `k` is the profile moved
105/// by the same rigid motion the PATH undergoes between its start and station
106/// `k`,
107///
108/// ```text
109///     section_k(x) = P_k + R_k · (x − P_0)
110/// ```
111///
112/// where `R_k` is the rotation taking the start frame `(T_0, r_0, s_0)` to the
113/// station's `(T_k, r_k, s_k)`. The profile keeps its drawn POSITION and its
114/// drawn ANGLE to the path; what changes along the sweep is only what the path
115/// itself does. Two consequences are the whole point of the mode:
116///   - a STRAIGHT path makes every `R_k` the identity, so the section merely
117///     translates and the result is the oblique prism `extrude_profile_brep`
118///     builds from the same profile and vector — `pathAlign` and `translate`
119///     agree on a straight path rather than merely having the same volume;
120///   - a circular ARC makes `R_k` the rotation about the ARC'S OWN CENTRE AXIS
121///     (for a planar path the RMF's transport rotation IS that rotation), so
122///     the profile is carried round the pivot and the end cap arrives at the
123///     angle the start cap had to the tangent. A full-circle path degenerates
124///     to exactly a revolve of the profile about that axis.
125///
126/// `R_k = F_k · F_0ᵀ` does NOT depend on which perpendicular `r_0` the RMF was
127/// seeded with: re-seeding rolls `F_0` and every `F_k` by the same angle about
128/// the tangent, and the two rolls cancel in the product. That matters because
129/// `Transplant` maps `pu → r_k` directly, so under IT the profile's roll about
130/// the path is whatever `tangents[0].perpendicular()` happened to return — an
131/// arbitrary orientation no caller can predict. `Rigid` has no such freedom.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum SectionPlacement {
134    /// Move the profile onto the path, square to it (the classic sweep).
135    Transplant,
136    /// Carry the profile by the path's own rigid motion (see above).
137    Rigid,
138}
139
140/// The placement anchor a path sweep transplants its profile with: the plane
141/// frame `(origin = boundary-sample centroid, normal, pu, pv)` the station
142/// loop maps profile points through (`local = p − origin` → `station + ri·(local
143/// ·pu) + si·(local·pv)`). Extracted as data so a HOLE loop can sweep with its
144/// OUTER loop's anchor — sweeping each loop with its OWN centroid would
145/// re-center every loop onto the path and lose the hole's in-plane offset.
146#[derive(Debug, Clone, Copy)]
147pub struct ProfileAnchor {
148    pub origin: Vec3,
149    pub normal: Vec3,
150    pub pu: Vec3,
151    pub pv: Vec3,
152}
153
154/// [`sweep_profile_along_path`] with an explicit placement anchor (see
155/// [`ProfileAnchor`]) — the hole-loop cutter path: the swept loop is validated
156/// as usual but PLACED in its outer loop's frame.
157pub fn sweep_profile_along_path_anchored(
158    profile: &[NurbsCurve],
159    path: &NurbsCurve,
160    name: Option<&str>,
161    anchor: ProfileAnchor,
162) -> Result<BrepSolid, String> {
163    sweep_profile_along_path_stations(
164        profile,
165        path,
166        name,
167        32,
168        0.0,
169        Some(anchor),
170        SectionPlacement::Transplant,
171    )
172}
173
174/// [`sweep_profile_twisted`] with an explicit placement anchor: the hole loop
175/// twists about the SAME path axis as its outer loop (shared anchor), so the
176/// cutter stays registered with the outer wall through the whole twist.
177pub fn sweep_profile_twisted_anchored(
178    profile: &[NurbsCurve],
179    path: &NurbsCurve,
180    twist_angle: f64,
181    name: Option<&str>,
182    anchor: ProfileAnchor,
183) -> Result<BrepSolid, String> {
184    use std::f64::consts::{FRAC_PI_2, TAU};
185
186    if !twist_angle.is_finite() {
187        return Err("sweep_profile_twisted: twist angle must be finite".into());
188    }
189    const MAX_TURNS: f64 = 16.0;
190    if twist_angle.abs() > MAX_TURNS * TAU {
191        return Err(format!(
192            "sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
193             limit the 1024-station cap can resolve at 16 stations per quarter turn; \
194             split the sweep or reduce the twist",
195            twist_angle.abs() / TAU
196        ));
197    }
198    let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
199    let stations = (quarter_turns * 16).clamp(32, 1024);
200    sweep_profile_along_path_stations(
201        profile,
202        path,
203        name,
204        stations,
205        twist_angle,
206        Some(anchor),
207        SectionPlacement::Transplant,
208    )
209    .map_err(|error| format!("sweep_profile_twisted: {error}"))
210}
211
212/// Sweep a CLOSED PLANAR profile along a CHAIN of path curves joined
213/// head-to-tail — the multi-segment entry point, with the twist and anchor
214/// options the single-curve family exposes as separate functions folded into
215/// one signature (the feature layer picks all three per loop, so splitting them
216/// four ways here would only push the same match into the caller).
217///
218/// `segment_names` name the chain's segments for the joint refusal; a short or
219/// empty slice degrades to `<unnamed>` rather than failing.
220///
221/// `corner_advice` is appended to the cornered-joint refusal and is the CALLER's
222/// to write, because the builder has two callers with different answers: path
223/// sweep sends the user to SW (a different feature), while SW's own `pathAlign`
224/// sends them to its `translate` mode. A builder that named one feature would be
225/// telling half its users to switch to the feature they are already in.
226///
227/// A ONE-segment chain delegates to the single-curve builders unchanged, so the
228/// geometry every existing path sweep emits is untouched by this entry point
229/// existing — the multi-segment sampler is reached only by a path that actually
230/// has a joint.
231///
232/// Joints must be tangent-continuous; see [`MAX_JOINT_TANGENT_BREAK`] for why a
233/// corner is refused here instead of rounded off, and where such a path belongs.
234pub fn sweep_profile_along_chain(
235    profile: &[NurbsCurve],
236    chain: &[NurbsCurve],
237    segment_names: &[String],
238    twist_angle: f64,
239    name: Option<&str>,
240    anchor: Option<ProfileAnchor>,
241    placement_mode: SectionPlacement,
242    corner_advice: &str,
243) -> Result<BrepSolid, String> {
244    use std::f64::consts::{FRAC_PI_2, TAU};
245
246    if chain.is_empty() {
247        return Err("sweepSolid: path chain is empty".into());
248    }
249    // Single segment under `Transplant`: the existing builders, byte for byte.
250    // `Rigid` has no single-curve wrapper to delegate to and falls through to the
251    // chain sampler, which samples a 1-element chain over exactly the domain the
252    // single-curve sampler would (no joints, the whole budget on one segment).
253    if let ([single], SectionPlacement::Transplant) = (chain, placement_mode) {
254        return match (anchor, twist_angle == 0.0) {
255            (None, true) => sweep_profile_along_path(profile, single, name),
256            (Some(anchor), true) => {
257                sweep_profile_along_path_anchored(profile, single, name, anchor)
258            }
259            (None, false) => sweep_profile_twisted(profile, single, twist_angle, name),
260            (Some(anchor), false) => {
261                sweep_profile_twisted_anchored(profile, single, twist_angle, name, anchor)
262            }
263        };
264    }
265
266    // Everything else — any multi-segment chain, and a single segment under
267    // `Rigid` — takes the same station law the single-curve twisted builder
268    // uses, so density scales with twist identically on a chained path.
269    if !twist_angle.is_finite() {
270        return Err("sweep_profile_along_chain: twist angle must be finite".into());
271    }
272    const MAX_TURNS: f64 = 16.0;
273    if twist_angle.abs() > MAX_TURNS * TAU {
274        return Err(format!(
275            "sweep_profile_along_chain: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
276             limit the 1024-station cap can resolve at 16 stations per quarter turn; \
277             split the sweep or reduce the twist",
278            twist_angle.abs() / TAU
279        ));
280    }
281    let stations = if twist_angle == 0.0 {
282        32
283    } else {
284        let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
285        (quarter_turns * 16).clamp(32, 1024)
286    };
287    let _ = name;
288    sweep_profile_along_chain_stations(
289        profile,
290        chain,
291        segment_names,
292        stations,
293        twist_angle,
294        anchor,
295        placement_mode,
296        corner_advice,
297    )
298}
299
300/// [`sweep_profile_along_chain`] with an explicit STATION budget and no twist
301/// — the entry a caller with a long, smooth chain uses when the default 32
302/// stations would under-sample it (a wire-harness bundle along a many-anchor
303/// spline: three pieces per span, so a handful of spans already starves each
304/// piece of stations). The budget is spread over the chain by arc length
305/// exactly as the twisted builder's law spreads its own; the 1024-station cap
306/// and the tangent-continuity refusal apply unchanged.
307pub fn sweep_profile_along_chain_with_stations(
308    profile: &[NurbsCurve],
309    chain: &[NurbsCurve],
310    segment_names: &[String],
311    stations: usize,
312    corner_advice: &str,
313) -> Result<BrepSolid, String> {
314    sweep_profile_along_chain_stations(
315        profile,
316        chain,
317        segment_names,
318        stations.clamp(2, 1024),
319        0.0,
320        None,
321        SectionPlacement::Transplant,
322        corner_advice,
323    )
324}
325
326/// Validate a closed planar profile loop and derive its placement anchor —
327/// the path sweep's §1 block, extracted bit-identically: 16 samples per curve,
328/// closure at `tolerance`, Newell normal, boundary-sample-mean origin,
329/// planarity at `tolerance * 100`, `pu = np.perpendicular()`, `pv = np × pu`.
330pub fn profile_anchor(profile: &[NurbsCurve]) -> Result<ProfileAnchor, String> {
331    let tolerance = 1e-6;
332    if profile.len() < 2 {
333        return Err("sweepSolid: profile needs at least 2 curves forming a closed loop".into());
334    }
335    let mut samples = Vec::new();
336    for (index, curve) in profile.iter().enumerate() {
337        let [start, end] = curve.domain()?;
338        let next = &profile[(index + 1) % profile.len()];
339        let next_start = next.domain()?[0];
340        if curve
341            .evaluate(end)?
342            .sub(next.evaluate(next_start)?)
343            .length()
344            > tolerance
345        {
346            return Err(format!(
347                "sweepSolid: profile is not closed at curve {index}"
348            ));
349        }
350        for sample in 0..16 {
351            samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0)?);
352        }
353    }
354    let normal = crate::polygon::newell_normal(&samples);
355    let centroid = samples.iter().fold(Vec3::default(), |sum, &point| sum.add(point));
356    let np = normal
357        .normalized()
358        .map_err(|_| "sweepSolid: profile is degenerate (zero enclosed area)".to_string())?;
359    let origin = centroid.scale(1.0 / samples.len() as f64);
360    if samples
361        .iter()
362        .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
363    {
364        return Err("sweepSolid: profile is not planar".into());
365    }
366    let pu = np.perpendicular()?;
367    let pv = np.cross(pu).normalized()?;
368    Ok(ProfileAnchor {
369        origin,
370        normal: np,
371        pu,
372        pv,
373    })
374}
375
376/// Core of the path sweep with an explicit station count.  Every consumer of
377/// the station count (path sampling, RMF propagation, section placement)
378/// derives from the one `stations` argument so the density scales as a unit.
379/// `twist_angle` (radians; 0 for the untwisted variants) rotates the placed
380/// profile about the path tangent linearly in sampled arc length — the
381/// `twist_angle == 0.0` fast path leaves the RMF axes bit-identical, so the
382/// untwisted callers keep golden parity.
383fn sweep_profile_along_path_stations(
384    profile: &[NurbsCurve],
385    path: &NurbsCurve,
386    name: Option<&str>,
387    stations: usize,
388    twist_angle: f64,
389    anchor: Option<ProfileAnchor>,
390    placement_mode: SectionPlacement,
391) -> Result<BrepSolid, String> {
392    // Loft carries no face names; the app stamps them onto the emitted face
393    // order.  Accept `name` for ABI symmetry with the other builders.
394    let _ = name;
395    let tolerance = 1e-6;
396    if stations < 2 {
397        return Err("sweepSolid: need at least 2 stations".into());
398    }
399
400    // --- 1. Validate the profile: closed + planar; derive (origin, np, pu, pv).
401    //        A caller-supplied anchor OVERRIDES the placement frame (the swept
402    //        loop is still validated against its own plane), so a hole loop
403    //        rides the path in its outer loop's frame instead of re-centering.
404    //        Resolved HERE, before the path is touched, so a bad profile is
405    //        still reported ahead of a bad path exactly as it always was.
406    let placement = resolve_placement(profile, anchor, placement_mode)?;
407
408    // --- 2. Sample the path; require a non-degenerate tangent at every station.
409    let [t0, t1] = path.domain()?;
410    if (t1 - t0).abs() <= tolerance {
411        return Err("sweepSolid: path domain is degenerate".into());
412    }
413    let mut points = Vec::with_capacity(stations);
414    let mut tangents = Vec::with_capacity(stations);
415    for index in 0..stations {
416        let t = t0 + (t1 - t0) * index as f64 / (stations - 1) as f64;
417        let derivatives = path.derivatives(t, 1)?;
418        let tangent = derivatives[1]
419            .normalized()
420            .map_err(|_| format!("sweepSolid: path tangent is degenerate at station {index}"))?;
421        points.push(derivatives[0]);
422        tangents.push(tangent);
423    }
424
425    sweep_sections_through_samples(
426        profile,
427        &points,
428        &tangents,
429        twist_angle,
430        placement,
431        placement_mode,
432    )
433}
434
435/// The placement frame a sweep transplants its profile with: the caller's
436/// `anchor` when it supplied one (a hole loop riding its outer loop's frame),
437/// otherwise the profile's own. Either way the profile is VALIDATED — closed and
438/// planar — because `profile_anchor` is what does that validating, and skipping
439/// it for an anchored loop would let an open hole loop through.
440///
441/// Called by each SAMPLER before it touches the path, so the "bad profile" error
442/// still precedes the "bad path" one, as it did when the two lived in one
443/// function.
444fn resolve_placement(
445    profile: &[NurbsCurve],
446    anchor: Option<ProfileAnchor>,
447    placement_mode: SectionPlacement,
448) -> Result<ProfileAnchor, String> {
449    let computed = profile_anchor(profile)?;
450    Ok(match placement_mode {
451        // RIGID never transplants, so there is no frame to borrow: every loop
452        // keeps its own position already, which is the whole reason the anchor
453        // exists under `Transplant`. Taking a caller's anchor here would silently
454        // swap in another loop's NORMAL for the fold-back guard below.
455        SectionPlacement::Rigid => computed,
456        SectionPlacement::Transplant => anchor.unwrap_or(computed),
457    })
458}
459
460/// The largest tangent break, in radians, a chained path may have at a joint.
461///
462/// This is a REAL geometric boundary, not a tuning knob. The builder skins
463/// consecutive stations by lofting through them, so a corner between two
464/// stations is rendered as a smooth blend across the corner — the tube cuts it,
465/// and near a sharp one the section sweeps through itself. A path sweep can
466/// honestly build a G1 chain (line→fillet→line, spline pieces, an arc train)
467/// and cannot honestly build a cornered polyline. `Sweep` (SW) is the feature
468/// for a cornered path: it builds one prism per segment and unions them, which
469/// is exactly the construction that gives a corner a real mitre.
470///
471/// ~1.15° (0.02 rad). Loose enough to absorb the tangent disagreement of two
472/// curves a sketch chained on endpoint coincidence, tight enough that anything
473/// a user would call a corner is refused rather than silently rounded off.
474///
475/// This is INDEPENDENT of the chainer's join tolerance, and deliberately so.
476/// `common::chain_path_segments` joins on POSITION (`1e-5 * scale`); this gate
477/// measures DIRECTION. Two collinear segments meeting with a positional gap have
478/// a zero tangent break, and two segments meeting exactly at a point can still
479/// break 90°. So a run the chainer happily orders head-to-tail may still be
480/// refused here — which is not an inconsistency but the SW/SWP split itself: the
481/// chainer's job is to decide what order the picks form a run in, and this gate's
482/// job is to decide whether that run is one this builder can skin.
483const MAX_JOINT_TANGENT_BREAK: f64 = 0.02;
484
485/// Sweep a CLOSED PLANAR profile along a CHAIN of path curves, joined
486/// head-to-tail, as one continuous tube.
487///
488/// The chain is sampled as a single trajectory and handed to the shared core,
489/// so ONE rotation-minimizing frame is propagated across the whole path: a joint
490/// is just another pair of adjacent stations to the RMF, which is what makes the
491/// profile arrive at the far end in the orientation the near end implies rather
492/// than snapping at each segment.
493///
494/// STATION BUDGET. `stations` are distributed across the segments in proportion
495/// to estimated ARC LENGTH (chord sums over 16 samples per curve), floored at 2
496/// per segment so a short fillet between two long lines still carries its
497/// curvature. The shared endpoint of two adjacent segments is sampled ONCE, not
498/// twice — a duplicated station would land in the RMF's coincident-step branch
499/// and waste a section on zero advance.
500///
501/// JOINTS must be tangent-continuous to within [`MAX_JOINT_TANGENT_BREAK`];
502/// a break beyond it is refused, naming the two segments and the angle.
503fn sweep_profile_along_chain_stations(
504    profile: &[NurbsCurve],
505    chain: &[NurbsCurve],
506    segment_names: &[String],
507    stations: usize,
508    twist_angle: f64,
509    anchor: Option<ProfileAnchor>,
510    placement_mode: SectionPlacement,
511    corner_advice: &str,
512) -> Result<BrepSolid, String> {
513    let tolerance = 1e-6;
514    if chain.is_empty() {
515        return Err("sweepSolid: path chain is empty".into());
516    }
517    if stations < 2 {
518        return Err("sweepSolid: need at least 2 stations".into());
519    }
520    // Profile first, matching the single-curve sampler's order of complaint.
521    let placement = resolve_placement(profile, anchor, placement_mode)?;
522
523    // --- Per-segment arc-length estimate, and the domain each one is sampled
524    //     over. A degenerate domain is refused here rather than producing a
525    //     silently collapsed station block.
526    let mut domains = Vec::with_capacity(chain.len());
527    let mut lengths = Vec::with_capacity(chain.len());
528    for (index, curve) in chain.iter().enumerate() {
529        let [t0, t1] = curve.domain()?;
530        if (t1 - t0).abs() <= tolerance {
531            return Err(format!(
532                "sweepSolid: path segment {index} has a degenerate domain"
533            ));
534        }
535        let mut length = 0.0;
536        let mut previous = curve.evaluate(t0)?;
537        for sample in 1..=16 {
538            let point = curve.evaluate(t0 + (t1 - t0) * sample as f64 / 16.0)?;
539            length += point.sub(previous).length();
540            previous = point;
541        }
542        domains.push([t0, t1]);
543        lengths.push(length);
544    }
545    let total_length: f64 = lengths.iter().sum();
546    if total_length <= tolerance {
547        return Err("sweepSolid: path chain has zero length".into());
548    }
549
550    // --- Station budget per segment: proportional to arc length, floored at 2
551    //     (a segment needs its two ends), and the remainder handed to the
552    //     longest segment so the totals land exactly on `stations`.
553    let mut budget: Vec<usize> = lengths
554        .iter()
555        .map(|length| {
556            let share = (stations as f64 * length / total_length).round() as usize;
557            share.max(2)
558        })
559        .collect();
560    // Each interior joint shares one station between its two segments, so the
561    // emitted total is sum(budget) - (segments - 1).
562    let joints = chain.len() - 1;
563    let emitted: usize = budget.iter().sum::<usize>() - joints;
564    // The 2-per-segment floor means a chain of n segments emits at least n+1
565    // stations, so a long enough chain cannot be held under the loft's station
566    // cap by any distribution. The loft's interpolation solve is O(stations³)
567    // per control column, so silently accepting such a path would look like a
568    // hang. Refuse with the count instead — the same honesty the twist cap uses.
569    const MAX_STATIONS: usize = 1024;
570    if emitted > MAX_STATIONS {
571        return Err(format!(
572            "sweepSolid: a {}-segment path needs at least {emitted} stations, past the \
573             {MAX_STATIONS}-station cap the loft solve can carry; sweep it in fewer, \
574             longer pieces",
575            chain.len()
576        ));
577    }
578    if emitted < stations {
579        let longest = lengths
580            .iter()
581            .enumerate()
582            .max_by(|a, b| a.1.total_cmp(b.1))
583            .map(|(index, _)| index)
584            .unwrap_or(0);
585        budget[longest] += stations - emitted;
586    }
587
588    // --- Sample the chain as ONE trajectory, checking tangent continuity at
589    //     every joint as it is crossed.
590    let mut points: Vec<Vec3> = Vec::new();
591    let mut tangents: Vec<Vec3> = Vec::new();
592    for (index, curve) in chain.iter().enumerate() {
593        let [t0, t1] = domains[index];
594        let count = budget[index];
595
596        // The JOINT check runs at this segment's START parameter, which is the
597        // point the previous segment ended on — comparing the tangent the path
598        // arrives with against the one it departs with. Sampling the next
599        // station instead would fold the segment's own curvature into the
600        // measured break and let a genuine corner through on a curved segment.
601        if index > 0 {
602            let departing = curve.derivatives(t0, 1)?[1].normalized().map_err(|_| {
603                format!("sweepSolid: path tangent is degenerate at the start of segment {index}")
604            })?;
605            let arriving = *tangents
606                .last()
607                .expect("a previous segment emitted stations");
608            // Both unit, so the dot is the cosine of the break.
609            let break_angle = arriving.dot(departing).clamp(-1.0, 1.0).acos();
610            if break_angle > MAX_JOINT_TANGENT_BREAK {
611                let previous_name = segment_names
612                    .get(index - 1)
613                    .map(String::as_str)
614                    .unwrap_or("<unnamed>");
615                let name = segment_names
616                    .get(index)
617                    .map(String::as_str)
618                    .unwrap_or("<unnamed>");
619                return Err(format!(
620                    "the path must be tangent-continuous: segments '{previous_name}' and \
621                     '{name}' meet at a {:.1}° corner. The profile is skinned between sampled \
622                     stations along the path, so a corner would be ROUNDED OFF rather than \
623                     mitred. {corner_advice}",
624                    break_angle.to_degrees()
625                ));
626            }
627        }
628
629        // Skip the first sample of every segment after the first: that station
630        // is the joint, already emitted as the previous segment's last.
631        let first = usize::from(index > 0);
632        for sample in first..count {
633            let t = t0 + (t1 - t0) * sample as f64 / (count - 1) as f64;
634            let derivatives = curve.derivatives(t, 1)?;
635            let tangent = derivatives[1].normalized().map_err(|_| {
636                format!("sweepSolid: path tangent is degenerate on segment {index}")
637            })?;
638            points.push(derivatives[0]);
639            tangents.push(tangent);
640        }
641    }
642
643    sweep_sections_through_samples(
644        profile,
645        &points,
646        &tangents,
647        twist_angle,
648        placement,
649        placement_mode,
650    )
651}
652
653/// The part of the sweep that does not care HOW the stations were sampled:
654/// validate the profile, distribute the twist over the sampled polyline,
655/// propagate the rotation-minimizing frame, place a profile copy per station
656/// and loft through them.
657///
658/// Split out so a MULTI-SEGMENT path can share it
659/// ([`sweep_profile_along_chain_stations`]). The single-curve sampler above is
660/// deliberately left calling this with its OWN sampling rather than being
661/// re-expressed as a one-element chain: two code paths that agree today are not
662/// the same thing as one code path, and the untwisted single-curve geometry is
663/// pinned by volume tests that must not move.
664///
665/// The RMF is propagated over the station sequence as a whole, so a chain's
666/// frame carries across a joint exactly as it carries across any other pair of
667/// adjacent stations — there is no per-segment restart to reconcile.
668fn sweep_sections_through_samples(
669    profile: &[NurbsCurve],
670    points: &[Vec3],
671    tangents: &[Vec3],
672    twist_angle: f64,
673    placement: ProfileAnchor,
674    placement_mode: SectionPlacement,
675) -> Result<BrepSolid, String> {
676    let tolerance = 1e-6;
677    let stations = points.len();
678    if stations < 2 {
679        return Err("sweepSolid: need at least 2 stations".into());
680    }
681
682    let ProfileAnchor { origin, pu, pv, .. } = placement;
683
684    // --- 2b. Twist distribution: cumulative ARC-LENGTH fractions over the
685    //         sampled station polyline (chord sums), so the twist advances
686    //         uniformly in space even on a non-uniformly parameterized path.
687    //         Only computed when a twist is actually requested — the
688    //         `twist_angle == 0.0` path must stay bit-identical to the
689    //         pre-twist builder.
690    let twist_fractions: Option<Vec<f64>> = if twist_angle != 0.0 {
691        let mut cumulative = vec![0.0; stations];
692        let mut total = 0.0;
693        for index in 1..stations {
694            total += points[index].sub(points[index - 1]).length();
695            cumulative[index] = total;
696        }
697        if total <= tolerance {
698            return Err("sweepSolid: path has zero length; cannot distribute the twist".into());
699        }
700        for length in &mut cumulative {
701            *length /= total;
702        }
703        Some(cumulative)
704    } else {
705        None
706    };
707
708    // --- 3. Rotation-minimizing frames via the double-reflection method.
709    let mut r_axes = Vec::with_capacity(stations);
710    let mut s_axes = Vec::with_capacity(stations);
711    let r0 = tangents[0].perpendicular()?; // any unit vector ⟂ T0
712    s_axes.push(tangents[0].cross(r0).normalized()?);
713    r_axes.push(r0);
714    for index in 0..stations - 1 {
715        let t_next = tangents[index + 1];
716        let v1 = points[index + 1].sub(points[index]);
717        let c1 = v1.dot(v1);
718        let r_candidate = if c1 <= 1e-18 {
719            // Coincident stations: carry the reference axis forward unchanged.
720            r_axes[index]
721        } else {
722            // First reflection across the plane bisecting the step vector.
723            let reflected_r = r_axes[index].sub(v1.scale(2.0 / c1 * v1.dot(r_axes[index])));
724            let reflected_t = tangents[index].sub(v1.scale(2.0 / c1 * v1.dot(tangents[index])));
725            // Second reflection across the plane bisecting the tangents.
726            let v2 = t_next.sub(reflected_t);
727            let c2 = v2.dot(v2);
728            if c2 <= 1e-18 {
729                reflected_r
730            } else {
731                reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
732            }
733        };
734        // Re-orthogonalize against the new tangent to shed floating drift.
735        let r_next = r_candidate
736            .sub(t_next.scale(r_candidate.dot(t_next)))
737            .normalized()
738            .map_err(|_| format!("sweepSolid: frame degenerated at station {index}"))?;
739        s_axes.push(t_next.cross(r_next).normalized()?);
740        r_axes.push(r_next);
741    }
742
743    // --- 3b. A CLOSED trajectory cannot be capped: the first and last sections
744    //         land on top of each other, and the loft's own complaint about that
745    //         ("end sections coincide") says nothing about the path. Name it here
746    //         instead, with the two things that do work. The check is on the
747    //         PATH's first and last station POINTS, which is enough: a path that
748    //         comes back to where it started puts the two caps in the same place
749    //         whatever direction it arrives from — coincident when it also
750    //         arrives pointing the same way, interpenetrating when it does not,
751    //         and neither is a solid worth building.
752    //
753    //         The scale is the path's own travel, so this is a proportion, not a
754    //         length: ends within a thousandth of the distance travelled are the
755    //         same place for capping purposes. On a circular arc that band is the
756    //         last ~0.36° before closure, so a 359° sweep still builds.
757    {
758        let travel: f64 = points
759            .windows(2)
760            .map(|pair| pair[1].sub(pair[0]).length())
761            .sum();
762        let closure = points[stations - 1].sub(points[0]).length();
763        if travel > tolerance && closure <= 1e-3 * travel {
764            return Err(format!(
765                "sweepSolid: the path returns to where it started ({closure:.3e} apart after \
766                 travelling {travel:.3e}), so the sweep's two end caps would land on top of each \
767                 other. A closed path swept this way IS a revolution — build it with Revolve \
768                 about the same axis, or sweep the run in two halves and union them"
769            ));
770        }
771    }
772
773    // --- 4. Place a transformed copy of the profile at each station, by the mode
774    //        the caller asked for (see `SectionPlacement`).
775    //
776    //   TRANSPLANT maps the profile's own plane frame onto the station frame,
777    //       world = P_k + r_k·(l·pu) + s_k·(l·pv),   l = x − origin
778    //   which lands the anchor origin ON the path and the profile square to it.
779    //   The profile's distance from the path and its angle to the path are both
780    //   discarded — there is no term along the tangent to carry them.
781    //
782    //   RIGID applies the path's own motion to the profile where it stands,
783    //       world = P_k + a·T_k + b·r_k + c·s_k,     (a,b,c) = (l·T_0, l·r_0, l·s_0)
784    //                                                l = x − P_0
785    //   which is `P_k + R_k·(x − P_0)` for `R_k = F_k·F_0ᵀ`, written in the frame
786    //   basis rather than as a matrix. The tangent term `a·T_k` is exactly what
787    //   TRANSPLANT drops, and carrying it is what preserves both the profile's
788    //   offset from the path and its angle to it. At station 0 the frames
789    //   coincide and the section IS the profile, so the START cap lies in the
790    //   plane it was drawn in.
791    let path_start = points[0];
792    let (t0, r0, s0) = (tangents[0], r_axes[0], s_axes[0]);
793    let rigid = placement_mode == SectionPlacement::Rigid;
794    let mut sections: Vec<Vec<NurbsCurve>> = Vec::with_capacity(stations);
795    // Only the RIGID guard below reads these; TRANSPLANT leaves them empty.
796    let mut station_points: Vec<Vec<Vec3>> = Vec::with_capacity(if rigid { stations } else { 0 });
797    let mut section_normals: Vec<Vec3> = Vec::with_capacity(if rigid { stations } else { 0 });
798    let mut anchor_track: Vec<Vec3> = Vec::with_capacity(if rigid { stations } else { 0 });
799    for station in 0..stations {
800        let station_origin = points[station];
801        // Rotate the RMF axes about the tangent by the station's twist angle
802        // (Rodrigues on vectors ⟂ the tangent: r' = r·cosφ + s·sinφ,
803        // s' = s·cosφ − r·sinφ, since s = t × r and t × s = −r).
804        let (ri, si) = match &twist_fractions {
805            Some(fractions) => {
806                let phi = twist_angle * fractions[station];
807                let (sin_phi, cos_phi) = phi.sin_cos();
808                let r = r_axes[station];
809                let s = s_axes[station];
810                (
811                    r.scale(cos_phi).add(s.scale(sin_phi)),
812                    s.scale(cos_phi).sub(r.scale(sin_phi)),
813                )
814            }
815            None => (r_axes[station], s_axes[station]),
816        };
817        let tk = tangents[station];
818        let place = |x: Vec3| -> Vec3 {
819            if rigid {
820                let local = x.sub(path_start);
821                station_origin
822                    .add(tk.scale(local.dot(t0)))
823                    .add(ri.scale(local.dot(r0)))
824                    .add(si.scale(local.dot(s0)))
825            } else {
826                let local = x.sub(origin);
827                station_origin
828                    .add(ri.scale(local.dot(pu)))
829                    .add(si.scale(local.dot(pv)))
830            }
831        };
832        if rigid {
833            // The section's own normal takes the same rotation (a direction, so
834            // `R_k` without the translation), and the anchor origin's track is
835            // the profile's bulk motion for the near-parallel guard.
836            let n0 = placement.normal;
837            section_normals.push(
838                tk.scale(n0.dot(t0))
839                    .add(ri.scale(n0.dot(r0)))
840                    .add(si.scale(n0.dot(s0))),
841            );
842            anchor_track.push(place(origin));
843            station_points.push(Vec::with_capacity(
844                profile.iter().map(|c| c.control_points.len()).sum(),
845            ));
846        }
847        let mut section = Vec::with_capacity(profile.len());
848        for curve in profile {
849            let control_points = curve
850                .control_points
851                .iter()
852                .map(|point| {
853                    let weight = point.w;
854                    let euclidean = Vec3::new(point.x / weight, point.y / weight, point.z / weight);
855                    let world = place(euclidean);
856                    if rigid {
857                        station_points[station].push(world);
858                    }
859                    Vec4 {
860                        x: world.x * weight,
861                        y: world.y * weight,
862                        z: world.z * weight,
863                        w: weight,
864                    }
865                })
866                .collect();
867            section.push(NurbsCurve::new(
868                curve.degree,
869                curve.knots.clone(),
870                control_points,
871            )?);
872        }
873        sections.push(section);
874    }
875
876    // --- 4b. RIGID only: refuse a sweep whose sections fold through each other.
877    //
878    // TRANSPLANT keeps every section square to the path, so it can only fold
879    // where the path's curvature radius drops below the profile's extent — the
880    // case this builder has always documented as the caller's to avoid. RIGID
881    // can fold for a second, much more reachable reason: a turning path carries
882    // the profile around the TURN'S OWN AXIS, so a profile straddling that axis
883    // has one half advancing while the other retreats. That is not a tolerance
884    // question, it is a sign question, so it is answered exactly.
885    //
886    // The placement map is AFFINE in `x`, so the advance is affine in `x` too and
887    // its extremes over the section lie on the control points. Checking the hull
888    // therefore bounds the advance over the curves AND over the region they
889    // enclose — this is a proof, not a sampling.
890    if rigid {
891        let mut most_positive = 0.0f64;
892        let mut most_negative = 0.0f64;
893        let mut longest_step = 0.0f64;
894        for station in 0..stations - 1 {
895            let normal = section_normals[station];
896            for (before, after) in station_points[station]
897                .iter()
898                .zip(&station_points[station + 1])
899            {
900                let step = after.sub(*before);
901                longest_step = longest_step.max(step.length());
902                let advance = step.dot(normal);
903                most_positive = most_positive.max(advance);
904                most_negative = most_negative.min(advance);
905            }
906        }
907        // Scale-relative, and far below any geometric tolerance: this band only
908        // has to separate a sign from floating noise, never a small motion from
909        // a large one.
910        let noise = 1e-9 * longest_step.max(tolerance);
911        if most_positive > noise && most_negative < -noise {
912            return Err(
913                "sweepSolid: the profile sweeps back through itself — part of it advances along \
914                 the path while part of it retreats. A turning path carries the profile around \
915                 the turn's own axis, so a profile that STRADDLES that axis folds into itself; \
916                 move the profile clear of the axis, or sweep the run in pieces"
917                    .into(),
918            );
919        }
920        if most_positive <= noise && most_negative >= -noise {
921            return Err(
922                "sweepSolid: the path does not advance through the profile — it runs inside the \
923                 profile plane, so the sweep encloses no volume"
924                    .into(),
925            );
926        }
927        // The near-parallel case, on the anchor origin's own track: the same 0.1
928        // threshold `extrude_profile_brep` refuses a sliver at, so a STRAIGHT
929        // path is accepted here exactly when the translational sweep of the same
930        // profile and vector is. A station whose origin barely moves is skipped —
931        // an origin sitting on the turn axis means the profile straddles it, and
932        // the fold-back guard above has already spoken.
933        for station in 0..stations - 1 {
934            let step = anchor_track[station + 1].sub(anchor_track[station]);
935            let length = step.length();
936            if length <= noise {
937                continue;
938            }
939            if step.dot(section_normals[station]).abs() / length < 0.1 {
940                return Err(
941                    "sweepSolid: the path is nearly parallel to the profile plane, which sweeps a \
942                     sliver rather than a solid"
943                        .into(),
944                );
945            }
946        }
947    }
948
949    // --- 5. Loft through the swept sections (side walls + planar end caps).
950    loft_profile_brep(&sections)
951        .map_err(|error| format!("sweepSolid: loft through swept sections failed: {error}"))
952}
953
954/// Helical sweep (§5.7): sweep a CLOSED PLANAR profile loop along a helix of
955/// `helix_radius` about the axis through `axis_origin` with direction
956/// `axis_direction`, rising `pitch` per revolution for `turns` revolutions.
957///
958/// Sample a helix — `turns` revolutions about `axis_direction` from
959/// `axis_origin`, starting at angle `start_angle` (radians, measured from the
960/// axis frame's `u`) and rising `pitch` per turn, the radius running linearly
961/// from `start_radius` to `end_radius` — uniformly in angle: 64 stations per
962/// turn (at least 9, at most 4097 in total, so beyond 64 turns the per-turn
963/// density thins). Returns the points and their chord parameters `s ∈ [0,1]`
964/// (uniform-in-angle IS chord-length for a constant-radius helix, which is
965/// what the averaged-knot interpolation assumes). `left_handed` winds the
966/// angle the other way. The axis frame is `w = axis`, `u` = the component of
967/// `reference` perpendicular to the axis (so angle zero points AT the
968/// reference — the helix feature's local +X, or its start point), falling
969/// back to `w.perpendicular()` when there is no usable reference, and
970/// `v = w × u`. Shared by [`sweep_profile_helix`] and the HX feature, so the
971/// coil a sweep builds and the edge a helix feature publishes agree exactly.
972///
973/// Errors on a degenerate axis, non-finite inputs, `turns ≤ 0`, more than 256
974/// turns, a negative radius or pitch, or a helix that is a single point (both
975/// radii AND the pitch zero).
976#[allow(clippy::too_many_arguments)]
977pub fn helix_sample_points(
978    axis_origin: Vec3,
979    axis_direction: Vec3,
980    reference: Option<Vec3>,
981    start_radius: f64,
982    end_radius: f64,
983    pitch: f64,
984    turns: f64,
985    start_angle: f64,
986    left_handed: bool,
987) -> Result<(Vec<Vec3>, Vec<f64>), String> {
988    use std::f64::consts::TAU;
989    let w = axis_direction
990        .normalized()
991        .map_err(|_| "helix: axis direction is degenerate".to_string())?;
992    for (name, value) in [
993        ("radius", start_radius),
994        ("end radius", end_radius),
995        ("pitch", pitch),
996        ("turns", turns),
997        ("start angle", start_angle),
998    ] {
999        if !value.is_finite() {
1000            return Err(format!("helix: {name} must be a finite number"));
1001        }
1002    }
1003    if start_radius < 0.0 || end_radius < 0.0 {
1004        return Err("helix: radius must not be negative".into());
1005    }
1006    if pitch < 0.0 {
1007        return Err("helix: pitch must not be negative".into());
1008    }
1009    if turns <= 0.0 {
1010        return Err("helix: turns must be positive".into());
1011    }
1012    if turns > 256.0 {
1013        return Err("helix: turns must be at most 256".into());
1014    }
1015    if start_radius == 0.0 && end_radius == 0.0 && pitch == 0.0 {
1016        return Err("helix: zero radius and zero pitch describe a single point".into());
1017    }
1018    let u = match reference {
1019        Some(reference) => {
1020            let radial = reference.sub(w.scale(reference.dot(w)));
1021            radial.normalized().or_else(|_| w.perpendicular())?
1022        }
1023        None => w.perpendicular()?,
1024    };
1025    let v = w.cross(u).normalized()?;
1026    let total_angle = turns * TAU;
1027    let height = pitch * turns;
1028    let sign = if left_handed { -1.0 } else { 1.0 };
1029    let count = ((turns * 64.0).ceil() as usize + 1).clamp(9, 4097);
1030    let mut points = Vec::with_capacity(count);
1031    let mut parameters = Vec::with_capacity(count);
1032    for index in 0..count {
1033        let s = index as f64 / (count - 1) as f64;
1034        let theta = start_angle + sign * total_angle * s;
1035        let radius = start_radius + (end_radius - start_radius) * s;
1036        points.push(
1037            axis_origin
1038                .add(u.scale(radius * theta.cos()))
1039                .add(v.scale(radius * theta.sin()))
1040                .add(w.scale(height * s)),
1041        );
1042        parameters.push(s);
1043    }
1044    Ok((points, parameters))
1045}
1046
1047/// [`helix_sample_points`] fitted as ONE cubic curve (global interpolation —
1048/// `interpolate_curve`, the machinery every fitted path here uses). Cubic
1049/// interpolation error on a circle of radius R with node spacing Δθ is
1050/// ≈ R·Δθ⁴/384: ~2·10⁻⁷·R at 64 stations per turn.
1051#[allow(clippy::too_many_arguments)]
1052pub fn fit_helix_curve(
1053    axis_origin: Vec3,
1054    axis_direction: Vec3,
1055    reference: Option<Vec3>,
1056    start_radius: f64,
1057    end_radius: f64,
1058    pitch: f64,
1059    turns: f64,
1060    start_angle: f64,
1061    left_handed: bool,
1062) -> Result<NurbsCurve, String> {
1063    let (points, parameters) = helix_sample_points(
1064        axis_origin,
1065        axis_direction,
1066        reference,
1067        start_radius,
1068        end_radius,
1069        pitch,
1070        turns,
1071        start_angle,
1072        left_handed,
1073    )?;
1074    interpolate_curve(&points, 3, &parameters)
1075}
1076
1077/// The helix is transcendental, not exactly NURBS-representable, so the path
1078/// is FIT: points (R·cosθ, R·sinθ, pitch·θ/2π) in the axis frame are sampled
1079/// uniformly in θ and globally interpolated with a cubic (`interpolate_curve`
1080/// — the same machinery every other fitted path here uses).  DENSITY: 64
1081/// samples per turn, capped at 1025 total nodes.  Cubic interpolation error
1082/// on a circle of radius R with node spacing Δθ is ≈ R·Δθ⁴/384: ~2·10⁻⁷·R at
1083/// 64/turn, and still ~6·10⁻⁵·R at the cap's worst case (16/turn at the
1084/// 64-turn limit) — orders below any profile a caller could sweep without
1085/// self-intersecting.  Uniform-in-θ parameters are exact chord-length for a
1086/// helix (constant speed), which is what the averaged-knot interpolation
1087/// assumes.
1088///
1089/// The fitted path then drives the EXISTING path-sweep core with 32 stations
1090/// per turn (min 32, capped at 1024 — the loft's dense interpolation solve is
1091/// O(stations³) per control column, so the cap trades per-turn density, never
1092/// correctness, at high turn counts).
1093///
1094/// GUARDS beyond the path sweep's own: the path sweep documents
1095/// self-intersection as caller responsibility, so the helix variant — which
1096/// knows its curvature analytically — rejects the two garbage modes itself:
1097///   • fold-over: the helix curvature radius (R² + c²)/R (c = pitch/2π) must
1098///     exceed the profile's max extent about its centroid, or the tube folds
1099///     through itself on the inner side (the torus tube-radius > major-radius
1100///     failure, pitch-relaxed);
1101///   • coil collision (turns ≥ 1): the normal gap between consecutive coils,
1102///     pitch·2πR/√((2πR)² + pitch²), must exceed the profile diameter.
1103/// Both use the profile's max sample distance from its centroid — conservative
1104/// for asymmetric profiles (extent in a harmless direction still counts), but
1105/// a false reject beats silent garbage.
1106///
1107/// SHALLOW COILS BUILD. They did not until 2026-09-08: the loft measured its
1108/// advance by the END-TO-END chord, which on a whole-turn helix is purely
1109/// AXIAL while both caps face nearly tangentially, so `|n·chord|` fell under
1110/// the loft's 0.1 sliver band and everything with pitch ≲ 0.63·R — most
1111/// springs — was refused. The loft measures the advance at each END now
1112/// (`loft_topology::basic::advance_from`), which is the direction the tube
1113/// actually grows through the cap it is orienting, so the band only ever
1114/// catches a real sliver.
1115pub fn sweep_profile_helix(
1116    profile: &[NurbsCurve],
1117    axis_origin: Vec3,
1118    axis_direction: Vec3,
1119    helix_radius: f64,
1120    pitch: f64,
1121    turns: f64,
1122    name: Option<&str>,
1123) -> Result<BrepSolid, String> {
1124    use std::f64::consts::TAU;
1125
1126    // --- 1. Validate the helix parameters with honest errors.
1127    let w = axis_direction
1128        .normalized()
1129        .map_err(|_| "sweep_profile_helix: axis direction is degenerate".to_string())?;
1130    if !(helix_radius.is_finite() && helix_radius > 0.0) {
1131        return Err("sweep_profile_helix: helix radius must be positive".into());
1132    }
1133    if !(pitch.is_finite() && pitch > 0.0) {
1134        return Err("sweep_profile_helix: pitch must be positive".into());
1135    }
1136    if !(turns.is_finite() && turns > 0.0) {
1137        return Err("sweep_profile_helix: turns must be positive".into());
1138    }
1139    // 64 turns bounds the loft's O(stations³) interpolation solve; beyond it
1140    // the station cap would silently degrade per-turn density anyway.
1141    if turns > 64.0 {
1142        return Err("sweep_profile_helix: turns must be at most 64".into());
1143    }
1144
1145    // --- 2. Profile extent about its centroid, sampled exactly like the path
1146    //        sweep derives its placement origin (boundary-sample mean), so the
1147    //        extent is measured about the point that actually rides the path.
1148    let mut samples = Vec::new();
1149    for curve in profile {
1150        let [start, end] = curve.domain()?;
1151        for sample in 0..16 {
1152            samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0)?);
1153        }
1154    }
1155    if !samples.is_empty() {
1156        let mut centroid = Vec3::default();
1157        for point in &samples {
1158            centroid = centroid.add(*point);
1159        }
1160        let origin = centroid.scale(1.0 / samples.len() as f64);
1161        let extent = samples
1162            .iter()
1163            .map(|point| point.sub(origin).length())
1164            .fold(0.0, f64::max);
1165        let c = pitch / TAU; // axial rise per radian
1166                             // Fold-over: profile reaches past the helix's center of curvature.
1167        let curvature_radius = (helix_radius * helix_radius + c * c) / helix_radius;
1168        if extent >= curvature_radius {
1169            return Err(format!(
1170                "sweep_profile_helix: profile extent {extent:.6} reaches the helix \
1171                 curvature radius {curvature_radius:.6}; the tube would fold through \
1172                 itself — increase the helix radius or pitch, or shrink the profile"
1173            ));
1174        }
1175        // Coil collision: only possible once the sweep spans a full revolution.
1176        if turns >= 1.0 {
1177            let circumference = TAU * helix_radius;
1178            let turn_length = (circumference * circumference + pitch * pitch).sqrt();
1179            let coil_gap = pitch * circumference / turn_length;
1180            if coil_gap <= 2.0 * extent {
1181                return Err(format!(
1182                    "sweep_profile_helix: consecutive turns would self-intersect — \
1183                     coil gap {coil_gap:.6} does not clear the profile diameter {:.6}; \
1184                     increase the pitch or shrink the profile",
1185                    2.0 * extent
1186                ));
1187            }
1188        }
1189    }
1190
1191    // --- 3. Fit the helical path (see the density rationale in the fn docs) —
1192    //        the SAME sampler + fit the HX feature publishes as its edge.
1193    let path = fit_helix_curve(
1194        axis_origin,
1195        w,
1196        None,
1197        helix_radius,
1198        helix_radius,
1199        pitch,
1200        turns,
1201        0.0,
1202        false,
1203    )
1204    .map_err(|error| format!("sweep_profile_helix: helix path fit failed: {error}"))?;
1205
1206    // --- 4. Drive the existing sweep core; its (or the loft's) failures
1207    //        propagate with helix context prepended.
1208    let stations = ((turns * 32.0).ceil() as usize).clamp(32, 1024);
1209    // The helix builder places the profile ON the fitted helix (the coil's
1210    // section is drawn once and carried round), which is `Transplant` by
1211    // construction — the radius comes from the helix argument, not the profile's
1212    // own position.
1213    sweep_profile_along_path_stations(
1214        profile,
1215        &path,
1216        name,
1217        stations,
1218        0.0,
1219        None,
1220        SectionPlacement::Transplant,
1221    )
1222        .map_err(|error| format!("sweep_profile_helix: {error}"))
1223}