Skip to main content

brep_kernel/construction/sweep_topology/
twist.rs

1use super::*;
2
3/// The prefix of the refusal for a requested twist that does not close a CLOSED
4/// path, so a caller can classify one without matching free text.
5pub const SWEEP_TWIST_CLOSURE_REFUSAL: &str =
6    "sweepSolid: a requested twist does not close this closed path";
7
8/// What a REQUESTED twist does on a closed path, once it is known to close.
9///
10/// `twist` is the requested angle SNAPPED to the symmetry step it matched at
11/// round-off (`2π·n/k`), and `shift` is how far along the section's curve list
12/// one lap carries each curve: the wall leaving curve `j` arrives on curve
13/// `(j + shift) mod m`. A whole number of turns, or no twist, is shift 0.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub(super) struct ClosingTwist {
16    pub twist: f64,
17    pub shift: usize,
18}
19
20/// Which REQUESTED twists close a closed sweep of `section`, and what the one asked
21/// for does — or the refusal naming the nearest that close.
22///
23/// WHAT CLOSES. After one lap the frame's own roll, the path's holonomy, is taken
24/// out by the counter-twist, so the section comes back rolled by exactly the
25/// requested twist `T` about the PATH at station 0. The sweep closes only if that
26/// roll carries the section onto ITSELF. So `T` must be a rotational symmetry of
27/// the section about the path's own axis, `T ≡ 2π·n/k (mod 2π)` for a section with
28/// `k` such rotations. `k = 1` for any section with no symmetry about the path,
29/// including a symmetric one drawn off it, so only whole turns close there.
30///
31/// READ CURVE FOR CURVE, AS DRAWN. A wall per section curve must land on another
32/// curve after the lap, end for end, with the same degree, knots and weights, so the
33/// rotation is found on the curves' control points and not on the region they
34/// bound. A circle drawn as two half-arcs has `k = 2`, not a continuum; a square
35/// drawn with one side split has `k = 1`.
36///
37/// `axis_point` and `axis` are the path's first point and unit tangent there, and
38/// the rotation is right-handed about `axis`, the sense the frame rolls in.
39/// `floor` is the lane's own position floor, which is how far a control point may
40/// land from its image and still be the same point. The angle itself is matched at
41/// ROUND-OFF, not at a size: a twist that misses a closing value by `δ` would put
42/// the seam `ρ·δ` apart, which grows with the section, so `90.001°` is refused
43/// on a square.
44pub(super) fn closing_twist(
45    section: &[NurbsCurve],
46    axis_point: Vec3,
47    axis: Vec3,
48    requested: f64,
49    floor: f64,
50) -> Result<ClosingTwist, String> {
51    use std::f64::consts::TAU;
52    if requested == 0.0 {
53        return Ok(ClosingTwist {
54            twist: 0.0,
55            shift: 0,
56        });
57    }
58    if !requested.is_finite() {
59        return Err("sweepSolid: twist angle must be finite".into());
60    }
61    let symmetry = section_symmetry(section, axis_point, axis, floor)?;
62    let order = symmetry.len();
63    let step = TAU / order as f64;
64    let turns = (requested / step).round();
65    // Snapped in LOWEST TERMS, so every loop of a profile that closes on the same
66    // angle lays down the same bits: a hole with 6 rotations and an outer loop
67    // with 3 both read 120° as `TAU·1/3`, and their shares, swept in calls of
68    // their own, agree exactly.
69    let common = {
70        let (mut a, mut b) = ((turns as i64).unsigned_abs(), order as u64);
71        while b != 0 {
72            (a, b) = (b, a % b);
73        }
74        a.max(1) as f64
75    };
76    let snapped = TAU * (turns / common) / (order as f64 / common);
77    if (requested - snapped).abs() <= 64.0 * f64::EPSILON * requested.abs().max(1.0) {
78        let index = (turns as i64).rem_euclid(order as i64) as usize;
79        return Ok(ClosingTwist {
80            twist: snapped,
81            shift: symmetry[index],
82        });
83    }
84    let below = TAU * (requested / step).floor() / order as f64;
85    let above = TAU * (requested / step).ceil() / order as f64;
86    let reading = if order == 1 {
87        "this section has NO rotational symmetry about the path, so only a whole number of \
88         turns carries it onto itself"
89            .to_string()
90    } else {
91        format!(
92            "this section maps onto itself under {order} rotations about the path, so a twist \
93             closes only in steps of {:.6}°",
94            step.to_degrees()
95        )
96    };
97    Err(format!(
98        "{SWEEP_TWIST_CLOSURE_REFUSAL}: the section comes back from one lap rolled about the path \
99         by the requested {:.6}° (the frame's own roll, its holonomy, is closed separately by the \
100         counter-twist), and a roll that does not carry the section onto itself leaves the last \
101         wall meeting the first one turned. The symmetry is read off the section's boundary \
102         curves as drawn, each curve landing on another end for end, about the path's own axis \
103         (a section drawn off the path turns about the path, not about its centroid): {reading}. \
104         The nearest twists that close are {:.6}° and {:.6}°",
105        requested.to_degrees(),
106        below.to_degrees(),
107        above.to_degrees(),
108    ))
109}
110
111/// The ROTATIONAL SYMMETRY of `section` about the line through `axis_point` along
112/// unit `axis`: entry `i` is the curve shift that the rotation by `2π·i/k` applies,
113/// for the `k` rotations that carry the section's curve list onto itself. Entry 0
114/// is the identity, so the result is never empty.
115///
116/// A candidate shift `s` must map every curve `j` onto curve `(j + s) mod m` with
117/// the same degree, knots and weights. The angle is read off the control point
118/// farthest from the axis and then checked on every control point against
119/// `floor`. A shift whose angle does not sit on its own multiple of `2π/k` is
120/// dropped. A section that is symmetric only to within the floor then reads as
121/// less symmetric than it looks, and a twist is refused rather than welded.
122fn section_symmetry(
123    section: &[NurbsCurve],
124    axis_point: Vec3,
125    axis: Vec3,
126    floor: f64,
127) -> Result<Vec<usize>, String> {
128    use std::f64::consts::TAU;
129    let count = section.len();
130    let axis = axis
131        .normalized()
132        .map_err(|_| "sweepSolid: the path tangent is degenerate at its start".to_string())?;
133    let radial = |point: Vec3| {
134        let offset = point.sub(axis_point);
135        offset.sub(axis.scale(offset.dot(axis)))
136    };
137    // The control point farthest from the axis fixes each candidate's angle.
138    let mut reach = 0.0_f64;
139    let mut farthest = (0, 0);
140    for (curve_index, curve) in section.iter().enumerate() {
141        for (control_index, control) in curve.control_points.iter().enumerate() {
142            let distance = radial(control.point()?).length();
143            if distance > reach {
144                reach = distance;
145                farthest = (curve_index, control_index);
146            }
147        }
148    }
149    let mut found: Vec<(usize, f64)> = vec![(0, 0.0)];
150    if reach <= floor {
151        return Ok(vec![0]);
152    }
153    let rotate = |point: Vec3, angle: f64| {
154        let offset = point.sub(axis_point);
155        let (sin, cos) = angle.sin_cos();
156        axis_point.add(
157            offset
158                .scale(cos)
159                .add(axis.cross(offset).scale(sin))
160                .add(axis.scale(axis.dot(offset) * (1.0 - cos))),
161        )
162    };
163    'shift: for shift in 1..count {
164        for index in 0..count {
165            let (a, b) = (&section[index], &section[(index + shift) % count]);
166            if a.degree != b.degree
167                || a.control_points.len() != b.control_points.len()
168                || a.knots.len() != b.knots.len()
169                || a.knots
170                    .iter()
171                    .zip(&b.knots)
172                    .any(|(x, y)| (x - y).abs() > 1e-12 * (1.0 + x.abs()))
173                || a.control_points
174                    .iter()
175                    .zip(&b.control_points)
176                    .any(|(x, y)| (x.w - y.w).abs() > 1e-12 * x.w.abs().max(1.0))
177            {
178                continue 'shift;
179            }
180        }
181        let from = radial(section[farthest.0].control_points[farthest.1].point()?);
182        let to = radial(section[(farthest.0 + shift) % count].control_points[farthest.1].point()?);
183        let angle = axis.dot(from.cross(to)).atan2(from.dot(to)).rem_euclid(TAU);
184        for index in 0..count {
185            let (a, b) = (&section[index], &section[(index + shift) % count]);
186            for (x, y) in a.control_points.iter().zip(&b.control_points) {
187                if rotate(x.point()?, angle).sub(y.point()?).length() > floor {
188                    continue 'shift;
189                }
190            }
191        }
192        found.push((shift, angle));
193    }
194    // The rotations form a cyclic group of order k: each must sit on its own
195    // multiple of 2π/k, and no two on the same one.
196    let order = found.len();
197    let mut shifts = vec![None; order];
198    for &(shift, angle) in &found {
199        let step = (angle * order as f64 / TAU).round();
200        let miss = (angle - TAU * step / order as f64).abs() * reach;
201        let index = (step as i64).rem_euclid(order as i64) as usize;
202        if miss > floor || shifts[index].is_some() {
203            return Ok(vec![0]);
204        }
205        shifts[index] = Some(shift);
206    }
207    Ok(shifts.into_iter().map(|shift| shift.unwrap_or(0)).collect())
208}