brep_kernel/geometry/sphere_chart.rs
1//! Six pole-free coordinate charts for a spherical carrier — the cube atlas.
2//!
3//! A sphere in this kernel is ONE exact rational-NURBS surface of revolution
4//! over a polar `(azimuth, latitude)` domain. That domain is not a chart in the
5//! differential-geometry sense: it is singular at the two poles (a whole
6//! parameter line collapses to one point, and `Su × Sv` vanishes there) and it
7//! is periodic in `u`, so a region straddling the seam is not a connected set of
8//! parameters at all. Every trimming, splitting and tessellation decision taken
9//! in that domain inherits both defects, and the kernel carries a long tail of
10//! special cases — pole caps, seam bands, seam walls, unwrapped covers — that
11//! exist only to work around them.
12//!
13//! This module supplies the alternative: an ATLAS of six regular charts, one per
14//! cube face, that between them cover the sphere with no singular point and no
15//! periodic identification. Chart `k` maps the square `(s, t) ∈ [-1, 1]²` to
16//!
17//! ```text
18//! p(s, t) = centre + radius · normalize(n_k + s·e_k + t·f_k)
19//! ```
20//!
21//! with `(e_k, f_k, n_k)` a right-handed orthonormal frame — the central
22//! projection of a cube face onto its circumscribed sphere. The map is a
23//! diffeomorphism on the closed square, its Jacobian never degenerates, and it
24//! is exact: every image point lies on the sphere to the accuracy of one
25//! `normalize`, so NOTHING about the carrier's geometry is approximated by using
26//! it. The map does contain a square root, so it is deliberately NOT offered as
27//! a NURBS patch — the exact rational sphere surface remains the geometry of
28//! record and the charts are a computational device layered over it.
29//!
30//! # What the atlas is aligned to
31//!
32//! The cube basis is the sphere's own recognition frame, so `basis[2]` is the
33//! polar axis and the two degenerate poles land at the CENTRES of the `±z`
34//! charts. A former pole is then an ordinary interior point of a regular chart:
35//! a cut through it is no more special than a cut anywhere else. Because the
36//! basis is read off the surface, two call sites looking at the same surface
37//! always build the same atlas — a prerequisite for the shared-sample rules
38//! below.
39//!
40//! # Sharing, and why it is the whole problem
41//!
42//! Charts are internal. They must mint no face, no edge, no name, and above all
43//! no crack. The watertight tessellator's guarantee is that every edge is
44//! sampled ONCE and both adjacent faces consume the same positions; an
45//! artificial chart boundary has to inherit exactly that guarantee. Three rules
46//! do it, and they are the reason this module owns the sample generation rather
47//! than leaving it to each consumer:
48//!
49//! 1. **A cube edge is subdivided once.** [`cube_edge_parameters`] returns the
50//! subdivision of a cube edge in the edge's OWN parameter `q`, and both
51//! charts that meet along that edge read the same list through
52//! [`ChartSide::edge`]. Neither chart may add a point of its own.
53//! 2. **A corner belongs to no chart.** The eight cube corners are named by
54//! [`corner_index`], and the three charts and three cube edges meeting at one
55//! all address it by that name, so ownership is never ambiguous.
56//! 3. **A crossing is computed once.** Where a trim polyline leaves one chart,
57//! [`arc_chart_crossings`] returns the crossing as a function of the segment
58//! alone — never of which chart is asking — so the two charts that share it
59//! get the identical point, and the same value can be pushed back into the
60//! SHARED edge-sample table so the neighbouring face gets it too.
61//!
62//! # Classification
63//!
64//! [`SphericalRegion`] decides inside/outside for a trimmed spherical face
65//! without reference to any chart, by the signed solid angle each trim loop
66//! subtends at the query point (Van Oosterom & Strackee's formula). It is
67//! pole-free and seam-free by construction: a slit — the seam traversed once
68//! each way, or a collapsed pole loop — cancels exactly, and needs no special
69//! case. Trimming and triangulation share this one classifier so they cannot
70//! disagree about where the material is.
71
72use crate::{AnalyticSurface, NurbsSurface, Vec3};
73
74/// Charts in the atlas: one per cube face.
75pub const CHART_COUNT: usize = 6;
76/// Cube edges, each shared by exactly two charts.
77pub const CUBE_EDGE_COUNT: usize = 12;
78/// Cube corners, each shared by exactly three charts and three cube edges.
79pub const CUBE_CORNER_COUNT: usize = 8;
80
81/// How far along a segment a chart crossing must be to count as one, as a
82/// fraction of the segment. Anything nearer an end is that end.
83const ENDPOINT_LAMBDA: f64 = 1e-9;
84
85/// Relative slack used when asking whether a direction lies in a chart or on a
86/// cube edge. Chart membership is a comparison of coordinates that are exactly
87/// equal on a boundary, so the slack only has to absorb rounding.
88const CHART_EPSILON: f64 = 1e-12;
89
90/// One cube-face chart: `direction(s, t) = normal + s·tangent_s + t·tangent_t`,
91/// with `tangent_s × tangent_t = normal`, so `∂p/∂s × ∂p/∂t` points OUT of the
92/// sphere everywhere on the chart.
93#[derive(Clone, Copy, Debug)]
94pub struct Chart {
95 pub normal: Vec3,
96 pub tangent_s: Vec3,
97 pub tangent_t: Vec3,
98 /// Basis axis the chart faces along, `0..3`.
99 pub axis: usize,
100 /// `+1` or `-1`: which end of that axis.
101 pub sign: f64,
102}
103
104/// Which side of a chart square a cube edge is: the chart's other coordinate
105/// runs along the edge.
106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
107pub enum ChartSide {
108 /// `s = +1`
109 SPlus,
110 /// `s = -1`
111 SMinus,
112 /// `t = +1`
113 TPlus,
114 /// `t = -1`
115 TMinus,
116}
117
118impl ChartSide {
119 pub const ALL: [ChartSide; 4] = [
120 ChartSide::SPlus,
121 ChartSide::SMinus,
122 ChartSide::TPlus,
123 ChartSide::TMinus,
124 ];
125
126 /// The cube edge this side of chart `chart` lies on, and the sign relating
127 /// the chart's free coordinate `f` to the edge parameter: `q = sign · f`.
128 ///
129 /// This is the single place the two charts meeting along a cube edge agree
130 /// about its identity and its direction, so a subdivision computed once in
131 /// `q` reaches both of them unchanged.
132 pub fn edge(self, chart: usize) -> (usize, f64) {
133 let axis = chart / 2;
134 let sign = if chart % 2 == 0 { 1.0 } else { -1.0 };
135 let b = (axis + 1) % 3;
136 let c = (axis + 2) % 3;
137 match self {
138 // Varying axis c; fixed axes in canonical order (c+1)%3 = axis,
139 // (c+2)%3 = b.
140 ChartSide::SPlus => (cube_edge_index(c, sign, sign), 1.0),
141 ChartSide::SMinus => (cube_edge_index(c, sign, -sign), 1.0),
142 // Varying axis b; fixed axes in canonical order (b+1)%3 = c,
143 // (b+2)%3 = axis. The chart's s maps to the edge parameter with
144 // the chart's own sign.
145 ChartSide::TPlus => (cube_edge_index(b, 1.0, sign), sign),
146 ChartSide::TMinus => (cube_edge_index(b, -1.0, sign), sign),
147 }
148 }
149
150 /// `(s, t)` of the point at free coordinate `f` on this side.
151 pub fn coords(self, f: f64) -> (f64, f64) {
152 match self {
153 ChartSide::SPlus => (1.0, f),
154 ChartSide::SMinus => (-1.0, f),
155 ChartSide::TPlus => (f, 1.0),
156 ChartSide::TMinus => (f, -1.0),
157 }
158 }
159}
160
161/// Canonical index of the cube edge whose VARYING axis is `varying` and whose
162/// two other axes `(varying+1) % 3` and `(varying+2) % 3` are pinned to the
163/// given signs. Twelve edges: three varying axes times four sign pairs.
164pub fn cube_edge_index(varying: usize, sign_first: f64, sign_second: f64) -> usize {
165 varying * 4 + usize::from(sign_first > 0.0) * 2 + usize::from(sign_second > 0.0)
166}
167
168/// Inverse of [`cube_edge_index`]: `(varying axis, sign of (v+1)%3, sign of (v+2)%3)`.
169pub fn cube_edge_parts(index: usize) -> (usize, f64, f64) {
170 let varying = index / 4;
171 let rest = index % 4;
172 (
173 varying,
174 if rest & 2 != 0 { 1.0 } else { -1.0 },
175 if rest & 1 != 0 { 1.0 } else { -1.0 },
176 )
177}
178
179/// Canonical index of the cube corner with the given per-axis signs.
180pub fn corner_index(signs: [f64; 3]) -> usize {
181 usize::from(signs[0] > 0.0) | (usize::from(signs[1] > 0.0) << 1) | (usize::from(signs[2] > 0.0) << 2)
182}
183
184/// The corner at the `q = +1` (`positive_end`) or `q = -1` end of a cube edge.
185pub fn cube_edge_corner(edge: usize, positive_end: bool) -> usize {
186 let (varying, first, second) = cube_edge_parts(edge);
187 let mut signs = [0.0; 3];
188 signs[varying] = if positive_end { 1.0 } else { -1.0 };
189 signs[(varying + 1) % 3] = first;
190 signs[(varying + 2) % 3] = second;
191 corner_index(signs)
192}
193
194/// The shared subdivision of EVERY cube edge, in the edge parameter `q ∈ [-1, 1]`.
195///
196/// Uniform in ARC ANGLE rather than in `q`: a cube edge's direction at `q` makes
197/// an angle `atan(q/√2)` with the edge's midpoint, so equal angle steps give
198/// equal chord sag, which is what a chord tolerance actually asks for. The list
199/// is symmetric, strictly increasing, and pinned to `±1` at the ends (the two
200/// corners), and it is a pure function of `divisions` — which is why both charts
201/// meeting along the edge can compute it independently and still agree
202/// bit-for-bit.
203pub fn cube_edge_parameters(divisions: usize) -> Vec<f64> {
204 let divisions = divisions.max(1);
205 let half = std::f64::consts::SQRT_2.recip().atan();
206 (0..=divisions)
207 .map(|index| {
208 if index == 0 {
209 -1.0
210 } else if index == divisions {
211 1.0
212 } else {
213 let angle = -half + 2.0 * half * index as f64 / divisions as f64;
214 std::f64::consts::SQRT_2 * angle.tan()
215 }
216 })
217 .collect()
218}
219
220/// The interior grid of a chart square, in the chart coordinate `s` (or `t`).
221///
222/// A chart spans 90° each way and its coordinate is the TANGENT of the angle, so
223/// equal angle steps — the thing a chord tolerance actually constrains — are
224/// `tan` of a uniform division, not a uniform division. Interior points are free
225/// (no other chart reads them), so this list is independent of
226/// [`cube_edge_parameters`]; sizing both from the same angular step is what keeps
227/// the triangles the same size on either side of a chart boundary.
228pub fn chart_grid_parameters(divisions: usize) -> Vec<f64> {
229 let divisions = divisions.max(2);
230 let half = std::f64::consts::FRAC_PI_4;
231 (1..divisions)
232 .map(|index| (-half + 2.0 * half * index as f64 / divisions as f64).tan())
233 .collect()
234}
235
236/// The angular step a chord tolerance allows on a sphere of `radius`: a chord
237/// subtending `θ` sags `radius·(1 − cos(θ/2))`.
238fn angular_step(radius: f64, chord_tolerance: f64) -> Option<f64> {
239 if !(chord_tolerance > 0.0) || !(radius > 0.0) {
240 return None;
241 }
242 let ratio = 1.0 - (chord_tolerance / radius).min(1.0);
243 let step = 2.0 * ratio.clamp(-1.0, 1.0).acos();
244 (step > 0.0 && step.is_finite()).then_some(step)
245}
246
247/// Divisions across a chart square (90°) at `chord_tolerance`.
248///
249/// Sized against the CELL DIAGONAL, not the cell side: a Delaunay triangle over a
250/// square grid has the diagonal as its longest edge, and it is that chord whose
251/// sag the tolerance is about. Sizing by the side leaves every triangle a factor
252/// of two over tolerance, so the refinement pass fires on all of them and rebuilds
253/// the grid it was meant to correct — three times the triangles and a crop of
254/// slivers for the same accuracy.
255pub fn chart_grid_divisions(radius: f64, chord_tolerance: f64) -> usize {
256 let divisions = match angular_step(radius, chord_tolerance) {
257 Some(step) => (((std::f64::consts::FRAC_PI_2 * std::f64::consts::SQRT_2) / step).ceil()
258 as usize)
259 .clamp(2, 96),
260 None => 16,
261 };
262 // EVEN, so `0` is a grid value and the chart's CENTRE is a mesh vertex.
263 //
264 // The six chart centres are the sphere's extreme points along its own basis —
265 // two of them are the former poles. The polar domain always had a vertex at a
266 // pole, and a mesh that stops short of one is visibly flat there and fails any
267 // check on the solid's extent. At a fine tolerance the shortfall is invisible;
268 // at a coarse one (a 40 mm ball at 1 mm chord: five divisions) the nearest
269 // grid point sits 12.6 degrees off the pole, a whole millimetre short.
270 divisions + divisions % 2
271}
272
273/// Divisions per cube edge that hold the chord sag of a `radius` sphere under
274/// `chord_tolerance`. A chord subtending `θ` sags `radius·(1 − cos(θ/2))`, so
275/// `θ = 2·acos(1 − tolerance/radius)`; the edge arc is `2·atan(1/√2)` long.
276pub fn cube_edge_divisions(radius: f64, chord_tolerance: f64) -> usize {
277 let arc = 2.0 * std::f64::consts::SQRT_2.recip().atan();
278 match angular_step(radius, chord_tolerance) {
279 // The same diagonal-aware step [`chart_grid_divisions`] uses. A cube edge
280 // is a 1-D chain and would meet the tolerance at the plain step, but a
281 // boundary sampled coarser than the interior it abuts leaves a row of
282 // thin triangles along every chart edge — matching the two steps is what
283 // makes a chart boundary invisible in the mesh as well as in the topology.
284 Some(step) => (((arc * std::f64::consts::SQRT_2) / step).ceil() as usize).clamp(2, 80),
285 None => 14,
286 }
287}
288
289/// A sphere's pole-free cube atlas: centre, radius, and the right-handed basis
290/// the six charts are built on.
291#[derive(Clone, Copy, Debug)]
292pub struct SphereAtlas {
293 pub centre: Vec3,
294 pub radius: f64,
295 /// `basis[2]` is the polar axis, so the two degenerate poles of the stored
296 /// polar domain sit at the centres of charts 4 and 5.
297 pub basis: [Vec3; 3],
298}
299
300impl SphereAtlas {
301 /// The atlas of a surface that IS a sphere, however it is parameterized.
302 ///
303 /// Routed through [`AnalyticSurface::sphere_frame`], so a REFLECTED sphere —
304 /// which recognizes as a general `Revolution`, not as `Sphere` — gets an
305 /// atlas exactly as a direct one does. Matching on the `Sphere` variant
306 /// here would silently drop every mirrored ball.
307 pub fn of_surface(surface: &NurbsSurface) -> Option<Self> {
308 Self::of_analytic(surface.analytic()?)
309 }
310
311 pub fn of_analytic(analytic: &AnalyticSurface) -> Option<Self> {
312 let (centre, radius, basis) = analytic.sphere_frame()?;
313 (radius > 0.0).then_some(Self {
314 centre,
315 radius,
316 basis,
317 })
318 }
319
320 /// Chart `index`: `axis = index / 2`, facing `+` for even and `-` for odd.
321 ///
322 /// The tangents are picked so `tangent_s × tangent_t = normal` for all six,
323 /// which makes every chart's `∂p/∂s × ∂p/∂t` point out of the sphere — the
324 /// atlas has ONE orientation, so a triangle wound counter-clockwise in any
325 /// chart faces outward in every other.
326 pub fn chart(&self, index: usize) -> Chart {
327 let axis = index / 2;
328 let sign = if index % 2 == 0 { 1.0 } else { -1.0 };
329 let b = (axis + 1) % 3;
330 let c = (axis + 2) % 3;
331 Chart {
332 normal: self.basis[axis].scale(sign),
333 tangent_s: self.basis[b].scale(sign),
334 tangent_t: self.basis[c],
335 axis,
336 sign,
337 }
338 }
339
340 /// Unnormalized chart direction — the point of the cube face itself.
341 pub fn direction(&self, chart: usize, s: f64, t: f64) -> Vec3 {
342 let chart = self.chart(chart);
343 chart
344 .normal
345 .add(chart.tangent_s.scale(s))
346 .add(chart.tangent_t.scale(t))
347 }
348
349 /// The sphere point at chart coordinates `(s, t)`. Exact: the result lies on
350 /// the sphere to the accuracy of one `normalize`.
351 pub fn point(&self, chart: usize, s: f64, t: f64) -> Result<Vec3, String> {
352 let direction = self.direction(chart, s, t).normalized()?;
353 Ok(self.centre.add(direction.scale(self.radius)))
354 }
355
356 /// Outward unit normal at chart coordinates `(s, t)` — for a sphere the
357 /// normal IS the radial direction, so no derivative is ever needed and there
358 /// is no pole at which one degenerates.
359 pub fn normal_at(&self, chart: usize, s: f64, t: f64) -> Result<Vec3, String> {
360 self.direction(chart, s, t).normalized()
361 }
362
363 /// Whether the surface's OWN `Su x Sv` points out of the sphere, sampled at
364 /// the middle of its parameter domain — for a polar sphere the equator, as
365 /// far from either degenerate pole as the domain allows.
366 ///
367 /// The trim convention (material to the LEFT of the directed boundary) is
368 /// stated against this normal, not against the face's sense, and a reflected
369 /// sphere has it pointing inward — so no consumer may assume it.
370 pub fn parameterization_is_outward(&self, surface: &NurbsSurface) -> Result<bool, String> {
371 let [u0, u1] = surface.domain_u()?;
372 let [v0, v1] = surface.domain_v()?;
373 let (point, du, dv) = surface.deriv1(0.5 * (u0 + u1), 0.5 * (v0 + v1))?;
374 Ok(du.cross(dv).dot(point.sub(self.centre)) > 0.0)
375 }
376
377 /// Basis coordinates of `point` relative to the centre.
378 pub fn axis_coordinates(&self, point: Vec3) -> [f64; 3] {
379 let d = point.sub(self.centre);
380 [
381 d.dot(self.basis[0]),
382 d.dot(self.basis[1]),
383 d.dot(self.basis[2]),
384 ]
385 }
386
387 /// The chart a point belongs to when only one answer is wanted: the chart
388 /// whose face the point projects furthest onto, lowest index breaking a tie.
389 /// Deterministic, so it can be used as a key.
390 pub fn locate(&self, point: Vec3) -> usize {
391 let x = self.axis_coordinates(point);
392 let mut best = 0usize;
393 let mut best_value = f64::NEG_INFINITY;
394 for chart in 0..CHART_COUNT {
395 let axis = chart / 2;
396 let sign = if chart % 2 == 0 { 1.0 } else { -1.0 };
397 let value = sign * x[axis];
398 if value > best_value {
399 best_value = value;
400 best = chart;
401 }
402 }
403 best
404 }
405
406 /// Chart coordinates of `point` in `chart`, or `None` when the point is on
407 /// the far side of the sphere from it. A point outside the chart's square
408 /// still returns coordinates (with `|s| > 1` or `|t| > 1`); use
409 /// [`Self::contains`] to ask about membership.
410 pub fn coordinates(&self, chart: usize, point: Vec3) -> Option<(f64, f64)> {
411 let x = self.axis_coordinates(point);
412 let chart = self.chart(chart);
413 let b = (chart.axis + 1) % 3;
414 let c = (chart.axis + 2) % 3;
415 let w = chart.sign * x[chart.axis];
416 if !(w > 0.0) {
417 return None;
418 }
419 Some((chart.sign * x[b] / w, x[c] / w))
420 }
421
422 /// Whether `point` lies in `chart`'s closed square, within a relative slack.
423 pub fn contains(&self, chart: usize, point: Vec3) -> bool {
424 match self.coordinates(chart, point) {
425 Some((s, t)) => {
426 let slack = 1.0 + CHART_EPSILON;
427 s.abs() <= slack && t.abs() <= slack
428 }
429 None => false,
430 }
431 }
432
433 /// Every chart whose closed square contains `point`: one in a chart
434 /// interior, two on a cube edge, three at a corner. Ascending index, so the
435 /// answer is order-independent.
436 pub fn charts_containing(&self, point: Vec3) -> Vec<usize> {
437 (0..CHART_COUNT)
438 .filter(|chart| self.contains(*chart, point))
439 .collect()
440 }
441
442 /// The cube edge `point` lies on, with its edge parameter `q`, or `None`
443 /// when the point is in a chart interior.
444 ///
445 /// A point sits on a cube edge exactly when the two LARGEST of its three
446 /// basis coordinates are equal in magnitude; `q` is the third coordinate
447 /// scaled so those two are `±1`. Returning `q` — rather than either chart's
448 /// own coordinate — is what lets a crossing be filed against the edge once
449 /// and read back by both charts.
450 pub fn edge_of(&self, point: Vec3, relative_tolerance: f64) -> Option<(usize, f64)> {
451 self.edges_containing(point, relative_tolerance)
452 .into_iter()
453 .next()
454 }
455
456 /// EVERY cube edge `point` lies on: one along an edge, three at a corner,
457 /// none in a chart interior.
458 ///
459 /// Callers that have to decide whether two points share an edge must use
460 /// this rather than [`Self::edge_of`]: a corner lies on three edges at once,
461 /// and picking one of them canonically would make a segment running INTO a
462 /// corner look as if it left the edge it is on.
463 pub fn edges_containing(&self, point: Vec3, relative_tolerance: f64) -> Vec<(usize, f64)> {
464 let x = self.axis_coordinates(point);
465 let scale = x[0].abs().max(x[1].abs()).max(x[2].abs());
466 if !(scale > 0.0) {
467 return Vec::new();
468 }
469 let tolerance = relative_tolerance.max(CHART_EPSILON) * scale;
470 let mut found = Vec::new();
471 // The varying axis is the one whose magnitude is NOT tied for largest.
472 for varying in 0..3 {
473 let b = (varying + 1) % 3;
474 let c = (varying + 2) % 3;
475 let pinned = x[b].abs().min(x[c].abs());
476 if (x[b].abs() - x[c].abs()).abs() <= tolerance
477 && pinned >= x[varying].abs() - tolerance
478 && pinned > 0.0
479 {
480 let magnitude = 0.5 * (x[b].abs() + x[c].abs());
481 found.push((
482 cube_edge_index(varying, x[b], x[c]),
483 (x[varying] / magnitude).clamp(-1.0, 1.0),
484 ));
485 }
486 }
487 found
488 }
489
490 /// The sphere point on cube edge `edge` at edge parameter `q`.
491 pub fn edge_point(&self, edge: usize, q: f64) -> Result<Vec3, String> {
492 let (varying, first, second) = cube_edge_parts(edge);
493 let direction = self.basis[varying]
494 .scale(q)
495 .add(self.basis[(varying + 1) % 3].scale(first))
496 .add(self.basis[(varying + 2) % 3].scale(second))
497 .normalized()?;
498 Ok(self.centre.add(direction.scale(self.radius)))
499 }
500
501 /// The parameters `λ ∈ (0, 1)` at which the great-circle arc from `start` to
502 /// `end` crosses a chart boundary, ascending.
503 ///
504 /// A chart boundary is one of the six planes `|x_i| = |x_j|` through the
505 /// centre, so the crossing is the root of a function that is LINEAR along
506 /// the chord — no Newton iteration, no seeding, and nothing that could
507 /// converge onto an extrapolated surface. `λ` interpolates the chord
508 /// `(1−λ)·d_start + λ·d_end`; the crossing point is that direction
509 /// normalized back onto the sphere, which is exactly where the arc meets the
510 /// plane.
511 ///
512 /// The result depends only on the two endpoints, never on which chart is
513 /// asking, so both charts sharing the crossing derive the identical point.
514 /// A plane crossed AWAY from the cube edge it carries (the other coordinate
515 /// is larger there) is not a chart boundary at that point and is dropped.
516 pub fn arc_chart_crossings(&self, start: Vec3, end: Vec3) -> Vec<f64> {
517 let a = self.axis_coordinates(start);
518 let b = self.axis_coordinates(end);
519 let scale = (0..3)
520 .map(|k| a[k].abs().max(b[k].abs()))
521 .fold(0.0, f64::max);
522 if !(scale > 0.0) {
523 return Vec::new();
524 }
525 // A segment that LIES IN one of the six planes evaluates that plane's
526 // function as rounding noise, and the sign of noise flips at random. Every
527 // flip brackets a "root", and each one splits the segment at a point no
528 // neighbouring face holds — a T-junction, on the very trim that runs along
529 // a chart boundary. A crossing has to be transversal, so a bracket counts
530 // only when the function is meaningfully non-zero at an end.
531 let significant = 1e-9 * scale;
532 let mut crossings = Vec::new();
533 for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
534 for combination in [1.0f64, -1.0] {
535 let g0 = a[i] - combination * a[j];
536 let g1 = b[i] - combination * b[j];
537 if (g0 > 0.0) == (g1 > 0.0) || g0 == g1 {
538 continue;
539 }
540 if g0.abs().max(g1.abs()) <= significant {
541 continue;
542 }
543 let lambda = g0 / (g0 - g1);
544 // A crossing AT an endpoint is not a crossing: the endpoint is
545 // already a vertex, and splitting there mints a second one a few
546 // ulps away that no neighbouring face holds. This is the common
547 // case, not a corner one — `sample_all_edges` has already put a
548 // sample on the boundary, so the segments either side of it each
549 // report a root at their shared end.
550 if !(lambda > ENDPOINT_LAMBDA && lambda < 1.0 - ENDPOINT_LAMBDA) {
551 continue;
552 }
553 let at = |k: usize| a[k] + (b[k] - a[k]) * lambda;
554 let tied = at(i).abs().max(at(j).abs());
555 let third = at(3 - i - j).abs();
556 // Only a crossing where the tied pair is the LARGEST pair is on
557 // a cube edge; elsewhere the plane runs through a chart's
558 // interior and means nothing.
559 if tied >= third - CHART_EPSILON * scale && tied > 0.0 {
560 crossings.push(lambda);
561 }
562 }
563 }
564 crossings.sort_by(f64::total_cmp);
565 crossings.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
566 crossings
567 }
568
569 /// Split a closed polyline on the sphere so that no segment crosses a chart
570 /// boundary, returning `(point, chart)` for every segment: the vertices of
571 /// the segment and the single chart it lies in.
572 ///
573 /// Crossing points are inserted at the exact positions
574 /// [`Self::arc_chart_crossings`] reports, so a vertex on a cube edge belongs
575 /// to both adjacent charts with identical coordinates.
576 pub fn split_polyline(&self, points: &[Vec3]) -> Result<Vec<ChartSegment>, String> {
577 let mut segments = Vec::new();
578 for index in 0..points.len() {
579 segments.extend(self.split_segment(points[index], points[(index + 1) % points.len()])?);
580 }
581 Ok(segments)
582 }
583
584 /// [`Self::split_polyline`] for ONE segment. A caller holding individual
585 /// boundary segments must use this: handing a two-point slice to the polyline
586 /// form would close it and emit the segment twice, once each way.
587 pub fn split_segment(&self, start: Vec3, end: Vec3) -> Result<Vec<ChartSegment>, String> {
588 let mut segments = Vec::new();
589 {
590 if start.sub(end).length() <= 0.0 {
591 return Ok(segments);
592 }
593 let crossings = self.arc_chart_crossings(start, end);
594 let da = self.axis_coordinates(start);
595 let db = self.axis_coordinates(end);
596 let mut cursor = start;
597 let mut previous = 0.0f64;
598 for lambda in crossings.iter().copied().chain(std::iter::once(1.0)) {
599 let next = if lambda >= 1.0 {
600 end
601 } else {
602 let direction = self.basis[0]
603 .scale(da[0] + (db[0] - da[0]) * lambda)
604 .add(self.basis[1].scale(da[1] + (db[1] - da[1]) * lambda))
605 .add(self.basis[2].scale(da[2] + (db[2] - da[2]) * lambda))
606 .normalized()?;
607 self.centre.add(direction.scale(self.radius))
608 };
609 let final_piece = lambda >= 1.0;
610 // A crossing that landed on the far endpoint adds nothing but a
611 // duplicate vertex; the final piece reaches that endpoint anyway.
612 let distinct = next.sub(cursor).length() > 0.0
613 && (final_piece || next.sub(end).length() > 0.0);
614 if distinct {
615 let middle = 0.5 * (previous + lambda);
616 let probe = self.basis[0]
617 .scale(da[0] + (db[0] - da[0]) * middle)
618 .add(self.basis[1].scale(da[1] + (db[1] - da[1]) * middle))
619 .add(self.basis[2].scale(da[2] + (db[2] - da[2]) * middle));
620 segments.push(ChartSegment {
621 start: cursor,
622 end: next,
623 chart: self.locate(self.centre.add(probe)),
624 });
625 }
626 cursor = next;
627 previous = lambda;
628 }
629 }
630 Ok(segments)
631 }
632}
633
634/// One piece of a trim polyline, already confined to a single chart.
635#[derive(Clone, Copy, Debug)]
636pub struct ChartSegment {
637 pub start: Vec3,
638 pub end: Vec3,
639 pub chart: usize,
640}
641
642/// Whether the minor great-circle arcs `a0→a1` and `b0→b1` cross, for unit
643/// directions.
644///
645/// The two arcs' plane normals are NORMALIZED before they are crossed, so the
646/// magnitude of the result is the sine of the angle between the planes — a
647/// meaningful quantity with a meaningful threshold. Crossing the raw normals
648/// instead makes that magnitude scale with both arcs' lengths, so a short probe
649/// against a short boundary sample yields a direction that is mostly rounding
650/// noise, and the crossing decision becomes a coin toss. Parity cannot survive
651/// that: one miscounted crossing inverts the answer for a whole region.
652///
653/// The arcs are half-open at their end, so a crossing exactly at a shared vertex
654/// of a polyline is counted once rather than twice.
655///
656/// Both normals arrive precomputed: `na` once per LEG (it depends only on the
657/// path, not on the arc it is tested against) and `nb` once per REGION (it is a
658/// property of the boundary arc). A trim query counts crossings over every arc of
659/// the region six times over, so computing either here put two square roots per
660/// arc per query in the innermost loop of the boolean.
661fn arcs_cross(a0: Vec3, a1: Vec3, na: Vec3, arc: &Arc) -> bool {
662 let (b0, b1, nb) = (arc.start, arc.end, arc.normal);
663 if nb.dot(nb) <= 0.0 {
664 // Ends that name no plane — antipodal or coincident. Not a crossing.
665 return false;
666 }
667 // Straddle test first, and it settles most arcs: any crossing point lies on
668 // BOTH great circles, so it satisfies `na · p == 0`. An arc whose two ends are
669 // strictly on one side of the leg's plane contains no such point, and the same
670 // holds for the leg against the arc's plane. Equality is left to the full test
671 // below — an end exactly ON the other plane is a crossing the half-open
672 // convention still has to adjudicate.
673 let straddles = |n: Vec3, x0: Vec3, x1: Vec3| {
674 let (d0, d1) = (n.dot(x0), n.dot(x1));
675 !((d0 > 0.0 && d1 > 0.0) || (d0 < 0.0 && d1 < 0.0))
676 };
677 if !straddles(na, b0, b1) || !straddles(nb, a0, a1) {
678 return false;
679 }
680 let line = na.cross(nb);
681 if line.length() <= 1e-9 {
682 // The two great circles coincide, or meet at too shallow an angle to
683 // locate: an overlap is not a transversal crossing and must not flip
684 // parity.
685 return false;
686 }
687 let Ok(unit) = line.normalized() else {
688 return false;
689 };
690 let within = |p: Vec3, x0: Vec3, x1: Vec3, n: Vec3| -> bool {
691 x0.cross(p).dot(n) >= 0.0 && p.cross(x1).dot(n) > 0.0
692 };
693 [unit, unit.scale(-1.0)]
694 .into_iter()
695 .any(|p| within(p, a0, a1, na) && within(p, b0, b1, nb))
696}
697
698/// The angle between unit directions `a` and `b`, from their chord (accurate
699/// near zero, where `acos` of their dot product is not).
700fn unit_angle(a: Vec3, b: Vec3) -> f64 {
701 2.0 * (0.5 * a.sub(b).length()).min(1.0).asin()
702}
703
704/// The angle from unit direction `probe` to the great-circle arc from `start` to
705/// `end` (units), whose plane has unit normal `normal` (zero when the ends name
706/// no plane): to its nearest end, or to the arc itself where the probe's foot on
707/// the arc's plane falls between them.
708pub(crate) fn arc_distance(start: Vec3, end: Vec3, normal: Vec3, probe: Vec3) -> f64 {
709 let ends = unit_angle(probe, start).min(unit_angle(probe, end));
710 if normal.length_squared() == 0.0 {
711 return ends;
712 }
713 let height = probe.dot(normal);
714 let foot = probe.sub(normal.scale(height));
715 if start.cross(foot).dot(normal) >= 0.0 && foot.cross(end).dot(normal) >= 0.0 {
716 height.abs().min(1.0).asin().min(ends)
717 } else {
718 ends
719 }
720}
721
722/// The unit normal of the plane through the origin and unit directions `a`
723/// and `b`, zero when they name none (as [`Arc::normal`]).
724pub(crate) fn arc_normal(a: Vec3, b: Vec3) -> Vec3 {
725 a.cross(b).normalized().unwrap_or(Vec3::new(0.0, 0.0, 0.0))
726}
727
728/// One directed boundary arc, with the great-circle plane it lies in.
729#[derive(Clone, Copy, Debug)]
730struct Arc {
731 start: Vec3,
732 end: Vec3,
733 /// The unit normal of `start × end`, or zero when the ends name no plane.
734 /// Precomputed: every crossing count in every query walks every arc.
735 normal: Vec3,
736}
737
738/// The trim of a spherical face, as directed boundary arcs on the unit sphere,
739/// ready to classify points without touching any chart or parameter domain.
740///
741/// Containment is decided by CROSSING PARITY against a seed the region carries:
742/// one point placed just to the material side of one boundary arc. The parity of
743/// the crossings between the seed and a query decides the query, and both the
744/// seed and the test are ordinary great-circle geometry — no parameter domain, no
745/// pole, no seam, and no reliance on a signed-area formula whose branch collapses
746/// when the query sits opposite a small loop (which is exactly the near-tangent
747/// pocket, and exactly where it was wrong).
748///
749/// The seed encodes the BREP convention in the one place it belongs: material
750/// lies to the LEFT of the directed boundary as seen from the FACE normal — the
751/// face's own normal, which for a cavity wall points into the sphere, not out of
752/// it.
753#[derive(Clone, Debug, Default)]
754pub struct SphericalRegion {
755 /// Directed boundary arcs as unit directions from the sphere centre, with
756 /// slits already cancelled.
757 arcs: Vec<Arc>,
758 /// A unit direction known to be material, or `None` when there is no
759 /// boundary at all (the whole sphere) or none usable.
760 seed: Option<Vec3>,
761 /// The arcs in runs of [`ARC_GROUP`] consecutive ones, each under a
762 /// bounding cone, so a crossing count skips every run whose ends all lie
763 /// strictly on one side of the leg's plane ([`ArcGroup::clear_of`]).
764 groups: Vec<ArcGroup>,
765}
766
767/// How many consecutive arcs share one bounding cone.
768const ARC_GROUP: usize = 16;
769
770/// A run of consecutive boundary arcs under a bounding cone: `axis` is a
771/// unit direction and `cos_half`/`sin_half` the cosine and sine of the
772/// largest angle from it to any end of the run's arcs. A run with no usable
773/// cone (ends spread past a quarter turn, or none) carries `cos_half <= 0`
774/// and is never skipped.
775#[derive(Clone, Copy, Debug)]
776struct ArcGroup {
777 first: usize,
778 last: usize,
779 axis: Vec3,
780 cos_half: f64,
781 sin_half: f64,
782}
783
784impl ArcGroup {
785 /// The runs of `arcs`, in order.
786 fn runs(arcs: &[Arc]) -> Vec<ArcGroup> {
787 (0..arcs.len())
788 .step_by(ARC_GROUP)
789 .map(|first| {
790 let last = (first + ARC_GROUP).min(arcs.len());
791 let ends = arcs[first..last].iter().flat_map(|arc| [arc.start, arc.end]);
792 let sum = ends.clone().fold(Vec3::new(0.0, 0.0, 0.0), |acc, p| acc.add(p));
793 let (axis, cos_half) = match sum.normalized() {
794 Ok(axis) => (axis, ends.map(|p| axis.dot(p)).fold(1.0, f64::min)),
795 Err(_) => (Vec3::new(0.0, 0.0, 1.0), 0.0),
796 };
797 let sin_half = if cos_half > 0.0 { (1.0 - cos_half * cos_half).max(0.0).sqrt() } else { 1.0 };
798 ArcGroup { first, last, axis, cos_half, sin_half }
799 })
800 .collect()
801 }
802
803 /// Whether every end of this run's arcs lies STRICTLY on one side of the
804 /// plane with unit normal `leg`, by more than the rounding of a dot
805 /// product of unit vectors, so [`arcs_cross`]'s straddle test declines
806 /// every arc of the run against that leg: for an end at angle `a <=
807 /// half` from the axis, `|leg · end| >= |leg · axis| cos(half) -
808 /// sqrt(1 - (leg · axis)^2) sin(half)`, and that bound must clear `1e-9`.
809 fn clear_of(&self, leg: Vec3) -> bool {
810 if !(self.cos_half > 0.0) {
811 return false;
812 }
813 let d = leg.dot(self.axis).abs();
814 d * self.cos_half - (1.0 - d * d).max(0.0).sqrt() * self.sin_half > 1e-9
815 }
816}
817
818impl SphericalRegion {
819 /// Build the region from the face's trim loops given as closed 3D polylines.
820 ///
821 /// `outward_face_normal` says whether the FACE's normal points out of the
822 /// sphere — `same_sense == parameterization_is_outward`, never `same_sense`
823 /// alone and never the surface normal alone.
824 pub fn new(centre: Vec3, polylines: &[Vec<Vec3>], outward_face_normal: bool) -> Self {
825 let mut segments = Vec::new();
826 for polyline in polylines {
827 for index in 0..polyline.len() {
828 segments.push((polyline[index], polyline[(index + 1) % polyline.len()]));
829 }
830 }
831 Self::from_segments(centre, &segments, outward_face_normal)
832 }
833
834 /// As [`Self::new`], from the directed boundary segments themselves.
835 ///
836 /// A consumer that has one loop joining TWO rims through the seam — a ball
837 /// drilled through — MUST use this: concatenating that loop's surviving
838 /// coedges and closing the ring bridges rim to rim with two chords that are
839 /// not reverses of each other, and the region that comes out is not the one
840 /// the face means.
841 pub fn from_segments(centre: Vec3, segments: &[(Vec3, Vec3)], outward_face_normal: bool) -> Self {
842 // The maps are looked up by key and never walked for an answer (the
843 // walk over `counts` only fills `budget`, another map), so the hasher
844 // cannot change the region: Fx, because the trim of a corner patch
845 // is 2 500 chords and the two maps were 1.5 ms of its first query.
846 use rustc_hash::FxHashMap as HashMap;
847 type Key = [u64; 3];
848 let key = |p: Vec3| -> Key { [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()] };
849 let mut counts: HashMap<(Key, Key), usize> = HashMap::default();
850 let mut directed: Vec<(Vec3, Vec3)> = Vec::new();
851 for &(a, b) in segments {
852 if key(a) == key(b) {
853 // A collapsed pole loop: every sample is the same point.
854 continue;
855 }
856 *counts.entry((key(a), key(b))).or_insert(0) += 1;
857 directed.push((a, b));
858 }
859 // Cancel each directed segment against a matching reverse. The seam of a
860 // trimmed ball is used once each way by the SAME face, so both uses go;
861 // what is left is the material boundary and nothing else.
862 let mut budget: HashMap<(Key, Key), usize> = HashMap::default();
863 for (&(from, to), &forward) in &counts {
864 let backward = counts.get(&(to, from)).copied().unwrap_or(0);
865 budget.insert((from, to), forward.saturating_sub(backward.min(forward)));
866 }
867 let unit = |p: Vec3| p.sub(centre).normalized().ok();
868 let mut arcs: Vec<Arc> = Vec::new();
869 for (a, b) in directed {
870 let entry = budget.entry((key(a), key(b))).or_insert(0);
871 if *entry == 0 {
872 continue;
873 }
874 *entry -= 1;
875 if let (Some(a), Some(b)) = (unit(a), unit(b)) {
876 if a.sub(b).length() > 0.0 {
877 arcs.push(Arc {
878 start: a,
879 end: b,
880 normal: a.cross(b).normalized().unwrap_or_default(),
881 });
882 }
883 }
884 }
885 let groups = ArcGroup::runs(&arcs);
886 let seed = Self::seed_from(&arcs, &groups, outward_face_normal);
887 Self { arcs, seed, groups }
888 }
889
890 /// A unit direction just to the MATERIAL side of the longest boundary arc.
891 ///
892 /// The offset is bisected until stepping the same distance to the other side
893 /// crosses the boundary exactly once: that is the proof the seed cleared the
894 /// arc it came from and reached no further boundary, so it is inside the
895 /// material and not merely near it. A region thinner than the first offset is
896 /// found by the bisection rather than mis-seeded.
897 fn seed_from(arcs: &[Arc], groups: &[ArcGroup], outward_face_normal: bool) -> Option<Vec3> {
898 let longest = arcs.iter().copied().max_by(|x, y| {
899 x.start
900 .sub(x.end)
901 .length()
902 .total_cmp(&y.start.sub(y.end).length())
903 })?;
904 let (a, b) = (longest.start, longest.end);
905 let middle = a.add(b).normalized().ok()?;
906 let along = b.sub(a);
907 let tangent = along.sub(middle.scale(along.dot(middle)));
908 let normal = if outward_face_normal {
909 middle
910 } else {
911 middle.scale(-1.0)
912 };
913 let left = normal.cross(tangent).normalized().ok()?;
914 // Start well inside the arc's own sampling step, so the common case
915 // settles on the first try: this runs once per region build and a region
916 // is built once per trim query, in the innermost loop of the boolean.
917 let mut step = 0.1 * a.sub(b).length().max(1e-9);
918 for _ in 0..24 {
919 let inside = middle.add(left.scale(step)).normalized().ok()?;
920 let outside = middle.sub(left.scale(step)).normalized().ok()?;
921 if Self::crossings(arcs, groups, inside, outside) == 1 {
922 return Some(inside);
923 }
924 step *= 0.5;
925 }
926 None
927 }
928
929 fn crossings(arcs: &[Arc], groups: &[ArcGroup], from: Vec3, to: Vec3) -> usize {
930 // The leg's own plane, once for the whole walk: a leg that names no plane
931 // crosses nothing, which is what testing each arc against it used to
932 // conclude one arc at a time.
933 let Ok(leg) = from.cross(to).normalized() else {
934 return 0;
935 };
936 // Run by run, skipping a run whose cone clears the leg's plane: every
937 // arc of it would fail the straddle test, so the count is the walk's.
938 groups
939 .iter()
940 .filter(|group| !group.clear_of(leg))
941 .map(|group| {
942 arcs[group.first..group.last]
943 .iter()
944 .filter(|arc| arcs_cross(from, to, leg, arc))
945 .count()
946 })
947 .sum()
948 }
949
950 /// Crossing parity along a TWO-LEG path `from → via → to`.
951 ///
952 /// One straight leg is not enough. Two nearly antipodal endpoints do not name
953 /// a great circle at all — their cross product is noise — and a single leg
954 /// that grazes a boundary vertex can count a crossing twice or not at all.
955 /// Routing through a via point that is far from both endpoints makes each leg
956 /// well conditioned, and taking three different via points and a majority
957 /// makes a single grazed vertex harmless.
958 fn parity(arcs: &[Arc], groups: &[ArcGroup], from: Vec3, to: Vec3) -> bool {
959 let mut even = 0usize;
960 let mut odd = 0usize;
961 for index in 0..3 {
962 let Some(via) = Self::via_point(from, to, index) else {
963 continue;
964 };
965 let count = Self::crossings(arcs, groups, from, via) + Self::crossings(arcs, groups, via, to);
966 if count % 2 == 0 {
967 even += 1;
968 } else {
969 odd += 1;
970 }
971 }
972 even >= odd
973 }
974
975 /// A unit direction well away from both endpoints, deterministic in `index`.
976 fn via_point(from: Vec3, to: Vec3, index: usize) -> Option<Vec3> {
977 let axis = from.cross(to);
978 let base = match axis.normalized() {
979 Ok(unit) => unit,
980 // Antipodal or coincident: any direction off the pair will do.
981 Err(_) => from.perpendicular().ok()?,
982 };
983 let other = base.cross(from).normalized().ok()?;
984 let angle = std::f64::consts::TAU * index as f64 / 3.0;
985 base.scale(angle.cos())
986 .add(other.scale(angle.sin()))
987 .normalized()
988 .ok()
989 }
990
991 /// Whether the face has no material boundary at all — an untrimmed ball,
992 /// whose only "loops" were the seam slit and the two collapsed poles.
993 pub fn is_whole_sphere(&self) -> bool {
994 self.arcs.is_empty()
995 }
996
997 /// Whether this region can answer at all: either it has no boundary, or the
998 /// seed search cleared one.
999 ///
1000 /// A region with arcs but no seed must make its CALLER decline — answering
1001 /// anyway would report every point material, which in the mesh is silent
1002 /// double coverage and in the trim query is a face that swallowed the ball.
1003 /// A classifier that cannot decide has to say so.
1004 pub fn is_decidable(&self) -> bool {
1005 self.arcs.is_empty() || self.seed.is_some()
1006 }
1007
1008 /// Whether the direction of `point` from `centre` lies farther than `angle`
1009 /// (radians, under a quarter turn) from every boundary arc. A run of arcs is
1010 /// passed whole when the point is farther from its cone's axis than the cone's
1011 /// half-angle and `angle` together: the arcs between ends inside a cap under a
1012 /// hemisphere stay inside it.
1013 pub fn clear_of_boundary(&self, centre: Vec3, point: Vec3, angle: f64) -> bool {
1014 let Ok(probe) = point.sub(centre).normalized() else {
1015 return false;
1016 };
1017 let (cos_margin, sin_margin) = (angle.cos(), angle.sin());
1018 for group in &self.groups {
1019 if group.cos_half > 0.0 {
1020 let bound = group.cos_half * cos_margin - group.sin_half * sin_margin;
1021 if probe.dot(group.axis) < bound - 1e-12 {
1022 continue;
1023 }
1024 }
1025 if self.arcs[group.first..group.last].iter().any(|arc| arc_distance(arc.start, arc.end, arc.normal, probe) <= angle) {
1026 return false;
1027 }
1028 }
1029 true
1030 }
1031
1032 /// Whether the sphere point `point` is material. Pole-free and seam-free:
1033 /// the query never enters a parameter domain.
1034 pub fn contains(&self, centre: Vec3, point: Vec3) -> bool {
1035 let Ok(probe) = point.sub(centre).normalized() else {
1036 return false;
1037 };
1038 let Some(seed) = self.seed else {
1039 // No boundary at all: the face is the whole ball. A region that HAS a
1040 // boundary but no seed is not decidable and the caller must have
1041 // declined already ([`Self::is_decidable`]).
1042 return true;
1043 };
1044 Self::parity(&self.arcs, &self.groups, seed, probe)
1045 }
1046
1047 /// How many boundary arcs separate `point` from the region's seed. A caller
1048 /// letting one probe speak for a whole flood region prefers the probe whose
1049 /// count is smallest — it is the one furthest from a grazing arc.
1050 pub fn separation(&self, centre: Vec3, point: Vec3) -> usize {
1051 let Ok(probe) = point.sub(centre).normalized() else {
1052 return usize::MAX;
1053 };
1054 match self.seed {
1055 Some(seed) => Self::via_point(seed, probe, 0)
1056 .map(|via| {
1057 Self::crossings(&self.arcs, &self.groups, seed, via) + Self::crossings(&self.arcs, &self.groups, via, probe)
1058 })
1059 .unwrap_or(usize::MAX),
1060 None => 0,
1061 }
1062 }
1063}
1064
1065/// Collapse points that are the same point to ONE value.
1066///
1067/// Trim polylines assembled from per-coedge evaluations name a shared vertex
1068/// twice — once from each side — and the two evaluations agree only to rounding.
1069/// [`SphericalRegion`] cancels a slit by matching a segment against its exact
1070/// reverse, which those two nearly-equal values would defeat, so a consumer that
1071/// did not read its points from one shared table runs them through here first.
1072/// The tolerance is relative to the point cloud's own extent.
1073pub fn canonicalize_points(points: &mut [Vec3], relative_tolerance: f64) {
1074 let scale = points
1075 .iter()
1076 .map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs()))
1077 .fold(0.0, f64::max)
1078 .max(1.0);
1079 let cell = (relative_tolerance * scale).max(f64::MIN_POSITIVE);
1080 // One representative per cell, found by the 27-cell walk below in its own
1081 // fixed order; the table is only ever looked up by cell, never walked, so
1082 // the hasher cannot change which representative a point takes. Fx: the
1083 // walk is 27 lookups per point, 2.8 ms of a corner patch's first query
1084 // with the default hasher.
1085 let mut table: rustc_hash::FxHashMap<[i64; 3], Vec3> = rustc_hash::FxHashMap::default();
1086 // Occupancy of the cells, by a hash of the cell into 2^16 bits: a probe
1087 // whose bit is clear names a cell no point has been inserted at, so the
1088 // lookup is skipped; a set bit only sends the probe to the table. Of the
1089 // 27 probes a point makes, nearly every one is at an empty cell (a trim
1090 // of 2 500 samples merges a handful of shared vertices), and the walk
1091 // was 0.8 ms of a corner patch's first query in the table alone.
1092 let mut occupied = vec![0u64; 1024];
1093 let bit = |cell: [i64; 3]| -> (usize, u64) {
1094 let hash = (cell[0] as u64)
1095 .wrapping_mul(0x9e37_79b9_7f4a_7c15)
1096 .wrapping_add((cell[1] as u64).wrapping_mul(0xbf58_476d_1ce4_e5b9))
1097 .wrapping_add((cell[2] as u64).wrapping_mul(0x94d0_49bb_1331_11eb));
1098 let index = (hash >> 48) as usize;
1099 (index >> 6, 1u64 << (index & 63))
1100 };
1101 for point in points.iter_mut() {
1102 let base = [
1103 (point.x / cell).round() as i64,
1104 (point.y / cell).round() as i64,
1105 (point.z / cell).round() as i64,
1106 ];
1107 let mut found = None;
1108 'search: for dx in -1..=1 {
1109 for dy in -1..=1 {
1110 for dz in -1..=1 {
1111 let probe = [base[0] + dx, base[1] + dy, base[2] + dz];
1112 let (word, mask) = bit(probe);
1113 if occupied[word] & mask == 0 {
1114 continue;
1115 }
1116 if let Some(&existing) = table.get(&probe) {
1117 if existing.sub(*point).length() <= cell {
1118 found = Some(existing);
1119 break 'search;
1120 }
1121 }
1122 }
1123 }
1124 }
1125 match found {
1126 Some(existing) => *point = existing,
1127 None => {
1128 let (word, mask) = bit(base);
1129 occupied[word] |= mask;
1130 table.insert(base, *point);
1131 }
1132 }
1133 }
1134}
1135