1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15use ogeom_geom::fit::Fitted;
16use ogeom_geom::{BSpline2d, BSplineCurve, Curve, Curve2d as _, Curve3d as _, PlanarCurve};
17use ogeom_math::{KnotVector, Point, Point2};
18
19const MOST: usize = ogeom_geom::fit::SAMPLED_SPANS;
22
23#[must_use]
27pub fn stations(curve: &Curve, range: (f64, f64), at_least: usize) -> Vec<f64> {
28 let (breaks, degree) = breaks_3d(curve);
29 spread(range, &breaks, degree, at_least)
30}
31
32#[must_use]
34pub fn stations_2d(curve: &PlanarCurve, range: (f64, f64), at_least: usize) -> Vec<f64> {
35 let (breaks, degree) = breaks_2d(curve);
36 spread(range, &breaks, degree, at_least)
37}
38
39#[must_use]
42pub fn stations_between(
43 range: (f64, f64),
44 breaks: &[f64],
45 degree: usize,
46 at_least: usize,
47) -> Vec<f64> {
48 spread(range, breaks, degree, at_least)
49}
50
51fn breaks_3d(curve: &Curve) -> (Vec<f64>, usize) {
53 match curve {
54 Curve::BSpline(spline) => (
55 spline
56 .knots()
57 .distinct()
58 .into_iter()
59 .map(|(value, _)| value)
60 .collect(),
61 spline.knots().degree(),
62 ),
63 Curve::Trimmed(trim) => {
64 let (lo, hi) = trim.domain();
65 let (inner, degree) = breaks_3d(trim.basis());
66 if trim.is_reversed() {
67 (inner.into_iter().map(|b| lo + hi - b).collect(), degree)
68 } else {
69 (inner, degree)
70 }
71 }
72 Curve::Offset(offset) => breaks_3d(offset.basis()),
73 Curve::OnSurface(on) => breaks_2d(on.pcurve()),
74 _ => (Vec::new(), 3),
75 }
76}
77
78fn breaks_2d(curve: &PlanarCurve) -> (Vec<f64>, usize) {
80 match curve {
81 PlanarCurve::BSpline(spline) => (
82 spline
83 .knots()
84 .distinct()
85 .into_iter()
86 .map(|(value, _)| value)
87 .collect(),
88 spline.knots().degree(),
89 ),
90 PlanarCurve::Trimmed(trim) => {
91 let (lo, hi) = trim.domain();
92 let (inner, degree) = breaks_2d(trim.basis());
93 if trim.is_reversed() {
94 (inner.into_iter().map(|b| lo + hi - b).collect(), degree)
95 } else {
96 (inner, degree)
97 }
98 }
99 PlanarCurve::Offset(offset) => breaks_2d(offset.basis()),
100 _ => (Vec::new(), 3),
101 }
102}
103
104fn spread(range: (f64, f64), breaks: &[f64], degree: usize, at_least: usize) -> Vec<f64> {
107 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
108 let uniform = |n: usize| -> Vec<f64> {
109 (0..=n)
110 .map(|i| {
111 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
112 let f = i as f64 / n as f64;
113 if i == n { hi } else { lo + (hi - lo) * f }
114 })
115 .collect()
116 };
117 let at_least = at_least.clamp(1, MOST);
118 if hi - lo <= 0.0 {
119 return vec![lo, hi];
120 }
121 let mut cuts = vec![lo];
122 cuts.extend(
123 breaks
124 .iter()
125 .copied()
126 .filter(|&b| b > lo + (hi - lo) * 1e-9 && b < hi - (hi - lo) * 1e-9),
127 );
128 cuts.push(hi);
129 let pieces = (degree + 1).max(2);
130 if (cuts.len() - 1) * pieces > MOST {
131 return uniform(MOST);
132 }
133 let mut out = Vec::with_capacity((cuts.len() - 1) * pieces + 1);
134 for pair in cuts.windows(2) {
135 for k in 0..pieces {
136 #[allow(clippy::cast_precision_loss, reason = "a small count")]
137 let f = k as f64 / pieces as f64;
138 out.push(pair[0] + (pair[1] - pair[0]) * f);
139 }
140 }
141 out.push(hi);
142 if out.len() > at_least {
143 return out;
144 }
145 let per = (at_least).div_ceil(out.len() - 1);
147 let mut finer = Vec::with_capacity((out.len() - 1) * per + 1);
148 for pair in out.windows(2) {
149 for k in 0..per {
150 #[allow(clippy::cast_precision_loss, reason = "a small count")]
151 let f = k as f64 / per as f64;
152 finer.push(pair[0] + (pair[1] - pair[0]) * f);
153 }
154 }
155 finer.push(hi);
156 finer
157}
158
159#[must_use]
162pub fn corners_2d(curve: &BSpline2d, range: (f64, f64)) -> Vec<f64> {
163 let knots = curve.knots();
164 let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
165 knots
166 .distinct()
167 .into_iter()
168 .filter(|&(value, mult)| mult >= knots.degree() && value > lo && value < hi)
169 .map(|(value, _)| value)
170 .collect()
171}
172
173pub fn joined(pieces: &[BSplineCurve], tol: Tolerances) -> OgeomResult<(BSplineCurve, f64)> {
185 let Some(first) = pieces.first() else {
186 ogeom_bail!(Construction, "nothing to join");
187 };
188 let degree = first.degree();
189 let mut knots: Vec<f64> = Vec::new();
190 let mut control: Vec<Point> = Vec::new();
191 let mut moved = 0.0_f64;
192 for (i, piece) in pieces.iter().enumerate() {
193 if piece.degree() != degree || piece.is_rational() {
194 ogeom_bail!(Construction, "only polynomial pieces of one degree join");
195 }
196 let own = piece.knots().knots();
197 let points: Vec<Point> = piece.control_points().iter().map(|c| c.point()).collect();
198 if own.len() < 2 * (degree + 1) || points.is_empty() {
199 ogeom_bail!(Construction, "a piece too short to join");
200 }
201 if i == 0 {
202 knots.extend_from_slice(&own[..own.len() - 1]);
203 control.extend(points);
204 continue;
205 }
206 let at = own[0];
207 let held = knots[knots.len() - 1];
208 if (at - held).abs() > tol.parametric().max(held.abs() * 1e-12) {
209 ogeom_bail!(Construction, "pieces to join must abut in parameter");
210 }
211 let last = control.len() - 1;
212 let gap = control[last].distance(points[0]);
213 moved = moved.max(gap * 0.5);
214 control[last] = control[last].midpoint(points[0]);
215 knots.extend_from_slice(&own[degree + 1..own.len() - 1]);
218 control.extend(points.into_iter().skip(1));
219 }
220 let end = pieces[pieces.len() - 1].knots().knots();
221 knots.push(end[end.len() - 1]);
222 let curve = BSplineCurve::new(KnotVector::new(knots, degree)?, control, tol)?;
223 Ok((curve, moved))
224}
225
226pub fn fit_traced(
239 trace: impl FnMut(f64) -> OgeomResult<Point>,
240 stations: &[f64],
241 degree: usize,
242 tolerance: f64,
243 tol: Tolerances,
244) -> OgeomResult<Fitted<BSplineCurve>> {
245 ogeom_geom::fit::fit_curve_sampled(trace, stations, false, degree, tolerance, tol)
246}
247
248pub fn fit_traced_2d(
255 trace: impl FnMut(f64) -> OgeomResult<Point2>,
256 stations: &[f64],
257 degree: usize,
258 tolerance: f64,
259 tol: Tolerances,
260) -> OgeomResult<Fitted<BSpline2d>> {
261 ogeom_geom::fit::fit_curve_2d_sampled(trace, stations, false, degree, tolerance, tol)
262}
263
264#[cfg(test)]
265mod tests {
266 #![allow(clippy::unwrap_used, reason = "test code")]
267 use super::*;
268
269 const T: Tolerances = Tolerances::millimetres();
270
271 #[test]
275 fn a_traced_fit_holds_between_its_samples() {
276 let trace =
277 |t: f64| -> OgeomResult<Point> { Ok(Point::new(t, 0.05 * (t * 173.0).sin(), 0.0)) };
278 let start: Vec<f64> = (0..=8).map(|i| f64::from(i) / 8.0).collect();
279 let fitted = fit_traced(trace, &start, 3, 1e-4, T).unwrap();
280 assert!(fitted.met, "{}", fitted.error);
281 let mut worst = 0.0_f64;
282 for i in 0..=5000 {
283 let t = f64::from(i) / 5000.0;
284 let p = fitted.curve.point_at(t, T).unwrap();
285 worst = worst.max(p.distance(trace(t).unwrap()));
286 }
287 assert!(worst <= 1e-4, "{worst}");
288 }
289
290 #[test]
292 fn stations_follow_the_spans() {
293 let control: Vec<Point> = (0..67)
294 .map(|i| Point::new(f64::from(i), f64::from(i % 2), 0.0))
295 .collect();
296 let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
297 let curve = Curve::BSpline(BSplineCurve::new(knots, control, T).unwrap());
298 let s = stations(&curve, (0.0, 1.0), 33);
299 assert_eq!(s.len(), 64 * 4 + 1);
300 assert!(s.windows(2).all(|w| w[1] > w[0]));
301 let few = stations(&curve, (0.25, 0.5), 33);
302 assert!(few.len() >= 34 && few[0] == 0.25 && few[few.len() - 1] == 0.5);
303 }
304}