brep_kernel/construction/sweep_topology/sweep.rs
1use super::*;
2
3/// Sweep a CLOSED PLANAR profile loop along a path curve (§3.3/§5.7).
4///
5/// Reuses the loft builder rather than a bespoke swept surface: the path is
6/// sampled at `STATIONS` uniform stations, a rotation-minimizing frame is
7/// propagated along it (double-reflection RMF, Wang et al. 2008 — the frame
8/// does NOT spin at inflections the way raw Frenet does), a rigidly
9/// transformed copy of the profile is placed at each station (identical
10/// degree/knots/weights, only the control points moved — which guarantees
11/// loft's per-section compatibility), and the stations are lofted through
12/// to produce the tube plus planar end caps.
13///
14/// Guards return a clear `Err` on an open/non-planar profile, a degenerate
15/// path tangent, or a loft failure. A self-intersecting result (path
16/// curvature radius smaller than the profile extent) is OUT OF SCOPE — the
17/// caller is responsible for keeping the tube from folding onto itself.
18pub fn sweep_profile_along_path(
19 profile: &[NurbsCurve],
20 path: &NurbsCurve,
21 name: Option<&str>,
22) -> Result<BrepSolid, String> {
23 // 32 stations is the original fixed sampling — golden parity pins the
24 // emitted geometry to it. The helix variant raises the count with the
25 // turn count instead, hence the shared `_stations` core.
26 sweep_profile_along_path_stations(profile, path, name, 32, 0.0, None)
27}
28
29/// Twisted path sweep (§5.7): identical to [`sweep_profile_along_path`], but
30/// the profile additionally ROTATES about the path tangent, linearly in ARC
31/// LENGTH, from 0 at the sweep start to `twist_angle` radians (right-handed
32/// about the tangent) at the end. The arc-length fraction comes from the
33/// sampled station polyline, not the raw path parameter, so a non-uniformly
34/// parameterized path still twists uniformly in space.
35///
36/// STATION LAW: 16 stations per quarter turn of twist, floored at the path
37/// sweep's 32 and capped at 1024 (the loft's dense interpolation solve is
38/// O(stations³) per control column — the same cap the helix uses). At
39/// 16/quarter-turn the inter-station twist step is Δφ ≈ 5.6°, so the cubic
40/// v-interpolation error on a profile point circling at radius r is
41/// ≈ r·Δφ⁴/384 ≈ 2.4·10⁻¹⁰·r — far below any geometric tolerance. The cap
42/// holds that density up to 16 full turns (|twist| = 32π = 1024/16 quarter
43/// turns); a larger twist would silently alias under the cap, so it is
44/// REJECTED with an honest error instead. Everything else the path sweep
45/// documents (profile validity, self-intersection being the caller's
46/// responsibility) applies unchanged; twisting about the profile's own
47/// centroid adds no new radial extent, so no extra collision guard exists
48/// to compute here.
49pub fn sweep_profile_twisted(
50 profile: &[NurbsCurve],
51 path: &NurbsCurve,
52 twist_angle: f64,
53 name: Option<&str>,
54) -> Result<BrepSolid, String> {
55 use std::f64::consts::{FRAC_PI_2, TAU};
56
57 if !twist_angle.is_finite() {
58 return Err("sweep_profile_twisted: twist angle must be finite".into());
59 }
60 // 16 turns is where the 1024-station cap meets 16 stations/quarter-turn;
61 // beyond it the cap would degrade the twist sampling density silently.
62 const MAX_TURNS: f64 = 16.0;
63 if twist_angle.abs() > MAX_TURNS * TAU {
64 return Err(format!(
65 "sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
66 limit the 1024-station cap can resolve at 16 stations per quarter turn; \
67 split the sweep or reduce the twist",
68 twist_angle.abs() / TAU
69 ));
70 }
71 let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
72 let stations = (quarter_turns * 16).clamp(32, 1024);
73 sweep_profile_along_path_stations(profile, path, name, stations, twist_angle, None)
74 .map_err(|error| format!("sweep_profile_twisted: {error}"))
75}
76
77/// The placement anchor a path sweep transplants its profile with: the plane
78/// frame `(origin = boundary-sample centroid, normal, pu, pv)` the station
79/// loop maps profile points through (`local = p − origin` → `station + ri·(local
80/// ·pu) + si·(local·pv)`). Extracted as data so a HOLE loop can sweep with its
81/// OUTER loop's anchor — sweeping each loop with its OWN centroid would
82/// re-center every loop onto the path and lose the hole's in-plane offset.
83#[derive(Debug, Clone, Copy)]
84pub struct ProfileAnchor {
85 pub origin: Vec3,
86 pub normal: Vec3,
87 pub pu: Vec3,
88 pub pv: Vec3,
89}
90
91/// [`sweep_profile_along_path`] with an explicit placement anchor (see
92/// [`ProfileAnchor`]) — the hole-loop cutter path: the swept loop is validated
93/// as usual but PLACED in its outer loop's frame.
94pub fn sweep_profile_along_path_anchored(
95 profile: &[NurbsCurve],
96 path: &NurbsCurve,
97 name: Option<&str>,
98 anchor: ProfileAnchor,
99) -> Result<BrepSolid, String> {
100 sweep_profile_along_path_stations(profile, path, name, 32, 0.0, Some(anchor))
101}
102
103/// [`sweep_profile_twisted`] with an explicit placement anchor: the hole loop
104/// twists about the SAME path axis as its outer loop (shared anchor), so the
105/// cutter stays registered with the outer wall through the whole twist.
106pub fn sweep_profile_twisted_anchored(
107 profile: &[NurbsCurve],
108 path: &NurbsCurve,
109 twist_angle: f64,
110 name: Option<&str>,
111 anchor: ProfileAnchor,
112) -> Result<BrepSolid, String> {
113 use std::f64::consts::{FRAC_PI_2, TAU};
114
115 if !twist_angle.is_finite() {
116 return Err("sweep_profile_twisted: twist angle must be finite".into());
117 }
118 const MAX_TURNS: f64 = 16.0;
119 if twist_angle.abs() > MAX_TURNS * TAU {
120 return Err(format!(
121 "sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
122 limit the 1024-station cap can resolve at 16 stations per quarter turn; \
123 split the sweep or reduce the twist",
124 twist_angle.abs() / TAU
125 ));
126 }
127 let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
128 let stations = (quarter_turns * 16).clamp(32, 1024);
129 sweep_profile_along_path_stations(profile, path, name, stations, twist_angle, Some(anchor))
130 .map_err(|error| format!("sweep_profile_twisted: {error}"))
131}
132
133/// Validate a closed planar profile loop and derive its placement anchor —
134/// the path sweep's §1 block, extracted bit-identically: 16 samples per curve,
135/// closure at `tolerance`, Newell normal, boundary-sample-mean origin,
136/// planarity at `tolerance * 100`, `pu = np.perpendicular()`, `pv = np × pu`.
137pub fn profile_anchor(profile: &[NurbsCurve]) -> Result<ProfileAnchor, String> {
138 let tolerance = 1e-6;
139 if profile.len() < 2 {
140 return Err("sweepSolid: profile needs at least 2 curves forming a closed loop".into());
141 }
142 let mut samples = Vec::new();
143 for (index, curve) in profile.iter().enumerate() {
144 let [start, end] = curve.domain()?;
145 let next = &profile[(index + 1) % profile.len()];
146 let next_start = next.domain()?[0];
147 if curve
148 .evaluate(end)?
149 .sub(next.evaluate(next_start)?)
150 .length()
151 > tolerance
152 {
153 return Err(format!(
154 "sweepSolid: profile is not closed at curve {index}"
155 ));
156 }
157 for sample in 0..16 {
158 samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0)?);
159 }
160 }
161 let mut normal = Vec3::default();
162 let mut centroid = Vec3::default();
163 for index in 0..samples.len() {
164 let point = samples[index];
165 let next = samples[(index + 1) % samples.len()];
166 normal.x += (point.y - next.y) * (point.z + next.z);
167 normal.y += (point.z - next.z) * (point.x + next.x);
168 normal.z += (point.x - next.x) * (point.y + next.y);
169 centroid = centroid.add(point);
170 }
171 let np = normal
172 .normalized()
173 .map_err(|_| "sweepSolid: profile is degenerate (zero enclosed area)".to_string())?;
174 let origin = centroid.scale(1.0 / samples.len() as f64);
175 if samples
176 .iter()
177 .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
178 {
179 return Err("sweepSolid: profile is not planar".into());
180 }
181 let pu = np.perpendicular()?;
182 let pv = np.cross(pu).normalized()?;
183 Ok(ProfileAnchor {
184 origin,
185 normal: np,
186 pu,
187 pv,
188 })
189}
190
191/// Core of the path sweep with an explicit station count. Every consumer of
192/// the station count (path sampling, RMF propagation, section placement)
193/// derives from the one `stations` argument so the density scales as a unit.
194/// `twist_angle` (radians; 0 for the untwisted variants) rotates the placed
195/// profile about the path tangent linearly in sampled arc length — the
196/// `twist_angle == 0.0` fast path leaves the RMF axes bit-identical, so the
197/// untwisted callers keep golden parity.
198fn sweep_profile_along_path_stations(
199 profile: &[NurbsCurve],
200 path: &NurbsCurve,
201 name: Option<&str>,
202 stations: usize,
203 twist_angle: f64,
204 anchor: Option<ProfileAnchor>,
205) -> Result<BrepSolid, String> {
206 // Loft carries no face names; the app stamps them onto the emitted face
207 // order. Accept `name` for ABI symmetry with the other builders.
208 let _ = name;
209 let tolerance = 1e-6;
210 if stations < 2 {
211 return Err("sweepSolid: need at least 2 stations".into());
212 }
213
214 // --- 1. Validate the profile: closed + planar; derive (origin, np, pu, pv).
215 // A caller-supplied anchor OVERRIDES the placement frame (the swept
216 // loop is still validated against its own plane), so a hole loop
217 // rides the path in its outer loop's frame instead of re-centering.
218 let computed = profile_anchor(profile)?;
219 let ProfileAnchor {
220 origin, pu, pv, ..
221 } = anchor.unwrap_or(computed);
222
223 // --- 2. Sample the path; require a non-degenerate tangent at every station.
224 let [t0, t1] = path.domain()?;
225 if (t1 - t0).abs() <= tolerance {
226 return Err("sweepSolid: path domain is degenerate".into());
227 }
228 let mut points = Vec::with_capacity(stations);
229 let mut tangents = Vec::with_capacity(stations);
230 for index in 0..stations {
231 let t = t0 + (t1 - t0) * index as f64 / (stations - 1) as f64;
232 let derivatives = path.derivatives(t, 1)?;
233 let tangent = derivatives[1]
234 .normalized()
235 .map_err(|_| format!("sweepSolid: path tangent is degenerate at station {index}"))?;
236 points.push(derivatives[0]);
237 tangents.push(tangent);
238 }
239
240 // --- 2b. Twist distribution: cumulative ARC-LENGTH fractions over the
241 // sampled station polyline (chord sums), so the twist advances
242 // uniformly in space even on a non-uniformly parameterized path.
243 // Only computed when a twist is actually requested — the
244 // `twist_angle == 0.0` path must stay bit-identical to the
245 // pre-twist builder.
246 let twist_fractions: Option<Vec<f64>> = if twist_angle != 0.0 {
247 let mut cumulative = vec![0.0; stations];
248 let mut total = 0.0;
249 for index in 1..stations {
250 total += points[index].sub(points[index - 1]).length();
251 cumulative[index] = total;
252 }
253 if total <= tolerance {
254 return Err("sweepSolid: path has zero length; cannot distribute the twist".into());
255 }
256 for length in &mut cumulative {
257 *length /= total;
258 }
259 Some(cumulative)
260 } else {
261 None
262 };
263
264 // --- 3. Rotation-minimizing frames via the double-reflection method.
265 let mut r_axes = Vec::with_capacity(stations);
266 let mut s_axes = Vec::with_capacity(stations);
267 let r0 = tangents[0].perpendicular()?; // any unit vector ⟂ T0
268 s_axes.push(tangents[0].cross(r0).normalized()?);
269 r_axes.push(r0);
270 for index in 0..stations - 1 {
271 let t_next = tangents[index + 1];
272 let v1 = points[index + 1].sub(points[index]);
273 let c1 = v1.dot(v1);
274 let r_candidate = if c1 <= 1e-18 {
275 // Coincident stations: carry the reference axis forward unchanged.
276 r_axes[index]
277 } else {
278 // First reflection across the plane bisecting the step vector.
279 let reflected_r = r_axes[index].sub(v1.scale(2.0 / c1 * v1.dot(r_axes[index])));
280 let reflected_t = tangents[index].sub(v1.scale(2.0 / c1 * v1.dot(tangents[index])));
281 // Second reflection across the plane bisecting the tangents.
282 let v2 = t_next.sub(reflected_t);
283 let c2 = v2.dot(v2);
284 if c2 <= 1e-18 {
285 reflected_r
286 } else {
287 reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
288 }
289 };
290 // Re-orthogonalize against the new tangent to shed floating drift.
291 let r_next = r_candidate
292 .sub(t_next.scale(r_candidate.dot(t_next)))
293 .normalized()
294 .map_err(|_| format!("sweepSolid: frame degenerated at station {index}"))?;
295 s_axes.push(t_next.cross(r_next).normalized()?);
296 r_axes.push(r_next);
297 }
298
299 // --- 4. Place a rigidly transformed copy of the profile at each station.
300 let mut sections: Vec<Vec<NurbsCurve>> = Vec::with_capacity(stations);
301 for station in 0..stations {
302 let station_origin = points[station];
303 // Rotate the RMF axes about the tangent by the station's twist angle
304 // (Rodrigues on vectors ⟂ the tangent: r' = r·cosφ + s·sinφ,
305 // s' = s·cosφ − r·sinφ, since s = t × r and t × s = −r).
306 let (ri, si) = match &twist_fractions {
307 Some(fractions) => {
308 let phi = twist_angle * fractions[station];
309 let (sin_phi, cos_phi) = phi.sin_cos();
310 let r = r_axes[station];
311 let s = s_axes[station];
312 (
313 r.scale(cos_phi).add(s.scale(sin_phi)),
314 s.scale(cos_phi).sub(r.scale(sin_phi)),
315 )
316 }
317 None => (r_axes[station], s_axes[station]),
318 };
319 let mut section = Vec::with_capacity(profile.len());
320 for curve in profile {
321 let control_points = curve
322 .control_points
323 .iter()
324 .map(|point| {
325 let weight = point.w;
326 let euclidean = Vec3::new(point.x / weight, point.y / weight, point.z / weight);
327 let local = euclidean.sub(origin);
328 let world = station_origin
329 .add(ri.scale(local.dot(pu)))
330 .add(si.scale(local.dot(pv)));
331 Vec4 {
332 x: world.x * weight,
333 y: world.y * weight,
334 z: world.z * weight,
335 w: weight,
336 }
337 })
338 .collect();
339 section.push(NurbsCurve::new(
340 curve.degree,
341 curve.knots.clone(),
342 control_points,
343 )?);
344 }
345 sections.push(section);
346 }
347
348 // --- 5. Loft through the swept sections (side walls + planar end caps).
349 loft_profile_brep(§ions)
350 .map_err(|error| format!("sweepSolid: loft through swept sections failed: {error}"))
351}
352
353/// Helical sweep (§5.7): sweep a CLOSED PLANAR profile loop along a helix of
354/// `helix_radius` about the axis through `axis_origin` with direction
355/// `axis_direction`, rising `pitch` per revolution for `turns` revolutions.
356///
357/// The helix is transcendental, not exactly NURBS-representable, so the path
358/// is FIT: points (R·cosθ, R·sinθ, pitch·θ/2π) in the axis frame are sampled
359/// uniformly in θ and globally interpolated with a cubic (`interpolate_curve`
360/// — the same machinery every other fitted path here uses). DENSITY: 64
361/// samples per turn, capped at 1025 total nodes. Cubic interpolation error
362/// on a circle of radius R with node spacing Δθ is ≈ R·Δθ⁴/384: ~2·10⁻⁷·R at
363/// 64/turn, and still ~6·10⁻⁵·R at the cap's worst case (16/turn at the
364/// 64-turn limit) — orders below any profile a caller could sweep without
365/// self-intersecting. Uniform-in-θ parameters are exact chord-length for a
366/// helix (constant speed), which is what the averaged-knot interpolation
367/// assumes.
368///
369/// The fitted path then drives the EXISTING path-sweep core with 32 stations
370/// per turn (min 32, capped at 1024 — the loft's dense interpolation solve is
371/// O(stations³) per control column, so the cap trades per-turn density, never
372/// correctness, at high turn counts).
373///
374/// GUARDS beyond the path sweep's own: the path sweep documents
375/// self-intersection as caller responsibility, so the helix variant — which
376/// knows its curvature analytically — rejects the two garbage modes itself:
377/// • fold-over: the helix curvature radius (R² + c²)/R (c = pitch/2π) must
378/// exceed the profile's max extent about its centroid, or the tube folds
379/// through itself on the inner side (the torus tube-radius > major-radius
380/// failure, pitch-relaxed);
381/// • coil collision (turns ≥ 1): the normal gap between consecutive coils,
382/// pitch·2πR/√((2πR)² + pitch²), must exceed the profile diameter.
383/// Both use the profile's max sample distance from its centroid — conservative
384/// for asymmetric profiles (extent in a harmless direction still counts), but
385/// a false reject beats silent garbage.
386///
387/// KNOWN LIMITATION: whole-turn shallow helixes (pitch ≲ 0.63·R) are rejected
388/// by the loft's cap-plane guard (`|n·axis| ≥ 0.1` — the end-to-end axis is
389/// purely axial while the cap normal is nearly tangential); the error
390/// propagates honestly rather than being worked around.
391pub fn sweep_profile_helix(
392 profile: &[NurbsCurve],
393 axis_origin: Vec3,
394 axis_direction: Vec3,
395 helix_radius: f64,
396 pitch: f64,
397 turns: f64,
398 name: Option<&str>,
399) -> Result<BrepSolid, String> {
400 use std::f64::consts::TAU;
401
402 // --- 1. Validate the helix parameters with honest errors.
403 let w = axis_direction
404 .normalized()
405 .map_err(|_| "sweep_profile_helix: axis direction is degenerate".to_string())?;
406 if !(helix_radius.is_finite() && helix_radius > 0.0) {
407 return Err("sweep_profile_helix: helix radius must be positive".into());
408 }
409 if !(pitch.is_finite() && pitch > 0.0) {
410 return Err("sweep_profile_helix: pitch must be positive".into());
411 }
412 if !(turns.is_finite() && turns > 0.0) {
413 return Err("sweep_profile_helix: turns must be positive".into());
414 }
415 // 64 turns bounds the loft's O(stations³) interpolation solve; beyond it
416 // the station cap would silently degrade per-turn density anyway.
417 if turns > 64.0 {
418 return Err("sweep_profile_helix: turns must be at most 64".into());
419 }
420
421 // --- 2. Profile extent about its centroid, sampled exactly like the path
422 // sweep derives its placement origin (boundary-sample mean), so the
423 // extent is measured about the point that actually rides the path.
424 let mut samples = Vec::new();
425 for curve in profile {
426 let [start, end] = curve.domain()?;
427 for sample in 0..16 {
428 samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0)?);
429 }
430 }
431 if !samples.is_empty() {
432 let mut centroid = Vec3::default();
433 for point in &samples {
434 centroid = centroid.add(*point);
435 }
436 let origin = centroid.scale(1.0 / samples.len() as f64);
437 let extent = samples
438 .iter()
439 .map(|point| point.sub(origin).length())
440 .fold(0.0, f64::max);
441 let c = pitch / TAU; // axial rise per radian
442 // Fold-over: profile reaches past the helix's center of curvature.
443 let curvature_radius = (helix_radius * helix_radius + c * c) / helix_radius;
444 if extent >= curvature_radius {
445 return Err(format!(
446 "sweep_profile_helix: profile extent {extent:.6} reaches the helix \
447 curvature radius {curvature_radius:.6}; the tube would fold through \
448 itself — increase the helix radius or pitch, or shrink the profile"
449 ));
450 }
451 // Coil collision: only possible once the sweep spans a full revolution.
452 if turns >= 1.0 {
453 let circumference = TAU * helix_radius;
454 let turn_length = (circumference * circumference + pitch * pitch).sqrt();
455 let coil_gap = pitch * circumference / turn_length;
456 if coil_gap <= 2.0 * extent {
457 return Err(format!(
458 "sweep_profile_helix: consecutive turns would self-intersect — \
459 coil gap {coil_gap:.6} does not clear the profile diameter {:.6}; \
460 increase the pitch or shrink the profile",
461 2.0 * extent
462 ));
463 }
464 }
465 }
466
467 // --- 3. Fit the helical path (see the density rationale in the fn docs).
468 let u = w.perpendicular()?;
469 let v = w.cross(u).normalized()?;
470 let total_angle = turns * TAU;
471 let rise = pitch / TAU;
472 let count = ((turns * 64.0).ceil() as usize + 1).clamp(9, 1025);
473 let mut points = Vec::with_capacity(count);
474 let mut parameters = Vec::with_capacity(count);
475 for index in 0..count {
476 let s = index as f64 / (count - 1) as f64;
477 let theta = total_angle * s;
478 points.push(
479 axis_origin
480 .add(u.scale(helix_radius * theta.cos()))
481 .add(v.scale(helix_radius * theta.sin()))
482 .add(w.scale(rise * theta)),
483 );
484 parameters.push(s);
485 }
486 let path = interpolate_curve(&points, 3, ¶meters)
487 .map_err(|error| format!("sweep_profile_helix: helix path fit failed: {error}"))?;
488
489 // --- 4. Drive the existing sweep core; its (or the loft's) failures
490 // propagate with helix context prepended.
491 let stations = ((turns * 32.0).ceil() as usize).clamp(32, 1024);
492 sweep_profile_along_path_stations(profile, &path, name, stations, 0.0, None)
493 .map_err(|error| format!("sweep_profile_helix: {error}"))
494}