Skip to content

Commit 1c04b76

Browse files
committed
feat(s2): centroids
1 parent c26606e commit 1c04b76

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

s2/centroids.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { Vector } from '../r3/Vector'
2+
import { Point } from './Point'
3+
4+
/**
5+
* There are several notions of the "centroid" of a triangle. First, there
6+
* is the planar centroid, which is simply the centroid of the ordinary
7+
* (non-spherical) triangle defined by the three vertices. Second, there is
8+
* the surface centroid, which is defined as the intersection of the three
9+
* medians of the spherical triangle. It is possible to show that this
10+
* point is simply the planar centroid projected to the surface of the
11+
* sphere. Finally, there is the true centroid (mass centroid), which is
12+
* defined as the surface integral over the spherical triangle of (x,y,z)
13+
* divided by the triangle area. This is the point that the triangle would
14+
* rotate around if it was spinning in empty space.
15+
*
16+
* The best centroid for most purposes is the true centroid. Unlike the
17+
* planar and surface centroids, the true centroid behaves linearly as
18+
* regions are added or subtracted. That is, if you split a triangle into
19+
* pieces and compute the average of their centroids (weighted by triangle
20+
* area), the result equals the centroid of the original triangle. This is
21+
* not true of the other centroids.
22+
*
23+
* Also note that the surface centroid may be nowhere near the intuitive
24+
* "center" of a spherical triangle. For example, consider the triangle
25+
* with vertices A=(1,eps,0), B=(0,0,1), C=(-1,eps,0) (a quarter-sphere).
26+
* The surface centroid of this triangle is at S=(0, 2*eps, 1), which is
27+
* within a distance of 2*eps of the vertex B. Note that the median from A
28+
* (the segment connecting A to the midpoint of BC) passes through S, since
29+
* this is the shortest path connecting the two endpoints. On the other
30+
* hand, the true centroid is at M=(0, 0.5, 0.5), which when projected onto
31+
* the surface is a much more reasonable interpretation of the "center" of
32+
* this triangle.
33+
*/
34+
35+
/**
36+
* Returns the true centroid of the spherical triangle ABC
37+
* multiplied by the signed area of spherical triangle ABC. The reasons for
38+
* multiplying by the signed area are (1) this is the quantity that needs to be
39+
* summed to compute the centroid of a union or difference of triangles, and
40+
* (2) it's actually easier to calculate this way. All points must have unit length.
41+
*
42+
* Note that the result of this function is defined to be Point(0, 0, 0) if
43+
* the triangle is degenerate.
44+
*/
45+
export const trueCentroid = (a: Point, b: Point, c: Point): Point => {
46+
// Use Distance to get accurate results for small triangles.
47+
let ra = 1
48+
const sa = b.distance(c)
49+
if (sa !== 0) ra = sa / Math.sin(sa)
50+
51+
let rb = 1
52+
const sb = c.distance(a)
53+
if (sb !== 0) rb = sb / Math.sin(sb)
54+
55+
let rc = 1
56+
const sc = a.distance(b)
57+
if (sc !== 0) rc = sc / Math.sin(sc)
58+
59+
// Now compute a point M such that:
60+
//
61+
// [Ax Ay Az] [Mx] [ra]
62+
// [Bx By Bz] [My] = 0.5 * det(A,B,C) * [rb]
63+
// [Cx Cy Cz] [Mz] [rc]
64+
//
65+
// To improve the numerical stability we subtract the first row (A) from the
66+
// other two rows; this reduces the cancellation error when A, B, and C are
67+
// very close together. Then we solve it using Cramer's rule.
68+
//
69+
// The result is the true centroid of the triangle multiplied by the
70+
// triangle's area.
71+
//
72+
// This code still isn't as numerically stable as it could be.
73+
// The biggest potential improvement is to compute B-A and C-A more
74+
// accurately so that (B-A)x(C-A) is always inside triangle ABC.
75+
const x = new Vector(a.x, b.x - a.x, c.x - a.x)
76+
const y = new Vector(a.y, b.y - a.y, c.y - a.y)
77+
const z = new Vector(a.z, b.z - a.z, c.z - a.z)
78+
const r = new Vector(ra, rb - ra, rc - ra)
79+
80+
return Point.fromVector(new Vector(y.cross(z).dot(r), z.cross(x).dot(r), x.cross(y).dot(r)).mul(0.5))
81+
}
82+
83+
/**
84+
* Returns the true centroid of the spherical geodesic edge AB
85+
* multiplied by the length of the edge AB. As with triangles, the true centroid
86+
* of a collection of line segments may be computed simply by summing the result
87+
* of this method for each segment.
88+
*
89+
* Note that the planar centroid of a line segment is simply 0.5 * (a + b),
90+
* while the surface centroid is (a + b).Normalize(). However neither of
91+
* these values is appropriate for computing the centroid of a collection of
92+
* edges (such as a polyline).
93+
*
94+
* Also note that the result of this function is defined to be Point(0, 0, 0)
95+
* if the edge is degenerate.
96+
*/
97+
export const edgeTrueCentroid = (a: Point, b: Point): Point => {
98+
// The centroid (multiplied by length) is a vector toward the midpoint
99+
// of the edge, whose length is twice the sine of half the angle between
100+
// the two vertices. Defining theta to be this angle, we have:
101+
const vDiff = a.vector.sub(b.vector) // Length == 2*sin(theta)
102+
const vSum = a.vector.add(b.vector) // Length == 2*cos(theta)
103+
const sin2 = vDiff.norm2()
104+
const cos2 = vSum.norm2()
105+
106+
if (cos2 === 0) return new Point(0, 0, 0) // Ignore antipodal edges.
107+
108+
return Point.fromVector(vSum.mul(Math.sqrt(sin2 / cos2))) // Length == 2*sin(theta)
109+
}
110+
111+
/**
112+
* Returns the centroid of the planar triangle ABC. This can be
113+
* normalized to unit length to obtain the "surface centroid" of the corresponding
114+
* spherical triangle, i.e. the intersection of the three medians. However, note
115+
* that for large spherical triangles the surface centroid may be nowhere near
116+
* the intuitive "center".
117+
*/
118+
export const planarCentroid = (a: Point, b: Point, c: Point): Point => {
119+
return Point.fromVector(
120+
a.vector
121+
.add(b.vector)
122+
.add(c.vector)
123+
.mul(1 / 3)
124+
)
125+
}

s2/centroids_test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { test, describe } from 'node:test'
2+
import { equal, ok } from 'node:assert/strict'
3+
import { Point } from './Point'
4+
import { edgeTrueCentroid, planarCentroid, trueCentroid } from './centroids'
5+
import { randomFrame, randomFrameAtPoint, randomPoint } from './testing'
6+
import * as matrix from './matrix3x3'
7+
8+
describe('s2.centroids', () => {
9+
test('planarCentroid', () => {
10+
const tests = [
11+
{
12+
name: 'xyz axis',
13+
p0: new Point(0, 0, 1),
14+
p1: new Point(0, 1, 0),
15+
p2: new Point(1, 0, 0),
16+
want: new Point(1 / 3, 1 / 3, 1 / 3)
17+
},
18+
{
19+
name: 'Same point',
20+
p0: new Point(1, 0, 0),
21+
p1: new Point(1, 0, 0),
22+
p2: new Point(1, 0, 0),
23+
want: new Point(1, 0, 0)
24+
}
25+
]
26+
27+
for (const { name, p0, p1, p2, want } of tests) {
28+
const got = planarCentroid(p0, p1, p2)
29+
ok(got.approxEqual(want), `${name}: PlanarCentroid(${p0}, ${p1}, ${p2}) = ${got}, want ${want}`)
30+
}
31+
})
32+
33+
test('trueCentroid', () => {
34+
for (let i = 0; i < 100; i++) {
35+
const f = randomFrame()
36+
const p = matrix.col(f, 0)
37+
const x = matrix.col(f, 1)
38+
const y = matrix.col(f, 2)
39+
const d = 1e-4 * Math.pow(1e-4, Math.random())
40+
41+
let p0 = Point.fromVector(p.vector.sub(x.vector.mul(d)).normalize())
42+
let p1 = Point.fromVector(p.vector.add(x.vector.mul(d)).normalize())
43+
let p2 = Point.fromVector(p.vector.add(y.vector.mul(d * 3)).normalize())
44+
let want = Point.fromVector(p.vector.add(y.vector.mul(d)).normalize())
45+
46+
let got = trueCentroid(p0, p1, p2).vector.normalize()
47+
ok(got.distance(want.vector) < 2e-8, `TrueCentroid(${p0}, ${p1}, ${p2}).Normalize() = ${got}, want ${want}`)
48+
49+
p0 = Point.fromVector(p.vector)
50+
p1 = Point.fromVector(p.vector.add(x.vector.mul(d * 3)).normalize())
51+
p2 = Point.fromVector(p.vector.add(y.vector.mul(d * 6)).normalize())
52+
want = Point.fromVector(p.vector.add(x.vector.add(y.vector.mul(2)).mul(d)).normalize())
53+
54+
got = trueCentroid(p0, p1, p2).vector.normalize()
55+
ok(got.distance(want.vector) < 2e-8, `TrueCentroid(${p0}, ${p1}, ${p2}).Normalize() = ${got}, want ${want}`)
56+
}
57+
})
58+
59+
test('edgeTrueCentroid semi-circles', () => {
60+
const a = Point.fromCoords(0, -1, 0)
61+
const b = Point.fromCoords(1, 0, 0)
62+
const c = Point.fromCoords(0, 1, 0)
63+
const centroid = Point.fromVector(edgeTrueCentroid(a, b).vector.add(edgeTrueCentroid(b, c).vector))
64+
65+
ok(
66+
b.approxEqual(Point.fromVector(centroid.vector.normalize())),
67+
`EdgeTrueCentroid(${a}, ${b}) + EdgeTrueCentroid(${b}, ${c}) = ${centroid}, want ${b}`
68+
)
69+
equal(centroid.vector.norm(), 2.0, `${centroid}.Norm() = ${centroid.vector.norm()}, want 2.0`)
70+
})
71+
72+
test('edgeTrueCentroid great-circles', () => {
73+
for (let iter = 0; iter < 100; iter++) {
74+
const f = randomFrameAtPoint(randomPoint())
75+
const x = matrix.col(f, 0)
76+
const y = matrix.col(f, 1)
77+
78+
let centroid = new Point(0, 0, 0)
79+
80+
let v0 = x
81+
for (let theta = 0.0; theta < 2 * Math.PI; theta += Math.pow(Math.random(), 10)) {
82+
const v1 = Point.fromVector(x.vector.mul(Math.cos(theta)).add(y.vector.mul(Math.sin(theta))))
83+
centroid = Point.fromVector(centroid.vector.add(edgeTrueCentroid(v0, v1).vector))
84+
v0 = v1
85+
}
86+
87+
centroid = Point.fromVector(centroid.vector.add(edgeTrueCentroid(v0, x).vector))
88+
ok(centroid.vector.norm() <= 2e-14, `${centroid}.Norm() = ${centroid.vector.norm()}, want <= 2e-14`)
89+
}
90+
})
91+
})

0 commit comments

Comments
 (0)