geop_core_geometry/nurb_curve/
helix.rs1use geop_core_math::{
31 geop_error::{GeopError, GeopResult},
32 primitives::CoordinateSystem,
33 scalars::Scalar,
34 vector::{Vector2, Vector3},
35};
36
37use super::{NurbCurve, NurbCurve3D};
38
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub enum Handedness {
43 #[default]
44 Right,
45 Left,
46}
47
48#[derive(Clone, Copy, Debug)]
52pub struct Helix {
53 pub pitch: Scalar,
54 pub turns: f64,
55 pub handedness: Handedness,
56 pub taper: Scalar,
57}
58
59impl Helix {
60 pub fn new(pitch: Scalar, turns: f64, handedness: Handedness) -> Self {
62 Self {
63 pitch,
64 turns,
65 handedness,
66 taper: Scalar::ZERO,
67 }
68 }
69
70 pub fn tapered(self, taper: Scalar) -> Self {
74 Self { taper, ..self }
75 }
76}
77
78#[derive(Clone, Debug)]
83pub struct HelixRow {
84 pub radial: Vector3,
87 pub rise: Scalar,
88 pub out: Option<Scalar>,
91 pub weight: Option<Scalar>,
92}
93
94pub fn cos_sin(degrees: f64) -> (f64, f64) {
99 match degrees.rem_euclid(360.0) {
100 0.0 => (1.0, 0.0),
101 90.0 => (0.0, 1.0),
102 180.0 => (-1.0, 0.0),
103 270.0 => (0.0, -1.0),
104 d => (d.to_radians().cos(), d.to_radians().sin()),
105 }
106}
107
108pub fn sqrt2_over_2() -> Scalar {
111 Scalar::from_f64(std::f64::consts::SQRT_2 / 2.0)
112}
113
114pub fn arc_span(step: f64) -> (Scalar, Scalar) {
122 let (cos_step, _) = cos_sin(step);
123 let weight = if step.abs() == 90.0 {
124 sqrt2_over_2()
125 } else {
126 Scalar::from_f64((step / 2.0).to_radians().cos())
127 };
128 (weight, Scalar::from_f64(1.0 / (1.0 + cos_step)))
129}
130
131pub fn helix_rows(frame: &CoordinateSystem, helix: &Helix) -> GeopResult<Vec<HelixRow>> {
135 let Helix {
136 pitch,
137 turns,
138 handedness,
139 taper,
140 } = *helix;
141 if !(turns.is_finite() && turns > 0.0) {
142 return Err(GeopError::new(format!(
143 "helix: cannot turn {turns} times: it must be more than none"
144 )));
145 }
146 if !pitch.definitely_greater(Scalar::ZERO) {
147 return Err(GeopError::new(format!(
148 "helix: the pitch {pitch:?} must be definitely positive"
149 )));
150 }
151 let spans = (turns * 4.0).ceil() as usize;
152 let sign = match handedness {
153 Handedness::Right => 1.0,
154 Handedness::Left => -1.0,
155 };
156 let step = sign * 360.0 * turns / spans as f64;
157 let direction = |degrees: f64| {
158 let (c, s) = cos_sin(degrees);
159 frame.uv_direction(&Vector2::from_array([
160 Scalar::from_f64(c),
161 Scalar::from_f64(s),
162 ]))
163 };
164 let (weight, outwards) = arc_span(step);
165 let total = pitch.mul(Scalar::from_f64(turns));
166 let rise = |k: usize| -> GeopResult<Scalar> {
169 Ok(match k {
170 0 => Scalar::ZERO,
171 k if k == spans => total,
172 k => total.mul(Scalar::from_ratio(k as i64, spans as i64)?),
173 })
174 };
175 let out = |rise: Scalar| (!taper.is_zero()).then(|| taper.mul(rise));
178 let mut rows = Vec::with_capacity(2 * spans + 1);
179 let mut start = HelixRow {
180 radial: direction(0.0),
181 rise: Scalar::ZERO,
182 out: out(Scalar::ZERO),
183 weight: None,
184 };
185 for k in 0..spans {
186 let rise_end = rise(k + 1)?;
187 let end = HelixRow {
188 radial: direction((k + 1) as f64 * step),
189 rise: rise_end,
190 out: out(rise_end),
191 weight: None,
192 };
193 let rise_middle = start.rise.add(end.rise).div(Scalar::TWO)?;
194 let middle = HelixRow {
195 radial: start.radial.add(&end.radial).prod_scalar(outwards),
196 rise: rise_middle,
197 out: out(rise_middle),
198 weight: Some(weight),
199 };
200 rows.push(start);
201 rows.push(middle);
202 start = end;
203 }
204 rows.push(start);
205 Ok(rows)
206}
207
208impl NurbCurve3D {
209 pub fn helix(frame: &CoordinateSystem, radius: Scalar, helix: &Helix) -> GeopResult<Self> {
218 let rows = helix_rows(frame, helix)?;
219 let radius_at = |row: &HelixRow| row.out.map_or(radius, |out| radius.add(out));
220 if let Some(row) = rows
221 .iter()
222 .find(|row| !radius_at(row).definitely_greater(Scalar::ZERO))
223 {
224 return Err(GeopError::new(format!(
225 "helix: the radius {:?} must be definitely positive, starting at {radius:?}",
226 radius_at(row)
227 )));
228 }
229 let spans = rows.len() / 2;
230 let (origin, w) = (frame.origin(), frame.w());
231 let control_points = rows
232 .iter()
233 .map(|row| {
234 origin
235 .add(&row.radial.prod_scalar(radius_at(row)))
236 .add(&w.prod_scalar(row.rise))
237 .homogeneous(row.weight.unwrap_or(Scalar::ONE))
238 })
239 .collect();
240 let mut knots = vec![Scalar::ZERO; 3];
241 for k in 1..spans {
242 let knot = Scalar::from_ratio(k as i64, spans as i64)?;
243 knots.extend([knot, knot]);
244 }
245 knots.extend([Scalar::ONE; 3]);
246 NurbCurve::try_new(2, control_points, knots)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use geop_core_math::{primitives::CoordinateSystem, scalars::Scalar, vector::Vector3};
253
254 use super::*;
255
256 fn frame() -> CoordinateSystem {
257 CoordinateSystem::world_at(Vector3::from_array([1.0, 2.0, 3.0].map(Scalar::from_f64)))
258 }
259
260 #[test]
264 fn helix_is_on_its_cylinder_and_close_to_the_true_helix() {
265 let (radius, pitch, turns) = (2.5, 0.8, 2.3);
266 for handedness in [Handedness::Right, Handedness::Left] {
267 let curve = NurbCurve3D::helix(
268 &frame(),
269 Scalar::from_f64(radius),
270 &Helix::new(Scalar::from_f64(pitch), turns, handedness),
271 )
272 .unwrap();
273 let sign = if handedness == Handedness::Right {
274 1.0
275 } else {
276 -1.0
277 };
278 let samples = 997;
279 let mut unwrapped = 0.0f64;
280 let mut last = 0.0f64;
281 let mut worst = 0.0f64;
282 for i in 0..=samples {
283 let t = Scalar::from_ratio(i, samples).unwrap();
284 let p = curve.evaluate(t).unwrap();
285 let centre = Vector3::from_array([1.0, 2.0, p[2].to_f64()].map(Scalar::from_f64));
286 let off = p.sub(¢re);
287 let r2 = off[0].mul(off[0]).add(off[1].mul(off[1]));
288 assert!(
289 r2.could_be_equal(Scalar::from_f64(radius * radius)),
290 "{p:?} is off the cylinder"
291 );
292 let (x, y, z) = (
293 p[0].to_f64() - 1.0,
294 p[1].to_f64() - 2.0,
295 p[2].to_f64() - 3.0,
296 );
297 let angle = sign * y.atan2(x);
298 let mut d = angle - last;
299 if d < -std::f64::consts::PI {
300 d += 2.0 * std::f64::consts::PI;
301 }
302 if d > std::f64::consts::PI {
303 d -= 2.0 * std::f64::consts::PI;
304 }
305 assert!(d >= -1e-12, "{i}: {d}");
307 unwrapped += d;
308 last = angle;
309 let expected = pitch * unwrapped / (2.0 * std::f64::consts::PI);
310 worst = worst.max((z - expected).abs());
311 }
312 assert!(
313 (unwrapped - turns * 2.0 * std::f64::consts::PI).abs() < 1e-9,
314 "{unwrapped}"
315 );
316 assert!(worst <= 5.3e-3 * pitch, "{worst}");
317 assert!(worst > 1e-4 * pitch, "{worst}");
319 let end = curve.evaluate(Scalar::ONE).unwrap();
320 assert!(
321 end[2].could_be_equal(
322 Scalar::from_f64(3.0).add(Scalar::from_f64(pitch).mul(Scalar::from_f64(turns)))
323 ),
324 "{end:?}"
325 );
326 }
327 }
328
329 #[test]
331 fn degenerate_helices_are_refused() {
332 use geop_core_math::scalars::Scalar;
333 let f = frame();
334 let one = Scalar::from_f64(1.0);
335 let right = |pitch: Scalar, turns: f64| Helix::new(pitch, turns, Handedness::Right);
336 assert!(NurbCurve3D::helix(&f, one, &right(one, 0.0)).is_err());
337 assert!(NurbCurve3D::helix(&f, one, &right(Scalar::ZERO, 1.0)).is_err());
338 assert!(NurbCurve3D::helix(&f, Scalar::ZERO, &right(one, 1.0)).is_err());
339 let inwards = right(one, 2.0).tapered(Scalar::from_f64(-0.6));
341 assert!(NurbCurve3D::helix(&f, one, &inwards).is_err());
342 }
343
344 #[test]
349 fn tapered_helix_is_on_its_cone_at_every_quarter_and_close_between() {
350 let (radius, pitch, turns, taper) = (10.0, 1.8, 3.5, 1.0 / 32.0);
351 let helix = Helix::new(Scalar::from_f64(pitch), turns, Handedness::Right)
352 .tapered(Scalar::from_f64(taper));
353 let curve = NurbCurve3D::helix(&frame(), Scalar::from_f64(radius), &helix).unwrap();
354 let off_cone = |p: &Vector3| {
355 let (x, y, z) = (
356 p[0].to_f64() - 1.0,
357 p[1].to_f64() - 2.0,
358 p[2].to_f64() - 3.0,
359 );
360 x.hypot(y) - (radius + taper * z)
361 };
362 let spans = (turns * 4.0).ceil() as i64;
363 for k in 0..=spans {
364 let p = curve
365 .evaluate(Scalar::from_ratio(k, spans).unwrap())
366 .unwrap();
367 assert!(off_cone(&p).abs() < 1e-12, "quarter {k}: {p:?}");
368 }
369 let quarter = taper * pitch / 4.0;
370 let samples = 997;
371 let worst = (0..=samples)
372 .map(|i| {
373 off_cone(
374 &curve
375 .evaluate(Scalar::from_ratio(i, samples).unwrap())
376 .unwrap(),
377 )
378 })
379 .fold(0.0f64, |w, d| w.max(d.abs()));
380 assert!(worst < 1e-2 * quarter, "{worst} against {quarter}");
381 assert!(worst > 0.0);
382 let end = curve.evaluate(Scalar::ONE).unwrap();
383 assert!(
384 off_cone(&end).abs() < 1e-12 && (end[2].to_f64() - 3.0 - pitch * turns).abs() < 1e-12,
385 "{end:?}"
386 );
387 }
388}