subdiv_kernels/closest_point.rs
1//! Closest point and signed distance to the Catmull-Clark limit
2//! surface (limit-surface SDF design s3, per design sections 4-4/4-5).
3//!
4//! [`LimitEvaluator::closest_point`] answers point queries against the
5//! exact limit surface the s1/s2 machinery evaluates: a best-first
6//! descent of a per-refined-quad AABB hierarchy proposes candidate
7//! quads, a safeguarded Gauss-Newton iteration on each candidate's
8//! `(u, v)` polishes the foot point -- walking across refined-quad
9//! seams through the [`RefinementResult`] adjacency when the minimizer
10//! pins to a quad edge or corner -- and an angle-weighted pseudonormal
11//! signs the distance on closed surfaces.
12//!
13//! The query lives on [`LimitEvaluator`] itself rather than a wrapping
14//! query struct: the acceleration index depends on exactly the
15//! topology + positions pair the evaluator already binds, shares its
16//! lifetime and invalidation story with the s2 isolation cache, and
17//! the s4 SDF sampler and s5 amendment drag both hold an evaluator
18//! anyway. The index is built lazily on the first query (a `OnceCell`
19//! next to the isolation `RefCell`) and reused by every later query --
20//! like the evaluator, cheap to build per thread but not `Sync`.
21//!
22//! # Acceleration: conservative per-quad AABBs
23//!
24//! One axis-aligned box per refined quad, each guaranteed to contain
25//! the quad's entire limit patch, so box-distance pruning can never
26//! cut off the true minimizer:
27//!
28//! - **Regular quads**: the box of the patch's 16 B-spline control
29//! points -- the bicubic basis is nonnegative and partitions unity,
30//! so the patch lies in the control points' convex hull.
31//! - **Feature quads**: the box of the quad's *support submesh*
32//! vertices (the quad plus every face sharing a vertex with it, the
33//! exact point set s2's recursive isolation refines). Every
34//! Catmull-Clark refinement rule and every limit mask the isolation
35//! applies is a convex combination -- nonnegative weights summing to
36//! one -- and by the s2 support contract each isolation level's
37//! central children read only points derived from the previous
38//! level's support, so by induction the central quad's limit lies in
39//! the convex hull of the support cage, hence in its box.
40//!
41//! The boxes feed a median-split binary BVH (built once, O(n log n),
42//! flat nodes). A BVH over patch boxes was chosen over a point
43//! kd-tree/grid because the boxes bound the *continuous* patches --
44//! pruning is exact rather than sample-resolution-limited -- and over
45//! no index at all because the SDF sampler and amendment drags are
46//! many-query consumers (design section 6).
47//!
48//! # Candidates -> Gauss-Newton, and the seam walk
49//!
50//! Best-first descent orders nodes by box distance in a min-heap and
51//! stops when the nearest unvisited box is no closer than the best
52//! foot point so far. Each surviving candidate quad runs a damped
53//! Gauss-Newton minimization of `|S(u, v) - q|^2` from five starts
54//! (center plus the four corners) using
55//! [`eval_with_derivatives`](LimitEvaluator::eval_with_derivatives)'s
56//! first derivatives -- the normal-equations step; the curvature term
57//! of the true Hessian is unavailable through the s2 interface and
58//! unnecessary, since the step-halving line search only ever accepts
59//! strictly improving iterates. Steps are clamped to the unit square,
60//! and when the full step fails the line search against a pinned
61//! bound, the active-set reduction retries with the 1D Gauss-Newton
62//! step along the free coordinate (quadratic along edge minimizers
63//! where the clamped full step would creep linearly). When an
64//! accepted step pins to a quad edge with the unclamped step
65//! pointing outside, the iteration *transfers* across the seam to the
66//! adjacent refined quad (winding-consistent parameter remap) and
67//! continues, and a run that converges pinned to an edge or corner
68//! seeds the adjacent quad -- the whole vertex fan at a corner -- so a
69//! minimizer on a seam is polished from every side instead of being
70//! accepted as a clamped one-sided local minimum. Strict-improvement
71//! acceptance rules out non-improving walk cycles; hop and seed caps
72//! are backstops only.
73//!
74//! *Feature lines* -- open boundaries and persistent sharp creases --
75//! get special treatment: derivatives evaluated exactly on such a
76//! line are depth-cap-degraded (the s2 module docs), so a run pinned
77//! against one minimizes along the edge on positions only (a coarse
78//! scan plus dyadic resolution doubling), continuing through endpoint
79//! corners via the fan seeds; Gauss-Newton never iterates on degraded
80//! on-line gradients.
81//!
82//! Because every evaluated point (all seeds and every accepted
83//! iterate) updates the running best, the returned point is never
84//! farther than the best brute-force candidate the search encountered.
85//!
86//! Near the medial axis the box pruning necessarily degrades (many
87//! quads tie), so per-query dedup keeps the candidate cost flat:
88//! corner starts run once per corner *vertex* (they are shared surface
89//! points; skipped repeats are still evaluated and offered), a vertex
90//! fan expands once, and a feature edge is slid along once.
91//!
92//! # Convergence tolerances
93//!
94//! A run stops when no halved step improves, when it exhausts
95//! [`MAX_NEWTON_ITERATIONS`](self), or when an accepted step moves the
96//! foot point by less than `1e-6` of the root box diagonal -- positions
97//! are f32, so ~`1e-7` relative is the noise floor and the stop
98//! criterion sits one decade above it. `(u, v)` quantizes to f32 at
99//! evaluation, matching the s1/s2 interfaces.
100//!
101//! # Sign (design section 4-5): angle-weighted pseudonormal
102//!
103//! `signed_distance` is `Some` exactly when the refined topology is
104//! closed (no boundary edge); open meshes keep the documented-unsigned
105//! status quo. The sign tests `query - position` against:
106//!
107//! - **quad interior**: the sector-correct limit normal `du x dv`.
108//! - **on a seam edge** (within `1e-4` in parameter): the two incident
109//! quads' unit normals, equal-weighted. Across a smooth seam they
110//! agree; across an infinitely sharp crease this is the classic
111//! two-face edge pseudonormal.
112//! - **on a corner**: the corner vertex's full face fan, each face's
113//! sector normal weighted by its wedge angle (the angle between the
114//! one-sided derivatives along the quad's two corner edges) -- s2's
115//! corner snap supplies per-sector tangents at persistent features.
116//!
117//! Cone-point caveat: at a multi-sector pinned vertex the limit has no
118//! convergent normal, so the fan normals entering the pseudonormal are
119//! s2's deterministic per-sector tangent planes -- the sign is
120//! deterministic but the pseudonormal is a fan aggregate, not a limit
121//! of surface normals. Should the aggregate degenerate outright
122//! (antipodal fan normals), the reported normal is zero and the sign
123//! falls back to positive.
124
125use std::cmp::Ordering;
126use std::collections::{BTreeSet, BinaryHeap};
127
128use crate::limit_eval::persistent_sharp_edge;
129use crate::{KernelError, LimitEvaluator};
130
131/// Faces per BVH leaf.
132const LEAF_SIZE: usize = 4;
133/// Gauss-Newton iterations per run (one seed, transfers included).
134const MAX_NEWTON_ITERATIONS: usize = 32;
135/// Seam transfers per run.
136const MAX_SEAM_HOPS: usize = 8;
137/// Seeds per candidate quad (five starts plus walk/fan seeds).
138const MAX_SEEDS: usize = 24;
139/// Step halvings per line search.
140const MAX_HALVINGS: usize = 12;
141/// Converged-step threshold relative to the root box diagonal.
142const STEP_TOLERANCE_REL: f64 = 1e-6;
143/// In-parameter snap classifying a foot point as on-edge/on-corner
144/// for the pseudonormal (an interior minimizer this close to a seam
145/// is normal-indistinguishable from the seam at f32 resolution).
146const EDGE_SNAP: f64 = 1e-4;
147
148/// In-quad `(u, v)` of CSR corner `k`, the `PatchTable` convention.
149const CORNER_UV: [[f64; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
150
151/// One f64 limit sample: `(position, dp/du, dp/dv)`.
152type SampleF64 = ([f64; 3], [f64; 3], [f64; 3]);
153
154/// An accepted line-search step:
155/// `(raw uv, clamped uv, position, dp/du, dp/dv, dist2)`.
156type AcceptedStep = ([f64; 2], [f64; 2], [f64; 3], [f64; 3], [f64; 3], f64);
157
158/// Result of [`LimitEvaluator::closest_point`].
159#[derive(Debug, Clone, Copy, PartialEq)]
160pub struct ClosestPoint {
161 /// The foot point on the limit surface.
162 pub position: [f32; 3],
163 /// The refined quad of the closest point.
164 pub face: u32,
165 /// In-quad `(u, v)` of the closest point
166 /// (`position == eval(face, uv)`).
167 pub uv: [f32; 2],
168 /// Euclidean distance from the query to `position`.
169 pub distance: f32,
170 /// Signed variant of `distance` when sign is available (closed
171 /// surface), negative inside (the anti-winding-normal side).
172 pub signed_distance: Option<f32>,
173 /// The limit normal at the closest point (sector-correct at
174 /// creases): the surface normal in quad interiors, the
175 /// angle-weighted pseudonormal on seam edges/corners. Unit length,
176 /// or zero if it degenerates (see the module docs).
177 pub normal: [f32; 3],
178}
179
180// -- The acceleration index --------------------------------------------------
181
182/// Conservative axis-aligned box (f32 bounds are exact min/max of f32
183/// points; distances are computed in f64).
184#[derive(Debug, Clone, Copy)]
185struct Aabb {
186 min: [f32; 3],
187 max: [f32; 3],
188}
189
190impl Aabb {
191 const EMPTY: Self = Aabb {
192 min: [f32::INFINITY; 3],
193 max: [f32::NEG_INFINITY; 3],
194 };
195
196 fn add(&mut self, p: [f32; 3]) {
197 for (c, &coord) in p.iter().enumerate() {
198 self.min[c] = self.min[c].min(coord);
199 self.max[c] = self.max[c].max(coord);
200 }
201 }
202
203 fn union(self, other: Self) -> Self {
204 Aabb {
205 min: [
206 self.min[0].min(other.min[0]),
207 self.min[1].min(other.min[1]),
208 self.min[2].min(other.min[2]),
209 ],
210 max: [
211 self.max[0].max(other.max[0]),
212 self.max[1].max(other.max[1]),
213 self.max[2].max(other.max[2]),
214 ],
215 }
216 }
217
218 fn centroid(&self) -> [f64; 3] {
219 [
220 (self.min[0] as f64 + self.max[0] as f64) * 0.5,
221 (self.min[1] as f64 + self.max[1] as f64) * 0.5,
222 (self.min[2] as f64 + self.max[2] as f64) * 0.5,
223 ]
224 }
225
226 /// Squared distance from `q` to the box (zero inside) -- a lower
227 /// bound on the squared distance to anything the box bounds.
228 fn distance2(&self, q: [f64; 3]) -> f64 {
229 (0..3)
230 .map(|c| {
231 let t = q[c].clamp(self.min[c] as f64, self.max[c] as f64) - q[c];
232 t * t
233 })
234 .sum()
235 }
236
237 fn diagonal(&self) -> f64 {
238 (0..3)
239 .map(|c| ((self.max[c] - self.min[c]) as f64).powi(2))
240 .sum::<f64>()
241 .sqrt()
242 }
243}
244
245enum NodeKind {
246 Internal {
247 left: u32,
248 right: u32,
249 },
250 /// Faces `order[start..start + len]`.
251 Leaf {
252 start: u32,
253 len: u32,
254 },
255}
256
257struct Node {
258 aabb: Aabb,
259 kind: NodeKind,
260}
261
262/// The per-evaluator closest-point index: a BVH over conservative
263/// per-refined-quad boxes, plus the closed-surface flag and the
264/// tolerance scale. Built once by [`LimitEvaluator::closest_point`]
265/// and cached on the evaluator.
266pub(crate) struct SearchIndex {
267 nodes: Vec<Node>,
268 root: u32,
269 /// Refined-face indices permuted into leaf order.
270 order: Vec<u32>,
271 /// Conservative box per refined face (indexed by face).
272 boxes: Vec<Aabb>,
273 /// No boundary edge at the refined level: sign is available.
274 closed: bool,
275 /// Root box diagonal -- the physical tolerance scale.
276 diag: f64,
277}
278
279fn build_index(evaluator: &LimitEvaluator) -> Result<SearchIndex, KernelError> {
280 let mesh = &evaluator.result.topology;
281 let adjacency = &evaluator.result.adjacency;
282 let face_count = mesh.face_vertex_counts.len();
283 if face_count == 0 {
284 return Err(KernelError::InvalidTopology(
285 "refined level has no faces to run closest-point queries against",
286 ));
287 }
288 let boxes: Vec<Aabb> = (0..face_count as u32)
289 .map(|face| face_box(evaluator, face))
290 .collect();
291 let centroids: Vec<[f64; 3]> = boxes.iter().map(Aabb::centroid).collect();
292 let mut faces: Vec<u32> = (0..face_count as u32).collect();
293 let mut nodes = Vec::new();
294 let mut order = Vec::with_capacity(face_count);
295 let root = build_node(&mut faces, &boxes, ¢roids, &mut nodes, &mut order);
296 let diag = nodes[root as usize].aabb.diagonal();
297 Ok(SearchIndex {
298 nodes,
299 root,
300 order,
301 boxes,
302 closed: !adjacency.edge_is_boundary.iter().any(|&b| b),
303 diag,
304 })
305}
306
307/// Conservative box of one refined quad's limit patch; see the module
308/// docs for why each variant bounds the limit.
309fn face_box(evaluator: &LimitEvaluator, face: u32) -> Aabb {
310 let mut aabb = Aabb::EMPTY;
311 match evaluator.table.face_patch(face) {
312 Some(patch) => {
313 for &cp in &evaluator.table.control_points[patch as usize] {
314 aabb.add(evaluator.positions[cp as usize]);
315 }
316 }
317 None => {
318 let mesh = &evaluator.result.topology;
319 let adjacency = &evaluator.result.adjacency;
320 let off = (face * 4) as usize;
321 let support: BTreeSet<u32> = mesh.face_vertex_indices[off..off + 4]
322 .iter()
323 .flat_map(|&corner| {
324 let start = adjacency.vertex_face_offsets[corner as usize] as usize;
325 let end = adjacency.vertex_face_offsets[corner as usize + 1] as usize;
326 adjacency.vertex_faces[start..end].iter().copied()
327 })
328 .collect();
329 for f in support {
330 for &v in &mesh.face_vertex_indices[(f * 4) as usize..(f * 4) as usize + 4] {
331 aabb.add(evaluator.positions[v as usize]);
332 }
333 }
334 }
335 }
336 aabb
337}
338
339/// Median-split build over box centroids; returns the node index.
340fn build_node(
341 faces: &mut [u32],
342 boxes: &[Aabb],
343 centroids: &[[f64; 3]],
344 nodes: &mut Vec<Node>,
345 order: &mut Vec<u32>,
346) -> u32 {
347 let aabb = faces
348 .iter()
349 .fold(Aabb::EMPTY, |acc, &f| acc.union(boxes[f as usize]));
350 let kind = if faces.len() <= LEAF_SIZE {
351 let start = order.len() as u32;
352 order.extend_from_slice(faces);
353 NodeKind::Leaf {
354 start,
355 len: faces.len() as u32,
356 }
357 } else {
358 // Split the centroid bounds on their widest axis.
359 let (lo, hi) = faces.iter().fold(
360 ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]),
361 |(mut lo, mut hi), &f| {
362 for c in 0..3 {
363 lo[c] = lo[c].min(centroids[f as usize][c]);
364 hi[c] = hi[c].max(centroids[f as usize][c]);
365 }
366 (lo, hi)
367 },
368 );
369 let axis = (0..3).fold(0, |best, c| {
370 if hi[c] - lo[c] > hi[best] - lo[best] {
371 c
372 } else {
373 best
374 }
375 });
376 let mid = faces.len() / 2;
377 faces.select_nth_unstable_by(mid, |&a, &b| {
378 centroids[a as usize][axis].total_cmp(¢roids[b as usize][axis])
379 });
380 let (left_faces, right_faces) = faces.split_at_mut(mid);
381 let left = build_node(left_faces, boxes, centroids, nodes, order);
382 let right = build_node(right_faces, boxes, centroids, nodes, order);
383 NodeKind::Internal { left, right }
384 };
385 nodes.push(Node { aabb, kind });
386 (nodes.len() - 1) as u32
387}
388
389/// Min-heap entry: smallest box distance pops first.
390#[derive(Clone, Copy)]
391struct HeapEntry {
392 d2: f64,
393 node: u32,
394}
395
396impl PartialEq for HeapEntry {
397 fn eq(&self, other: &Self) -> bool {
398 self.cmp(other) == Ordering::Equal
399 }
400}
401
402impl Eq for HeapEntry {}
403
404impl PartialOrd for HeapEntry {
405 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
406 Some(self.cmp(other))
407 }
408}
409
410impl Ord for HeapEntry {
411 fn cmp(&self, other: &Self) -> Ordering {
412 // Reversed: `BinaryHeap` is a max-heap, we pop the nearest box.
413 other
414 .d2
415 .total_cmp(&self.d2)
416 .then_with(|| other.node.cmp(&self.node))
417 }
418}
419
420// -- The query ----------------------------------------------------------------
421
422/// Running best foot point.
423struct Best {
424 dist2: f64,
425 face: u32,
426 uv: [f64; 2],
427 position: [f64; 3],
428}
429
430impl LimitEvaluator<'_> {
431 /// Closest point on the limit surface to `query`, signed on closed
432 /// surfaces. See the module docs of `closest_point.rs` for the
433 /// search, walk, tolerance, and sign semantics. The acceleration
434 /// index is built on the first call and reused afterwards.
435 pub fn closest_point(&self, query: [f32; 3]) -> Result<ClosestPoint, KernelError> {
436 let index = self.search_index()?;
437 let q = v3(query);
438 let mut minimizer = Minimizer {
439 evaluator: self,
440 q,
441 tolerance: STEP_TOLERANCE_REL * index.diag,
442 best: Best {
443 dist2: f64::INFINITY,
444 face: 0,
445 uv: [0.0; 2],
446 position: [0.0; 3],
447 },
448 seeds: Vec::new(),
449 seeded: Vec::new(),
450 corner_runs: Vec::new(),
451 fanned: Vec::new(),
452 polished: Vec::new(),
453 };
454 let mut heap = BinaryHeap::new();
455 heap.push(HeapEntry {
456 d2: index.nodes[index.root as usize].aabb.distance2(q),
457 node: index.root,
458 });
459 while let Some(entry) = heap.pop() {
460 if entry.d2 >= minimizer.best.dist2 {
461 // Min-heap order: every remaining box is farther still.
462 break;
463 }
464 match index.nodes[entry.node as usize].kind {
465 NodeKind::Internal { left, right } => {
466 for child in [left, right] {
467 let d2 = index.nodes[child as usize].aabb.distance2(q);
468 if d2 < minimizer.best.dist2 {
469 heap.push(HeapEntry { d2, node: child });
470 }
471 }
472 }
473 NodeKind::Leaf { start, len } => {
474 for &face in &index.order[start as usize..(start + len) as usize] {
475 if index.boxes[face as usize].distance2(q) < minimizer.best.dist2 {
476 minimizer.search_quad(face)?;
477 }
478 }
479 }
480 }
481 }
482 self.finish(q, index, minimizer.best)
483 }
484
485 /// The cached index, built on first use.
486 fn search_index(&self) -> Result<&SearchIndex, KernelError> {
487 if self.search.get().is_none() {
488 // Not Sync, so no concurrent set; an existing value is
489 // impossible on this path.
490 let _ = self.search.set(build_index(self)?);
491 }
492 // SAFETY: populated just above when it was empty.
493 Ok(self.search.get().expect("search index populated"))
494 }
495
496 /// f64 view of [`eval_with_derivatives`](Self::eval_with_derivatives).
497 fn sample(&self, face: u32, uv: [f64; 2]) -> Result<SampleF64, KernelError> {
498 let (p, du, dv) = self.eval_with_derivatives(face, [uv[0] as f32, uv[1] as f32])?;
499 Ok((v3(p), v3(du), v3(dv)))
500 }
501
502 /// Whether the edge at `slot` of `face` is a *feature line* --
503 /// open boundary or persistent sharp crease -- where exactly-on-
504 /// line derivatives are depth-cap-degraded (the s2 module docs):
505 /// the walk minimizes along such an edge on positions only
506 /// instead of Gauss-Newton-ing on it.
507 fn feature_line(&self, face: u32, slot: usize) -> bool {
508 let adjacency = &self.result.adjacency;
509 let edge = adjacency.face_edges[face as usize * 4 + slot] as usize;
510 adjacency.edge_is_boundary[edge]
511 || persistent_sharp_edge(
512 self.result.topology.edge_creases[edge],
513 &self.result.options,
514 )
515 }
516
517 /// The neighbor across edge slot `slot` of `face`, with the
518 /// in-edge parameter `x` remapped into the neighbor's frame
519 /// (winding-consistent meshes traverse a shared edge oppositely).
520 /// `None` on boundary edges or inconsistent adjacency.
521 fn across_edge(&self, face: u32, slot: usize, x: f64) -> Option<(u32, [f64; 2])> {
522 let adjacency = &self.result.adjacency;
523 let edge = adjacency.face_edges[face as usize * 4 + slot];
524 let [fa, fb] = adjacency.edge_faces[edge as usize];
525 let neighbor = if fa == face { fb } else { fa };
526 (neighbor != u32::MAX)
527 .then(|| {
528 adjacency.face_edges[neighbor as usize * 4..neighbor as usize * 4 + 4]
529 .iter()
530 .position(|&e| e == edge)
531 .map(|t| (neighbor, edge_uv(t, 1.0 - x)))
532 })
533 .flatten()
534 }
535
536 /// Package the best foot point: pseudonormal, sign, f32 narrowing.
537 fn finish(
538 &self,
539 q: [f64; 3],
540 index: &SearchIndex,
541 best: Best,
542 ) -> Result<ClosestPoint, KernelError> {
543 let distance = best.dist2.sqrt() as f32;
544 let pseudo = self.pseudonormal(best.face, best.uv)?;
545 let len = length(pseudo);
546 let normal = if len > 1e-12 {
547 scale(pseudo, 1.0 / len)
548 } else {
549 [0.0; 3]
550 };
551 let signed_distance = index.closed.then(|| {
552 if dot(sub(q, best.position), normal) < 0.0 {
553 -distance
554 } else {
555 distance
556 }
557 });
558 Ok(ClosestPoint {
559 position: [
560 best.position[0] as f32,
561 best.position[1] as f32,
562 best.position[2] as f32,
563 ],
564 face: best.face,
565 uv: [best.uv[0] as f32, best.uv[1] as f32],
566 distance,
567 signed_distance,
568 normal: [normal[0] as f32, normal[1] as f32, normal[2] as f32],
569 })
570 }
571
572 /// Unnormalized pseudonormal at a foot point, classified by the
573 /// [`EDGE_SNAP`] parameter snap; see the module docs.
574 fn pseudonormal(&self, face: u32, uv: [f64; 2]) -> Result<[f64; 3], KernelError> {
575 let pin = |t: f64| {
576 if t <= EDGE_SNAP {
577 Some(false)
578 } else if t >= 1.0 - EDGE_SNAP {
579 Some(true)
580 } else {
581 None
582 }
583 };
584 match (pin(uv[0]), pin(uv[1])) {
585 (None, None) => self.face_normal(face, uv),
586 (Some(u_hi), Some(v_hi)) => {
587 let corner = match (u_hi, v_hi) {
588 (false, false) => 0,
589 (true, false) => 1,
590 (true, true) => 2,
591 (false, true) => 3,
592 };
593 self.corner_pseudonormal(face, corner)
594 }
595 (u_pin, v_pin) => {
596 // Exactly one coordinate pinned: an on-edge foot point.
597 let (slot, x) = match (u_pin, v_pin) {
598 (None, Some(false)) => (0, uv[0]),
599 (Some(true), None) => (1, uv[1]),
600 (None, Some(true)) => (2, 1.0 - uv[0]),
601 _ => (3, 1.0 - uv[1]),
602 };
603 let own = self.face_normal(face, edge_uv(slot, x))?;
604 let other = match self.across_edge(face, slot, x) {
605 Some((neighbor, neighbor_uv)) => self.face_normal(neighbor, neighbor_uv)?,
606 None => [0.0; 3],
607 };
608 Ok(add(own, other))
609 }
610 }
611 }
612
613 /// Unit surface normal `du x dv`, or zero where it degenerates.
614 fn face_normal(&self, face: u32, uv: [f64; 2]) -> Result<[f64; 3], KernelError> {
615 let (_, du, dv) = self.sample(face, uv)?;
616 Ok(normalize_or_zero(cross(du, dv)))
617 }
618
619 /// Angle-weighted pseudonormal over the full face fan of the quad
620 /// corner's vertex: per incident face, the sector normal weighted
621 /// by the wedge angle between the one-sided derivatives along the
622 /// quad's two corner edges.
623 fn corner_pseudonormal(&self, face: u32, corner: usize) -> Result<[f64; 3], KernelError> {
624 let mesh = &self.result.topology;
625 let adjacency = &self.result.adjacency;
626 let vi = mesh.face_vertex_indices[face as usize * 4 + corner];
627 let start = adjacency.vertex_face_offsets[vi as usize] as usize;
628 let end = adjacency.vertex_face_offsets[vi as usize + 1] as usize;
629 let mut acc = [0.0; 3];
630 for &fan_face in &adjacency.vertex_faces[start..end] {
631 let off = fan_face as usize * 4;
632 let k = mesh.face_vertex_indices[off..off + 4]
633 .iter()
634 .position(|&c| c == vi)
635 .ok_or(KernelError::InvalidTopology(
636 "vertex fan face does not contain the fan vertex",
637 ))?;
638 let (_, du, dv) = self.sample(fan_face, CORNER_UV[k])?;
639 // Wedge directions toward the next/previous CSR corners.
640 let (toward_next, toward_prev) = match k {
641 0 => (du, dv),
642 1 => (dv, neg(du)),
643 2 => (neg(du), neg(dv)),
644 _ => (neg(dv), du),
645 };
646 let normal = normalize_or_zero(cross(du, dv));
647 acc = add(acc, scale(normal, wedge_angle(toward_next, toward_prev)));
648 }
649 Ok(acc)
650 }
651}
652
653/// Per-query Gauss-Newton state: the running best plus the seed queue
654/// of the candidate quad being searched.
655struct Minimizer<'e, 'a> {
656 evaluator: &'e LimitEvaluator<'a>,
657 q: [f64; 3],
658 /// Physical converged-step threshold.
659 tolerance: f64,
660 best: Best,
661 /// Pending `(face, uv)` seeds of the current candidate quad.
662 seeds: Vec<(u32, [f64; 2])>,
663 /// `(face, uv bits)` starts already seeded for the current
664 /// candidate quad -- keyed by the exact start, so a fan seed at a
665 /// different corner of an already-seeded face still runs.
666 seeded: Vec<(u32, [u64; 2])>,
667 /// Corner vertices already Newton-run this query. Corner seeds are
668 /// shared surface points between incident quads; near the medial
669 /// axis (where box pruning cannot cut candidates) running each one
670 /// once instead of once per quad is the difference between ~2 and
671 /// 5 runs per candidate. Skipped seeds are still evaluated and
672 /// offered, so the brute-candidate guarantee is untouched.
673 corner_runs: Vec<u32>,
674 /// Corner vertices whose full fan was already seeded this query
675 /// (the fan starts are identical wherever they are triggered from).
676 fanned: Vec<u32>,
677 /// Feature edges (by refined edge index) already slid along this
678 /// query: every seed converging onto the same boundary or crease
679 /// edge -- from either side -- shares one polish.
680 polished: Vec<u32>,
681}
682
683impl Minimizer<'_, '_> {
684 /// Multi-start search of one candidate quad: center + corners,
685 /// plus whatever walk/fan seeds the runs enqueue. Corner starts
686 /// whose vertex already ran this query are evaluated and offered
687 /// but not re-run (see [`corner_runs`](Self::corner_runs)).
688 fn search_quad(&mut self, face: u32) -> Result<(), KernelError> {
689 self.seeds.clear();
690 self.seeded.clear();
691 self.enqueue(face, [0.5, 0.5]);
692 let off = face as usize * 4;
693 for (k, &uv) in CORNER_UV.iter().enumerate() {
694 let vertex = self.evaluator.result.topology.face_vertex_indices[off + k];
695 if self.corner_runs.contains(&vertex) {
696 let (p, _, _) = self.evaluator.sample(face, uv)?;
697 let dist2 = norm2(sub(p, self.q));
698 self.offer(face, uv, p, dist2);
699 } else {
700 self.corner_runs.push(vertex);
701 self.enqueue(face, uv);
702 }
703 }
704 let mut next = 0;
705 while next < self.seeds.len() {
706 let (seed_face, seed_uv) = self.seeds[next];
707 next += 1;
708 self.run(seed_face, seed_uv)?;
709 }
710 Ok(())
711 }
712
713 fn offer(&mut self, face: u32, uv: [f64; 2], position: [f64; 3], dist2: f64) {
714 if dist2 < self.best.dist2 {
715 self.best = Best {
716 dist2,
717 face,
718 uv,
719 position,
720 };
721 }
722 }
723
724 /// One damped Gauss-Newton run with in-run seam transfers; pinned
725 /// convergence seeds the across-seam neighbors.
726 fn run(&mut self, face: u32, uv: [f64; 2]) -> Result<(), KernelError> {
727 let (mut face, mut uv) = (face, uv);
728 let (mut p, mut du, mut dv) = self.evaluator.sample(face, uv)?;
729 let mut dist2 = norm2(sub(p, self.q));
730 self.offer(face, uv, p, dist2);
731 let mut hops = 0;
732 for _ in 0..MAX_NEWTON_ITERATIONS {
733 let d = sub(p, self.q);
734 let full = gauss_newton_step(d, du, dv);
735 // The full step first; when it fails against a pinned
736 // bound, the reduced active-set step (1D Newton along the
737 // free coordinate) -- the clamped full step would only
738 // creep linearly along an edge minimizer.
739 let mut accepted = self.line_search(face, uv, full, dist2)?;
740 if accepted.is_none()
741 && let Some(reduced) = reduced_step(uv, full, d, du, dv)
742 {
743 accepted = self.line_search(face, uv, reduced, dist2)?;
744 }
745 let Some((raw, cand, cp, cdu, cdv, cd2)) = accepted else {
746 // Local (possibly constrained) minimum: slide along
747 // any pinned feature edge (Gauss-Newton cannot -- see
748 // `polish_feature_edge`) and polish any pinned seam
749 // from the other side too.
750 self.polish_pinned_feature_edges(face, uv)?;
751 self.enqueue_pinned(face, uv);
752 break;
753 };
754 let moved2 = norm2(sub(cp, p));
755 (uv, p, du, dv, dist2) = (cand, cp, cdu, cdv, cd2);
756 self.offer(face, uv, p, dist2);
757 if moved2 <= self.tolerance * self.tolerance {
758 self.polish_pinned_feature_edges(face, uv)?;
759 self.enqueue_pinned(face, uv);
760 break;
761 }
762 // Transfer across the seam when the step was clamped at an
763 // edge; strict improvement above rules out ping-pong.
764 let pin_u = pinned_crossing(uv[0], raw[0]);
765 let pin_v = pinned_crossing(uv[1], raw[1]);
766 match (pin_u, pin_v) {
767 (Some(_), Some(_)) => {
768 self.enqueue_corner_fan(face, uv);
769 break;
770 }
771 (Some(side), None) | (None, Some(side)) => {
772 let (slot, x) = if pin_u.is_some() {
773 if side { (1, uv[1]) } else { (3, 1.0 - uv[1]) }
774 } else if side {
775 (2, 1.0 - uv[0])
776 } else {
777 (0, uv[0])
778 };
779 if self.evaluator.feature_line(face, slot) {
780 // Boundary or sharp crease: Gauss-Newton on
781 // the line is derivative-degraded -- slide on
782 // positions only. No across-seed: a foot
783 // beyond the crease lives in the neighbor's
784 // interior, which stays an unpruned BVH
785 // candidate of its own, and an on-line seed
786 // would only grind deep snapped evals.
787 self.polish_feature_edge(face, slot)?;
788 break;
789 }
790 hops += 1;
791 if hops > MAX_SEAM_HOPS {
792 break;
793 }
794 match self.evaluator.across_edge(face, slot, x) {
795 Some((neighbor, neighbor_uv)) => {
796 (face, uv) = (neighbor, neighbor_uv);
797 (p, du, dv) = self.evaluator.sample(face, uv)?;
798 dist2 = norm2(sub(p, self.q));
799 self.offer(face, uv, p, dist2);
800 }
801 // Unreachable for manifold seams; stand pat.
802 None => break,
803 }
804 }
805 (None, None) => {}
806 }
807 }
808 Ok(())
809 }
810
811 /// [`polish_feature_edge`](Self::polish_feature_edge) for every
812 /// feature edge the converged point pins to (both incident edges
813 /// at a pinned corner).
814 fn polish_pinned_feature_edges(&mut self, face: u32, uv: [f64; 2]) -> Result<(), KernelError> {
815 let pin = |t: f64| (t == 0.0).then_some(false).or((t == 1.0).then_some(true));
816 let slots: &[usize] = match (pin(uv[0]), pin(uv[1])) {
817 (Some(false), Some(false)) => &[3, 0],
818 (Some(true), Some(false)) => &[0, 1],
819 (Some(true), Some(true)) => &[1, 2],
820 (Some(false), Some(true)) => &[2, 3],
821 (Some(false), None) => &[3],
822 (Some(true), None) => &[1],
823 (None, Some(false)) => &[0],
824 (None, Some(true)) => &[2],
825 (None, None) => &[],
826 };
827 for &slot in slots {
828 if self.evaluator.feature_line(face, slot) {
829 self.polish_feature_edge(face, slot)?;
830 }
831 }
832 Ok(())
833 }
834
835 /// Constrained 1D minimization along feature edge `slot` of
836 /// `face`, on positions only: *derivatives* exactly on a feature
837 /// line are depth-cap-degraded (the s2 module docs), so
838 /// Gauss-Newton cannot slide along a boundary or sharp-crease
839 /// minimizer. A coarse scan plus resolution-doubling descent on
840 /// *dyadic* parameters replaces it -- a dyadic `x` with `k` bits
841 /// snaps as an exact depth-`k` corner in the s2 isolation, so the
842 /// probes stay shallow and share ancestor nodes, where arbitrary
843 /// `x` would build a fresh chain to the depth cap each. An
844 /// endpoint minimizer seeds the corner fan so the slide continues
845 /// into the next quad along the feature.
846 fn polish_feature_edge(&mut self, face: u32, slot: usize) -> Result<(), KernelError> {
847 let edge = self.evaluator.result.adjacency.face_edges[face as usize * 4 + slot];
848 if self.polished.contains(&edge) {
849 return Ok(());
850 }
851 self.polished.push(edge);
852 // Depth-3 scan (the distance along one feature segment need
853 // not be unimodal over [0, 1])...
854 let mut best = (f64::INFINITY, 0.0f64);
855 let mut step = 1.0 / 8.0;
856 for k in 0..=8 {
857 let x = k as f64 * step;
858 let d2 = self.edge_distance2(face, slot, x)?;
859 if d2 < best.0 {
860 best = (d2, x);
861 }
862 }
863 // ...then double the dyadic resolution around the running
864 // argmin down to 2^-18 of the edge (well under the foot-point
865 // tolerances; deeper would out-resolve f32 positions anyway).
866 for _ in 0..15 {
867 step *= 0.5;
868 for x in [best.1 - step, best.1 + step] {
869 if (0.0..=1.0).contains(&x) {
870 let d2 = self.edge_distance2(face, slot, x)?;
871 if d2 < best.0 {
872 best = (d2, x);
873 }
874 }
875 }
876 }
877 if best.1 <= 1e-3 {
878 self.enqueue_corner_fan(face, edge_uv(slot, 0.0));
879 } else if best.1 >= 1.0 - 1e-3 {
880 self.enqueue_corner_fan(face, edge_uv(slot, 1.0));
881 }
882 Ok(())
883 }
884
885 /// One on-line probe of [`polish_feature_edge`]: squared distance
886 /// at parameter `x` along edge `slot`, offered to the running best.
887 fn edge_distance2(&mut self, face: u32, slot: usize, x: f64) -> Result<f64, KernelError> {
888 let uv = edge_uv(slot, x);
889 let (p, _, _) = self.evaluator.sample(face, uv)?;
890 let d2 = norm2(sub(p, self.q));
891 self.offer(face, uv, p, d2);
892 Ok(d2)
893 }
894
895 /// Clamped backtracking line search: the first halving that
896 /// strictly improves, as `(raw, clamped, position, du, dv, dist2)`.
897 fn line_search(
898 &self,
899 face: u32,
900 uv: [f64; 2],
901 step: [f64; 2],
902 dist2: f64,
903 ) -> Result<Option<AcceptedStep>, KernelError> {
904 let mut alpha = 1.0;
905 for _ in 0..MAX_HALVINGS {
906 let raw = [uv[0] + alpha * step[0], uv[1] + alpha * step[1]];
907 let cand = [raw[0].clamp(0.0, 1.0), raw[1].clamp(0.0, 1.0)];
908 if cand != uv {
909 let (cp, cdu, cdv) = self.evaluator.sample(face, cand)?;
910 let cd2 = norm2(sub(cp, self.q));
911 if cd2 < dist2 {
912 return Ok(Some((raw, cand, cp, cdu, cdv, cd2)));
913 }
914 }
915 alpha *= 0.5;
916 }
917 Ok(None)
918 }
919
920 /// Seed the neighbors of an exactly pinned converged point: the
921 /// whole vertex fan at a corner, the across-edge quad on a smooth
922 /// seam (feature lines are polished instead -- an on-line seed
923 /// would start on degraded derivatives).
924 fn enqueue_pinned(&mut self, face: u32, uv: [f64; 2]) {
925 let pin = |t: f64| (t == 0.0).then_some(false).or((t == 1.0).then_some(true));
926 match (pin(uv[0]), pin(uv[1])) {
927 (Some(_), Some(_)) => self.enqueue_corner_fan(face, uv),
928 (u_pin @ Some(side), None) | (u_pin @ None, Some(side)) => {
929 let (slot, x) = if u_pin.is_some() {
930 if side { (1, uv[1]) } else { (3, 1.0 - uv[1]) }
931 } else if side {
932 (2, 1.0 - uv[0])
933 } else {
934 (0, uv[0])
935 };
936 if !self.evaluator.feature_line(face, slot)
937 && let Some((neighbor, neighbor_uv)) = self.evaluator.across_edge(face, slot, x)
938 {
939 self.enqueue(neighbor, neighbor_uv);
940 }
941 }
942 (None, None) => {}
943 }
944 }
945
946 /// Seed every face around the corner vertex at its own corner
947 /// parameter (the corner walk: the true minimizer may live in any
948 /// fan face, including across the diagonal). Each vertex fans at
949 /// most once per query -- the starts are identical wherever the
950 /// fan is triggered from.
951 fn enqueue_corner_fan(&mut self, face: u32, uv: [f64; 2]) {
952 let corner = match (uv[0] == 1.0, uv[1] == 1.0) {
953 (false, false) => 0,
954 (true, false) => 1,
955 (true, true) => 2,
956 (false, true) => 3,
957 };
958 let mesh = &self.evaluator.result.topology;
959 let adjacency = &self.evaluator.result.adjacency;
960 let vi = mesh.face_vertex_indices[face as usize * 4 + corner];
961 if self.fanned.contains(&vi) {
962 return;
963 }
964 self.fanned.push(vi);
965 let start = adjacency.vertex_face_offsets[vi as usize] as usize;
966 let end = adjacency.vertex_face_offsets[vi as usize + 1] as usize;
967 for &fan_face in &adjacency.vertex_faces[start..end] {
968 let off = fan_face as usize * 4;
969 if let Some(k) = mesh.face_vertex_indices[off..off + 4]
970 .iter()
971 .position(|&c| c == vi)
972 {
973 self.enqueue(fan_face, CORNER_UV[k]);
974 }
975 }
976 }
977
978 fn enqueue(&mut self, face: u32, uv: [f64; 2]) {
979 let key = (face, [uv[0].to_bits(), uv[1].to_bits()]);
980 if self.seeds.len() < MAX_SEEDS && !self.seeded.contains(&key) {
981 self.seeded.push(key);
982 self.seeds.push((face, uv));
983 }
984 }
985}
986
987/// Gauss-Newton step for `|S - q|^2/2` from the first-derivative
988/// normal equations; falls back to scaled steepest descent when the
989/// tangent frame degenerates (sector-plane corner tangents,
990/// crease-crease corners) -- the line search safeguards either way.
991fn gauss_newton_step(d: [f64; 3], du: [f64; 3], dv: [f64; 3]) -> [f64; 2] {
992 let (a, b, c) = (dot(du, du), dot(dv, dv), dot(du, dv));
993 let (gu, gv) = (dot(d, du), dot(d, dv));
994 let det = a * b - c * c;
995 if det > 1e-12 * a * b {
996 [(c * gv - b * gu) / det, (c * gu - a * gv) / det]
997 } else {
998 let scale = (a + b).max(1e-30);
999 [-gu / scale, -gv / scale]
1000 }
1001}
1002
1003/// Active-set reduction when the full step pushes outward through
1004/// exactly one pinned bound: 1D Gauss-Newton along the free
1005/// coordinate (quadratic along an edge minimizer where the clamped
1006/// full step would creep linearly). `None` when nothing is pinned
1007/// outward, the frame degenerates, or both bounds pin (a corner --
1008/// the fan seeding owns that case).
1009fn reduced_step(
1010 uv: [f64; 2],
1011 full: [f64; 2],
1012 d: [f64; 3],
1013 du: [f64; 3],
1014 dv: [f64; 3],
1015) -> Option<[f64; 2]> {
1016 let outward = |t: f64, s: f64| (t == 0.0 && s < 0.0) || (t == 1.0 && s > 0.0);
1017 let one_d = |tangent: [f64; 3]| {
1018 let scale = dot(tangent, tangent);
1019 (scale > 1e-30).then(|| -dot(d, tangent) / scale)
1020 };
1021 match (outward(uv[0], full[0]), outward(uv[1], full[1])) {
1022 (true, false) => one_d(dv).map(|s| [0.0, s]),
1023 (false, true) => one_d(du).map(|s| [s, 0.0]),
1024 _ => None,
1025 }
1026}
1027
1028/// Whether a clamped coordinate is pinned at 0/1 with the raw step
1029/// strictly outside (the seam-crossing test).
1030fn pinned_crossing(clamped: f64, raw: f64) -> Option<bool> {
1031 (clamped == 0.0 && raw < 0.0)
1032 .then_some(false)
1033 .or((clamped == 1.0 && raw > 1.0).then_some(true))
1034}
1035
1036/// `(u, v)` at parameter `x` along CSR edge slot `slot` (corner `slot`
1037/// toward corner `slot + 1`).
1038fn edge_uv(slot: usize, x: f64) -> [f64; 2] {
1039 match slot {
1040 0 => [x, 0.0],
1041 1 => [1.0, x],
1042 2 => [1.0 - x, 1.0],
1043 _ => [0.0, 1.0 - x],
1044 }
1045}
1046
1047/// Angle between two wedge tangents; a neutral right angle when either
1048/// degenerates.
1049fn wedge_angle(a: [f64; 3], b: [f64; 3]) -> f64 {
1050 let (la, lb) = (length(a), length(b));
1051 if la > 1e-12 && lb > 1e-12 {
1052 (dot(a, b) / (la * lb)).clamp(-1.0, 1.0).acos()
1053 } else {
1054 std::f64::consts::FRAC_PI_2
1055 }
1056}
1057
1058// -- f64 vector helpers --------------------------------------------------------
1059
1060fn v3(p: [f32; 3]) -> [f64; 3] {
1061 [p[0] as f64, p[1] as f64, p[2] as f64]
1062}
1063
1064fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1065 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
1066}
1067
1068fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1069 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
1070}
1071
1072fn neg(a: [f64; 3]) -> [f64; 3] {
1073 [-a[0], -a[1], -a[2]]
1074}
1075
1076fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
1077 [a[0] * s, a[1] * s, a[2] * s]
1078}
1079
1080fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
1081 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1082}
1083
1084fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1085 [
1086 a[1] * b[2] - a[2] * b[1],
1087 a[2] * b[0] - a[0] * b[2],
1088 a[0] * b[1] - a[1] * b[0],
1089 ]
1090}
1091
1092fn norm2(a: [f64; 3]) -> f64 {
1093 dot(a, a)
1094}
1095
1096fn length(a: [f64; 3]) -> f64 {
1097 norm2(a).sqrt()
1098}
1099
1100fn normalize_or_zero(a: [f64; 3]) -> [f64; 3] {
1101 let len = length(a);
1102 if len > 1e-12 {
1103 scale(a, 1.0 / len)
1104 } else {
1105 [0.0; 3]
1106 }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::{Aabb, edge_uv, pinned_crossing};
1112
1113 /// The geometry gates live in `tests/closest_point.rs`; these unit
1114 /// tests pin the pure helpers.
1115 #[test]
1116 fn aabb_distance_is_zero_inside_and_exact_outside() {
1117 let mut aabb = Aabb::EMPTY;
1118 aabb.add([0.0, 0.0, 0.0]);
1119 aabb.add([2.0, 1.0, 3.0]);
1120 assert_eq!(aabb.distance2([1.0, 0.5, 1.5]), 0.0);
1121 assert_eq!(aabb.distance2([3.0, 0.5, 1.5]), 1.0);
1122 assert_eq!(aabb.distance2([-1.0, -1.0, 4.0]), 3.0);
1123 }
1124
1125 #[test]
1126 fn edge_parameterizations_traverse_csr_corners() {
1127 // Slot `s` runs corner `s` -> corner `s + 1`.
1128 assert_eq!(edge_uv(0, 0.0), [0.0, 0.0]);
1129 assert_eq!(edge_uv(0, 1.0), [1.0, 0.0]);
1130 assert_eq!(edge_uv(1, 0.25), [1.0, 0.25]);
1131 assert_eq!(edge_uv(2, 0.25), [0.75, 1.0]);
1132 assert_eq!(edge_uv(3, 0.25), [0.0, 0.75]);
1133 }
1134
1135 #[test]
1136 fn pinned_crossing_requires_clamp_and_overshoot() {
1137 assert_eq!(pinned_crossing(0.0, -0.5), Some(false));
1138 assert_eq!(pinned_crossing(1.0, 1.5), Some(true));
1139 // Pinned but moving along the edge: no crossing.
1140 assert_eq!(pinned_crossing(0.0, 0.0), None);
1141 assert_eq!(pinned_crossing(0.5, 0.5), None);
1142 }
1143}