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(§ions)
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, ¶meters)
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}