geop_core_geometry/nurb_surface/fit_pcurve.rs
1use crate::{
2 nurb_curve::{NurbCurve, NurbCurve2D, true_point_fractions},
3 spline::interior_knots,
4};
5use geop_core_math::{
6 geop_error::{GeopError, GeopResult, WithContext},
7 scalars::Scalar,
8 solvers::refine::{Refine, Refined, refine},
9 vector::Vector2,
10};
11
12use super::NurbSurface;
13
14/// How many intervals a curve is split into for projection to start with,
15/// shared out among its smooth pieces by their share of its domain (but at
16/// least [`MIN_PIECE_SAMPLES`] each). Each piece's pcurve passes exactly
17/// through its samples and is widened to enclose the trace between them
18/// (see `NurbCurve2D::interpolate_enclosing`).
19///
20/// A cubic interpolant's drift falls as `h^4` on a smooth trace, and since
21/// the pcurve carries that drift as width, this decides how *wide* the
22/// pcurve is, not whether it is right. At 9 samples the drift reached ~2e-5
23/// on strongly curved patches; 48 keeps it well inside the accuracy
24/// `validation::numerical_accuracy` holds every entity to. Where it does
25/// not, a piece is sampled more densely (see [`DRIFT_PER_RESOLUTION`]).
26///
27/// This bounds effort, not correctness: each sample is one Newton foot-point
28/// projection (plus one per true point between samples, see
29/// `true_point_fractions`), and a pcurve fitted through more of them is
30/// strictly narrower.
31const SAMPLES: usize = 48;
32
33/// The fewest intervals a smooth piece of a curve is sampled in.
34const MIN_PIECE_SAMPLES: usize = 8;
35
36/// The most intervals a smooth piece of a curve is sampled in. A pcurve
37/// still drifting then has a trace that is not smooth where the curve's
38/// knots say nothing (a seam of the surface): it is returned as it is, wide
39/// and honest.
40const MAX_PIECE_SAMPLES: usize = 1536;
41
42/// A piece is sampled twice as densely while its pcurve is wider than
43/// `min_subdivision_size` over this (and than twice its widest sample, a
44/// width no sampling removes): a width that small is lost in any
45/// search resolving to that size, which is what every question later asked
46/// of the pcurve is. Like `min_subdivision_size` itself, it decides effort,
47/// not correctness: the pcurve encloses its trace however wide it is.
48const DRIFT_PER_RESOLUTION: i64 = 16;
49
50impl NurbSurface<4> {
51 /// The `(u, v)` trace of `curve` across this surface: sample the curve,
52 /// Newton-project each sample onto the surface (each projection seeded
53 /// from the previous one's result, so the walk stays continuous), and
54 /// fit a pcurve through the results.
55 ///
56 /// The pcurve is fitted piece by piece, the curve broken at each knot
57 /// where it is less than twice continuously differentiable — the joints
58 /// of the kernel's rational arcs and helices, which are only `C1` — and
59 /// the pieces joined. The fit is a cubic spline, `C2` at its own knots:
60 /// across a jump in the trace's curvature it cannot follow, and its
61 /// drift there shrinks only as `h^2` with the spacing of the samples. A
62 /// helix of pitch 1 fitted across its joints drifted past the accuracy
63 /// limit. Within a piece, the samples are doubled while the pcurve is
64 /// wider than the target (see [`DRIFT_PER_RESOLUTION`]).
65 ///
66 /// The walk's first seed has to be found globally, because Newton only
67 /// polishes a foot point it is already near: seeded from anywhere else
68 /// on a curved patch it settles on whichever local foot point is closest
69 /// — including one clamped against a domain bound, where the residual is
70 /// merely orthogonal to the boundary — and every later sample, seeded
71 /// from its predecessor, follows it there. So the start is `pin_start`
72 /// when there is one (the face's own authoritative `(u, v)`), and is
73 /// otherwise located by [`Self::locate`] (`max_nodes` /
74 /// `min_subdivision_size` bound its search). A curve that does not start on this surface is
75 /// an error: there is no trace of it to fit.
76 ///
77 /// `pin_start` / `pin_end` override the projected `(u, v)` of the first
78 /// and last sample. Pass them whenever the curve's endpoint is a place
79 /// this surface's face *already* has a coedge for: that coedge's own
80 /// pcurve endpoint is the authoritative `(u, v)` there, and an
81 /// independently re-projected one lands a hair away from it — enough to
82 /// break the exact `could_be_equal` continuity a face's boundary loop
83 /// requires between one coedge's pcurve end and the next one's start.
84 /// The endpoints are free to pin without disturbing the rest of the
85 /// curve because `interpolate` produces a clamped B-spline, which
86 /// passes exactly through each sample.
87 pub fn fit_pcurve(
88 &self,
89 curve: &NurbCurve<4>,
90 pin_start: Option<Vector2>,
91 pin_end: Option<Vector2>,
92 max_nodes: usize,
93 min_subdivision_size: Scalar,
94 ) -> GeopResult<NurbCurve2D> {
95 let ctx = |e: GeopError| {
96 e.with_context(format!(
97 "NurbSurface::fit_pcurve: curve={curve:?}, domain_u={:?}, domain_v={:?}",
98 self.domain_u(),
99 self.domain_v(),
100 ))
101 };
102
103 let (t0, t1) = curve.domain();
104 // Seed the first projection where the curve actually starts on this
105 // surface (see above); every later one seeds from its predecessor.
106 // Sharp, since a seed is a free choice.
107 let (seed_u, seed_v) = match pin_start {
108 Some(pin) => (pin[0], pin[1]),
109 None => {
110 let start = curve.evaluate(t0).with_context(&ctx)?;
111 self.locate(&start, max_nodes, min_subdivision_size)
112 .with_context(&ctx)?
113 .ok_or_else(|| {
114 ctx(GeopError::new(format!(
115 "the curve starts at {start:?}, which is not on this surface"
116 )))
117 })?
118 }
119 };
120 let mut seed = (seed_u.sharpen(), seed_v.sharpen());
121
122 // The foot point of the curve at `frac` of the way from `a` to `b`,
123 // projected from `seed`, which then moves on to it. Seeded from a
124 // sharp value (any point inside the previous iterate is an equally
125 // valid starting guess), but the projection's own enclosure is
126 // what's returned: these `(u, v)` end up in the fitted pcurve, which
127 // is later compared against the edge's 3D points, so narrowing them
128 // here would claim precision the projection did not have.
129 let project_at = |seed: &mut (Scalar, Scalar),
130 (a, b): (Scalar, Scalar),
131 frac: Scalar|
132 -> GeopResult<Vector2> {
133 let t = a.add(b.sub(a).mul(frac));
134 let p = curve.evaluate(t)?;
135 let (u, v) = self.project(p, seed.0, seed.1, NEWTON_ITERATIONS)?;
136 *seed = (u.sharpen(), v.sharpen());
137 Ok(Vector2::from_array([u, v]))
138 };
139
140 // Where the curve is less than `C2`: the ends of its domain, and
141 // every interior knot of multiplicity at least `degree − 1`.
142 let p = curve.degree;
143 let mut breaks = vec![t0];
144 breaks.extend(
145 interior_knots(&curve.knot_vector, p, curve.control_points.len())
146 .into_iter()
147 .filter(|&(_, m)| m + 1 >= p)
148 .map(|(k, _)| k),
149 );
150 breaks.push(t1);
151
152 let target = min_subdivision_size
153 .div(Scalar::from_i64(DRIFT_PER_RESOLUTION))
154 .with_context(&ctx)?
155 .upper();
156 let length = t1.sub(t0).to_f64();
157 let mut pieces = Vec::with_capacity(breaks.len() - 1);
158 // Where the next piece starts: the pin, then where the last one
159 // ended — the same point projected again would land a hair away.
160 let mut start = pin_start;
161 for (k, piece) in breaks.windows(2).enumerate() {
162 let (a, b) = (piece[0], piece[1]);
163 let end = if k + 2 == breaks.len() { pin_end } else { None };
164 let piece_seed = seed;
165 // How many samples is a free choice: the piece's share of 48.
166 let share = b.sub(a).to_f64() / length;
167 let first =
168 ((SAMPLES as f64 * share).ceil() as usize).clamp(MIN_PIECE_SAMPLES, SAMPLES);
169 // The cap ends it: `intervals` doubles each step, so it passes
170 // any cap within as many steps as it has bits.
171 let refined = refine(first, usize::BITS as usize, |intervals| -> GeopResult<_> {
172 // Each attempt walks the piece afresh, from its start.
173 seed = piece_seed;
174 // The samples the pcurve passes through, and — walking the
175 // same path, so every projection seeds from its neighbour —
176 // the true trace between each consecutive pair, which the
177 // pcurve is widened to enclose
178 // (`NurbCurve2D::interpolate_enclosing`): an interpolant
179 // drifts from its trace between samples, and that drift is
180 // part of what the pcurve honestly knows about where the
181 // trace is.
182 let count = Scalar::from_i64(intervals as i64);
183 let mut uvs = Vec::with_capacity(intervals + 1);
184 let mut between = Vec::with_capacity(intervals);
185 for i in 0..=intervals {
186 let frac = Scalar::from_i64(i as i64).div(count).with_context(&ctx)?;
187 uvs.push(project_at(&mut seed, (a, b), frac).with_context(&ctx)?);
188 if i < intervals {
189 let fractions = true_point_fractions(i, intervals);
190 let mut inside = Vec::with_capacity(fractions.len());
191 for &(num, den) in fractions {
192 let frac =
193 Scalar::from_ratio(i as i64 * den + num, intervals as i64 * den)
194 .with_context(&ctx)?;
195 inside.push(project_at(&mut seed, (a, b), frac).with_context(&ctx)?);
196 }
197 between.push(inside);
198 }
199 }
200 // Still projected, as the walk's seed.
201 if let Some(at) = start {
202 uvs[0] = at;
203 }
204 if let Some(pin) = end {
205 *uvs.last_mut().expect("uvs is never empty") = pin;
206 }
207 let fitted =
208 NurbCurve2D::interpolate_enclosing(&uvs, &between, 3).with_context(&ctx)?;
209 // Denser samples narrow the pcurve only by its drift: as
210 // wide as its samples are, it stays however many there are.
211 // So it is refined while it is wider than the target and
212 // more than twice as wide as its widest sample.
213 let sampled = uvs
214 .iter()
215 .chain(between.iter().flatten())
216 .fold(Scalar::ZERO, |w, p| w.max(p[0].width()).max(p[1].width()));
217 let fitted_width = fitted.control_points.iter().fold(Scalar::ZERO, |w, cp| {
218 w.max(cp[0].width()).max(cp[1].width())
219 });
220 let drifting = fitted_width.definitely_greater(target)
221 && fitted_width.definitely_greater(sampled.add(sampled));
222 if !drifting || intervals * 2 > MAX_PIECE_SAMPLES {
223 return Ok(Refine::Done((fitted, uvs.last().copied())));
224 }
225 Ok(Refine::Finer(intervals * 2))
226 })?;
227 let Refined::Done((fitted, last)) = refined else {
228 return Err(ctx(GeopError::new(
229 "the samples of a piece doubled past every cap",
230 )));
231 };
232 start = last;
233 pieces.push(fitted);
234 }
235 NurbCurve2D::join(&pieces).with_context(&ctx)
236 }
237
238 /// The pcurve of `curve`, which lies on this surface, where the
239 /// surface is affine in `(u, v)` — degree one both ways, its four
240 /// control points a parallelogram of one weight: then `(u, v)` are an
241 /// affine function of the point, and the pcurve is `curve` itself with
242 /// each control point mapped to them, of the same weights and knots.
243 /// Exact, where a fit only encloses. None for any other surface.
244 pub fn affine_pcurve(&self, curve: &NurbCurve<4>) -> GeopResult<Option<NurbCurve2D>> {
245 let cps = &self.control_points;
246 if self.degree_u != 1
247 || self.degree_v != 1
248 || self.num_u != 2
249 || self.num_v != 2
250 || cps.iter().any(|p| !p[3].is_exactly(cps[0][3]))
251 {
252 return Ok(None);
253 }
254 let point = |k: usize| cps[k].dehomogenized();
255 // `cps[i * 2 + j]` is the corner at the `i`-th end of `u` and the
256 // `j`-th of `v`.
257 let origin = point(0)?;
258 let along_u = point(2)?.sub(&origin);
259 let along_v = point(1)?.sub(&origin);
260 let skew = point(3)?.sub(&origin).sub(&along_u).sub(&along_v);
261 if (0..3).any(|k| !skew[k].could_be_equal(Scalar::ZERO)) {
262 return Ok(None);
263 }
264 let (uu, uv, vv) = (
265 along_u.prod_dot(&along_u),
266 along_u.prod_dot(&along_v),
267 along_v.prod_dot(&along_v),
268 );
269 let det = uu.mul(vv).sub(uv.mul(uv));
270 if !det.definitely_greater(Scalar::ZERO) {
271 return Ok(None);
272 }
273 let ((u0, u1), (v0, v1)) = (self.domain_u(), self.domain_v());
274 let control_points = curve
275 .control_points
276 .iter()
277 .map(|q| {
278 let weight = q[3];
279 let d = q.dehomogenized()?.sub(&origin);
280 let (du, dv) = (along_u.prod_dot(&d), along_v.prod_dot(&d));
281 let s = vv.mul(du).sub(uv.mul(dv)).div(det)?;
282 let t = uu.mul(dv).sub(uv.mul(du)).div(det)?;
283 let u = u0.add(s.mul(u1.sub(u0)));
284 let v = v0.add(t.mul(v1.sub(v0)));
285 Ok(geop_core_math::vector::Vector3::from_array([
286 u.mul(weight),
287 v.mul(weight),
288 weight,
289 ]))
290 })
291 .collect::<GeopResult<Vec<_>>>()?;
292 NurbCurve::try_new(curve.degree, control_points, curve.knot_vector.clone()).map(Some)
293 }
294}
295
296/// Newton iteration count for each sample's foot-point projection. Unlike
297/// `max_nodes`/`min_subdivision_size` this doesn't decide whether a search
298/// converges to a correct-or-error answer, only how tightly a
299/// fixed-iteration projection tracks its target, so it's a constant rather
300/// than a threaded parameter.
301const NEWTON_ITERATIONS: usize = 20;
302
303#[cfg(test)]
304mod tests {
305 use geop_core_math::{
306 primitives::CoordinateSystem,
307 scalars::Scalar,
308 vector::{Vector3, Vector4},
309 };
310
311 use crate::{
312 nurb_curve::{Handedness, Helix, NurbCurve3D},
313 nurb_surface::NurbSurface,
314 shape::Axis,
315 };
316
317 /// The pcurve of the kernel's own helix — radius 1, pitch 1, almost a
318 /// full turn, so its quarter-turn spans meet at three joints where it is
319 /// only `C1` — on the cylinder it lies on: as narrow as a smooth trace's,
320 /// and on the helix at both ends. Fitted as one cubic across the joints
321 /// it drifted by about 1e-4, past what validation allows any entity to
322 /// carry (`a_strip_turning_more_than_once_is_cut_along_meridians` in
323 /// `geop-ops-step` had to use a pitch of a quarter).
324 #[test]
325 fn a_helix_is_fitted_between_its_joints() {
326 let f = Scalar::from_f64;
327 let v = |x: f64, y: f64, z: f64| Vector3::from_array([f(x), f(y), f(z)]);
328 let helix = NurbCurve3D::helix(
329 &CoordinateSystem::world_at(v(0.0, 0.0, 0.0)),
330 Scalar::ONE,
331 &Helix::new(Scalar::ONE, 0.9, Handedness::Right),
332 )
333 .unwrap();
334 let line = NurbCurve3D::try_new(
335 1,
336 vec![
337 Vector4::from_array([f(1.0), f(0.0), f(-0.5), Scalar::ONE]),
338 Vector4::from_array([f(1.0), f(0.0), f(1.5), Scalar::ONE]),
339 ],
340 vec![f(0.0), f(0.0), f(1.0), f(1.0)],
341 )
342 .unwrap();
343 // Its seam a little behind where the helix starts.
344 let axis = Axis::try_new(v(0.0, 0.0, 0.0), v(0.0, 0.0, 1.0)).unwrap();
345 let cylinder =
346 NurbSurface::revolve(&line, &axis, -0.5, std::f64::consts::TAU - 0.5).unwrap();
347 let pcurve = cylinder
348 .fit_pcurve(&helix, None, None, 5000, f(1e-4))
349 .unwrap();
350 let widest = pcurve
351 .control_points
352 .iter()
353 .flat_map(|cp| (0..2).map(|c| cp[c].width().to_f64()))
354 .fold(0.0, f64::max);
355 assert!(widest < 1e-5, "widest control point {widest:e}");
356 let ((s0, s1), (t0, t1)) = (pcurve.domain(), helix.domain());
357 for (s, t) in [(s0, t0), (s1, t1)] {
358 let uv = pcurve.evaluate(s).unwrap();
359 let on = cylinder.evaluate(uv[0], uv[1]).unwrap();
360 assert!(on.could_be_equal(&helix.evaluate(t).unwrap()), "{on:?}");
361 }
362 }
363}