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/// One directed boundary arc, with the great-circle plane it lies in.
699#[derive(Clone, Copy, Debug)]
700struct Arc {
701 start: Vec3,
702 end: Vec3,
703 /// The unit normal of `start × end`, or zero when the ends name no plane.
704 /// Precomputed: every crossing count in every query walks every arc.
705 normal: Vec3,
706}
707
708/// The trim of a spherical face, as directed boundary arcs on the unit sphere,
709/// ready to classify points without touching any chart or parameter domain.
710///
711/// Containment is decided by CROSSING PARITY against a seed the region carries:
712/// one point placed just to the material side of one boundary arc. The parity of
713/// the crossings between the seed and a query decides the query, and both the
714/// seed and the test are ordinary great-circle geometry — no parameter domain, no
715/// pole, no seam, and no reliance on a signed-area formula whose branch collapses
716/// when the query sits opposite a small loop (which is exactly the near-tangent
717/// pocket, and exactly where it was wrong).
718///
719/// The seed encodes the BREP convention in the one place it belongs: material
720/// lies to the LEFT of the directed boundary as seen from the FACE normal — the
721/// face's own normal, which for a cavity wall points into the sphere, not out of
722/// it.
723#[derive(Clone, Debug, Default)]
724pub struct SphericalRegion {
725 /// Directed boundary arcs as unit directions from the sphere centre, with
726 /// slits already cancelled.
727 arcs: Vec<Arc>,
728 /// A unit direction known to be material, or `None` when there is no
729 /// boundary at all (the whole sphere) or none usable.
730 seed: Option<Vec3>,
731}
732
733impl SphericalRegion {
734 /// Build the region from the face's trim loops given as closed 3D polylines.
735 ///
736 /// `outward_face_normal` says whether the FACE's normal points out of the
737 /// sphere — `same_sense == parameterization_is_outward`, never `same_sense`
738 /// alone and never the surface normal alone.
739 pub fn new(centre: Vec3, polylines: &[Vec<Vec3>], outward_face_normal: bool) -> Self {
740 let mut segments = Vec::new();
741 for polyline in polylines {
742 for index in 0..polyline.len() {
743 segments.push((polyline[index], polyline[(index + 1) % polyline.len()]));
744 }
745 }
746 Self::from_segments(centre, &segments, outward_face_normal)
747 }
748
749 /// As [`Self::new`], from the directed boundary segments themselves.
750 ///
751 /// A consumer that has one loop joining TWO rims through the seam — a ball
752 /// drilled through — MUST use this: concatenating that loop's surviving
753 /// coedges and closing the ring bridges rim to rim with two chords that are
754 /// not reverses of each other, and the region that comes out is not the one
755 /// the face means.
756 pub fn from_segments(centre: Vec3, segments: &[(Vec3, Vec3)], outward_face_normal: bool) -> Self {
757 use std::collections::HashMap;
758 type Key = [u64; 3];
759 let key = |p: Vec3| -> Key { [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()] };
760 let mut counts: HashMap<(Key, Key), usize> = HashMap::new();
761 let mut directed: Vec<(Vec3, Vec3)> = Vec::new();
762 for &(a, b) in segments {
763 if key(a) == key(b) {
764 // A collapsed pole loop: every sample is the same point.
765 continue;
766 }
767 *counts.entry((key(a), key(b))).or_insert(0) += 1;
768 directed.push((a, b));
769 }
770 // Cancel each directed segment against a matching reverse. The seam of a
771 // trimmed ball is used once each way by the SAME face, so both uses go;
772 // what is left is the material boundary and nothing else.
773 let mut budget: HashMap<(Key, Key), usize> = HashMap::new();
774 for (&(from, to), &forward) in &counts {
775 let backward = counts.get(&(to, from)).copied().unwrap_or(0);
776 budget.insert((from, to), forward.saturating_sub(backward.min(forward)));
777 }
778 let unit = |p: Vec3| p.sub(centre).normalized().ok();
779 let mut arcs: Vec<Arc> = Vec::new();
780 for (a, b) in directed {
781 let entry = budget.entry((key(a), key(b))).or_insert(0);
782 if *entry == 0 {
783 continue;
784 }
785 *entry -= 1;
786 if let (Some(a), Some(b)) = (unit(a), unit(b)) {
787 if a.sub(b).length() > 0.0 {
788 arcs.push(Arc {
789 start: a,
790 end: b,
791 normal: a.cross(b).normalized().unwrap_or_default(),
792 });
793 }
794 }
795 }
796 let seed = Self::seed_from(&arcs, outward_face_normal);
797 Self { arcs, seed }
798 }
799
800 /// A unit direction just to the MATERIAL side of the longest boundary arc.
801 ///
802 /// The offset is bisected until stepping the same distance to the other side
803 /// crosses the boundary exactly once: that is the proof the seed cleared the
804 /// arc it came from and reached no further boundary, so it is inside the
805 /// material and not merely near it. A region thinner than the first offset is
806 /// found by the bisection rather than mis-seeded.
807 fn seed_from(arcs: &[Arc], outward_face_normal: bool) -> Option<Vec3> {
808 let longest = arcs.iter().copied().max_by(|x, y| {
809 x.start
810 .sub(x.end)
811 .length()
812 .total_cmp(&y.start.sub(y.end).length())
813 })?;
814 let (a, b) = (longest.start, longest.end);
815 let middle = a.add(b).normalized().ok()?;
816 let along = b.sub(a);
817 let tangent = along.sub(middle.scale(along.dot(middle)));
818 let normal = if outward_face_normal {
819 middle
820 } else {
821 middle.scale(-1.0)
822 };
823 let left = normal.cross(tangent).normalized().ok()?;
824 // Start well inside the arc's own sampling step, so the common case
825 // settles on the first try: this runs once per region build and a region
826 // is built once per trim query, in the innermost loop of the boolean.
827 let mut step = 0.1 * a.sub(b).length().max(1e-9);
828 for _ in 0..24 {
829 let inside = middle.add(left.scale(step)).normalized().ok()?;
830 let outside = middle.sub(left.scale(step)).normalized().ok()?;
831 if Self::crossings(arcs, inside, outside) == 1 {
832 return Some(inside);
833 }
834 step *= 0.5;
835 }
836 None
837 }
838
839 fn crossings(arcs: &[Arc], from: Vec3, to: Vec3) -> usize {
840 // The leg's own plane, once for the whole walk: a leg that names no plane
841 // crosses nothing, which is what testing each arc against it used to
842 // conclude one arc at a time.
843 let Ok(leg) = from.cross(to).normalized() else {
844 return 0;
845 };
846 arcs.iter()
847 .filter(|arc| arcs_cross(from, to, leg, arc))
848 .count()
849 }
850
851 /// Crossing parity along a TWO-LEG path `from → via → to`.
852 ///
853 /// One straight leg is not enough. Two nearly antipodal endpoints do not name
854 /// a great circle at all — their cross product is noise — and a single leg
855 /// that grazes a boundary vertex can count a crossing twice or not at all.
856 /// Routing through a via point that is far from both endpoints makes each leg
857 /// well conditioned, and taking three different via points and a majority
858 /// makes a single grazed vertex harmless.
859 fn parity(arcs: &[Arc], from: Vec3, to: Vec3) -> bool {
860 let mut even = 0usize;
861 let mut odd = 0usize;
862 for index in 0..3 {
863 let Some(via) = Self::via_point(from, to, index) else {
864 continue;
865 };
866 let count = Self::crossings(arcs, from, via) + Self::crossings(arcs, via, to);
867 if count % 2 == 0 {
868 even += 1;
869 } else {
870 odd += 1;
871 }
872 }
873 even >= odd
874 }
875
876 /// A unit direction well away from both endpoints, deterministic in `index`.
877 fn via_point(from: Vec3, to: Vec3, index: usize) -> Option<Vec3> {
878 let axis = from.cross(to);
879 let base = match axis.normalized() {
880 Ok(unit) => unit,
881 // Antipodal or coincident: any direction off the pair will do.
882 Err(_) => from.perpendicular().ok()?,
883 };
884 let other = base.cross(from).normalized().ok()?;
885 let angle = std::f64::consts::TAU * index as f64 / 3.0;
886 base.scale(angle.cos())
887 .add(other.scale(angle.sin()))
888 .normalized()
889 .ok()
890 }
891
892 /// Whether the face has no material boundary at all — an untrimmed ball,
893 /// whose only "loops" were the seam slit and the two collapsed poles.
894 pub fn is_whole_sphere(&self) -> bool {
895 self.arcs.is_empty()
896 }
897
898 /// Whether this region can answer at all: either it has no boundary, or the
899 /// seed search cleared one.
900 ///
901 /// A region with arcs but no seed must make its CALLER decline — answering
902 /// anyway would report every point material, which in the mesh is silent
903 /// double coverage and in the trim query is a face that swallowed the ball.
904 /// A classifier that cannot decide has to say so.
905 pub fn is_decidable(&self) -> bool {
906 self.arcs.is_empty() || self.seed.is_some()
907 }
908
909 /// Whether the sphere point `point` is material. Pole-free and seam-free:
910 /// the query never enters a parameter domain.
911 pub fn contains(&self, centre: Vec3, point: Vec3) -> bool {
912 let Ok(probe) = point.sub(centre).normalized() else {
913 return false;
914 };
915 let Some(seed) = self.seed else {
916 // No boundary at all: the face is the whole ball. A region that HAS a
917 // boundary but no seed is not decidable and the caller must have
918 // declined already ([`Self::is_decidable`]).
919 return true;
920 };
921 Self::parity(&self.arcs, seed, probe)
922 }
923
924 /// How many boundary arcs separate `point` from the region's seed. A caller
925 /// letting one probe speak for a whole flood region prefers the probe whose
926 /// count is smallest — it is the one furthest from a grazing arc.
927 pub fn separation(&self, centre: Vec3, point: Vec3) -> usize {
928 let Ok(probe) = point.sub(centre).normalized() else {
929 return usize::MAX;
930 };
931 match self.seed {
932 Some(seed) => Self::via_point(seed, probe, 0)
933 .map(|via| {
934 Self::crossings(&self.arcs, seed, via) + Self::crossings(&self.arcs, via, probe)
935 })
936 .unwrap_or(usize::MAX),
937 None => 0,
938 }
939 }
940}
941
942/// Collapse points that are the same point to ONE value.
943///
944/// Trim polylines assembled from per-coedge evaluations name a shared vertex
945/// twice — once from each side — and the two evaluations agree only to rounding.
946/// [`SphericalRegion`] cancels a slit by matching a segment against its exact
947/// reverse, which those two nearly-equal values would defeat, so a consumer that
948/// did not read its points from one shared table runs them through here first.
949/// The tolerance is relative to the point cloud's own extent.
950pub fn canonicalize_points(points: &mut [Vec3], relative_tolerance: f64) {
951 let scale = points
952 .iter()
953 .map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs()))
954 .fold(0.0, f64::max)
955 .max(1.0);
956 let cell = (relative_tolerance * scale).max(f64::MIN_POSITIVE);
957 let mut table: std::collections::HashMap<[i64; 3], Vec3> = std::collections::HashMap::new();
958 for point in points.iter_mut() {
959 let base = [
960 (point.x / cell).round() as i64,
961 (point.y / cell).round() as i64,
962 (point.z / cell).round() as i64,
963 ];
964 let mut found = None;
965 'search: for dx in -1..=1 {
966 for dy in -1..=1 {
967 for dz in -1..=1 {
968 let probe = [base[0] + dx, base[1] + dy, base[2] + dz];
969 if let Some(&existing) = table.get(&probe) {
970 if existing.sub(*point).length() <= cell {
971 found = Some(existing);
972 break 'search;
973 }
974 }
975 }
976 }
977 }
978 match found {
979 Some(existing) => *point = existing,
980 None => {
981 table.insert(base, *point);
982 }
983 }
984 }
985}
986
987// BREP private tests: 6b6f1c0e2ab4c7d1