ogeom_algo/pcurve_fit.rs
1//! Pcurves fitted by projection, for faces whose trims have no closed form.
2//!
3//! An exchange file's edge must end up with a curve in each bounding face's
4//! parameters, or the face cannot be split or triangulated. Where the
5//! curve/surface pair has a closed form the exact projection is used; where
6//! it does not (a spline surface, mostly), the pcurve is *fitted at the
7//! curve's own parameters*: sample the edge, project each sample into the
8//! chart, fit the trace with the parameters held fixed, so the same-parameter
9//! law holds by construction. This honours the standing decision that an
10//! exact curve never carries a fitted pcurve silently: the fit's error is
11//! returned, and the callers widen tolerances and warn with it. That error
12//! is reported as a *length*, in the model's own units: the fitted pcurve is
13//! walked through the surface and compared against the trace it was fitted
14//! to. A chart's units are whatever the file chose, and no single scale
15//! converts them: a patch can span four microns across its `u` and ten
16//! millimetres along its `v`.
17
18use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19use ogeom_geom::Curve3d as _;
20use ogeom_geom::Surface as _;
21use ogeom_geom::{Curve, PlanarCurve, SurfaceGeometry};
22use ogeom_math::Point;
23
24/// The fitted pcurve, its fit error as a length, whether the target was met,
25/// the worst distance any sample sat from the surface, and the slop warning
26/// to record when that distance is large enough to say out loud.
27pub type FittedPcurve = OgeomResult<(PlanarCurve, f64, bool, f64, Option<String>)>;
28
29pub(crate) fn chart_of(surface: &SurfaceGeometry, p: Point) -> Option<ogeom_math::Point2> {
30 let tau = core::f64::consts::TAU;
31 match surface {
32 SurfaceGeometry::Plane(s) => {
33 let l = s.plane().frame().to_local(p);
34 Some(ogeom_math::Point2::new(l.x, l.y))
35 }
36 SurfaceGeometry::Cylinder(s) => {
37 let l = s.cylinder().frame().to_local(p);
38 Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), l.z))
39 }
40 SurfaceGeometry::Cone(s) => {
41 let l = s.cone().frame().to_local(p);
42 Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), l.z))
43 }
44 SurfaceGeometry::Sphere(s) => {
45 let sphere = s.sphere();
46 let l = sphere.frame().to_local(p);
47 let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
48 Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), lat))
49 }
50 SurfaceGeometry::Torus(s) => {
51 let torus = s.torus();
52 let l = torus.frame().to_local(p);
53 let u = l.y.atan2(l.x).rem_euclid(tau);
54 let radial = l.x.hypot(l.y) - torus.major_radius();
55 let v = l.z.atan2(radial).rem_euclid(tau);
56 Some(ogeom_math::Point2::new(u, v))
57 }
58 _ => None,
59 }
60}
61
62/// Where a point of the curve lands on the surface: its chart position and
63/// how far off the surface it sat.
64///
65/// Analytic surfaces invert in closed form: grid seeding over a plane's
66/// or cylinder's enormous stated extents lands microns off, and a fitted
67/// pcurve inherits every micron. On a patch, where the previous sample
68/// landed is a far better starting guess than any grid: consecutive
69/// samples of a curve are neighbouring points of the surface. Trusted only
70/// when it lands convincingly *on* the surface (the same bar the denser
71/// reseed is judged against), so a guess that wandered into the wrong
72/// basin, or a first sample with no predecessor, still pays for the grid.
73fn land(
74 surface: &SurfaceGeometry,
75 p: Point,
76 seed: Option<ogeom_math::Point2>,
77 tol: Tolerances,
78) -> OgeomResult<(ogeom_math::Point2, f64)> {
79 if let Some(uv) = chart_of(surface, p) {
80 let lifted = surface.point_at(uv.x, uv.y, tol)?;
81 return Ok((uv, p.distance(lifted)));
82 }
83 let near = seed.and_then(|luv| {
84 crate::measure::project_on_surface_from(surface, p, (luv.x, luv.y), tol).ok()
85 });
86 if let Some(close) = near.filter(|f| f.distance <= tol.confusion() * 1e5) {
87 return Ok((
88 ogeom_math::Point2::new(close.parameters.0, close.parameters.1),
89 close.distance,
90 ));
91 }
92 let mut projection = crate::measure::project_on_surface(surface, p, 24, tol)?;
93 if projection.distance > tol.confusion() * 1e5 {
94 // A miss this large on a spline surface is more often a projection
95 // stuck in the wrong basin than real slop; seed denser before
96 // believing it.
97 let denser = crate::measure::project_on_surface(surface, p, 96, tol)?;
98 if denser.distance < projection.distance {
99 projection = denser;
100 }
101 }
102 Ok((
103 ogeom_math::Point2::new(projection.parameters.0, projection.parameters.1),
104 projection.distance,
105 ))
106}
107
108/// A sample of the curve landed on the surface: the parameter, the point,
109/// where it landed in the chart, and how far off the surface it sat.
110type Landed = (f64, Point, ogeom_math::Point2, f64);
111
112/// Fit a pcurve by projection at the reader's own line: slop under a
113/// millimetre is a file's error, honestly carried; anything past it is a
114/// wrong pairing and refuses. The exchange readers call this; a healer
115/// acting on instruction calls [`fit_projected_pcurve_capped`] with the
116/// cap its caller chose.
117///
118/// # Errors
119///
120/// As [`fit_projected_pcurve_capped`], at the millimetre cap.
121pub fn fit_projected_pcurve(
122 curve: &Curve,
123 range: (f64, f64),
124 surface: &SurfaceGeometry,
125 tol: Tolerances,
126) -> FittedPcurve {
127 fit_projected_pcurve_capped(curve, range, surface, tol.confusion() * 1e7, tol)
128}
129
130/// As the reader's projected-pcurve fit, with the acceptance cap in the
131/// caller's hands.
132///
133/// The reader draws its line at a millimetre (below it is a file's own
134/// slop, above it a wrong pairing), but a *healer* acts on instruction, and
135/// the instruction carries the cap. Returns the fitted pcurve, the fit's
136/// reached error as a length, whether it met its target, the worst measured
137/// edge-to-surface offset, and the slop note when that offset is worth
138/// saying out loud.
139///
140/// # Errors
141///
142/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
143/// sample sits farther than `cap` from the surface, or the projection
144/// cannot converge at all.
145pub fn fit_projected_pcurve_capped(
146 curve: &Curve,
147 range: (f64, f64),
148 surface: &SurfaceGeometry,
149 cap: f64,
150 tol: Tolerances,
151) -> FittedPcurve {
152 const SAMPLES: usize = 96;
153 let mut parameters = Vec::with_capacity(SAMPLES + 1);
154 let mut trace = Vec::with_capacity(SAMPLES + 1);
155 let mut points = Vec::with_capacity(SAMPLES + 1);
156 let mut offs = Vec::with_capacity(SAMPLES + 1);
157 let mut previous: Option<(Point, ogeom_math::Point2)> = None;
158 for i in 0..=SAMPLES {
159 #[allow(clippy::cast_precision_loss)]
160 let t = range.0 + (range.1 - range.0) * i as f64 / SAMPLES as f64;
161 let p = curve.point_at(t, tol)?;
162 let (uv, off) = land(surface, p, previous.map(|(_, luv)| luv), tol)?;
163 previous = Some((p, uv));
164 parameters.push(t);
165 trace.push(uv);
166 points.push(p);
167 offs.push(off);
168 }
169 retry_stalled(surface, &points, &mut trace, &mut offs, tol);
170 // The cap separates a file's own slop from an edge paired with the
171 // wrong surface. Slop is routinely a micron or two, but real community
172 // exports carry as much as 0.34 mm, while a wrong pairing
173 // misses by the distance between two different surfaces of the body,
174 // whole millimetres. One millimetre stands between the worst slop
175 // observed and the smallest wrong pairing plausible. Slop inside the
176 // cap is accepted and *recorded*: the edge's tolerance is widened to
177 // cover it, so the model says what it knows instead of refusing to
178 // triangulate. Judged after the retry, so a projection that stalled
179 // cannot refuse an edge that is actually on its surface.
180 let worst_off = offs.iter().copied().fold(0.0_f64, f64::max);
181 if worst_off > cap {
182 ogeom_bail!(
183 Construction,
184 "the edge sits {worst_off:.2e} from the surface it should bound"
185 );
186 }
187 let mut space_run = 0.0;
188 let mut parameter_run = 0.0;
189 for i in 1..trace.len() {
190 space_run += points[i].distance(points[i - 1]);
191 parameter_run += trace[i].distance(trace[i - 1]);
192 }
193 // A trace on a periodic chart may cross the seam mid-edge; unwrap it
194 // pointwise so the fit sees a continuous curve. Closure, not
195 // periodicity, is the right test: a skinned loft's wall is a clamped
196 // B-spline that closes on itself without being periodic, and its
197 // projections near the joining column land in either copy: both
198 // answers are right pointwise, and only continuity chooses. This is
199 // the docs/PLAN.md F5 case, and it is decided here for both exchange
200 // readers at once.
201 let ((ua, ub), (va, vb)) = surface.domain();
202 let spans = (
203 if surface.is_periodic_u() || surface.is_closed_u(tol) {
204 ub - ua
205 } else {
206 0.0
207 },
208 if surface.is_periodic_v() || surface.is_closed_v(tol) {
209 vb - va
210 } else {
211 0.0
212 },
213 );
214 for i in 1..trace.len() {
215 if spans.0 > 0.0 {
216 while trace[i].x - trace[i - 1].x > spans.0 * 0.5 {
217 trace[i].x -= spans.0;
218 }
219 while trace[i].x - trace[i - 1].x < -spans.0 * 0.5 {
220 trace[i].x += spans.0;
221 }
222 }
223 if spans.1 > 0.0 {
224 while trace[i].y - trace[i - 1].y > spans.1 * 0.5 {
225 trace[i].y -= spans.1;
226 }
227 while trace[i].y - trace[i - 1].y < -spans.1 * 0.5 {
228 trace[i].y += spans.1;
229 }
230 }
231 }
232 slide_into_chart(&mut trace, spans, ((ua, ub), (va, vb)));
233 // Where the chart collapses (a sphere's pole, a cone's apex), the
234 // u of a sample is atan2 of noise: the point determines no angle.
235 // The *arc* does: a smooth curve through the pole approaches it at
236 // a definite chart angle, which is the limit of its well-conditioned
237 // neighbours. Samples whose u-direction has collapsed relative to
238 // their v-direction are repaired by interpolating u between the
239 // nearest sound samples, extrapolating at the ends.
240 //
241 // Weak is measured in millimetres, not against `dv`. A ratio calls a
242 // direction weak whenever the *other* one is strong, and a patch whose
243 // `v` is parameterised a thousand times more densely than its `u`
244 // (three millimetres over twelve thousandths of a unit, beside a unit of
245 // `u` for a little over half a millimetre) had every sample of every
246 // edge called weak, its `u` held at one value, and two edges half a
247 // millimetre long fitted as a single point. What makes a direction
248 // degenerate is that crossing the whole of it moves the point less than
249 // a micron; that question has an answer in length, and only in length.
250 let (u_span, _) = {
251 let ((ua, ub), (va, vb)) = surface.domain();
252 (ub - ua, vb - va)
253 };
254 let weak: Vec<bool> = trace
255 .iter()
256 .map(|uv| {
257 surface
258 .d1_at(uv.x, uv.y, tol)
259 .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
260 })
261 .collect();
262 if weak.iter().all(|w| *w) && !weak.is_empty() {
263 // Not a row that collapses but a whole patch that does: a sliver
264 // four microns wide and a tenth of a millimetre long, where `u` is
265 // noise everywhere and the projector answers 0 at one sample and 1
266 // at the next. A fit through that swings across the chart and its
267 // controls are dragged back by hundreds of units. Any `u` describes
268 // the same points to within the sliver's own width, so they all
269 // take one: the middle of what the projections claimed, which is
270 // the least arbitrary of the arbitrary answers.
271 let mut claimed: Vec<f64> = trace.iter().map(|uv| uv.x).collect();
272 claimed.sort_by(f64::total_cmp);
273 let held = claimed[claimed.len() / 2];
274 for uv in &mut trace {
275 uv.x = held;
276 }
277 } else if weak.iter().any(|w| *w) && weak.iter().filter(|w| !**w).count() >= 2 {
278 let strong: Vec<usize> = (0..trace.len()).filter(|&i| !weak[i]).collect();
279 let u_span = if surface.is_periodic_u() {
280 ua.max(ub) - ua.min(ub)
281 } else {
282 f64::INFINITY
283 };
284 for i in 0..trace.len() {
285 if !weak[i] {
286 continue;
287 }
288 let after = strong.iter().position(|&s| s > i);
289 let (a, b) = match after {
290 Some(0) => (strong[0], strong[1]),
291 Some(k) => (strong[k - 1], strong[k]),
292 None => (strong[strong.len() - 2], strong[strong.len() - 1]),
293 };
294 // A curve *through* the pole genuinely jumps its angle
295 // there; only a run whose sound neighbours agree is noise
296 // to smooth over.
297 if a < i && i < b && (trace[b].x - trace[a].x).abs() > u_span * 0.25 {
298 continue;
299 }
300 let (ta, tb) = (parameters[a], parameters[b]);
301 let f = if (tb - ta).abs() <= f64::MIN_POSITIVE {
302 0.0
303 } else {
304 (parameters[i] - ta) / (tb - ta)
305 };
306 trace[i].x = trace[a].x + (trace[b].x - trace[a].x) * f;
307 }
308 }
309
310 // The tolerance carried into the chart through the trace's own
311 // metric: the honest cheap version, refined by the fit's report.
312 let scale = if space_run > tol.confusion() {
313 parameter_run / space_run
314 } else {
315 1.0
316 };
317 let target = (tol.confusion() * 1e2 * scale).max(f64::MIN_POSITIVE);
318 let fit_and_clamp = |parameters: &[f64],
319 trace: &[ogeom_math::Point2]|
320 -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>> {
321 let fitted = ogeom_geom::fit::fit_points_2d_at(parameters, trace, 3, target, tol)?;
322 // A least-squares fit wiggles past its samples at the ends, and a
323 // surface with a *tight* stated window (an imported patch, not a
324 // reader-built analytic with its enormous extents) refuses
325 // evaluation a hair outside it. The control points clamp into the
326 // window on the non-periodic axes: the curve lives in its controls'
327 // hull, so the clamp is a guarantee. Whatever the clamp cost is not
328 // hidden either: it is the clamped curve that is measured below.
329 // Periodic axes stay free: an unwrapped trace crosses the seam on
330 // purpose.
331 let ((wa, wb), (va2, vb2)) = surface.domain();
332 let clamp_u = !(surface.is_periodic_u() || surface.is_closed_u(tol));
333 let clamp_v = !(surface.is_periodic_v() || surface.is_closed_v(tol));
334 if !(clamp_u || clamp_v) {
335 return Ok(fitted);
336 }
337 let mut moved = 0.0_f64;
338 let knots = fitted.curve.knots().clone();
339 let control: Vec<ogeom_math::Point2> = fitted
340 .curve
341 .control_points()
342 .iter()
343 .map(|w| {
344 let p = w.point();
345 let q = ogeom_math::Point2::new(
346 if clamp_u { p.x.clamp(wa, wb) } else { p.x },
347 if clamp_v { p.y.clamp(va2, vb2) } else { p.y },
348 );
349 moved = moved.max(p.distance(q));
350 q
351 })
352 .collect();
353 if moved > 0.0 {
354 Ok(ogeom_geom::fit::Fitted {
355 curve: ogeom_geom::BSpline2d::new(knots, control, tol)?,
356 ..fitted
357 })
358 } else {
359 Ok(fitted)
360 }
361 };
362 // What the caller is told, as a length. The fitter reports its error
363 // in *chart* units, and a chart's units are whatever the file chose: one
364 // patch met in the wild spans four microns across its `u` and ten
365 // millimetres along its `v`, so no single scale converts the one number
366 // into the other: a control point dragged back into that chart by the
367 // clamp read as seven hundred millimetres of mesh error, on a face a
368 // tenth of a millimetre across. So the fitted curve is walked instead,
369 // through the surface, against the trace it was fitted to: that
370 // difference is the fit's own and is measured where the mesh will be.
371 //
372 // Measured between the samples as well as at them. The samples are
373 // spaced evenly in the curve's parameter, and a curve is free to run
374 // sixteen times faster at one end than the other: a blade's root
375 // meeting a hub turns through most of its bend inside the first
376 // interval, and a cubic held only at the interval's ends hooked four
377 // tenths of a millimetre past the curve there, off the face and across
378 // its neighbouring ring, while every sample sat within a hundredth. A
379 // midpoint the fit leaves is projected and joins the samples, and the
380 // fit is asked again, a few rounds at most.
381 let closed_form = points
382 .first()
383 .is_some_and(|p| chart_of(surface, *p).is_some());
384 let deviation = |fitted: &ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>,
385 parameters: &[f64],
386 trace: &[ogeom_math::Point2],
387 offs: &[f64]|
388 -> (f64, f64, Vec<Landed>) {
389 let mut error = 0.0_f64;
390 let mut between_all = 0.0_f64;
391 let mut more = Vec::new();
392 let mut landed_middles = Vec::new();
393 let mut left = false;
394 // A landing is believed only where it belongs: on the surface as
395 // convincingly as its neighbours, and inside the chart interval
396 // they span, widened by the interval itself. A projection that
397 // settled in another basin, or on the far side of a seam, would
398 // otherwise be fitted as if the curve went there. Beside its
399 // neighbour on a periodic chart, as the trace was unwrapped; where
400 // the chart collapses its angle is noise, and the neighbours' is
401 // taken.
402 let landing = |index: usize, tm: f64| -> Option<Landed> {
403 let before = trace[index - 1];
404 let after = trace[index];
405 let p = curve.point_at(tm, tol).ok()?;
406 let (mut uv, off) = land(surface, p, Some(before), tol).ok()?;
407 if spans.0 > 0.0 {
408 while uv.x - before.x > spans.0 * 0.5 {
409 uv.x -= spans.0;
410 }
411 while uv.x - before.x < -spans.0 * 0.5 {
412 uv.x += spans.0;
413 }
414 }
415 if spans.1 > 0.0 {
416 while uv.y - before.y > spans.1 * 0.5 {
417 uv.y -= spans.1;
418 }
419 while uv.y - before.y < -spans.1 * 0.5 {
420 uv.y += spans.1;
421 }
422 }
423 if surface
424 .d1_at(uv.x, uv.y, tol)
425 .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
426 {
427 uv.x = 0.5 * (before.x + after.x);
428 }
429 let reach = before.distance(after).max(f64::EPSILON);
430 let mid =
431 ogeom_math::Point2::new(0.5 * (before.x + after.x), 0.5 * (before.y + after.y));
432 let sound = offs[index - 1].max(offs[index]).max(tol.confusion() * 1e5);
433 (off <= 2.0 * sound && mid.distance(uv) <= reach).then_some((tm, p, uv, off))
434 };
435 for (index, t) in parameters.iter().enumerate() {
436 let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, *t, tol) else {
437 continue;
438 };
439 let (Ok(fitted_at), Ok(traced_at)) = (
440 surface.point_at(at.x, at.y, tol),
441 surface.point_at(trace[index].x, trace[index].y, tol),
442 ) else {
443 continue;
444 };
445 error = error.max(fitted_at.distance(traced_at));
446 if index == 0 {
447 continue;
448 }
449 // Probed at the quarters as well as the middle where the chart
450 // inverts in closed form, which costs nothing: a hook sits
451 // where the curve turns, wherever in the interval that is. On a
452 // patch every landing is a projection, and the middle alone is
453 // asked: a hook is the cubic's own excursion, broad across the
454 // interval, and once the fit is asked again at twice the
455 // samples the quarters of this round are the middles of the
456 // next.
457 let (ta, tb) = (parameters[index - 1], *t);
458 let tm = 0.5 * (ta + tb);
459 let quarters = [0.5 * (ta + tm), tm, 0.5 * (tm + tb)];
460 let probes: &[f64] = if closed_form {
461 &quarters
462 } else {
463 &quarters[1..2]
464 };
465 for &probe in probes {
466 let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, probe, tol) else {
467 continue;
468 };
469 let Ok(fitted_at) = surface.point_at(at.x, at.y, tol) else {
470 continue;
471 };
472 if !closed_form {
473 // Against the curve's own point first, which costs no
474 // projection: the curve sits about as far off the
475 // surface here as at the samples either side, so a
476 // fitted point within the bar and that slop of it is
477 // within the bar of where the curve lands. A hook worth
478 // the name is hundreds of times the slop; only a probe
479 // that misses by more than the slop is projected.
480 let Ok(p) = curve.point_at(probe, tol) else {
481 continue;
482 };
483 let slop = offs[index - 1].max(offs[index]);
484 let rough = fitted_at.distance(p);
485 if rough <= tol.confusion() * 1e4 + slop {
486 between_all = between_all.max((rough - slop).max(0.0));
487 continue;
488 }
489 }
490 let Some(landed) = landing(index, probe) else {
491 continue;
492 };
493 let Ok(traced_at) = surface.point_at(landed.2.x, landed.2.y, tol) else {
494 continue;
495 };
496 let between = fitted_at.distance(traced_at);
497 between_all = between_all.max(between);
498 if between > tol.confusion() * 1e4 {
499 left = true;
500 }
501 if probe == tm {
502 landed_middles.push(landed);
503 }
504 }
505 }
506 // The fit is asked again at twice the samples everywhere, the
507 // middle of every interval joining them, landed now where the
508 // rough test spared it the projection.
509 if left {
510 more = landed_middles;
511 if !closed_form {
512 more = (1..parameters.len())
513 .filter_map(|index| {
514 landing(index, 0.5 * (parameters[index - 1] + parameters[index]))
515 })
516 .collect();
517 }
518 }
519 (error, between_all, more)
520 };
521 // A micron between samples is the bar, a tenth of the finest chord a
522 // mesh is asked for: the hook was four hundred times that. Every fit
523 // pays one pass of probes; only the few that leave the curve pay a
524 // refit, at twice the samples everywhere (a handful of new samples
525 // in one interval draw the fitter's knots to themselves and the curve
526 // wobbles on either side, while an even doubling keeps it steady), and
527 // a curve the fit cannot follow, a corner inside the edge, stops at a
528 // few hundred samples rather than doubling for ever.
529 const DENSIFY: usize = 6;
530 const MOST: usize = 512;
531 let mut fitted = fit_and_clamp(¶meters, &trace)?;
532 let mut best: Option<(ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>, f64, f64)> = None;
533 let mut round = 0;
534 loop {
535 let (at_samples, between, more) = deviation(&fitted, ¶meters, &trace, &offs);
536 // The best round stands, whichever it was: a refit is not obliged
537 // to improve, and the fit handed on is the one measured closest.
538 if best
539 .as_ref()
540 .is_none_or(|(_, a, b)| at_samples.max(between) < a.max(*b))
541 {
542 best = Some((fitted.clone(), at_samples, between));
543 }
544 if more.is_empty() || round == DENSIFY || parameters.len() >= MOST {
545 break;
546 }
547 for (tm, p, uv, off) in more {
548 let k = parameters.partition_point(|&t| t < tm);
549 parameters.insert(k, tm);
550 trace.insert(k, uv);
551 points.insert(k, p);
552 offs.insert(k, off);
553 }
554 fitted = fit_and_clamp(¶meters, &trace)?;
555 round += 1;
556 }
557 let (fitted, at_samples, between) = best.unwrap_or((fitted, f64::INFINITY, f64::INFINITY));
558 let worst_off = offs.iter().copied().fold(worst_off, f64::max);
559 // Each miss against its own bar: the samples hold the fit to a hair,
560 // and the probes between them to the micron that sent them back.
561 let error = at_samples.max(between);
562 let met = at_samples <= tol.confusion() * 1e2 && between <= tol.confusion() * 1e4;
563 let slop = (worst_off > tol.confusion() * 1e3).then(|| {
564 format!(
565 "an edge sits up to {worst_off:.2e} from the surface it \
566 bounds; the file's own slop, carried into the chart"
567 )
568 });
569 Ok((fitted.curve.into(), error, met, worst_off, slop))
570}
571
572/// Re-project the samples a stalled projection left behind.
573///
574/// Where a chart collapses (a spline patch whose whole `v = 0` row is a
575/// single point), the projector has no direction to move in, and it answers
576/// with the pole's own parameters and the distance to it. Four consecutive
577/// samples of one imported edge came back pinned to such a row, the last of
578/// them a tenth of a millimetre out; the reader repeated that as the file's
579/// own boundary slop, widened the edge to cover it, and the fitter tried to
580/// draw a curve through it. A sample that landed badly is retried from a
581/// neighbour that landed well, the same seeding the forward walk already
582/// trusts, run in both directions so a run of them unwinds from whichever
583/// end is sound. The retry is kept only when it lands closer, so it can
584/// never make an honest projection worse: a file's real slop is left alone.
585fn retry_stalled(
586 surface: &SurfaceGeometry,
587 points: &[Point],
588 trace: &mut [ogeom_math::Point2],
589 offs: &mut [f64],
590 tol: Tolerances,
591) {
592 let sound = tol.confusion() * 1e5;
593 if offs.iter().all(|off| *off <= sound) {
594 return;
595 }
596 for backwards in [true, false] {
597 let order: Vec<usize> = if backwards {
598 (0..offs.len()).rev().collect()
599 } else {
600 (0..offs.len()).collect()
601 };
602 for i in order {
603 if offs[i] <= sound {
604 continue;
605 }
606 let Some(j) = (if backwards {
607 i.checked_add(1)
608 } else {
609 i.checked_sub(1)
610 }) else {
611 continue;
612 };
613 if offs.get(j).is_none_or(|off| *off > sound) {
614 continue;
615 }
616 let seed = (trace[j].x, trace[j].y);
617 if let Ok(found) =
618 crate::measure::project_on_surface_from(surface, points[i], seed, tol)
619 && found.distance < offs[i]
620 {
621 trace[i] = ogeom_math::Point2::new(found.parameters.0, found.parameters.1);
622 offs[i] = found.distance;
623 }
624 }
625 }
626}
627
628/// Slide a trace back into its chart by whole turns.
629///
630/// Unwrapped for continuity, a trace can end up a whole turn outside the
631/// chart it belongs to: a projection that starts near one edge of a closed
632/// chart and walks off it keeps walking, and the surface then refuses to be
633/// evaluated where its own trim lies: an imported face whose
634/// fitted v ran to −2.5π on a chart that stops at −π, and drew as a hole.
635///
636/// A rigid shift keeps the trace exactly as continuous as the unwrap left
637/// it and can only move it inward. One that genuinely spans more than a
638/// turn has nowhere to go and is left alone; one that fits nowhere whole
639/// takes the turn that centres it, which is the nearest thing to inside
640/// there is.
641fn slide_into_chart(
642 trace: &mut [ogeom_math::Point2],
643 spans: (f64, f64),
644 domain: ((f64, f64), (f64, f64)),
645) {
646 let ((ua, ub), (va, vb)) = domain;
647 for (across, span, lo, hi) in [(true, spans.0, ua, ub), (false, spans.1, va, vb)] {
648 if span <= 0.0 {
649 continue;
650 }
651 let read = |uv: &ogeom_math::Point2| if across { uv.x } else { uv.y };
652 let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
653 for uv in trace.iter() {
654 least = least.min(read(uv));
655 most = most.max(read(uv));
656 }
657 if !(least.is_finite() && most.is_finite()) || most - least > span {
658 continue;
659 }
660 let turns = {
661 let up = ((lo - least) / span).ceil();
662 let down = ((hi - most) / span).floor();
663 if up <= down {
664 // Somewhere it fits whole; the nearest such turn.
665 up.max(down.min(0.0))
666 } else {
667 (f64::midpoint(lo, hi) - f64::midpoint(least, most)) / span
668 }
669 };
670 let turns = if turns.is_finite() {
671 turns.round()
672 } else {
673 0.0
674 };
675 if turns == 0.0 {
676 continue;
677 }
678 for uv in trace.iter_mut() {
679 if across {
680 uv.x += turns * span;
681 } else {
682 uv.y += turns * span;
683 }
684 }
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 #![allow(clippy::unwrap_used, reason = "test code")]
691 use ogeom_math::Point2;
692
693 /// A trace unwrapped clean off its chart is slid back by whole turns.
694 ///
695 /// A real assembly has a face whose fitted `v` ran from −2.5π to
696 /// −π on a chart that stops at −π: continuous, outside, and the surface
697 /// refuses to be asked about it, so the face drew as a hole.
698 #[test]
699 fn a_trace_that_walked_off_its_chart_is_slid_back() {
700 let pi = core::f64::consts::PI;
701 let chart = ((0.0, 1.0), (-pi, pi));
702 let turn = (0.0, 2.0 * pi);
703
704 // A whole turn below: slid up, and as continuous as it was.
705 let mut trace = vec![
706 Point2::new(0.5, -2.5 * pi),
707 Point2::new(0.5, -2.0 * pi),
708 Point2::new(0.5, -1.5 * pi),
709 ];
710 super::slide_into_chart(&mut trace, turn, chart);
711 assert!(
712 trace.iter().all(|at| at.y >= -pi && at.y <= pi),
713 "slid into the chart: {trace:?}"
714 );
715 for pair in trace.windows(2) {
716 assert!(
717 (pair[1].y - pair[0].y - 0.5 * pi).abs() < 1e-12,
718 "and rigidly"
719 );
720 }
721
722 // Already inside: untouched.
723 let mut held = vec![Point2::new(0.5, -1.0), Point2::new(0.5, 1.0)];
724 let was = held.clone();
725 super::slide_into_chart(&mut held, turn, chart);
726 assert_eq!(held, was);
727
728 // Wider than a turn: nowhere to slide to, and left alone.
729 let mut wide = vec![Point2::new(0.5, -4.0 * pi), Point2::new(0.5, 0.0)];
730 let was = wide.clone();
731 super::slide_into_chart(&mut wide, turn, chart);
732 assert_eq!(wide, was);
733
734 // An axis that does not close is not slid on at all.
735 let mut across = vec![Point2::new(9.0, 0.0), Point2::new(9.5, 0.0)];
736 let was = across.clone();
737 super::slide_into_chart(&mut across, (0.0, 2.0 * pi), chart);
738 assert_eq!(across, was);
739 }
740
741 use super::*;
742 use ogeom_core::Tolerances;
743 use ogeom_geom::{CircleCurve, CylinderSurface};
744 use ogeom_math::{Circle, Cylinder, Frame};
745
746 const T: Tolerances = Tolerances::millimetres();
747
748 /// A sample the projector left at a pole is retried from its neighbour.
749 ///
750 /// The patch is `S(u, v) = v·C(u)`: its whole `v = 0` row is the origin,
751 /// so a projection that reaches the pole has no direction left to move
752 /// in and stops there, however far off it is. Seeding from the pole is
753 /// shown stuck first (that is the trap the forward walk falls into,
754 /// once per imported edge that starts on such a row), and the retry from a
755 /// sound neighbour is shown to get out of it.
756 #[test]
757 fn a_sample_stalled_at_a_pole_is_retried_from_its_neighbour() {
758 use ogeom_geom::{BSplineSurface, Surface as _};
759 use ogeom_math::{ControlGrid, KnotVector};
760
761 let mut control = Vec::new();
762 for i in 0..4 {
763 let across = -1.0 + 2.0 * f64::from(i) / 3.0;
764 for j in 0..4 {
765 let out = f64::from(j) / 3.0;
766 control.push(Point::new(out, across * out, (0.5 + across * across) * out));
767 }
768 }
769 let grid = ControlGrid::new(control, 4, 4).unwrap();
770 let cone: SurfaceGeometry = BSplineSurface::new(
771 KnotVector::clamped_uniform(3, 4).unwrap(),
772 KnotVector::clamped_uniform(3, 4).unwrap(),
773 &grid,
774 T,
775 )
776 .unwrap()
777 .into();
778 assert!(
779 cone.d1_at(0.5, 0.0, T).unwrap().0.magnitude() < 1e-12,
780 "the v = 0 row is a pole"
781 );
782
783 let at = cone.point_at(1.0, 0.2, T).unwrap();
784 let stuck = crate::measure::project_on_surface_from(&cone, at, (0.0, 0.0), T).unwrap();
785 assert!(
786 stuck.distance > 0.1,
787 "a projection seeded at the pole stays there: {stuck:?}"
788 );
789
790 let mut trace = vec![
791 ogeom_math::Point2::new(0.0, 0.0),
792 ogeom_math::Point2::new(0.0, 0.0),
793 ogeom_math::Point2::new(1.0, 0.5),
794 ];
795 let points = vec![
796 cone.point_at(1.0, 0.1, T).unwrap(),
797 at,
798 cone.point_at(1.0, 0.5, T).unwrap(),
799 ];
800 let mut offs = vec![points[0].distance(Point::ORIGIN), stuck.distance, 0.0];
801 retry_stalled(&cone, &points, &mut trace, &mut offs, T);
802 for (index, off) in offs.iter().enumerate() {
803 assert!(
804 *off < T.confusion(),
805 "sample {index} found its surface: {off:.3e}"
806 );
807 }
808 assert!(
809 (trace[1].x - 1.0).abs() < 1e-6 && (trace[1].y - 0.2).abs() < 1e-6,
810 "and its own parameters: {:?}",
811 trace[1]
812 );
813
814 // An honest miss is not a stall: nothing lands closer, so the
815 // file's own slop survives the retry untouched.
816 let adrift = Point::new(0.0, 0.0, -0.5);
817 let mut honest = vec![trace[2], ogeom_math::Point2::new(1.0, 0.5)];
818 let was = honest.clone();
819 let mut misses = vec![0.0, 0.5];
820 retry_stalled(&cone, &[points[2], adrift], &mut honest, &mut misses, T);
821 assert_eq!(honest[1], was[1]);
822 assert!((misses[1] - 0.5).abs() < 1e-12);
823 }
824
825 /// A direction is weak by what crossing it moves, not by its neighbour.
826 ///
827 /// The patch is a flat strip: `u` runs a millimetre across it and `v`
828 /// runs twenty millimetres along it over a parameter span of a hundredth,
829 /// two thousand times denser than `u`. Against `dv`, `du` looks weak at
830 /// every sample, and a ratio test held every `u` at one value: an edge
831 /// a millimetre long across the strip fitted as a single chart point.
832 /// Crossing the whole of `u` moves the point a millimetre, which is the
833 /// only thing "weak" can honestly mean, and it is not.
834 #[test]
835 fn a_direction_is_weak_by_what_crossing_it_moves() {
836 use ogeom_geom::{BSplineSurface, LineCurve};
837 use ogeom_math::{ControlGrid, KnotVector, Point};
838 let mut control = Vec::new();
839 for i in 0..2 {
840 for j in 0..2 {
841 control.push(Point::new(f64::from(i), 20.0 * f64::from(j), 0.0));
842 }
843 }
844 let strip: SurfaceGeometry = BSplineSurface::new(
845 KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
846 KnotVector::new(vec![0.0, 0.0, 0.01, 0.01], 1).unwrap(),
847 &ControlGrid::new(control, 2, 2).unwrap(),
848 T,
849 )
850 .unwrap()
851 .into();
852 // Across the strip at v = 0.005 (the middle in space, 10 mm along).
853 let across: Curve =
854 LineCurve::segment(Point::new(0.0, 10.0, 0.0), Point::new(1.0, 10.0, 0.0), T)
855 .unwrap()
856 .into();
857 let (pcurve, error, _, _, _) =
858 fit_projected_pcurve(&across, (0.0, 1.0), &strip, T).unwrap();
859 let a = ogeom_geom::Curve2d::point_at(&pcurve, 0.0, T).unwrap();
860 let b = ogeom_geom::Curve2d::point_at(&pcurve, 1.0, T).unwrap();
861 assert!(
862 (b.x - a.x).abs() > 0.99,
863 "the edge crosses the whole of u: {a:?} -> {b:?}"
864 );
865 assert!(error < 1e-6, "and fits: {error:.2e}");
866 }
867
868 /// A boundary 0.3 mm off its surface fits, and says so.
869 ///
870 /// Community exports carry boundary curves that far from the surfaces
871 /// they trim: 0.12–0.34 mm on a real assembly. The fit
872 /// accepts anything under a millimetre and reports the offset, so the
873 /// reader widens the edge's tolerance instead of leaving the face
874 /// without a trim; a miss of whole millimetres (the signature of an
875 /// edge paired with the wrong surface) still refuses.
876 #[test]
877 fn slop_under_a_millimetre_fits_and_is_reported() {
878 let wall: SurfaceGeometry =
879 CylinderSurface::new(Cylinder::new(Frame::WORLD, 10.0, T).unwrap(), (-50.0, 50.0))
880 .unwrap()
881 .into();
882 let rim = |radius: f64| -> Curve {
883 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
884 };
885 // 0.3 mm proud of the wall: every sample sits exactly that far off.
886 let (_, _, _, worst_off, warning) =
887 fit_projected_pcurve(&rim(10.3), (0.0, core::f64::consts::TAU), &wall, T).unwrap();
888 assert!(
889 (worst_off - 0.3).abs() < 1e-6,
890 "the offset is measured: {worst_off}"
891 );
892 assert!(warning.is_some(), "slop this large is worth a warning");
893 // 3 mm off is not slop; it is the wrong surface.
894 assert!(
895 fit_projected_pcurve(&rim(13.0), (0.0, core::f64::consts::TAU), &wall, T).is_err(),
896 "a miss of millimetres still refuses"
897 );
898 }
899
900 /// A fit is held between its samples, where a fast-running curve bends.
901 ///
902 /// The samples are spaced evenly in the curve's parameter, and this
903 /// curve spends a twentieth of its parameter on a steep drop of a
904 /// millimetre before running slowly round the drum for the rest: the
905 /// whole of the drop, and the bend at its foot, fall inside the first
906 /// sample interval. A cubic held only at the samples hooked past the
907 /// curve there; measured between the samples and refitted where it
908 /// leaves them, the pcurve follows the curve everywhere.
909 #[test]
910 fn a_fit_is_held_between_its_samples_where_the_curve_runs_fast() {
911 use ogeom_geom::{BSplineCurve, Curve, Curve2d, Curve3d, Surface, SurfaceGeometry};
912 use ogeom_math::{KnotVector, Point};
913 let radius = 10.0;
914 let on = |angle: f64, z: f64| Point::new(radius * angle.cos(), radius * angle.sin(), z);
915 let mut knots = vec![0.0, 0.0, 0.0, 0.0, 0.05];
916 knots.extend((1..8).map(f64::from));
917 knots.extend([8.0; 4]);
918 let control = vec![
919 on(0.12, 4.0),
920 on(0.13, 3.7),
921 on(0.14, 3.4),
922 on(0.15, 3.1),
923 on(0.10, 2.5),
924 on(0.0, 1.5),
925 on(-0.1, 0.5),
926 on(-0.2, -0.5),
927 on(-0.3, -1.5),
928 on(-0.35, -2.5),
929 on(-0.38, -3.3),
930 on(-0.4, -4.0),
931 ];
932 let curve: Curve = BSplineCurve::new(KnotVector::new(knots, 3).unwrap(), control, T)
933 .unwrap()
934 .into();
935 let drum: SurfaceGeometry =
936 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-5.0, 5.0))
937 .unwrap()
938 .into();
939 let (pcurve, error, _, _, _) = fit_projected_pcurve(&curve, (0.0, 8.0), &drum, T).unwrap();
940 let mut worst = 0.0_f64;
941 for i in 0..=2000 {
942 let t = 8.0 * f64::from(i) / 2000.0;
943 let p = curve.point_at(t, T).unwrap();
944 let uv = pcurve.point_at(t, T).unwrap();
945 let lifted = drum.point_at(uv.x, uv.y, T).unwrap();
946 // The pcurve's own miss: the curve's point, pulled onto the drum.
947 worst = worst.max(lifted.distance(on(p.y.atan2(p.x), p.z)));
948 }
949 assert!(
950 worst <= T.confusion() * 3e4,
951 "the pcurve leaves the curve by {worst:.3e} between the samples, \
952 {error:.3e} reported"
953 );
954 assert!(
955 error >= 0.5 * worst,
956 "and the miss between the samples is reported: {error:.3e} for {worst:.3e}"
957 );
958 }
959}