@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.
- package/LICENSE +51 -0
- package/README.md +8 -1
- package/dist/constants.d.ts +10 -5
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +9 -5
- package/dist/constants.js.map +1 -1
- package/dist/index.cjs +640 -113
- package/dist/index.cjs.map +3 -4
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/bounding-volumes/axis-aligned-bounding-box.d.ts +2 -1
- package/dist/lib/bounding-volumes/axis-aligned-bounding-box.d.ts.map +1 -1
- package/dist/lib/bounding-volumes/axis-aligned-bounding-box.js +3 -4
- package/dist/lib/bounding-volumes/axis-aligned-bounding-box.js.map +1 -1
- package/dist/lib/bounding-volumes/bounding-sphere.d.ts +2 -1
- package/dist/lib/bounding-volumes/bounding-sphere.d.ts.map +1 -1
- package/dist/lib/bounding-volumes/bounding-sphere.js +3 -4
- package/dist/lib/bounding-volumes/bounding-sphere.js.map +1 -1
- package/dist/lib/bounding-volumes/bounding-volume.d.ts +5 -4
- package/dist/lib/bounding-volumes/bounding-volume.d.ts.map +1 -1
- package/dist/lib/bounding-volumes/oriented-bounding-box.d.ts +2 -1
- package/dist/lib/bounding-volumes/oriented-bounding-box.d.ts.map +1 -1
- package/dist/lib/bounding-volumes/oriented-bounding-box.js +3 -4
- package/dist/lib/bounding-volumes/oriented-bounding-box.js.map +1 -1
- package/dist/lib/culling-volume.d.ts +2 -1
- package/dist/lib/culling-volume.d.ts.map +1 -1
- package/dist/lib/culling-volume.js +7 -8
- package/dist/lib/culling-volume.js.map +1 -1
- package/dist/lib/plane.d.ts +2 -2
- package/dist/lib/plane.d.ts.map +1 -1
- package/dist/lib/plane.js +3 -6
- package/dist/lib/plane.js.map +1 -1
- package/dist/lib/shapes/box-shape.d.ts +19 -0
- package/dist/lib/shapes/box-shape.d.ts.map +1 -0
- package/dist/lib/shapes/box-shape.js +69 -0
- package/dist/lib/shapes/box-shape.js.map +1 -0
- package/dist/lib/shapes/capsule-shape.d.ts +23 -0
- package/dist/lib/shapes/capsule-shape.d.ts.map +1 -0
- package/dist/lib/shapes/capsule-shape.js +151 -0
- package/dist/lib/shapes/capsule-shape.js.map +1 -0
- package/dist/lib/shapes/cylinder-shape.d.ts +22 -0
- package/dist/lib/shapes/cylinder-shape.d.ts.map +1 -0
- package/dist/lib/shapes/cylinder-shape.js +99 -0
- package/dist/lib/shapes/cylinder-shape.js.map +1 -0
- package/dist/lib/shapes/implicit-shape.d.ts +56 -0
- package/dist/lib/shapes/implicit-shape.d.ts.map +1 -0
- package/dist/lib/shapes/implicit-shape.js +142 -0
- package/dist/lib/shapes/implicit-shape.js.map +1 -0
- package/dist/lib/shapes/plane-shape.d.ts +27 -0
- package/dist/lib/shapes/plane-shape.d.ts.map +1 -0
- package/dist/lib/shapes/plane-shape.js +71 -0
- package/dist/lib/shapes/plane-shape.js.map +1 -0
- package/dist/lib/shapes/sphere-shape.d.ts +18 -0
- package/dist/lib/shapes/sphere-shape.d.ts.map +1 -0
- package/dist/lib/shapes/sphere-shape.js +43 -0
- package/dist/lib/shapes/sphere-shape.js.map +1 -0
- package/package.json +4 -4
- package/src/constants.ts +11 -5
- package/src/index.ts +13 -0
- package/src/lib/bounding-volumes/axis-aligned-bounding-box.ts +5 -5
- package/src/lib/bounding-volumes/bounding-sphere.ts +5 -5
- package/src/lib/bounding-volumes/bounding-volume.ts +5 -4
- package/src/lib/bounding-volumes/oriented-bounding-box.ts +5 -5
- package/src/lib/culling-volume.ts +9 -9
- package/src/lib/plane.ts +3 -6
- package/src/lib/shapes/box-shape.ts +78 -0
- package/src/lib/shapes/capsule-shape.ts +232 -0
- package/src/lib/shapes/cylinder-shape.ts +133 -0
- package/src/lib/shapes/implicit-shape.ts +209 -0
- package/src/lib/shapes/plane-shape.ts +78 -0
- 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 {
|
|
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):
|
|
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
|
|
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
|
|
146
|
+
return 'intersecting';
|
|
147
147
|
}
|
|
148
148
|
// The center point and radius is positive side of the plane
|
|
149
|
-
return
|
|
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
|
-
* - `
|
|
32
|
-
* - `
|
|
33
|
-
* - `
|
|
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):
|
|
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 {
|
|
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):
|
|
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
|
|
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
|
|
165
|
+
return 'inside';
|
|
166
166
|
}
|
|
167
|
-
return
|
|
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 {
|
|
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):
|
|
96
|
+
computeVisibility(boundingVolume: BoundingVolume): CullingResult {
|
|
97
97
|
// const planes = this.planes;
|
|
98
|
-
let intersect:
|
|
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
|
|
102
|
+
case 'outside':
|
|
103
103
|
// We are done
|
|
104
|
-
return
|
|
104
|
+
return 'outside';
|
|
105
105
|
|
|
106
|
-
case
|
|
106
|
+
case 'intersecting':
|
|
107
107
|
// If no other intersection is outside, return INTERSECTING
|
|
108
|
-
intersect =
|
|
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 ===
|
|
152
|
+
if (result === 'outside') {
|
|
153
153
|
return CullingVolume.MASK_OUTSIDE;
|
|
154
|
-
} else if (result ===
|
|
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
|
|
96
|
+
* @returns {Vector3} The intersection point or undefined if there is no intersection.
|
|
97
97
|
*/
|
|
98
|
-
intersectWithRay(ray: Ray, result
|
|
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
|
-
|
|
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
|
+
}
|