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