Skip to main content

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}