1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::{
17 BSpline2d, BSplineCurve, CircleCurve, Curve, Curve3d as _, OffsetCurve, Transformable as _,
18};
19use ogeom_math::{
20 Circle, Frame, KnotVector, Plane, Point, Point2, Transform, Vector, Vector2, Weighted,
21};
22use ogeom_topo::{EdgeRepr, Model, NodeData, Shape};
23
24#[derive(Debug, Clone, PartialEq)]
27pub enum ProjectedCurve {
28 Point(Point2),
30 Line {
32 start: Point2,
34 end: Point2,
36 },
37 Circle {
41 centre: Point2,
43 radius: f64,
45 range: (f64, f64),
47 },
48 Ellipse {
52 centre: Point2,
54 major: Vector2,
56 ratio: f64,
58 range: (f64, f64),
60 },
61 Parabola {
67 vertex: Point2,
69 axis: Vector2,
71 focal: f64,
73 range: (f64, f64),
75 },
76 Hyperbola {
80 centre: Point2,
82 major: Vector2,
84 minor: Vector2,
86 range: (f64, f64),
88 },
89 BSpline {
93 curve: BSpline2d,
95 fit_error: Option<f64>,
97 },
98}
99
100pub fn project_edge_onto_plane(
111 model: &Model,
112 edge: &Shape,
113 plane: &Plane,
114 tol: Tolerances,
115) -> OgeomResult<ProjectedCurve> {
116 let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
117 ogeom_bail!(Construction, "only an edge projects onto a plane");
118 };
119 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
120 ogeom_bail!(
121 Construction,
122 "the edge has no curve in space (a degenerate edge), so there is nothing to project"
123 );
124 };
125 let Some(curve) = model.geometry().curve(*curve) else {
126 ogeom_bail!(Dangling, "the edge's curve is not in this model");
127 };
128 let curve = curve
129 .clone()
130 .transformed(&edge.transform(model.datums())?, tol)?;
131 project_curve(&curve, *range, &Onto::new(plane), tol)
132}
133
134struct Onto {
136 origin: Point,
137 x: Vector,
138 y: Vector,
139}
140
141impl Onto {
142 fn new(plane: &Plane) -> Self {
143 let frame = plane.frame();
144 Self {
145 origin: frame.origin(),
146 x: frame.x().vector(),
147 y: frame.y().vector(),
148 }
149 }
150
151 fn point(&self, p: Point) -> Point2 {
152 let d = p - self.origin;
153 Point2::new(d.dot(self.x), d.dot(self.y))
154 }
155
156 fn vector(&self, v: Vector) -> Vector2 {
157 Vector2::new(v.dot(self.x), v.dot(self.y))
158 }
159}
160
161fn project_curve(
162 curve: &Curve,
163 range: (f64, f64),
164 onto: &Onto,
165 tol: Tolerances,
166) -> OgeomResult<ProjectedCurve> {
167 match curve {
168 Curve::Line(_) => {
169 let start = onto.point(curve.point_at(range.0, tol)?);
170 let end = onto.point(curve.point_at(range.1, tol)?);
171 Ok(if start.distance(end) <= tol.confusion() {
172 ProjectedCurve::Point(start.midpoint(end))
173 } else {
174 ProjectedCurve::Line { start, end }
175 })
176 }
177 Curve::Circle(c) => {
178 let circle = c.circle();
179 let frame = circle.frame();
180 let r = circle.radius();
181 conic(
182 onto.point(frame.origin()),
183 onto.vector(frame.x().vector() * r),
184 onto.vector(frame.y().vector() * r),
185 range,
186 tol,
187 )
188 }
189 Curve::Ellipse(e) => {
190 let ellipse = e.ellipse();
191 let frame = ellipse.frame();
192 conic(
193 onto.point(frame.origin()),
194 onto.vector(frame.x().vector() * ellipse.major_radius()),
195 onto.vector(frame.y().vector() * ellipse.minor_radius()),
196 range,
197 tol,
198 )
199 }
200 Curve::Parabola(c) if !c.is_reversed() => {
201 let parabola = c.parabola();
202 let frame = parabola.frame();
203 Ok(parabolic(
204 onto.point(parabola.apex()),
205 onto.vector(frame.x().vector() / (4.0 * parabola.focal())),
206 onto.vector(frame.y().vector()),
207 range,
208 tol,
209 ))
210 }
211 Curve::Hyperbola(c) if !c.is_reversed() => {
212 let hyperbola = c.hyperbola();
213 let frame = hyperbola.frame();
214 Ok(hyperbolic(
215 onto.point(hyperbola.centre()),
216 onto.vector(frame.x().vector() * hyperbola.major_radius()),
217 onto.vector(frame.y().vector() * hyperbola.minor_radius()),
218 range,
219 tol,
220 ))
221 }
222 Curve::BSpline(spline) => {
223 let piece = clamped(&restricted(spline, range, tol)?, tol)?;
224 Ok(ProjectedCurve::BSpline {
225 curve: projected_spline(&piece, onto, tol)?,
226 fit_error: None,
227 })
228 }
229 Curve::Trimmed(trimmed) => {
230 let (s, e) = trimmed.domain();
233 let on_basis = if trimmed.is_reversed() {
234 (s + e - range.1, s + e - range.0)
235 } else {
236 range
237 };
238 project_curve(trimmed.basis(), on_basis, onto, tol)
239 }
240 Curve::Offset(offset) => match exact_offset(offset, range, tol)? {
241 Some(exact) => project_curve(&exact, range, onto, tol),
242 None => fitted(curve, range, onto, tol),
243 },
244 _ => fitted(curve, range, onto, tol),
245 }
246}
247
248fn exact_offset(
253 offset: &OffsetCurve,
254 range: (f64, f64),
255 tol: Tolerances,
256) -> OgeomResult<Option<Curve>> {
257 match offset.basis() {
258 Curve::Line(line) => {
259 let at = f64::midpoint(range.0, range.1);
261 let shift = offset.point_at(at, tol)? - line.point_at(at, tol)?;
262 Ok(Some(
263 Curve::from(*line).transformed(&Transform::translation(shift), tol)?,
264 ))
265 }
266 Curve::Circle(circle) => {
267 let circle = circle.circle();
268 let frame = circle.frame();
269 let normal = frame.z().vector();
270 let reference = offset.reference().vector();
271 if normal.cross(reference).magnitude() > tol.angular() {
272 return Ok(None);
273 }
274 let radius = circle.radius() + offset.distance() * normal.dot(reference).signum();
278 if radius.abs() <= tol.confusion() {
279 return Ok(None);
280 }
281 let frame = if radius > 0.0 {
284 frame
285 } else {
286 Frame::new(frame.origin(), frame.z(), -frame.x(), tol)?
287 };
288 Ok(Some(
289 CircleCurve::new(Circle::new(frame, radius.abs(), tol)?).into(),
290 ))
291 }
292 _ => Ok(None),
293 }
294}
295
296fn conic(
300 centre: Point2,
301 u: Vector2,
302 v: Vector2,
303 range: (f64, f64),
304 tol: Tolerances,
305) -> OgeomResult<ProjectedCurve> {
306 let t0 = 0.5 * (2.0 * u.dot(v)).atan2(u.dot(u) - v.dot(v));
309 let major = u * t0.cos() + v * t0.sin();
310 let minor = v * t0.cos() - u * t0.sin();
311 let (major, minor, t0) = if minor.magnitude() > major.magnitude() {
312 (minor, -major, t0 + core::f64::consts::FRAC_PI_2)
313 } else {
314 (major, minor, t0)
315 };
316 let length = major.magnitude();
318 if length <= tol.confusion() {
319 return Ok(ProjectedCurve::Point(centre));
320 }
321 let width = minor.magnitude();
322 if width <= tol.confusion() {
323 return Ok(segment(centre, major, (range.0 - t0, range.1 - t0)));
324 }
325 let (from, to) = if major.cross(minor) > 0.0 {
328 (range.0 - t0, range.1 - t0)
329 } else {
330 (t0 - range.1, t0 - range.0)
331 };
332 let ratio = width / length;
333 if (1.0 - ratio) * length <= tol.confusion() {
334 let angle = major.y.atan2(major.x);
335 return Ok(ProjectedCurve::Circle {
336 centre,
337 radius: length,
338 range: normalized(from + angle, to + angle),
339 });
340 }
341 Ok(ProjectedCurve::Ellipse {
342 centre,
343 major,
344 ratio,
345 range: normalized(from, to),
346 })
347}
348
349fn parabolic(
353 origin: Point2,
354 p: Vector2,
355 q: Vector2,
356 range: (f64, f64),
357 tol: Tolerances,
358) -> ProjectedCurve {
359 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
360 let reach = (lo.abs().max(hi.abs())).max(1.0);
361 let pp = p.dot(p);
363 let alpha = if pp > 0.0 { q.dot(p) / pp } else { 0.0 };
364 let across = q - p * alpha;
365 let at = |t: f64| origin + p * (t * t) + q * t;
366 if p.magnitude() * reach * reach <= tol.confusion()
367 || across.magnitude() * reach <= tol.confusion()
368 {
369 let mut points = vec![at(lo), at(hi)];
371 if pp > 0.0 {
372 let turn = -0.5 * alpha;
373 if turn > lo && turn < hi {
374 points.push(at(turn));
375 }
376 }
377 return line_through(&points, tol);
378 }
379 let w = across.magnitude();
382 let vertex = origin - p * (alpha * alpha / 4.0) - across * (alpha / 2.0);
383 let axis = p / p.magnitude();
384 let focal = w * w / (4.0 * p.magnitude());
385 let sense = if axis.cross(across) > 0.0 { 1.0 } else { -1.0 };
388 let (a, b) = (
389 sense * w * (lo + alpha / 2.0),
390 sense * w * (hi + alpha / 2.0),
391 );
392 ProjectedCurve::Parabola {
393 vertex,
394 axis,
395 focal,
396 range: (a.min(b), a.max(b)),
397 }
398}
399
400fn hyperbolic(
404 centre: Point2,
405 u: Vector2,
406 v: Vector2,
407 range: (f64, f64),
408 tol: Tolerances,
409) -> ProjectedCurve {
410 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
411 let at = |t: f64| centre + u * t.cosh() + v * t.sinh();
412 let scale = u.magnitude().max(v.magnitude()) * lo.abs().max(hi.abs()).cosh();
413 let squared = u.dot(u) + v.dot(v);
414 if u.cross(v).abs() * scale <= tol.confusion() * squared.max(f64::MIN_POSITIVE).sqrt()
415 || 2.0 * u.dot(v).abs() >= squared
416 {
417 let mut points = vec![at(lo), at(hi)];
420 let along = if u.magnitude() >= v.magnitude() { u } else { v };
421 if along.magnitude() > 0.0 {
422 let (a, b) = (u.dot(along), v.dot(along));
423 if b.abs() < a.abs() {
424 let turn = (-b / a).atanh();
425 if turn > lo && turn < hi {
426 points.push(at(turn));
427 }
428 }
429 }
430 return line_through(&points, tol);
431 }
432 let t0 = 0.5 * (-2.0 * u.dot(v) / squared).atanh();
435 let major = u * t0.cosh() + v * t0.sinh();
436 let minor = u * t0.sinh() + v * t0.cosh();
437 ProjectedCurve::Hyperbola {
438 centre,
439 major,
440 minor,
441 range: (lo - t0, hi - t0),
442 }
443}
444
445fn line_through(points: &[Point2], tol: Tolerances) -> ProjectedCurve {
447 let mut start = points[0];
448 let mut end = points[0];
449 for &p in points {
450 if p.distance(start) > end.distance(start) {
451 end = p;
452 }
453 }
454 for &p in points {
455 if p.distance(end) > start.distance(end) {
456 start = p;
457 }
458 }
459 if start.distance(end) <= tol.confusion() {
460 ProjectedCurve::Point(start.midpoint(end))
461 } else {
462 ProjectedCurve::Line { start, end }
463 }
464}
465
466fn normalized(from: f64, to: f64) -> (f64, f64) {
468 let start = from.rem_euclid(core::f64::consts::TAU);
469 (start, start + (to - from))
470}
471
472fn segment(centre: Point2, major: Vector2, range: (f64, f64)) -> ProjectedCurve {
476 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
477 let mut values = vec![lo.cos(), hi.cos()];
478 let pi = core::f64::consts::PI;
479 let mut k = (lo / pi).ceil();
480 while k * pi <= hi && values.len() < 4 {
481 values.push((k * pi).cos());
482 k += 1.0;
483 }
484 let least = values.iter().copied().fold(f64::INFINITY, f64::min);
485 let most = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
486 ProjectedCurve::Line {
487 start: centre + major * least,
488 end: centre + major * most,
489 }
490}
491
492fn restricted(
495 spline: &BSplineCurve,
496 range: (f64, f64),
497 tol: Tolerances,
498) -> OgeomResult<BSplineCurve> {
499 let (lo, hi) = spline.domain();
500 let reach = tol.parametric();
501 let mut piece = spline.clone();
502 if range.0 > lo + reach {
503 piece = piece.split_at(range.0, tol)?.1;
504 }
505 if range.1 < hi - reach {
506 piece = piece.split_at(range.1, tol)?.0;
507 }
508 Ok(piece)
509}
510
511fn clamped(spline: &BSplineCurve, tol: Tolerances) -> OgeomResult<BSplineCurve> {
514 let knots = spline.knots();
515 if knots.is_clamped() {
516 return Ok(spline.clone());
517 }
518 let control: Vec<Weighted<Point>> = spline.control_points().to_vec();
519 let (knots, control) = clamped_start(knots, &control, tol)?;
520 let (knots, control) = reversed(&knots, &control)?;
522 let (knots, control) = clamped_start(&knots, &control, tol)?;
523 let (knots, control) = reversed(&knots, &control)?;
524 if spline.is_rational() {
525 BSplineCurve::rational(knots, control)
526 } else {
527 BSplineCurve::new(knots, control.iter().map(|c| c.scaled).collect(), tol)
528 }
529}
530
531fn clamped_start(
535 knots: &KnotVector,
536 control: &[Weighted<Point>],
537 tol: Tolerances,
538) -> OgeomResult<(KnotVector, Vec<Weighted<Point>>)> {
539 let p = knots.degree();
540 let (lo, _) = knots.domain();
541 let (refined, points) = ogeom_math::bspline::insert_knot(
542 knots,
543 control,
544 lo,
545 p.saturating_sub(knots.multiplicity_of(lo)),
546 tol,
547 )?;
548 let cut = refined.knots().partition_point(|k| *k < lo);
549 let mut start = vec![lo];
550 start.extend_from_slice(&refined.knots()[cut..]);
551 Ok((
552 KnotVector::new(start, p)?,
553 points[cut.saturating_sub(1)..].to_vec(),
554 ))
555}
556
557fn reversed(
559 knots: &KnotVector,
560 control: &[Weighted<Point>],
561) -> OgeomResult<(KnotVector, Vec<Weighted<Point>>)> {
562 let mirrored: Vec<f64> = knots.knots().iter().rev().map(|k| -k).collect();
563 Ok((
564 KnotVector::new(mirrored, knots.degree())?,
565 control.iter().rev().copied().collect(),
566 ))
567}
568
569fn projected_spline(spline: &BSplineCurve, onto: &Onto, tol: Tolerances) -> OgeomResult<BSpline2d> {
572 let knots: KnotVector = spline.knots().clone();
573 if spline.is_rational() {
574 let control = spline
577 .control_points()
578 .iter()
579 .map(|c| {
580 let d = c.scaled.to_vector() - onto.origin.to_vector() * c.weight;
581 Weighted {
582 scaled: Point2::new(d.dot(onto.x), d.dot(onto.y)),
583 weight: c.weight,
584 }
585 })
586 .collect();
587 BSpline2d::rational(knots, control)
588 } else {
589 let control = spline
590 .control_points()
591 .iter()
592 .map(|c| onto.point(c.scaled))
593 .collect();
594 BSpline2d::new(knots, control, tol)
595 }
596}
597
598fn fitted(
603 curve: &Curve,
604 range: (f64, f64),
605 onto: &Onto,
606 tol: Tolerances,
607) -> OgeomResult<ProjectedCurve> {
608 const SAMPLES: usize = 200;
609 let target = tol.confusion() * 100.0;
610 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
611 let stations = crate::traced::stations(curve, (lo, hi), SAMPLES);
612 let fit = crate::traced::fit_traced_2d(
613 |t| Ok(onto.point(curve.point_at(t, tol)?)),
614 &stations,
615 3,
616 target,
617 tol,
618 )?;
619 if !fit.met {
620 ogeom_bail!(
621 Construction,
622 "the projected curve could not be fitted within {target}; its best fit misses by {}",
623 fit.error
624 );
625 }
626 Ok(ProjectedCurve::BSpline {
627 curve: fit.curve,
628 fit_error: Some(fit.error),
629 })
630}