Skip to main content

brep_kernel/construction/sweep_topology/
sweep.rs

1use super::*;
2
3/// Sweep a CLOSED PLANAR profile loop along a path curve (§3.3/§5.7).
4///
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(&sections)
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, &parameters)
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}