brep_kernel/geometry/analytic_surface.rs
1//! Analytic carrier recognition and closed-form geometry for the exact
2//! rational-NURBS surfaces the kernel builds by revolution.
3//!
4//! Every analytic surface in this kernel *is* an exact rational NURBS patch
5//! (built by `make_plane` / `make_revolution`), so recognition never changes
6//! geometry — it only unlocks closed-form fast paths (projection now, exact
7//! intersection curves next) that bypass grid seeding and Newton marching.
8//! Recognition is by exact reconstruction: candidate parameters are extracted
9//! from the control net, the surface is rebuilt with the same constructor,
10//! and every knot/control/weight must match to a scale-relative tolerance.
11//! A surface that fails reconstruction is simply not analytic — there are no
12//! partial matches and no approximation.
13
14use crate::{make_arc, make_revolution, NurbsCurve, NurbsSurface, SurfaceProjection, Vec3};
15
16const RECOGNITION_TOLERANCE: f64 = 1e-9;
17
18#[derive(Clone, Debug)]
19pub struct RevolutionFrame {
20 /// A point on the revolution axis.
21 pub origin: Vec3,
22 /// Unit axis direction; positive rotation follows the right-hand rule.
23 pub axis: Vec3,
24 /// Unit radial direction of the u = 0 meridian.
25 pub x_axis: Vec3,
26 /// axis × x_axis, so azimuth θ = atan2(d·y_axis, d·x_axis).
27 pub y_axis: Vec3,
28}
29
30impl RevolutionFrame {
31 /// Azimuth of `point` about the axis in [0, 2π), plus its radial
32 /// distance and signed axial coordinate relative to `origin`.
33 /// Returns `None` for the azimuth when the point lies on the axis.
34 fn cylindrical(&self, point: Vec3) -> (Option<f64>, f64, f64) {
35 let d = point.sub(self.origin);
36 let axial = d.dot(self.axis);
37 let radial_vector = d.sub(self.axis.scale(axial));
38 let radius = radial_vector.length();
39 if radius <= 1e-14 * (1.0 + axial.abs()) {
40 return (None, radius, axial);
41 }
42 let mut theta = radial_vector
43 .dot(self.y_axis)
44 .atan2(radial_vector.dot(self.x_axis));
45 if theta < 0.0 {
46 theta += std::f64::consts::TAU;
47 }
48 (Some(theta), radius, axial)
49 }
50}
51
52#[derive(Clone, Debug)]
53pub enum AnalyticSurface {
54 /// Affine patch: S(u,v) = origin + u·u_dir + v·v_dir over the knot domain.
55 Plane {
56 origin: Vec3,
57 u_dir: Vec3,
58 v_dir: Vec3,
59 u_domain: [f64; 2],
60 v_domain: [f64; 2],
61 },
62 /// Full revolution of a straight generatrix: cylinders (rho0 == rho1)
63 /// and cones/frusta, with v linear along the generatrix over [0, 1].
64 RuledRevolution {
65 frame: RevolutionFrame,
66 rho0: f64,
67 rho1: f64,
68 height: f64,
69 },
70 /// Full revolution of a -π/2..π/2 polar meridian arc (two 90° spans).
71 Sphere { frame: RevolutionFrame, radius: f64 },
72 /// Full revolution of a full tube circle (four 90° spans in v).
73 Torus {
74 frame: RevolutionFrame,
75 major_radius: f64,
76 minor_radius: f64,
77 },
78 /// GENERAL revolution (full or partial sweep) of an arbitrary
79 /// generatrix — every other `make_revolution` product the classic
80 /// quadric variants above do not cover. The closest surface point to
81 /// a query lies in the query's meridian half-plane, so projection
82 /// reduces exactly to a 1D projection onto the generatrix (rotated
83 /// rigidly about the axis); out-of-sweep queries compare the two
84 /// boundary meridians instead (Golovanov §4.13: prefer analytic
85 /// constructions — this was the unrecognized carrier that sent
86 /// tangent glue pairs into the marcher).
87 Revolution {
88 frame: RevolutionFrame,
89 spans: usize,
90 sweep: f64,
91 generatrix: NurbsCurve,
92 },
93}
94
95/// Parameter of the standard tangent-intersection rational quadratic arc
96/// construction (`make_arc` / `make_revolution`): a sweep split uniformly
97/// into `spans` segments, each with middle weight cos(segment/2). Maps an
98/// angle in [0, sweep] to the curve parameter in [0, 1] exactly.
99pub fn circle_angle_to_parameter(spans: usize, sweep: f64, angle: f64) -> f64 {
100 let segment = sweep / spans as f64;
101 let clamped = angle.clamp(0.0, sweep);
102 let mut span = (clamped / segment).floor() as usize;
103 if span >= spans {
104 span = spans - 1;
105 }
106 let local = clamped - span as f64 * segment;
107 // Derived from tan(θ/2) = t·sin(α/2) / (1 − t + t·cos(α/2)) for the
108 // symmetric rational quadratic arc of sweep α: exact, monotone, and
109 // singularity-free for α ≤ π/2.
110 let half = (0.5 * local).tan();
111 let s = (0.5 * segment).sin();
112 let c = (0.5 * segment).cos();
113 let t = half / (s + half * (1.0 - c));
114 (span as f64 + t.clamp(0.0, 1.0)) / spans as f64
115}
116
117#[path = "analytic_surface/recognition.rs"]
118mod recognition;
119#[path = "analytic_surface/intersect.rs"]
120mod intersect;
121#[path = "analytic_surface/revolution.rs"]
122mod revolution;
123#[cfg(test)]
124#[path = "analytic_surface/tests.rs"]
125mod tests;
126#[cfg(test)]
127#[path = "analytic_surface/coaxial_tests.rs"]
128mod coaxial_tests;
129
130pub use intersect::intersect_analytic_pair;
131pub use recognition::recognize;
132pub use revolution::{revolution_structure, RevolutionStructure};
133pub(crate) use revolution::circumcenter;
134use recognition::generatrix_is_meridional_half_ray;
135
136impl AnalyticSurface {
137 /// Closed-form point projection in the surface's own parameterization.
138 /// Returns the exact nearest parameter; the caller evaluates the NURBS at
139 /// that parameter so results stay bit-consistent with the carrier.
140 pub fn project(&self, surface: &NurbsSurface, point: Vec3) -> Option<SurfaceProjection> {
141 let (u, v) = match self {
142 AnalyticSurface::Plane {
143 origin,
144 u_dir,
145 v_dir,
146 u_domain,
147 v_domain,
148 } => {
149 // Least-squares foot on the (possibly non-orthogonal) affine
150 // patch, clamped to the trimmed domain.
151 let d = point.sub(*origin);
152 let a = u_dir.dot(*u_dir);
153 let b = u_dir.dot(*v_dir);
154 let c = v_dir.dot(*v_dir);
155 let determinant = a * c - b * b;
156 if determinant.abs() <= 1e-16 * (a * c).max(1.0) {
157 return None;
158 }
159 let fu = u_dir.dot(d);
160 let fv = v_dir.dot(d);
161 let u =
162 ((fu * c - fv * b) / determinant + u_domain[0]).clamp(u_domain[0], u_domain[1]);
163 let v =
164 ((fv * a - fu * b) / determinant + v_domain[0]).clamp(v_domain[0], v_domain[1]);
165 (u, v)
166 }
167 AnalyticSurface::RuledRevolution {
168 frame,
169 rho0,
170 rho1,
171 height,
172 } => {
173 let (theta, rho, z) = frame.cylindrical(point);
174 let u = theta
175 .map(|angle| circle_angle_to_parameter(4, std::f64::consts::TAU, angle))
176 .unwrap_or(0.0);
177 // 2D meridian problem: project (rho, z) onto the generatrix
178 // segment (rho0, 0) -> (rho1, height).
179 let delta_rho = rho1 - rho0;
180 let length_squared = delta_rho * delta_rho + height * height;
181 let t = (((rho - rho0) * delta_rho + z * height) / length_squared).clamp(0.0, 1.0);
182 (u, t)
183 }
184 AnalyticSurface::Sphere { frame, radius: _ } => {
185 let (theta, rho, z) = frame.cylindrical(point);
186 let u = theta
187 .map(|angle| circle_angle_to_parameter(4, std::f64::consts::TAU, angle))
188 .unwrap_or(0.0);
189 // Polar angle from the south pole: β ∈ [0, π] over two spans.
190 let beta = rho.atan2(-z);
191 let v = circle_angle_to_parameter(2, std::f64::consts::PI, beta);
192 (u, v)
193 }
194 AnalyticSurface::Torus {
195 frame,
196 major_radius,
197 minor_radius: _,
198 } => {
199 let (theta, rho, z) = frame.cylindrical(point);
200 let u = theta
201 .map(|angle| circle_angle_to_parameter(4, std::f64::consts::TAU, angle))
202 .unwrap_or(0.0);
203 let mut psi = z.atan2(rho - major_radius);
204 if psi < 0.0 {
205 psi += std::f64::consts::TAU;
206 }
207 let v = circle_angle_to_parameter(4, std::f64::consts::TAU, psi);
208 (u, v)
209 }
210 AnalyticSurface::Revolution {
211 frame,
212 spans,
213 sweep,
214 generatrix,
215 } => {
216 if !generatrix_is_meridional_half_ray(generatrix, frame) {
217 return None;
218 }
219 let (theta, rho, z) = frame.cylindrical(point);
220 let full = *sweep >= std::f64::consts::TAU - 1e-9;
221 // Rotating the query rigidly about the axis to a meridian
222 // preserves its distance to that meridian's generatrix copy,
223 // so each candidate reduces to a 1D curve projection.
224 let point_at_angle = |angle: f64| {
225 let radial = frame
226 .x_axis
227 .scale(angle.cos())
228 .add(frame.y_axis.scale(angle.sin()));
229 frame.origin.add(radial.scale(rho)).add(frame.axis.scale(z))
230 };
231 let mut candidates: Vec<(f64, Vec3)> = Vec::with_capacity(2);
232 match theta {
233 None => candidates.push((0.0, point)),
234 Some(angle) if full || angle <= *sweep => {
235 // Rotate the query to the generatrix meridian (angle 0).
236 candidates.push((
237 circle_angle_to_parameter(*spans, *sweep, angle),
238 point_at_angle(0.0),
239 ));
240 }
241 Some(angle) => {
242 // Outside the sweep: the minimizer sits on one of the
243 // two boundary meridians; compare both. The start
244 // meridian IS the generatrix, so the original point
245 // projects onto it directly; the end meridian maps to
246 // the generatrix by a rigid rotation of the query.
247 candidates.push((0.0, point));
248 candidates.push((1.0, point_at_angle(angle - *sweep)));
249 }
250 }
251 let mut best: Option<(f64, f64, f64)> = None;
252 for (u, query) in candidates {
253 let Ok(projection) = crate::project_point_to_curve(generatrix, query) else {
254 return None;
255 };
256 if best
257 .map(|(_, _, distance)| projection.distance < distance)
258 .unwrap_or(true)
259 {
260 best = Some((u, projection.u, projection.distance));
261 }
262 }
263 let (u, v, _) = best?;
264 (u, v)
265 }
266 };
267 let projected = surface.evaluate(u, v).ok()?;
268 Some(SurfaceProjection {
269 u,
270 v,
271 point: projected,
272 distance: projected.sub(point).length(),
273 })
274 }
275
276 /// True when the closed-form projection is the exact global minimizer
277 /// (everywhere except on-axis queries, where any meridian ties).
278 pub fn frame(&self) -> Option<&RevolutionFrame> {
279 match self {
280 AnalyticSurface::Plane { .. } => None,
281 AnalyticSurface::RuledRevolution { frame, .. }
282 | AnalyticSurface::Sphere { frame, .. }
283 | AnalyticSurface::Torus { frame, .. }
284 | AnalyticSurface::Revolution { frame, .. } => Some(frame),
285 }
286 }
287}