@math.gl/culling 4.2.0-alpha.3 → 4.2.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +51 -0
  2. package/README.md +8 -1
  3. package/dist/constants.d.ts +10 -5
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/constants.js +9 -5
  6. package/dist/constants.js.map +1 -1
  7. package/dist/index.cjs +640 -113
  8. package/dist/index.cjs.map +3 -4
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +5 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/bounding-volumes/axis-aligned-bounding-box.d.ts +2 -1
  14. package/dist/lib/bounding-volumes/axis-aligned-bounding-box.d.ts.map +1 -1
  15. package/dist/lib/bounding-volumes/axis-aligned-bounding-box.js +3 -4
  16. package/dist/lib/bounding-volumes/axis-aligned-bounding-box.js.map +1 -1
  17. package/dist/lib/bounding-volumes/bounding-sphere.d.ts +2 -1
  18. package/dist/lib/bounding-volumes/bounding-sphere.d.ts.map +1 -1
  19. package/dist/lib/bounding-volumes/bounding-sphere.js +3 -4
  20. package/dist/lib/bounding-volumes/bounding-sphere.js.map +1 -1
  21. package/dist/lib/bounding-volumes/bounding-volume.d.ts +5 -4
  22. package/dist/lib/bounding-volumes/bounding-volume.d.ts.map +1 -1
  23. package/dist/lib/bounding-volumes/oriented-bounding-box.d.ts +2 -1
  24. package/dist/lib/bounding-volumes/oriented-bounding-box.d.ts.map +1 -1
  25. package/dist/lib/bounding-volumes/oriented-bounding-box.js +3 -4
  26. package/dist/lib/bounding-volumes/oriented-bounding-box.js.map +1 -1
  27. package/dist/lib/culling-volume.d.ts +2 -1
  28. package/dist/lib/culling-volume.d.ts.map +1 -1
  29. package/dist/lib/culling-volume.js +7 -8
  30. package/dist/lib/culling-volume.js.map +1 -1
  31. package/dist/lib/plane.d.ts +2 -2
  32. package/dist/lib/plane.d.ts.map +1 -1
  33. package/dist/lib/plane.js +3 -6
  34. package/dist/lib/plane.js.map +1 -1
  35. package/dist/lib/shapes/box-shape.d.ts +19 -0
  36. package/dist/lib/shapes/box-shape.d.ts.map +1 -0
  37. package/dist/lib/shapes/box-shape.js +69 -0
  38. package/dist/lib/shapes/box-shape.js.map +1 -0
  39. package/dist/lib/shapes/capsule-shape.d.ts +23 -0
  40. package/dist/lib/shapes/capsule-shape.d.ts.map +1 -0
  41. package/dist/lib/shapes/capsule-shape.js +151 -0
  42. package/dist/lib/shapes/capsule-shape.js.map +1 -0
  43. package/dist/lib/shapes/cylinder-shape.d.ts +22 -0
  44. package/dist/lib/shapes/cylinder-shape.d.ts.map +1 -0
  45. package/dist/lib/shapes/cylinder-shape.js +99 -0
  46. package/dist/lib/shapes/cylinder-shape.js.map +1 -0
  47. package/dist/lib/shapes/implicit-shape.d.ts +56 -0
  48. package/dist/lib/shapes/implicit-shape.d.ts.map +1 -0
  49. package/dist/lib/shapes/implicit-shape.js +142 -0
  50. package/dist/lib/shapes/implicit-shape.js.map +1 -0
  51. package/dist/lib/shapes/plane-shape.d.ts +27 -0
  52. package/dist/lib/shapes/plane-shape.d.ts.map +1 -0
  53. package/dist/lib/shapes/plane-shape.js +71 -0
  54. package/dist/lib/shapes/plane-shape.js.map +1 -0
  55. package/dist/lib/shapes/sphere-shape.d.ts +18 -0
  56. package/dist/lib/shapes/sphere-shape.d.ts.map +1 -0
  57. package/dist/lib/shapes/sphere-shape.js +43 -0
  58. package/dist/lib/shapes/sphere-shape.js.map +1 -0
  59. package/package.json +4 -4
  60. package/src/constants.ts +11 -5
  61. package/src/index.ts +13 -0
  62. package/src/lib/bounding-volumes/axis-aligned-bounding-box.ts +5 -5
  63. package/src/lib/bounding-volumes/bounding-sphere.ts +5 -5
  64. package/src/lib/bounding-volumes/bounding-volume.ts +5 -4
  65. package/src/lib/bounding-volumes/oriented-bounding-box.ts +5 -5
  66. package/src/lib/culling-volume.ts +9 -9
  67. package/src/lib/plane.ts +3 -6
  68. package/src/lib/shapes/box-shape.ts +78 -0
  69. package/src/lib/shapes/capsule-shape.ts +232 -0
  70. package/src/lib/shapes/cylinder-shape.ts +133 -0
  71. package/src/lib/shapes/implicit-shape.ts +209 -0
  72. package/src/lib/shapes/plane-shape.ts +78 -0
  73. package/src/lib/shapes/sphere-shape.ts +60 -0
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {NumericArray, Vector3, mat4} from '@math.gl/core';
9
9
 
10
- import {INTERSECTION} from '../../constants';
10
+ import type {CullingResult} from '../../constants';
11
11
  import {BoundingVolume} from './bounding-volume';
12
12
  import {Plane} from '../plane';
13
13
 
@@ -131,7 +131,7 @@ export class BoundingSphere implements BoundingVolume {
131
131
  }
132
132
 
133
133
  /** Determines which side of a plane a sphere is located. */
134
- intersectPlane(plane: Plane): number {
134
+ intersectPlane(plane: Plane): CullingResult {
135
135
  const center = this.center;
136
136
  const radius = this.radius;
137
137
  const normal = plane.normal;
@@ -139,13 +139,13 @@ export class BoundingSphere implements BoundingVolume {
139
139
 
140
140
  // The center point is negative side of the plane normal
141
141
  if (distanceToPlane < -radius) {
142
- return INTERSECTION.OUTSIDE;
142
+ return 'outside';
143
143
  }
144
144
  // The center point is positive side of the plane, but radius extends beyond it; partial overlap
145
145
  if (distanceToPlane < radius) {
146
- return INTERSECTION.INTERSECTING;
146
+ return 'intersecting';
147
147
  }
148
148
  // The center point and radius is positive side of the plane
149
- return INTERSECTION.INSIDE;
149
+ return 'inside';
150
150
  }
151
151
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  // import {INTERSECTION} from '../../constants';
6
6
  import {Plane} from '../plane';
7
+ import type {CullingResult} from '../../constants';
7
8
 
8
9
  /**
9
10
  * Common interface for BoundingVolumes
@@ -28,9 +29,9 @@ export interface BoundingVolume {
28
29
  *
29
30
  * @param plane The plane to test against.
30
31
  * @returns
31
- * - `INTERSECTION.INSIDE` if the entire box is on the side of the plane the normal is pointing.
32
- * - `INTERSECTION.OUTSIDE` if the entire box is on the opposite side.
33
- * - `INTERSECTION.INTERSECTING` if the box intersects the plane.
32
+ * - `'inside'` if the entire box is on the side of the plane the normal is pointing.
33
+ * - `'outside'` if the entire box is on the opposite side.
34
+ * - `'intersecting'` if the box intersects the plane.
34
35
  */
35
- intersectPlane(plane: Plane): number;
36
+ intersectPlane(plane: Plane): CullingResult;
36
37
  }
@@ -11,7 +11,7 @@ import {Vector3, Matrix3, Matrix4, Quaternion} from '@math.gl/core';
11
11
  import type {BoundingVolume} from './bounding-volume';
12
12
  import {BoundingSphere} from './bounding-sphere';
13
13
  import {Plane} from '../plane';
14
- import {INTERSECTION} from '../../constants';
14
+ import type {CullingResult} from '../../constants';
15
15
 
16
16
  const scratchVector3 = new Vector3();
17
17
  const scratchOffset = new Vector3();
@@ -129,7 +129,7 @@ export class OrientedBoundingBox implements BoundingVolume {
129
129
  }
130
130
 
131
131
  /** Determines which side of a plane the oriented bounding box is located. */
132
- intersectPlane(plane: Plane): number {
132
+ intersectPlane(plane: Plane): CullingResult {
133
133
  const center = this.center;
134
134
  const normal = plane.normal;
135
135
  const halfAxes = this.halfAxes;
@@ -159,12 +159,12 @@ export class OrientedBoundingBox implements BoundingVolume {
159
159
 
160
160
  if (distanceToPlane <= -radEffective) {
161
161
  // The entire box is on the negative side of the plane normal
162
- return INTERSECTION.OUTSIDE;
162
+ return 'outside';
163
163
  } else if (distanceToPlane >= radEffective) {
164
164
  // The entire box is on the positive side of the plane normal
165
- return INTERSECTION.INSIDE;
165
+ return 'inside';
166
166
  }
167
- return INTERSECTION.INTERSECTING;
167
+ return 'intersecting';
168
168
  }
169
169
 
170
170
  /** Computes the estimated distance from the closest point on a bounding box to a point. */
@@ -7,7 +7,7 @@
7
7
 
8
8
  /* eslint-disable */
9
9
  import {Vector3, assert} from '@math.gl/core';
10
- import {INTERSECTION} from '../constants';
10
+ import type {CullingResult} from '../constants';
11
11
  import {Plane} from './plane';
12
12
  import type {BoundingVolume} from './bounding-volumes/bounding-volume';
13
13
  import type {BoundingSphere} from './bounding-volumes/bounding-sphere';
@@ -93,19 +93,19 @@ export class CullingVolume {
93
93
  }
94
94
 
95
95
  /** Determines whether a bounding volume intersects the culling volume. */
96
- computeVisibility(boundingVolume: BoundingVolume): number {
96
+ computeVisibility(boundingVolume: BoundingVolume): CullingResult {
97
97
  // const planes = this.planes;
98
- let intersect: number = INTERSECTION.INSIDE;
98
+ let intersect: CullingResult = 'inside';
99
99
  for (const plane of this.planes) {
100
100
  const result = boundingVolume.intersectPlane(plane);
101
101
  switch (result) {
102
- case INTERSECTION.OUTSIDE:
102
+ case 'outside':
103
103
  // We are done
104
- return INTERSECTION.OUTSIDE;
104
+ return 'outside';
105
105
 
106
- case INTERSECTION.INTERSECTING:
106
+ case 'intersecting':
107
107
  // If no other intersection is outside, return INTERSECTING
108
- intersect = INTERSECTION.INTERSECTING;
108
+ intersect = 'intersecting';
109
109
  break;
110
110
 
111
111
  default:
@@ -149,9 +149,9 @@ export class CullingVolume {
149
149
 
150
150
  const plane = planes[k];
151
151
  const result = boundingVolume.intersectPlane(plane);
152
- if (result === INTERSECTION.OUTSIDE) {
152
+ if (result === 'outside') {
153
153
  return CullingVolume.MASK_OUTSIDE;
154
- } else if (result === INTERSECTION.INTERSECTING) {
154
+ } else if (result === 'intersecting') {
155
155
  mask |= flag;
156
156
  }
157
157
  }
package/src/lib/plane.ts CHANGED
@@ -93,11 +93,9 @@ export class Plane {
93
93
  *
94
94
  * @param {Ray} ray The ray.
95
95
  * @param {Vector3} [result] The object onto which to store the result.
96
- * @returns {Vector3} The intersection point or undefined if there is no intersections.
96
+ * @returns {Vector3} The intersection point or undefined if there is no intersection.
97
97
  */
98
- intersectWithRay(ray: Ray, result?: Vector3): Vector3 {
99
- if (!result) result = new Vector3();
100
-
98
+ intersectWithRay(ray: Ray, result: Vector3 = new Vector3()): Vector3 | undefined {
101
99
  const origin = ray.origin;
102
100
  const direction = ray.direction;
103
101
  const normal = this.normal;
@@ -113,7 +111,6 @@ export class Plane {
113
111
  return undefined;
114
112
  }
115
113
 
116
- result = result.copy(direction).multiplyByScalar(t);
117
- return origin.add(result);
114
+ return result.copy(direction).multiplyByScalar(t).add(origin);
118
115
  }
119
116
  }
@@ -0,0 +1,78 @@
1
+ // math.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {ImplicitShapeBase, type ImplicitShape, type LocalRayIntersection} from './implicit-shape';
6
+
7
+ const EPSILON = 1e-6;
8
+
9
+ export type BoxShapeProps = {size?: readonly [number, number, number]; matrix?: readonly number[]};
10
+
11
+ /** Analytic glTF box shape. */
12
+ export class BoxShape extends ImplicitShapeBase {
13
+ readonly type = 'box' as const;
14
+ readonly size: readonly [number, number, number];
15
+ private readonly halfSize: readonly [number, number, number];
16
+
17
+ constructor(props: BoxShapeProps = {}) {
18
+ super(props.matrix);
19
+ this.size = props.size ? [...props.size] : [1, 1, 1];
20
+ if (this.size.some(value => !Number.isFinite(value) || value <= 0))
21
+ throw new Error('Box size values must be greater than zero');
22
+ this.halfSize = [this.size[0] / 2, this.size[1] / 2, this.size[2] / 2];
23
+ }
24
+
25
+ clone(): BoxShape {
26
+ return new BoxShape({size: this.size, matrix: this.matrix});
27
+ }
28
+ override equals(shape: ImplicitShape): boolean {
29
+ return (
30
+ shape instanceof BoxShape &&
31
+ super.equals(shape) &&
32
+ this.size.every((value, i) => value === shape.size[i])
33
+ );
34
+ }
35
+ containsLocalPoint(point: readonly number[]): boolean {
36
+ return point.every((value, i) => Math.abs(value) <= this.halfSize[i] + EPSILON);
37
+ }
38
+ distanceToLocalPoint(point: readonly number[]): number {
39
+ return Math.hypot(...point.map((value, i) => Math.max(0, Math.abs(value) - this.halfSize[i])));
40
+ }
41
+ supportLocal(direction: readonly number[]): readonly number[] {
42
+ return direction.map((value, i) => (value < 0 ? -this.halfSize[i] : this.halfSize[i]));
43
+ }
44
+ intersectLocalRay(
45
+ origin: readonly number[],
46
+ direction: readonly number[]
47
+ ): LocalRayIntersection | undefined {
48
+ let near = -Infinity;
49
+ let far = Infinity;
50
+ let nearAxis = 0;
51
+ let farAxis = 0;
52
+ for (let axis = 0; axis < 3; axis++) {
53
+ if (Math.abs(direction[axis]) < 1e-15) {
54
+ if (Math.abs(origin[axis]) > this.halfSize[axis]) return undefined;
55
+ continue;
56
+ }
57
+ let t1 = (-this.halfSize[axis] - origin[axis]) / direction[axis];
58
+ let t2 = (this.halfSize[axis] - origin[axis]) / direction[axis];
59
+ if (t1 > t2) [t1, t2] = [t2, t1];
60
+ if (t1 > near) {
61
+ near = t1;
62
+ nearAxis = axis;
63
+ }
64
+ if (t2 < far) {
65
+ far = t2;
66
+ farAxis = axis;
67
+ }
68
+ if (near > far) return undefined;
69
+ }
70
+ const distance = near >= 0 ? near : far;
71
+ if (distance < 0 || !Number.isFinite(distance)) return undefined;
72
+ const axis = near >= 0 ? nearAxis : farAxis;
73
+ const pointCoordinate = origin[axis] + distance * direction[axis];
74
+ const normal = [0, 0, 0];
75
+ normal[axis] = pointCoordinate < 0 ? -1 : 1;
76
+ return {distance, normal};
77
+ }
78
+ }
@@ -0,0 +1,232 @@
1
+ // math.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {
6
+ closestDistanceToCircleArc2D,
7
+ closestDistanceToSegment2D,
8
+ ImplicitShapeBase,
9
+ solveQuadratic,
10
+ type ImplicitShape,
11
+ type LocalRayIntersection
12
+ } from './implicit-shape';
13
+
14
+ const EPSILON = 1e-6;
15
+
16
+ export type CapsuleShapeProps = {
17
+ height?: number;
18
+ radiusBottom?: number;
19
+ radiusTop?: number;
20
+ matrix?: readonly number[];
21
+ };
22
+
23
+ /** Analytic glTF capsule: the convex hull of two spheres on the Y axis. */
24
+ export class CapsuleShape extends ImplicitShapeBase {
25
+ readonly type = 'capsule' as const;
26
+ readonly height: number;
27
+ readonly radiusBottom: number;
28
+ readonly radiusTop: number;
29
+
30
+ constructor(props: CapsuleShapeProps = {}) {
31
+ super(props.matrix);
32
+ this.height = props.height ?? 1;
33
+ this.radiusBottom = props.radiusBottom ?? 0.5;
34
+ this.radiusTop = props.radiusTop ?? 0.5;
35
+ if (!Number.isFinite(this.height) || this.height <= 0)
36
+ throw new Error('Capsule height must be greater than zero');
37
+ if (![this.radiusBottom, this.radiusTop].every(value => Number.isFinite(value) && value >= 0))
38
+ throw new Error('Capsule radii must be non-negative');
39
+ if (this.radiusBottom === 0 && this.radiusTop === 0)
40
+ throw new Error('At least one capsule radius must be greater than zero');
41
+ }
42
+
43
+ clone(): CapsuleShape {
44
+ return new CapsuleShape({
45
+ height: this.height,
46
+ radiusBottom: this.radiusBottom,
47
+ radiusTop: this.radiusTop,
48
+ matrix: this.matrix
49
+ });
50
+ }
51
+ override equals(shape: ImplicitShape): boolean {
52
+ return (
53
+ shape instanceof CapsuleShape &&
54
+ super.equals(shape) &&
55
+ this.height === shape.height &&
56
+ this.radiusBottom === shape.radiusBottom &&
57
+ this.radiusTop === shape.radiusTop
58
+ );
59
+ }
60
+ containsLocalPoint(point: readonly number[]): boolean {
61
+ const profile = this.getProfile();
62
+ const radial = Math.hypot(point[0], point[2]);
63
+ if (profile.sphere)
64
+ return (
65
+ Math.hypot(radial, point[1] - profile.sphere.center) <= profile.sphere.radius + EPSILON
66
+ );
67
+ if (point[1] < profile.bottom.y)
68
+ return Math.hypot(radial, point[1] + this.height / 2) <= this.radiusBottom + EPSILON;
69
+ if (point[1] > profile.top.y)
70
+ return Math.hypot(radial, point[1] - this.height / 2) <= this.radiusTop + EPSILON;
71
+ const t = (point[1] - profile.bottom.y) / (profile.top.y - profile.bottom.y);
72
+ return (
73
+ radial <= profile.bottom.radius + (profile.top.radius - profile.bottom.radius) * t + EPSILON
74
+ );
75
+ }
76
+ distanceToLocalPoint(point: readonly number[]): number {
77
+ if (this.containsLocalPoint(point)) return 0;
78
+ const profile = this.getProfile();
79
+ const radial = Math.hypot(point[0], point[2]);
80
+ if (profile.sphere)
81
+ return Math.max(
82
+ 0,
83
+ Math.hypot(radial, point[1] - profile.sphere.center) - profile.sphere.radius
84
+ );
85
+ const delta = this.radiusTop - this.radiusBottom;
86
+ const sideNormalY = -delta / this.height;
87
+ const bottomArc = closestDistanceToCircleArc2D(
88
+ radial,
89
+ point[1],
90
+ -this.height / 2,
91
+ this.radiusBottom,
92
+ -1,
93
+ sideNormalY
94
+ );
95
+ const topArc = closestDistanceToCircleArc2D(
96
+ radial,
97
+ point[1],
98
+ this.height / 2,
99
+ this.radiusTop,
100
+ sideNormalY,
101
+ 1
102
+ );
103
+ const side = closestDistanceToSegment2D(
104
+ radial,
105
+ point[1],
106
+ profile.bottom.radius,
107
+ profile.bottom.y,
108
+ profile.top.radius,
109
+ profile.top.y
110
+ );
111
+ return Math.min(bottomArc, topArc, side);
112
+ }
113
+ supportLocal(direction: readonly number[]): readonly number[] {
114
+ const length = Math.hypot(...direction);
115
+ const unit = length ? direction.map(value => value / length) : [1, 0, 0];
116
+ const bottom = [
117
+ unit[0] * this.radiusBottom,
118
+ -this.height / 2 + unit[1] * this.radiusBottom,
119
+ unit[2] * this.radiusBottom
120
+ ];
121
+ const top = [
122
+ unit[0] * this.radiusTop,
123
+ this.height / 2 + unit[1] * this.radiusTop,
124
+ unit[2] * this.radiusTop
125
+ ];
126
+ return dot(top, direction) >= dot(bottom, direction) ? top : bottom;
127
+ }
128
+ intersectLocalRay(
129
+ origin: readonly number[],
130
+ direction: readonly number[]
131
+ ): LocalRayIntersection | undefined {
132
+ const profile = this.getProfile();
133
+ if (profile.sphere)
134
+ return intersectSphere(origin, direction, profile.sphere.center, profile.sphere.radius);
135
+ const candidates: LocalRayIntersection[] = [];
136
+ const slope = (profile.top.radius - profile.bottom.radius) / (profile.top.y - profile.bottom.y);
137
+ const intercept = profile.bottom.radius - slope * profile.bottom.y;
138
+ const radialAtOrigin = intercept + slope * origin[1];
139
+ const radialAlongRay = slope * direction[1];
140
+ for (const distance of solveQuadratic(
141
+ direction[0] ** 2 + direction[2] ** 2 - radialAlongRay ** 2,
142
+ 2 * (origin[0] * direction[0] + origin[2] * direction[2] - radialAtOrigin * radialAlongRay),
143
+ origin[0] ** 2 + origin[2] ** 2 - radialAtOrigin ** 2
144
+ )) {
145
+ const y = origin[1] + distance * direction[1];
146
+ if (distance >= 0 && y >= profile.bottom.y - EPSILON && y <= profile.top.y + EPSILON) {
147
+ const x = origin[0] + distance * direction[0];
148
+ const z = origin[2] + distance * direction[2];
149
+ const radial = Math.hypot(x, z) || 1;
150
+ const normal = [x / radial, -slope, z / radial];
151
+ const length = Math.hypot(...normal);
152
+ candidates.push({distance, normal: normal.map(value => value / length)});
153
+ }
154
+ }
155
+ for (const bottomHit of intersectSphereRoots(
156
+ origin,
157
+ direction,
158
+ -this.height / 2,
159
+ this.radiusBottom
160
+ )) {
161
+ const y = origin[1] + bottomHit.distance * direction[1];
162
+ if (y <= profile.bottom.y + EPSILON) candidates.push(bottomHit);
163
+ }
164
+ for (const topHit of intersectSphereRoots(origin, direction, this.height / 2, this.radiusTop)) {
165
+ const y = origin[1] + topHit.distance * direction[1];
166
+ if (y >= profile.top.y - EPSILON) candidates.push(topHit);
167
+ }
168
+ return candidates.sort((a, b) => a.distance - b.distance)[0];
169
+ }
170
+
171
+ private getProfile(): {
172
+ sphere?: {center: number; radius: number};
173
+ bottom: {radius: number; y: number};
174
+ top: {radius: number; y: number};
175
+ } {
176
+ const delta = this.radiusTop - this.radiusBottom;
177
+ if (Math.abs(delta) >= this.height) {
178
+ const topWins = this.radiusTop >= this.radiusBottom;
179
+ return {
180
+ sphere: {
181
+ center: topWins ? this.height / 2 : -this.height / 2,
182
+ radius: topWins ? this.radiusTop : this.radiusBottom
183
+ },
184
+ bottom: {radius: 0, y: 0},
185
+ top: {radius: 0, y: 0}
186
+ };
187
+ }
188
+ const sinSlope = delta / this.height;
189
+ const radialNormal = Math.sqrt(1 - sinSlope * sinSlope);
190
+ const normalY = -sinSlope;
191
+ return {
192
+ bottom: {
193
+ radius: this.radiusBottom * radialNormal,
194
+ y: -this.height / 2 + this.radiusBottom * normalY
195
+ },
196
+ top: {radius: this.radiusTop * radialNormal, y: this.height / 2 + this.radiusTop * normalY}
197
+ };
198
+ }
199
+ }
200
+
201
+ function intersectSphere(
202
+ origin: readonly number[],
203
+ direction: readonly number[],
204
+ centerY: number,
205
+ radius: number
206
+ ): LocalRayIntersection | undefined {
207
+ return intersectSphereRoots(origin, direction, centerY, radius)[0];
208
+ }
209
+
210
+ function intersectSphereRoots(
211
+ origin: readonly number[],
212
+ direction: readonly number[],
213
+ centerY: number,
214
+ radius: number
215
+ ): LocalRayIntersection[] {
216
+ if (radius === 0) return [];
217
+ const relative = [origin[0], origin[1] - centerY, origin[2]];
218
+ return solveQuadratic(
219
+ dot(direction, direction),
220
+ 2 * dot(relative, direction),
221
+ dot(relative, relative) - radius * radius
222
+ )
223
+ .filter(distance => distance >= 0)
224
+ .map(distance => ({
225
+ distance,
226
+ normal: relative.map((value, i) => (value + distance * direction[i]) / radius)
227
+ }));
228
+ }
229
+
230
+ function dot(a: readonly number[], b: readonly number[]): number {
231
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
232
+ }
@@ -0,0 +1,133 @@
1
+ // math.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {
6
+ closestDistanceToSegment2D,
7
+ ImplicitShapeBase,
8
+ solveQuadratic,
9
+ type ImplicitShape,
10
+ type LocalRayIntersection
11
+ } from './implicit-shape';
12
+
13
+ const EPSILON = 1e-6;
14
+
15
+ export type CylinderShapeProps = {
16
+ height?: number;
17
+ radiusBottom?: number;
18
+ radiusTop?: number;
19
+ matrix?: readonly number[];
20
+ };
21
+
22
+ /** Analytic closed, Y-aligned glTF cylinder shape, including tapered cylinders. */
23
+ export class CylinderShape extends ImplicitShapeBase {
24
+ readonly type = 'cylinder' as const;
25
+ readonly height: number;
26
+ readonly radiusBottom: number;
27
+ readonly radiusTop: number;
28
+
29
+ constructor(props: CylinderShapeProps = {}) {
30
+ super(props.matrix);
31
+ this.height = props.height ?? 2;
32
+ this.radiusBottom = props.radiusBottom ?? 0.5;
33
+ this.radiusTop = props.radiusTop ?? 0.5;
34
+ if (!Number.isFinite(this.height) || this.height <= 0)
35
+ throw new Error('Cylinder height must be greater than zero');
36
+ if (![this.radiusBottom, this.radiusTop].every(value => Number.isFinite(value) && value >= 0))
37
+ throw new Error('Cylinder radii must be non-negative');
38
+ if (this.radiusBottom === 0 && this.radiusTop === 0)
39
+ throw new Error('At least one cylinder radius must be greater than zero');
40
+ }
41
+
42
+ clone(): CylinderShape {
43
+ return new CylinderShape({
44
+ height: this.height,
45
+ radiusBottom: this.radiusBottom,
46
+ radiusTop: this.radiusTop,
47
+ matrix: this.matrix
48
+ });
49
+ }
50
+ override equals(shape: ImplicitShape): boolean {
51
+ return (
52
+ shape instanceof CylinderShape &&
53
+ super.equals(shape) &&
54
+ this.height === shape.height &&
55
+ this.radiusBottom === shape.radiusBottom &&
56
+ this.radiusTop === shape.radiusTop
57
+ );
58
+ }
59
+ containsLocalPoint(point: readonly number[]): boolean {
60
+ const half = this.height / 2;
61
+ if (point[1] < -half - EPSILON || point[1] > half + EPSILON) return false;
62
+ const t = (point[1] + half) / this.height;
63
+ const radius = this.radiusBottom + (this.radiusTop - this.radiusBottom) * t;
64
+ return Math.hypot(point[0], point[2]) <= radius + EPSILON;
65
+ }
66
+ distanceToLocalPoint(point: readonly number[]): number {
67
+ if (this.containsLocalPoint(point)) return 0;
68
+ const radial = Math.hypot(point[0], point[2]);
69
+ const half = this.height / 2;
70
+ const side = closestDistanceToSegment2D(
71
+ radial,
72
+ point[1],
73
+ this.radiusBottom,
74
+ -half,
75
+ this.radiusTop,
76
+ half
77
+ );
78
+ const bottom = closestDistanceToSegment2D(radial, point[1], 0, -half, this.radiusBottom, -half);
79
+ const top = closestDistanceToSegment2D(radial, point[1], 0, half, this.radiusTop, half);
80
+ return Math.min(side, bottom, top);
81
+ }
82
+ supportLocal(direction: readonly number[]): readonly number[] {
83
+ const radialLength = Math.hypot(direction[0], direction[2]);
84
+ const x = radialLength ? direction[0] / radialLength : 1;
85
+ const z = radialLength ? direction[2] / radialLength : 0;
86
+ const half = this.height / 2;
87
+ const bottomValue = radialLength * this.radiusBottom - direction[1] * half;
88
+ const topValue = radialLength * this.radiusTop + direction[1] * half;
89
+ return topValue >= bottomValue
90
+ ? [x * this.radiusTop, half, z * this.radiusTop]
91
+ : [x * this.radiusBottom, -half, z * this.radiusBottom];
92
+ }
93
+ intersectLocalRay(
94
+ origin: readonly number[],
95
+ direction: readonly number[]
96
+ ): LocalRayIntersection | undefined {
97
+ const half = this.height / 2;
98
+ const slope = (this.radiusTop - this.radiusBottom) / this.height;
99
+ const intercept = (this.radiusTop + this.radiusBottom) / 2;
100
+ const radialAtOrigin = intercept + slope * origin[1];
101
+ const radialAlongRay = slope * direction[1];
102
+ const candidates: LocalRayIntersection[] = [];
103
+ const roots = solveQuadratic(
104
+ direction[0] ** 2 + direction[2] ** 2 - radialAlongRay ** 2,
105
+ 2 * (origin[0] * direction[0] + origin[2] * direction[2] - radialAtOrigin * radialAlongRay),
106
+ origin[0] ** 2 + origin[2] ** 2 - radialAtOrigin ** 2
107
+ );
108
+ for (const distance of roots) {
109
+ const y = origin[1] + distance * direction[1];
110
+ if (distance >= 0 && y >= -half - EPSILON && y <= half + EPSILON) {
111
+ const x = origin[0] + distance * direction[0];
112
+ const z = origin[2] + distance * direction[2];
113
+ const radial = Math.hypot(x, z) || 1;
114
+ const normal = [x / radial, -slope, z / radial];
115
+ const length = Math.hypot(...normal);
116
+ candidates.push({distance, normal: normal.map(value => value / length)});
117
+ }
118
+ }
119
+ if (Math.abs(direction[1]) > 1e-15) {
120
+ for (const [y, radius, normalY] of [
121
+ [-half, this.radiusBottom, -1],
122
+ [half, this.radiusTop, 1]
123
+ ]) {
124
+ const distance = (y - origin[1]) / direction[1];
125
+ const x = origin[0] + distance * direction[0];
126
+ const z = origin[2] + distance * direction[2];
127
+ if (distance >= 0 && Math.hypot(x, z) <= radius + EPSILON)
128
+ candidates.push({distance, normal: [0, normalY, 0]});
129
+ }
130
+ }
131
+ return candidates.sort((a, b) => a.distance - b.distance)[0];
132
+ }
133
+ }