geop_core_geometry/nurb_curve/mod.rs
1mod convex_hull;
2mod derivative;
3mod evaluate;
4mod interpolate;
5mod project;
6mod refine;
7pub use interpolate::true_point_fractions;
8pub use refine::ParameterRefinable;
9mod reverse;
10mod split;
11mod sweep;
12mod tangent;
13mod translate;
14
15use std::fmt::Display;
16
17pub use convex_hull::HasConvexHull;
18// `fat_axis` needs the same dehomogenized points `convex_hull()` builds —
19// re-exported at this level since `convex_hull` itself is a private
20// submodule (only `HasConvexHull` was public before).
21pub(crate) use convex_hull::dehomogenize;
22
23use crate::aabb::compute_aabb;
24use geop_core_math::{
25 geop_error::{GeopError, GeopResult},
26 scalars::Scalar,
27 vector::Vector,
28};
29
30/// A NURBS curve whose control points live in `D`-dimensional homogeneous space.
31///
32/// - `D = 4`: 3-D curve — control points are `(wx, wy, wz, w)`.
33/// - `D = 3`: 2-D curve (pcurve) — control points are `(wu, wv, w)`.
34#[derive(Clone, Debug)]
35pub struct NurbCurve<S: Scalar, const D: usize> {
36 pub degree: usize,
37 pub control_points: Vec<Vector<S, D>>,
38 pub knot_vector: Vec<S>,
39 /// Cached axis-aligned bounding box of the (dehomogenized) control
40 /// points — see [`crate::aabb::compute_aabb`]. Every constructor here
41 /// fills this in once, so the intersection search's per-node prefilter
42 /// (`aabb_could_overlap`, in `intersection::curve_curve`/
43 /// `curve_surface`) never has to recompute it.
44 pub(crate) aabb: [S; 3],
45}
46
47/// 3-D NURBS curve (homogeneous control points in ℝ⁴).
48pub type NurbCurve3D<S> = NurbCurve<S, 4>;
49
50/// 2-D parameter-space NURBS curve / pcurve (homogeneous control points in ℝ³).
51pub type NurbCurve2D<S> = NurbCurve<S, 3>;
52
53impl<S: Scalar, const D: usize> NurbCurve<S, D> {
54 pub fn try_new(
55 degree: usize,
56 control_points: Vec<Vector<S, D>>,
57 knot_vector: Vec<S>,
58 ) -> GeopResult<Self> {
59 let n = control_points.len();
60 if knot_vector.len() != n + degree + 1 {
61 return Err(GeopError::new(&format!(
62 "Invalid knot vector length: expected {}, got {}",
63 n + degree + 1,
64 knot_vector.len()
65 )));
66 }
67 // Every weight must be definitely positive. That is what makes
68 // `W(t) = Σ w_i N_i(t) > 0` on the whole domain, which the convex hull
69 // property and `contains::curve`'s division-free per-axis functions
70 // `X_k(t) - p_k W(t)` rely on. Zero or negative weights have no use
71 // case here, so they are rejected at construction rather than
72 // surfacing as a division by zero far downstream. `split` forms
73 // convex combinations of existing control points and so preserves
74 // this; `derivative` does not (its last coordinate is `W'`, not a
75 // weight), so a derivative curve is a hodograph for evaluation only
76 // and must never be handed to a containment/intersection search.
77 for p in &control_points {
78 if !p[D - 1].definitely_greater(S::ZERO) {
79 return Err(GeopError::new(&format!(
80 "NurbCurve::try_new: control point {p:?} has a weight that is not definitely positive"
81 )));
82 }
83 }
84 let aabb = compute_aabb(&control_points);
85 Ok(Self {
86 degree,
87 control_points,
88 knot_vector,
89 aabb,
90 })
91 }
92
93 /// Refresh the cached [`Self::aabb`] from the current `control_points`.
94 ///
95 /// Every constructor in this module keeps `aabb` in sync automatically,
96 /// but `control_points` is a `pub` field and at least one caller outside
97 /// this crate (`Model::reverse_face`, mirroring a pcurve's control
98 /// points in place to flip a face) legitimately mutates it directly
99 /// rather than building a new curve — call this afterwards or the
100 /// cached box silently goes stale and the intersection search's
101 /// `aabb_could_overlap` prefilter starts pruning real overlaps.
102 pub fn recompute_aabb(&mut self) {
103 self.aabb = compute_aabb(&self.control_points);
104 }
105
106 /// Valid parameter range `(start_t, end_t)` of this curve.
107 pub fn domain(&self) -> (S, S) {
108 let p = self.degree;
109 let n = self.control_points.len() - 1;
110 (self.knot_vector[p], self.knot_vector[n + 1])
111 }
112
113 pub fn domain_as_scalar(&self) -> S {
114 let (s, e) = self.domain();
115 s.union(e)
116 }
117
118 /// A degenerate, maximally-unsharp curve: a single control point whose
119 /// every coordinate is [`Scalar::ENTIRE`], over a domain that accepts
120 /// any parameter. `evaluate()` at any `t` returns `ENTIRE` in every
121 /// coordinate, so it `could_be_equal`s any point — a placeholder for
122 /// geometry that is not yet known.
123 pub fn everything() -> Self {
124 let mut cp = Vector::<S, D>::everything();
125 cp[D - 1] = S::ONE;
126 NurbCurve {
127 degree: 0,
128 control_points: vec![cp],
129 knot_vector: vec![S::ENTIRE, S::ENTIRE],
130 // Matches-anything, same as every other coordinate of this
131 // placeholder — no bounding box has been established yet.
132 aabb: [S::ENTIRE; 3],
133 }
134 }
135}
136
137impl<S: Scalar> Display for NurbCurve2D<S> {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 let start = self
140 .evaluate(self.domain().0)
141 .map(|p| p.to_string())
142 .unwrap_or_else(|_| "N/A".to_string());
143 let end = self
144 .evaluate(self.domain().1)
145 .map(|p| p.to_string())
146 .unwrap_or_else(|_| "N/A".to_string());
147 write!(f, "NurbCurve({} -> {})", start, end)
148 }
149}
150
151impl<S: Scalar> Display for NurbCurve3D<S> {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 let start = self
154 .evaluate(self.domain().0)
155 .map(|p| p.to_string())
156 .unwrap_or_else(|_| "N/A".to_string());
157 let end = self
158 .evaluate(self.domain().1)
159 .map(|p| p.to_string())
160 .unwrap_or_else(|_| "N/A".to_string());
161 write!(f, "NurbCurve({} -> {})", start, end)
162 }
163}