Skip to main content

ogeom_algo/
project_plane.rs

1//! An edge projected orthogonally onto a plane, as an exact curve in the
2//! plane's own coordinates.
3//!
4//! Orthogonal projection onto a plane is affine, so it keeps every curve
5//! whose family is closed under affine maps: a line stays a line (or
6//! collapses to a point), a circle or ellipse becomes an ellipse (a circle
7//! when its plane is parallel, a segment when it stands square), a parabola
8//! a parabola and a hyperbola a hyperbola (each a segment when its plane
9//! stands square), and a B-spline becomes the B-spline on its projected
10//! control points, weights unchanged, clamped first where its knots are
11//! not. An offset of a line is a line, and an offset of a circle in its
12//! own plane a circle, and each projects as one. Any other curve is
13//! fitted, and the fit's error is returned with it.
14
15use 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/// An edge's orthogonal projection onto a plane, in the plane frame's `x`
25/// and `y` coordinates.
26#[derive(Debug, Clone, PartialEq)]
27pub enum ProjectedCurve {
28    /// The edge projects to one point: a line along the plane's normal.
29    Point(Point2),
30    /// A segment.
31    Line {
32        /// One end.
33        start: Point2,
34        /// The other end.
35        end: Point2,
36    },
37    /// A circle or circular arc, `centre + radius (cos t, sin t)`, turning
38    /// counter-clockwise about the plane's normal from `range.0` to
39    /// `range.1`; a full turn where the range spans one.
40    Circle {
41        /// The centre.
42        centre: Point2,
43        /// The radius.
44        radius: f64,
45        /// The arc's angles, `range.0 < range.1`.
46        range: (f64, f64),
47    },
48    /// An ellipse or elliptical arc, `centre + major cos t + minor sin t`
49    /// with `minor` the major axis turned a quarter counter-clockwise and
50    /// scaled by `ratio`, from `range.0` to `range.1`.
51    Ellipse {
52        /// The centre.
53        centre: Point2,
54        /// The major semi-axis, as a vector.
55        major: Vector2,
56        /// The minor semi-axis over the major, in `(0, 1)`.
57        ratio: f64,
58        /// The arc's eccentric angles, `range.0 < range.1`.
59        range: (f64, f64),
60    },
61    /// A parabola or a piece of one, `vertex + axis s^2 / (4 focal) + side s`
62    /// with `axis` a unit vector and `side` it turned a quarter
63    /// counter-clockwise, over `s` from `range.0` to `range.1`. `s` is the
64    /// edge's own parameter scaled and shifted, increasing with it or
65    /// against it.
66    Parabola {
67        /// The vertex.
68        vertex: Point2,
69        /// The unit direction the parabola opens in.
70        axis: Vector2,
71        /// The distance from the vertex to the focus.
72        focal: f64,
73        /// The piece's parameters, `range.0 < range.1`.
74        range: (f64, f64),
75    },
76    /// A hyperbola's branch or a piece of one, `centre + major cosh s +
77    /// minor sinh s` with `major` and `minor` square, over `s` from
78    /// `range.0` to `range.1`: the edge's own parameter shifted.
79    Hyperbola {
80        /// The centre, off the branch.
81        centre: Point2,
82        /// The major semi-axis: centre to vertex.
83        major: Vector2,
84        /// The minor semi-axis, square to the major.
85        minor: Vector2,
86        /// The piece's parameters, `range.0 < range.1`.
87        range: (f64, f64),
88    },
89    /// A B-spline: exact for a B-spline edge, fitted for any other curve at
90    /// the edge's own parameter, with the fit's largest miss in
91    /// `fit_error`.
92    BSpline {
93        /// The curve.
94        curve: BSpline2d,
95        /// `None` where the projection is exact.
96        fit_error: Option<f64>,
97    },
98}
99
100/// The orthogonal projection of an edge onto a plane, in the plane's own
101/// 2D coordinates (its frame's `x` and `y` axes). The edge's placement is
102/// applied first.
103///
104/// # Errors
105///
106/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
107/// `edge` is not an edge with a curve of its own, or if a curve with no
108/// closed-form projection cannot be fitted; plus whatever evaluating the
109/// curve refuses.
110pub 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
134/// The plane, as the map it is.
135struct 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            // A trimmed curve's parameter is its basis's, run backwards
231            // where it is reversed; the projection takes no side.
232            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
248/// An offset curve as the curve it is, at the same parameter, where that
249/// has a closed form: a line's offset is the line moved square to it, and
250/// a circle's, offset in its own plane, the circle about the same centre
251/// with the offset added to its radius. `None` for any other.
252fn 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            // The offset direction is the same all along a line.
260            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            // Square to the tangent and the circle's normal: outward from
275            // the centre with the reference along the normal, inward
276            // against it.
277            let radius = circle.radius() + offset.distance() * normal.dot(reference).signum();
278            if radius.abs() <= tol.confusion() {
279                return Ok(None);
280            }
281            // A radius turned negative runs the circle half a turn round,
282            // at the same parameter.
283            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
296/// The projection of `centre + u cos t + v sin t` over `range`: an
297/// ellipse in general, a circle where `u` and `v` stay square and equal,
298/// a segment where the ellipse closes to no width.
299fn conic(
300    centre: Point2,
301    u: Vector2,
302    v: Vector2,
303    range: (f64, f64),
304    tol: Tolerances,
305) -> OgeomResult<ProjectedCurve> {
306    // Principal axes of the conjugate pair: at `t0` the point is furthest
307    // from the centre, and the pair turned by `t0` is square.
308    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    // The point is `centre + major cos(t - t0) + minor sin(t - t0)`.
317    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    // Counter-clockwise where the minor axis leads the major by a quarter
326    // turn; otherwise the parameter runs the other way round.
327    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
349/// The projection of `origin + p t^2 + q t` over `range`: a parabola, a
350/// segment where `q` lies along `p` or `p` vanishes (the parabola seen
351/// edge-on), a point where both do.
352fn 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    // `q` split along `p` and square to it.
362    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        // On one line: the ends and, where it lies inside, the turn.
370        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    // With `s = w (t + alpha / 2)`, `w = |across|`, the point is
380    // `vertex + p s^2 / w^2 + across / w s`.
381    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    // `side` the axis turned counter-clockwise; where the parabola runs the
386    // other way, `s` runs against `t`.
387    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
400/// The projection of `centre + u cosh t + v sinh t` over `range`: a
401/// hyperbola on its principal axes, a segment where `u` and `v` lie along
402/// one line (the hyperbola seen edge-on).
403fn 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        // On one line through the centre: the ends and, where the point
418        // turns back along it, the turn, where `tanh t` is `-b / a`.
419        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    // Shifted by `t0`, the conjugate pair turns square:
433    // `tanh 2 t0 = -2 u.v / (|u|^2 + |v|^2)`.
434    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
445/// The segment holding points on one line, or the point they all are.
446fn 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
466/// A range moved by whole turns to start in `[0, 2 pi)`.
467fn normalized(from: f64, to: f64) -> (f64, f64) {
468    let start = from.rem_euclid(core::f64::consts::TAU);
469    (start, start + (to - from))
470}
471
472/// The segment `centre + major cos s` covers for `s` over `range`: its
473/// ends are the extremes of `cos s`, which an arc through a multiple of pi
474/// reaches inside the range.
475fn 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
492/// The part of a spline an edge covers, split out where the edge covers
493/// less than the whole.
494fn 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
511/// A spline clamped at both ends of its domain, the curve unchanged; a
512/// clamped spline as it is.
513fn 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    // The end clamped as the start of the spline run backwards.
521    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
531/// The domain's first knot taken to the degree's multiplicity by knot
532/// insertion, and the knots before its first copy dropped with the control
533/// points they alone held.
534fn 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
557/// The same spline run backwards: knots mirrored, control points reversed.
558fn 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
569/// A spline's projection: the same knots, each control point projected,
570/// each weight kept.
571fn projected_spline(spline: &BSplineCurve, onto: &Onto, tol: Tolerances) -> OgeomResult<BSpline2d> {
572    let knots: KnotVector = spline.knots().clone();
573    if spline.is_rational() {
574        // The homogeneous point `w p` maps to `w` times the projected point,
575        // which is the projection of `w p` less `w` times the origin's.
576        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
598/// A curve with no closed-form projection, fitted at the curve's own
599/// parameters through its projected points to a hundred times the
600/// confusion distance, sampled at every span of the curve and held to
601/// that between the samples as well as at them.
602fn 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}