1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
13use ogeom_geom::{BSpline2d, BSplineCurve, Curve, Curve3d as _, Transformable as _};
14use ogeom_math::{KnotVector, Plane, Point, Point2, Vector, Vector2, Weighted};
15use ogeom_topo::{EdgeRepr, Model, NodeData, Shape};
16
17#[derive(Debug, Clone, PartialEq)]
20pub enum ProjectedCurve {
21 Point(Point2),
23 Line {
25 start: Point2,
27 end: Point2,
29 },
30 Circle {
34 centre: Point2,
36 radius: f64,
38 range: (f64, f64),
40 },
41 Ellipse {
45 centre: Point2,
47 major: Vector2,
49 ratio: f64,
51 range: (f64, f64),
53 },
54 BSpline {
57 curve: BSpline2d,
59 fit_error: Option<f64>,
61 },
62}
63
64pub fn project_edge_onto_plane(
75 model: &Model,
76 edge: &Shape,
77 plane: &Plane,
78 tol: Tolerances,
79) -> OgeomResult<ProjectedCurve> {
80 let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
81 ogeom_bail!(Construction, "only an edge projects onto a plane");
82 };
83 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
84 ogeom_bail!(
85 Construction,
86 "the edge has no curve in space (a degenerate edge), so there is nothing to project"
87 );
88 };
89 let Some(curve) = model.geometry().curve(*curve) else {
90 ogeom_bail!(Dangling, "the edge's curve is not in this model");
91 };
92 let curve = curve
93 .clone()
94 .transformed(&edge.transform(model.datums())?, tol)?;
95 project_curve(&curve, *range, &Onto::new(plane), tol)
96}
97
98struct Onto {
100 origin: Point,
101 x: Vector,
102 y: Vector,
103}
104
105impl Onto {
106 fn new(plane: &Plane) -> Self {
107 let frame = plane.frame();
108 Self {
109 origin: frame.origin(),
110 x: frame.x().vector(),
111 y: frame.y().vector(),
112 }
113 }
114
115 fn point(&self, p: Point) -> Point2 {
116 let d = p - self.origin;
117 Point2::new(d.dot(self.x), d.dot(self.y))
118 }
119
120 fn vector(&self, v: Vector) -> Vector2 {
121 Vector2::new(v.dot(self.x), v.dot(self.y))
122 }
123}
124
125fn project_curve(
126 curve: &Curve,
127 range: (f64, f64),
128 onto: &Onto,
129 tol: Tolerances,
130) -> OgeomResult<ProjectedCurve> {
131 match curve {
132 Curve::Line(_) => {
133 let start = onto.point(curve.point_at(range.0, tol)?);
134 let end = onto.point(curve.point_at(range.1, tol)?);
135 Ok(if start.distance(end) <= tol.confusion() {
136 ProjectedCurve::Point(start.midpoint(end))
137 } else {
138 ProjectedCurve::Line { start, end }
139 })
140 }
141 Curve::Circle(c) => {
142 let circle = c.circle();
143 let frame = circle.frame();
144 let r = circle.radius();
145 conic(
146 onto.point(frame.origin()),
147 onto.vector(frame.x().vector() * r),
148 onto.vector(frame.y().vector() * r),
149 range,
150 tol,
151 )
152 }
153 Curve::Ellipse(e) => {
154 let ellipse = e.ellipse();
155 let frame = ellipse.frame();
156 conic(
157 onto.point(frame.origin()),
158 onto.vector(frame.x().vector() * ellipse.major_radius()),
159 onto.vector(frame.y().vector() * ellipse.minor_radius()),
160 range,
161 tol,
162 )
163 }
164 Curve::BSpline(spline) => {
165 let piece = restricted(spline, range, tol)?;
166 Ok(ProjectedCurve::BSpline {
167 curve: projected_spline(&piece, onto, tol)?,
168 fit_error: None,
169 })
170 }
171 Curve::Trimmed(trimmed) => {
172 let (s, e) = trimmed.domain();
175 let on_basis = if trimmed.is_reversed() {
176 (s + e - range.1, s + e - range.0)
177 } else {
178 range
179 };
180 project_curve(trimmed.basis(), on_basis, onto, tol)
181 }
182 _ => fitted(curve, range, onto, tol),
183 }
184}
185
186fn conic(
190 centre: Point2,
191 u: Vector2,
192 v: Vector2,
193 range: (f64, f64),
194 tol: Tolerances,
195) -> OgeomResult<ProjectedCurve> {
196 let t0 = 0.5 * (2.0 * u.dot(v)).atan2(u.dot(u) - v.dot(v));
199 let major = u * t0.cos() + v * t0.sin();
200 let minor = v * t0.cos() - u * t0.sin();
201 let (major, minor, t0) = if minor.magnitude() > major.magnitude() {
202 (minor, -major, t0 + core::f64::consts::FRAC_PI_2)
203 } else {
204 (major, minor, t0)
205 };
206 let length = major.magnitude();
208 if length <= tol.confusion() {
209 return Ok(ProjectedCurve::Point(centre));
210 }
211 let width = minor.magnitude();
212 if width <= tol.confusion() {
213 return Ok(segment(centre, major, (range.0 - t0, range.1 - t0)));
214 }
215 let (from, to) = if major.cross(minor) > 0.0 {
218 (range.0 - t0, range.1 - t0)
219 } else {
220 (t0 - range.1, t0 - range.0)
221 };
222 let ratio = width / length;
223 if (1.0 - ratio) * length <= tol.confusion() {
224 let angle = major.y.atan2(major.x);
225 return Ok(ProjectedCurve::Circle {
226 centre,
227 radius: length,
228 range: normalized(from + angle, to + angle),
229 });
230 }
231 Ok(ProjectedCurve::Ellipse {
232 centre,
233 major,
234 ratio,
235 range: normalized(from, to),
236 })
237}
238
239fn normalized(from: f64, to: f64) -> (f64, f64) {
241 let start = from.rem_euclid(core::f64::consts::TAU);
242 (start, start + (to - from))
243}
244
245fn segment(centre: Point2, major: Vector2, range: (f64, f64)) -> ProjectedCurve {
249 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
250 let mut values = vec![lo.cos(), hi.cos()];
251 let pi = core::f64::consts::PI;
252 let mut k = (lo / pi).ceil();
253 while k * pi <= hi && values.len() < 4 {
254 values.push((k * pi).cos());
255 k += 1.0;
256 }
257 let least = values.iter().copied().fold(f64::INFINITY, f64::min);
258 let most = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
259 ProjectedCurve::Line {
260 start: centre + major * least,
261 end: centre + major * most,
262 }
263}
264
265fn restricted(
268 spline: &BSplineCurve,
269 range: (f64, f64),
270 tol: Tolerances,
271) -> OgeomResult<BSplineCurve> {
272 let (lo, hi) = spline.domain();
273 let reach = tol.parametric();
274 let mut piece = spline.clone();
275 if range.0 > lo + reach {
276 piece = piece.split_at(range.0, tol)?.1;
277 }
278 if range.1 < hi - reach {
279 piece = piece.split_at(range.1, tol)?.0;
280 }
281 Ok(piece)
282}
283
284fn projected_spline(spline: &BSplineCurve, onto: &Onto, tol: Tolerances) -> OgeomResult<BSpline2d> {
287 let knots: KnotVector = spline.knots().clone();
288 if spline.is_rational() {
289 let control = spline
292 .control_points()
293 .iter()
294 .map(|c| {
295 let d = c.scaled.to_vector() - onto.origin.to_vector() * c.weight;
296 Weighted {
297 scaled: Point2::new(d.dot(onto.x), d.dot(onto.y)),
298 weight: c.weight,
299 }
300 })
301 .collect();
302 BSpline2d::rational(knots, control)
303 } else {
304 let control = spline
305 .control_points()
306 .iter()
307 .map(|c| onto.point(c.scaled))
308 .collect();
309 BSpline2d::new(knots, control, tol)
310 }
311}
312
313fn fitted(
316 curve: &Curve,
317 range: (f64, f64),
318 onto: &Onto,
319 tol: Tolerances,
320) -> OgeomResult<ProjectedCurve> {
321 const SAMPLES: u32 = 200;
322 let mut points = Vec::with_capacity(SAMPLES as usize + 1);
323 for k in 0..=SAMPLES {
324 let t = range.0 + (range.1 - range.0) * f64::from(k) / f64::from(SAMPLES);
325 let p = onto.point(curve.point_at(t, tol)?);
326 points.push(Point::new(p.x, p.y, 0.0));
327 }
328 let fit = ogeom_geom::fit::fit_points(&points, 3, tol.confusion() * 100.0, tol)?;
329 if !fit.met {
330 ogeom_bail!(
331 Construction,
332 "the projected curve could not be fitted within {}; its best fit misses by {}",
333 tol.confusion() * 100.0,
334 fit.error
335 );
336 }
337 let flat = BSplineCurve::new(
338 fit.curve.knots().clone(),
339 fit.curve
340 .control_points()
341 .iter()
342 .map(|c| c.scaled)
343 .collect(),
344 tol,
345 )?;
346 let onto_xy = Onto {
347 origin: Point::ORIGIN,
348 x: Vector::X,
349 y: Vector::Y,
350 };
351 Ok(ProjectedCurve::BSpline {
352 curve: projected_spline(&flat, &onto_xy, tol)?,
353 fit_error: Some(fit.error),
354 })
355}