@flighthq/collision 0.4.0-next.1663.d9c5746 → 0.4.0-next.1694.598ef6f
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/dist/collideContactManifold2D.d.ts +3 -0
- package/dist/collideContactManifold2D.d.ts.map +1 -0
- package/dist/{collideContactManifold.js → collideContactManifold2D.js} +6 -6
- package/dist/collideContactManifold2D.js.map +1 -0
- package/dist/collisionShapeValidation.d.ts +3 -3
- package/dist/collisionShapeValidation.d.ts.map +1 -1
- package/dist/collisionShapeValidation.js +6 -3
- package/dist/collisionShapeValidation.js.map +1 -1
- package/dist/collisionSupport2D.d.ts +12 -0
- package/dist/collisionSupport2D.d.ts.map +1 -0
- package/dist/collisionSupport2D.js +111 -0
- package/dist/collisionSupport2D.js.map +1 -0
- package/dist/contactFeatureId.js +1 -1
- package/dist/contactFeatureId.js.map +1 -1
- package/dist/contactManifold.d.ts +3 -3
- package/dist/contactManifold.d.ts.map +1 -1
- package/dist/contactManifold.js +2 -2
- package/dist/contactManifold.js.map +1 -1
- package/dist/contract.d.ts +8 -5
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +8 -5
- package/dist/contract.js.map +1 -1
- package/dist/convexVertices.d.ts +3 -3
- package/dist/convexVertices.d.ts.map +1 -1
- package/dist/convexVertices.js.map +1 -1
- package/dist/enableCollisionGuards.js +30 -11
- package/dist/enableCollisionGuards.js.map +1 -1
- package/dist/explainCollisionTest2D.d.ts +3 -0
- package/dist/explainCollisionTest2D.d.ts.map +1 -0
- package/dist/explainCollisionTest2D.js +38 -0
- package/dist/explainCollisionTest2D.js.map +1 -0
- package/dist/gjk2D.d.ts +4 -0
- package/dist/gjk2D.d.ts.map +1 -0
- package/dist/gjk2D.js +340 -0
- package/dist/gjk2D.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/manifold.d.ts +3 -3
- package/dist/manifold.d.ts.map +1 -1
- package/dist/manifold.js +2 -2
- package/dist/manifold.js.map +1 -1
- package/dist/pointContainment.d.ts +2 -2
- package/dist/pointContainment.d.ts.map +1 -1
- package/dist/pointContainment.js +3 -3
- package/dist/pointContainment.js.map +1 -1
- package/dist/raycastCollisionShape2D.d.ts +4 -0
- package/dist/raycastCollisionShape2D.d.ts.map +1 -0
- package/dist/{raycastCollisionShape.js → raycastCollisionShape2D.js} +7 -7
- package/dist/raycastCollisionShape2D.js.map +1 -0
- package/dist/registerBuiltInCollisionPairTests2D.d.ts +2 -0
- package/dist/registerBuiltInCollisionPairTests2D.d.ts.map +1 -0
- package/dist/registerBuiltInCollisionPairTests2D.js +32 -0
- package/dist/registerBuiltInCollisionPairTests2D.js.map +1 -0
- package/dist/segmentCollision.d.ts +6 -6
- package/dist/segmentCollision.d.ts.map +1 -1
- package/dist/segmentCollision.js +2 -2
- package/dist/segmentCollision.js.map +1 -1
- package/dist/shapeCollision.d.ts +11 -11
- package/dist/shapeCollision.d.ts.map +1 -1
- package/dist/shapeCollision.js +62 -65
- package/dist/shapeCollision.js.map +1 -1
- package/dist/shapeContact.d.ts +11 -11
- package/dist/shapeContact.d.ts.map +1 -1
- package/dist/shapeContact.js +12 -12
- package/dist/shapeContact.js.map +1 -1
- package/dist/sweepCollisionShape2D.d.ts +4 -0
- package/dist/sweepCollisionShape2D.d.ts.map +1 -0
- package/dist/{sweepCollisionShape.js → sweepCollisionShape2D.js} +10 -10
- package/dist/sweepCollisionShape2D.js.map +1 -0
- package/dist/testCollision2D.d.ts +4 -0
- package/dist/testCollision2D.d.ts.map +1 -0
- package/dist/testCollision2D.js +43 -0
- package/dist/testCollision2D.js.map +1 -0
- package/package.json +4 -4
- package/src/{collideContactManifold.test.ts → collideContactManifold2D.test.ts} +54 -54
- package/src/collisionShapeValidation.test.ts +13 -13
- package/src/collisionSupport2D.test.ts +166 -0
- package/src/contactManifold.test.ts +11 -11
- package/src/convexVertices.test.ts +4 -4
- package/src/enableCollisionGuards.test.ts +50 -15
- package/src/explainCollisionTest2D.test.ts +75 -0
- package/src/gjk2D.test.ts +219 -0
- package/src/manifold.test.ts +7 -7
- package/src/pointContainment.test.ts +45 -41
- package/src/{raycastCollisionShape.test.ts → raycastCollisionShape2D.test.ts} +26 -19
- package/src/registerBuiltInCollisionPairTests2D.test.ts +62 -0
- package/src/segmentCollision.test.ts +10 -10
- package/src/shapeCollision.test.ts +103 -103
- package/src/shapeContact.test.ts +76 -76
- package/src/{sweepCollisionShape.test.ts → sweepCollisionShape2D.test.ts} +26 -26
- package/src/testCollision2D.test.ts +175 -0
- package/dist/collideContactManifold.d.ts +0 -3
- package/dist/collideContactManifold.d.ts.map +0 -1
- package/dist/collideContactManifold.js.map +0 -1
- package/dist/explainCollisionTest.d.ts +0 -3
- package/dist/explainCollisionTest.d.ts.map +0 -1
- package/dist/explainCollisionTest.js +0 -21
- package/dist/explainCollisionTest.js.map +0 -1
- package/dist/raycastCollisionShape.d.ts +0 -4
- package/dist/raycastCollisionShape.d.ts.map +0 -1
- package/dist/raycastCollisionShape.js.map +0 -1
- package/dist/sweepCollisionShape.d.ts +0 -4
- package/dist/sweepCollisionShape.d.ts.map +0 -1
- package/dist/sweepCollisionShape.js.map +0 -1
- package/dist/testCollision.d.ts +0 -4
- package/dist/testCollision.d.ts.map +0 -1
- package/dist/testCollision.js +0 -97
- package/dist/testCollision.js.map +0 -1
- package/src/explainCollisionTest.test.ts +0 -52
- package/src/testCollision.test.ts +0 -97
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { CollisionShape2D } from '@flighthq/types/contract';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getCollisionPairTest2D,
|
|
5
|
+
getCollisionSupport2D,
|
|
6
|
+
registerBuiltInCollisionSupports2D,
|
|
7
|
+
registerCollisionPairTest2D,
|
|
8
|
+
registerCollisionSupport2D,
|
|
9
|
+
supportCollisionAabb2D,
|
|
10
|
+
supportCollisionCircle2D,
|
|
11
|
+
supportCollisionObb2D,
|
|
12
|
+
supportCollisionPolygon2D,
|
|
13
|
+
writeVertexListSupport2D,
|
|
14
|
+
} from './collisionSupport2D';
|
|
15
|
+
|
|
16
|
+
registerBuiltInCollisionSupports2D();
|
|
17
|
+
|
|
18
|
+
describe('getCollisionPairTest2D', () => {
|
|
19
|
+
it('is null for a pair nobody registered', () => {
|
|
20
|
+
expect(getCollisionPairTest2D('circle', 'acme.capsule')).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keys on the ORDERED pair, so the reverse is a separate entry', () => {
|
|
24
|
+
const test = () => false;
|
|
25
|
+
registerCollisionPairTest2D('acme.left', 'acme.right', test);
|
|
26
|
+
|
|
27
|
+
expect(getCollisionPairTest2D('acme.left', 'acme.right')).toBe(test);
|
|
28
|
+
expect(getCollisionPairTest2D('acme.right', 'acme.left')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('cannot confuse two pairs whose names run together the same way', () => {
|
|
32
|
+
const first = () => false;
|
|
33
|
+
const second = () => true;
|
|
34
|
+
registerCollisionPairTest2D('ab', 'c', first);
|
|
35
|
+
registerCollisionPairTest2D('a', 'bc', second);
|
|
36
|
+
|
|
37
|
+
// Concatenating the kinds without a separator lands both on `abc`. The collision cannot occur among
|
|
38
|
+
// the built-ins and appears the first time a vendor kind is a prefix of another.
|
|
39
|
+
expect(getCollisionPairTest2D('ab', 'c')).toBe(first);
|
|
40
|
+
expect(getCollisionPairTest2D('a', 'bc')).toBe(second);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getCollisionSupport2D', () => {
|
|
45
|
+
it('is null for a kind nobody registered', () => {
|
|
46
|
+
expect(getCollisionSupport2D('acme.unregistered')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('resolves each built-in area kind', () => {
|
|
50
|
+
expect(getCollisionSupport2D('aabb')).toBe(supportCollisionAabb2D);
|
|
51
|
+
expect(getCollisionSupport2D('circle')).toBe(supportCollisionCircle2D);
|
|
52
|
+
expect(getCollisionSupport2D('obb')).toBe(supportCollisionObb2D);
|
|
53
|
+
expect(getCollisionSupport2D('polygon')).toBe(supportCollisionPolygon2D);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('leaves the area-less kinds unregistered', () => {
|
|
57
|
+
// Deliberate: a support function could be written for either, and GJK would then report a
|
|
58
|
+
// penetration depth against a shape that has no interior for one to mean anything in.
|
|
59
|
+
expect(getCollisionSupport2D('point')).toBeNull();
|
|
60
|
+
expect(getCollisionSupport2D('segment')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('registerBuiltInCollisionSupports2D', () => {
|
|
65
|
+
it('registers exactly the four area kinds', () => {
|
|
66
|
+
registerBuiltInCollisionSupports2D();
|
|
67
|
+
|
|
68
|
+
const registered = ['aabb', 'circle', 'obb', 'polygon', 'point', 'segment'].filter(
|
|
69
|
+
(kind) => getCollisionSupport2D(kind) !== null,
|
|
70
|
+
);
|
|
71
|
+
expect(registered).toEqual(['aabb', 'circle', 'obb', 'polygon']);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('registerCollisionPairTest2D', () => {
|
|
76
|
+
it('lets a later registration replace an earlier one', () => {
|
|
77
|
+
const first = () => false;
|
|
78
|
+
const second = () => true;
|
|
79
|
+
registerCollisionPairTest2D('acme.a', 'acme.b', first);
|
|
80
|
+
registerCollisionPairTest2D('acme.a', 'acme.b', second);
|
|
81
|
+
|
|
82
|
+
expect(getCollisionPairTest2D('acme.a', 'acme.b')).toBe(second);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('registerCollisionSupport2D', () => {
|
|
87
|
+
it('lets a caller override a built-in, last write wins', () => {
|
|
88
|
+
const replacement = supportCollisionCircle2D;
|
|
89
|
+
registerCollisionSupport2D('acme.override', replacement);
|
|
90
|
+
|
|
91
|
+
expect(getCollisionSupport2D('acme.override')).toBe(replacement);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('supportCollisionAabb2D', () => {
|
|
96
|
+
it('picks the corner the direction signs name', () => {
|
|
97
|
+
const box: CollisionShape2D = { kind: 'aabb', maxX: 2, maxY: 3, minX: -1, minY: -4 };
|
|
98
|
+
const out = [0, 0];
|
|
99
|
+
|
|
100
|
+
supportCollisionAabb2D(box, 1, 1, out);
|
|
101
|
+
expect(out).toEqual([2, 3]);
|
|
102
|
+
supportCollisionAabb2D(box, -1, -1, out);
|
|
103
|
+
expect(out).toEqual([-1, -4]);
|
|
104
|
+
supportCollisionAabb2D(box, -1, 1, out);
|
|
105
|
+
expect(out).toEqual([-1, 3]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('supportCollisionCircle2D', () => {
|
|
110
|
+
it('pushes the centre one radius along the direction, whatever its length', () => {
|
|
111
|
+
const circle: CollisionShape2D = { kind: 'circle', radius: 2, x: 1, y: 1 };
|
|
112
|
+
const unit = [0, 0];
|
|
113
|
+
const long = [0, 0];
|
|
114
|
+
|
|
115
|
+
supportCollisionCircle2D(circle, 1, 0, unit);
|
|
116
|
+
supportCollisionCircle2D(circle, 50, 0, long);
|
|
117
|
+
|
|
118
|
+
expect(unit[0]).toBeCloseTo(3, 12);
|
|
119
|
+
expect(unit[1]).toBeCloseTo(1, 12);
|
|
120
|
+
// A support direction is not required to be unit length; normalizing is the function's job.
|
|
121
|
+
expect(long).toEqual(unit);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('answers a zero direction with the centre rather than a NaN', () => {
|
|
125
|
+
const out = [0, 0];
|
|
126
|
+
|
|
127
|
+
supportCollisionCircle2D({ kind: 'circle', radius: 2, x: 1, y: -1 }, 0, 0, out);
|
|
128
|
+
|
|
129
|
+
expect(out).toEqual([1, -1]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('supportCollisionObb2D', () => {
|
|
134
|
+
it('picks the furthest rotated corner', () => {
|
|
135
|
+
const out = [0, 0];
|
|
136
|
+
// A quarter turn maps the box's local +x onto world +y, so the furthest corner along +y is the one
|
|
137
|
+
// the unrotated box would have reached along +x.
|
|
138
|
+
supportCollisionObb2D({ halfH: 1, halfW: 2, kind: 'obb', rotation: Math.PI / 2, x: 0, y: 0 }, 0, 1, out);
|
|
139
|
+
|
|
140
|
+
expect(out[1]).toBeCloseTo(2, 9);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('supportCollisionPolygon2D', () => {
|
|
145
|
+
it('picks the furthest vertex', () => {
|
|
146
|
+
const out = [0, 0];
|
|
147
|
+
const triangle: CollisionShape2D = { kind: 'polygon', points: [0, 0, 4, 0, 0, 3] };
|
|
148
|
+
|
|
149
|
+
supportCollisionPolygon2D(triangle, 1, 0, out);
|
|
150
|
+
expect(out).toEqual([4, 0]);
|
|
151
|
+
supportCollisionPolygon2D(triangle, 0, 1, out);
|
|
152
|
+
expect(out).toEqual([0, 3]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('writeVertexListSupport2D', () => {
|
|
157
|
+
it('reads a flat coordinate list and keeps the first of an exact tie', () => {
|
|
158
|
+
const out = [0, 0];
|
|
159
|
+
|
|
160
|
+
// Two vertices project identically along +y; a strict `>` keeps the first, which is what makes the
|
|
161
|
+
// scan deterministic for a caller feeding the same shape twice.
|
|
162
|
+
writeVertexListSupport2D([0, 1, 5, 1, 0, -1], 3, 0, 1, out);
|
|
163
|
+
|
|
164
|
+
expect(out).toEqual([0, 1]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { clearCollisionContactManifold2D, createCollisionContactManifold2D } from './contactManifold';
|
|
4
4
|
|
|
5
|
-
describe('
|
|
5
|
+
describe('clearCollisionContactManifold2D', () => {
|
|
6
6
|
it('resets the manifold to the non-overlapping state', () => {
|
|
7
|
-
const manifold =
|
|
7
|
+
const manifold = createCollisionContactManifold2D();
|
|
8
8
|
manifold.overlapping = true;
|
|
9
9
|
manifold.normalX = 1;
|
|
10
10
|
manifold.normalY = -1;
|
|
11
11
|
manifold.depth = 4;
|
|
12
12
|
manifold.pointCount = 2;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
clearCollisionContactManifold2D(manifold);
|
|
15
15
|
expect(manifold.overlapping).toBe(false);
|
|
16
16
|
expect(manifold.normalX).toBe(0);
|
|
17
17
|
expect(manifold.normalY).toBe(0);
|
|
@@ -20,17 +20,17 @@ describe('clearCollisionContactManifold', () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it('keeps the point array allocated so a cleared manifold stays reusable', () => {
|
|
23
|
-
const manifold =
|
|
23
|
+
const manifold = createCollisionContactManifold2D();
|
|
24
24
|
const points = manifold.points;
|
|
25
|
-
|
|
25
|
+
clearCollisionContactManifold2D(manifold);
|
|
26
26
|
expect(manifold.points).toBe(points);
|
|
27
27
|
expect(manifold.points).toHaveLength(2);
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
describe('
|
|
31
|
+
describe('createCollisionContactManifold2D', () => {
|
|
32
32
|
it('starts non-overlapping with zeroed normal, depth, and point count', () => {
|
|
33
|
-
const manifold =
|
|
33
|
+
const manifold = createCollisionContactManifold2D();
|
|
34
34
|
expect(manifold.overlapping).toBe(false);
|
|
35
35
|
expect(manifold.normalX).toBe(0);
|
|
36
36
|
expect(manifold.normalY).toBe(0);
|
|
@@ -39,7 +39,7 @@ describe('createCollisionContactManifold', () => {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it('preallocates both contact points so the hot path never allocates', () => {
|
|
42
|
-
const manifold =
|
|
42
|
+
const manifold = createCollisionContactManifold2D();
|
|
43
43
|
expect(manifold.points).toHaveLength(2);
|
|
44
44
|
for (const point of manifold.points) {
|
|
45
45
|
expect(point).toEqual({ x: 0, y: 0, depth: 0, featureId: 0 });
|
|
@@ -47,8 +47,8 @@ describe('createCollisionContactManifold', () => {
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it('gives each manifold its own points rather than sharing one array', () => {
|
|
50
|
-
const first =
|
|
51
|
-
const second =
|
|
50
|
+
const first = createCollisionContactManifold2D();
|
|
51
|
+
const second = createCollisionContactManifold2D();
|
|
52
52
|
expect(first.points).not.toBe(second.points);
|
|
53
53
|
expect(first.points[0]).not.toBe(second.points[0]);
|
|
54
54
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CollisionAabb2D, CollisionObb2D } from '@flighthq/types/contract';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { writeAabbVertices, writeObbVertices } from './convexVertices';
|
|
@@ -6,14 +6,14 @@ import { writeAabbVertices, writeObbVertices } from './convexVertices';
|
|
|
6
6
|
describe('writeAabbVertices', () => {
|
|
7
7
|
it('writes the four corners counter-clockwise from the min corner', () => {
|
|
8
8
|
const out = new Float64Array(8);
|
|
9
|
-
const aabb:
|
|
9
|
+
const aabb: CollisionAabb2D = { minX: 1, minY: 2, maxX: 5, maxY: 8 };
|
|
10
10
|
writeAabbVertices(aabb, out);
|
|
11
11
|
expect(Array.from(out)).toEqual([1, 2, 5, 2, 5, 8, 1, 8]);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
it('writes a degenerate box as four coincident corners rather than skipping any', () => {
|
|
15
15
|
const out = new Float64Array(8);
|
|
16
|
-
const aabb:
|
|
16
|
+
const aabb: CollisionAabb2D = { minX: 3, minY: 3, maxX: 3, maxY: 3 };
|
|
17
17
|
writeAabbVertices(aabb, out);
|
|
18
18
|
expect(Array.from(out)).toEqual([3, 3, 3, 3, 3, 3, 3, 3]);
|
|
19
19
|
});
|
|
@@ -30,7 +30,7 @@ describe('writeObbVertices', () => {
|
|
|
30
30
|
|
|
31
31
|
it('rotates the corners about the center, preserving the distance to it', () => {
|
|
32
32
|
const out = new Float64Array(8);
|
|
33
|
-
const obb:
|
|
33
|
+
const obb: CollisionObb2D = { x: 0, y: 0, halfW: 3, halfH: 4, rotation: Math.PI / 3 };
|
|
34
34
|
writeObbVertices(obb, out);
|
|
35
35
|
const expected = Math.sqrt(3 * 3 + 4 * 4);
|
|
36
36
|
for (let i = 0; i < 4; i++) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
|
|
2
|
-
import type {
|
|
2
|
+
import type { CollisionShape2D, LogEntry } from '@flighthq/types/contract';
|
|
3
3
|
|
|
4
4
|
import { areCollisionGuardsEnabled, disableCollisionGuards, enableCollisionGuards } from './enableCollisionGuards';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { createCollisionManifold2D } from './manifold';
|
|
6
|
+
import { testCollision2D } from './testCollision2D';
|
|
7
7
|
|
|
8
8
|
function captureLog(run: () => void): readonly LogEntry[] {
|
|
9
9
|
const sink = createMemoryLogSink(8);
|
|
@@ -33,10 +33,10 @@ describe('disableCollisionGuards', () => {
|
|
|
33
33
|
enableCollisionGuards();
|
|
34
34
|
disableCollisionGuards();
|
|
35
35
|
const entries = captureLog(() => {
|
|
36
|
-
|
|
36
|
+
testCollision2D(
|
|
37
37
|
{ kind: 'circle', radius: 0, x: 0, y: 0 },
|
|
38
38
|
{ kind: 'circle', radius: 1, x: 0, y: 0 },
|
|
39
|
-
|
|
39
|
+
createCollisionManifold2D(),
|
|
40
40
|
);
|
|
41
41
|
});
|
|
42
42
|
expect(entries).toHaveLength(0);
|
|
@@ -44,29 +44,64 @@ describe('disableCollisionGuards', () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
describe('enableCollisionGuards', () => {
|
|
47
|
-
it('stays silent for supported
|
|
47
|
+
it('stays silent for a supported pair, and warns once for a degenerate shape', () => {
|
|
48
48
|
enableCollisionGuards();
|
|
49
|
-
const out =
|
|
50
|
-
const valid:
|
|
49
|
+
const out = createCollisionManifold2D();
|
|
50
|
+
const valid: CollisionShape2D = { kind: 'circle', radius: 1, x: 0, y: 0 };
|
|
51
51
|
const entries = captureLog(() => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
testCollision({ kind: 'circle', radius: 0, x: 0, y: 0 }, valid, out);
|
|
52
|
+
testCollision2D(valid, { kind: 'circle', radius: 1, x: 4, y: 0 }, out);
|
|
53
|
+
testCollision2D({ kind: 'circle', radius: 0, x: 0, y: 0 }, valid, out);
|
|
54
|
+
testCollision2D({ kind: 'circle', radius: 0, x: 0, y: 0 }, valid, out);
|
|
56
55
|
});
|
|
57
56
|
expect(entries).toHaveLength(1);
|
|
58
57
|
expect(entries[0].channel).toBe('collision');
|
|
59
58
|
expect(entries[0].data).toMatchObject({ kind: 'circle', shapeIndex: 0, status: 'degenerate-shape' });
|
|
60
|
-
expect(String((entries[0].data as Record<string, unknown>).message)).toContain('
|
|
59
|
+
expect(String((entries[0].data as Record<string, unknown>).message)).toContain('explainCollisionTest2D');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('warns for an area-less kind the manifold path cannot answer', () => {
|
|
63
|
+
enableCollisionGuards();
|
|
64
|
+
const entries = captureLog(() => {
|
|
65
|
+
testCollision2D(
|
|
66
|
+
{ kind: 'point', x: 0, y: 0 },
|
|
67
|
+
{ kind: 'circle', radius: 1, x: 0, y: 0 },
|
|
68
|
+
createCollisionManifold2D(),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The point is INSIDE the circle, and the manifold path reports them as not overlapping. That
|
|
73
|
+
// silent false used to be the one refusal the guard stayed quiet about, and it is the worst
|
|
74
|
+
// sentinel available: indistinguishable from two shapes genuinely not touching.
|
|
75
|
+
expect(entries).toHaveLength(1);
|
|
76
|
+
expect(entries[0].data).toMatchObject({ kind: 'point', shapeIndex: 0, status: 'unsupported-shape-kind' });
|
|
77
|
+
expect(String((entries[0].data as Record<string, unknown>).message)).toContain('area-less');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('warns for a vendor kind nothing has registered', () => {
|
|
81
|
+
enableCollisionGuards();
|
|
82
|
+
const entries = captureLog(() => {
|
|
83
|
+
testCollision2D(
|
|
84
|
+
{ kind: 'circle', radius: 1, x: 0, y: 0 },
|
|
85
|
+
// Cast through `unknown` because `CollisionShape2D` is still a CLOSED tagged union while
|
|
86
|
+
// `CollisionShapeKind2D` is open — the mismatch the collision charter's open direction 2
|
|
87
|
+
// names. A vendor kind is not constructible without this, which is the compile-time half of
|
|
88
|
+
// the same lie the guard arm above fixes at runtime.
|
|
89
|
+
{ kind: 'acme.capsule' } as unknown as CollisionShape2D,
|
|
90
|
+
createCollisionManifold2D(),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(entries).toHaveLength(1);
|
|
95
|
+
expect(entries[0].data).toMatchObject({ kind: 'acme.capsule', shapeIndex: 1, status: 'unsupported-shape-kind' });
|
|
61
96
|
});
|
|
62
97
|
|
|
63
98
|
it('warns for a non-convex polygon and identifies its argument index', () => {
|
|
64
99
|
enableCollisionGuards();
|
|
65
100
|
const entries = captureLog(() => {
|
|
66
|
-
|
|
101
|
+
testCollision2D(
|
|
67
102
|
{ kind: 'circle', radius: 1, x: 0, y: 0 },
|
|
68
103
|
{ kind: 'polygon', points: [0, 0, 2, 0, 1, 1, 2, 2, 0, 2] },
|
|
69
|
-
|
|
104
|
+
createCollisionManifold2D(),
|
|
70
105
|
);
|
|
71
106
|
});
|
|
72
107
|
expect(entries).toHaveLength(1);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CollisionShape2D } from '@flighthq/types/contract';
|
|
2
|
+
|
|
3
|
+
import { explainCollisionTest2D } from './explainCollisionTest2D';
|
|
4
|
+
import { registerBuiltInCollisionPairTests2D } from './registerBuiltInCollisionPairTests2D';
|
|
5
|
+
|
|
6
|
+
registerBuiltInCollisionPairTests2D();
|
|
7
|
+
|
|
8
|
+
describe('explainCollisionTest2D', () => {
|
|
9
|
+
it('names an unregistered kind rather than calling the pair separated', async () => {
|
|
10
|
+
// A fresh module registry, so the two collision registries are genuinely empty — the state a caller
|
|
11
|
+
// who forgot to register is in. Reached this way rather than by ordering this test before the
|
|
12
|
+
// registrar above, so it cannot be silently broken by moving tests around.
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
const { explainCollisionTest2D: explainUnregistered } = await import('./explainCollisionTest2D');
|
|
15
|
+
|
|
16
|
+
// A circle is a valid circle whether or not anything is registered for it, so shape validation passes
|
|
17
|
+
// and the dispatcher returns its silent false. Reporting `separated` for two clearly overlapping
|
|
18
|
+
// circles would be the seam repeating the dispatcher's silence instead of explaining it.
|
|
19
|
+
expect(
|
|
20
|
+
explainUnregistered({ kind: 'circle', radius: 2, x: 0, y: 0 }, { kind: 'circle', radius: 2, x: 1, y: 0 }),
|
|
21
|
+
).toEqual({
|
|
22
|
+
kind: 'circle',
|
|
23
|
+
overlapping: false,
|
|
24
|
+
shapeIndex: 0,
|
|
25
|
+
status: 'unsupported-shape-kind',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('distinguishes overlap from ordinary separation', () => {
|
|
30
|
+
const circle: CollisionShape2D = { kind: 'circle', radius: 2, x: 0, y: 0 };
|
|
31
|
+
expect(explainCollisionTest2D(circle, { kind: 'circle', radius: 2, x: 1, y: 0 })).toEqual({
|
|
32
|
+
kind: null,
|
|
33
|
+
overlapping: true,
|
|
34
|
+
shapeIndex: null,
|
|
35
|
+
status: 'overlapping',
|
|
36
|
+
});
|
|
37
|
+
expect(explainCollisionTest2D(circle, { kind: 'circle', radius: 2, x: 10, y: 0 })).toEqual({
|
|
38
|
+
kind: null,
|
|
39
|
+
overlapping: false,
|
|
40
|
+
shapeIndex: null,
|
|
41
|
+
status: 'separated',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('identifies which input is degenerate or non-convex', () => {
|
|
46
|
+
const circle: CollisionShape2D = { kind: 'circle', radius: 2, x: 0, y: 0 };
|
|
47
|
+
expect(explainCollisionTest2D(circle, { kind: 'circle', radius: 0, x: 0, y: 0 })).toEqual({
|
|
48
|
+
kind: 'circle',
|
|
49
|
+
overlapping: false,
|
|
50
|
+
shapeIndex: 1,
|
|
51
|
+
status: 'degenerate-shape',
|
|
52
|
+
});
|
|
53
|
+
expect(explainCollisionTest2D({ kind: 'polygon', points: [0, 0, 2, 0, 1, 1, 2, 2, 0, 2] }, circle)).toEqual({
|
|
54
|
+
kind: 'polygon',
|
|
55
|
+
overlapping: false,
|
|
56
|
+
shapeIndex: 0,
|
|
57
|
+
status: 'non-convex-polygon',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('explains the silent false sentinel for area-less and unknown kinds', () => {
|
|
62
|
+
const circle: CollisionShape2D = { kind: 'circle', radius: 2, x: 0, y: 0 };
|
|
63
|
+
expect(explainCollisionTest2D({ kind: 'point', x: 0, y: 0 }, circle)).toMatchObject({
|
|
64
|
+
kind: 'point',
|
|
65
|
+
shapeIndex: 0,
|
|
66
|
+
status: 'unsupported-shape-kind',
|
|
67
|
+
});
|
|
68
|
+
const custom = { kind: 'acme.capsule' } as unknown as CollisionShape2D;
|
|
69
|
+
expect(explainCollisionTest2D(circle, custom)).toMatchObject({
|
|
70
|
+
kind: 'acme.capsule',
|
|
71
|
+
shapeIndex: 1,
|
|
72
|
+
status: 'unsupported-shape-kind',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { CollisionShape2D } from '@flighthq/types/contract';
|
|
2
|
+
|
|
3
|
+
import { registerBuiltInCollisionSupports2D } from './collisionSupport2D';
|
|
4
|
+
import { testCollisionSupport2D, testCollisionSupportOverlap2D } from './gjk2D';
|
|
5
|
+
import { createCollisionManifold2D } from './manifold';
|
|
6
|
+
import { testCollision2D } from './testCollision2D';
|
|
7
|
+
|
|
8
|
+
registerBuiltInCollisionSupports2D();
|
|
9
|
+
|
|
10
|
+
// The widest gap between the generic core's normal and the incumbent's over this corpus, measured
|
|
11
|
+
// rather than chosen: 3.3e-3, entirely from EPA's angular resolution on a curved boundary. Set just
|
|
12
|
+
// above it so the bound is a real ceiling and a genuine regression cannot hide under a round number.
|
|
13
|
+
const MAX_NORMAL_DEVIATION = 4e-3;
|
|
14
|
+
|
|
15
|
+
describe('testCollisionSupport2D', () => {
|
|
16
|
+
it('agrees with every incumbent SAT pair, on overlap and on the manifold itself', () => {
|
|
17
|
+
// The differential test the collision charter sequences this work behind: ten hand-written pair
|
|
18
|
+
// functions already encode years of degeneracy and orientation decisions, so the generic core is
|
|
19
|
+
// measured against them rather than against a fresh set of expectations that could be wrong the
|
|
20
|
+
// same way it is.
|
|
21
|
+
//
|
|
22
|
+
// It earned its keep. It found a real defect in the incumbent — the SAT paths oriented their normal
|
|
23
|
+
// by comparing shape centroids, which is only a heuristic and picks the wrong side whenever one
|
|
24
|
+
// projection nests inside the other asymmetrically. Both now read the side that actually won the
|
|
25
|
+
// penetration comparison, and the two agree exactly: NO exemptions, no tolerated boundary band.
|
|
26
|
+
const random = createSeededRandom(0x5eed);
|
|
27
|
+
const generic = createCollisionManifold2D();
|
|
28
|
+
const incumbent = createCollisionManifold2D();
|
|
29
|
+
let overlaps = 0;
|
|
30
|
+
let trials = 0;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < 4000; i += 1) {
|
|
33
|
+
const a = createRandomShape(random);
|
|
34
|
+
const b = createRandomShape(random);
|
|
35
|
+
trials += 1;
|
|
36
|
+
|
|
37
|
+
const genericOverlapping = testCollisionSupport2D(a, b, generic);
|
|
38
|
+
const incumbentOverlapping = testCollision2D(a, b, incumbent);
|
|
39
|
+
expect({ i, overlapping: genericOverlapping }).toEqual({ i, overlapping: incumbentOverlapping });
|
|
40
|
+
if (!genericOverlapping) continue;
|
|
41
|
+
|
|
42
|
+
overlaps += 1;
|
|
43
|
+
expect(generic.depth).toBeCloseTo(incumbent.depth, 5);
|
|
44
|
+
// The normal is held looser than the depth, and only because of EPA's own geometry: it terminates
|
|
45
|
+
// on a distance, and distance is second-order insensitive to angular error on a curved boundary,
|
|
46
|
+
// so a depth converged to 1e-10 still leaves a circle's normal a few parts in a thousand out. The
|
|
47
|
+
// SIGN is exact, which is what a solver acts on.
|
|
48
|
+
// Measured as one distance between the two unit normals rather than per component, so the bound
|
|
49
|
+
// means the same thing whichever way the axis happens to lie.
|
|
50
|
+
expect(Math.hypot(generic.normalX - incumbent.normalX, generic.normalY - incumbent.normalY)).toBeLessThan(
|
|
51
|
+
MAX_NORMAL_DEVIATION,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A differential test that never overlapped would pass while proving nothing.
|
|
56
|
+
expect(overlaps).toBeGreaterThan(trials / 10);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('orients the normal to push A out of B', () => {
|
|
60
|
+
const out = createCollisionManifold2D();
|
|
61
|
+
const a: CollisionShape2D = { kind: 'circle', radius: 1, x: 0, y: 0 };
|
|
62
|
+
const b: CollisionShape2D = { kind: 'circle', radius: 1, x: 1.5, y: 0 };
|
|
63
|
+
|
|
64
|
+
expect(testCollisionSupport2D(a, b, out)).toBe(true);
|
|
65
|
+
|
|
66
|
+
// A is left of B, so separating A means moving it further left. The depth is exact to 1e-9; the
|
|
67
|
+
// direction is a circle's, so it is held to the accuracy EPA can actually reach.
|
|
68
|
+
expect(out.normalX).toBeCloseTo(-1, 4);
|
|
69
|
+
expect(out.normalY).toBeCloseTo(0, 4);
|
|
70
|
+
expect(out.depth).toBeCloseTo(0.5, 9);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reverses the normal when the arguments are reversed', () => {
|
|
74
|
+
const forward = createCollisionManifold2D();
|
|
75
|
+
const reverse = createCollisionManifold2D();
|
|
76
|
+
const a: CollisionShape2D = { kind: 'aabb', maxX: 1, maxY: 1, minX: -1, minY: -1 };
|
|
77
|
+
const b: CollisionShape2D = { kind: 'aabb', maxX: 2.5, maxY: 1, minX: 0.5, minY: -1 };
|
|
78
|
+
|
|
79
|
+
testCollisionSupport2D(a, b, forward);
|
|
80
|
+
testCollisionSupport2D(b, a, reverse);
|
|
81
|
+
|
|
82
|
+
expect(reverse.normalX).toBeCloseTo(-forward.normalX, 9);
|
|
83
|
+
expect(reverse.normalY).toBeCloseTo(-forward.normalY, 9);
|
|
84
|
+
expect(reverse.depth).toBeCloseTo(forward.depth, 9);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('reports a clear manifold for a kind with no registered support', () => {
|
|
88
|
+
const out = createCollisionManifold2D();
|
|
89
|
+
out.overlapping = true;
|
|
90
|
+
out.depth = 5;
|
|
91
|
+
|
|
92
|
+
// A point is INSIDE the circle, and is refused anyway: area-less kinds are deliberately absent
|
|
93
|
+
// from the support registry, because a penetration depth against one means nothing.
|
|
94
|
+
const overlapping = testCollisionSupport2D(
|
|
95
|
+
{ kind: 'point', x: 0, y: 0 },
|
|
96
|
+
{ kind: 'circle', radius: 1, x: 0, y: 0 },
|
|
97
|
+
out,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(overlapping).toBe(false);
|
|
101
|
+
expect(out.overlapping).toBe(false);
|
|
102
|
+
expect(out.depth).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('treats a pair that only touches as not overlapping', () => {
|
|
106
|
+
const out = createCollisionManifold2D();
|
|
107
|
+
|
|
108
|
+
const overlapping = testCollisionSupport2D(
|
|
109
|
+
{ kind: 'circle', radius: 1, x: 0, y: 0 },
|
|
110
|
+
{ kind: 'circle', radius: 1, x: 2, y: 0 },
|
|
111
|
+
out,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(overlapping).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('takes the shallow axis out of a pair that overlaps on both', () => {
|
|
118
|
+
const out = createCollisionManifold2D();
|
|
119
|
+
// Overlapping by 0.2 vertically and by 3 horizontally. The minimum translation is the SHALLOW one,
|
|
120
|
+
// which is what makes a manifold usable: separating along the deep axis would shove a body across
|
|
121
|
+
// the room to resolve a graze.
|
|
122
|
+
testCollisionSupport2D(
|
|
123
|
+
{ kind: 'aabb', maxX: 2, maxY: 1, minX: -2, minY: -1 },
|
|
124
|
+
{ kind: 'aabb', maxX: 1, maxY: 3, minX: -1, minY: 0.8 },
|
|
125
|
+
out,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(Math.abs(out.normalX)).toBeCloseTo(0, 6);
|
|
129
|
+
expect(out.normalY).toBeCloseTo(-1, 6);
|
|
130
|
+
expect(out.depth).toBeCloseTo(0.2, 6);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('testCollisionSupportOverlap2D', () => {
|
|
135
|
+
it('agrees with the manifold path about whether a pair overlaps', () => {
|
|
136
|
+
const random = createSeededRandom(0xc0ffee);
|
|
137
|
+
const out = createCollisionManifold2D();
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < 1000; i += 1) {
|
|
140
|
+
const a = createRandomShape(random);
|
|
141
|
+
const b = createRandomShape(random);
|
|
142
|
+
expect({ i, overlapping: testCollisionSupportOverlap2D(a, b) }).toEqual({
|
|
143
|
+
i,
|
|
144
|
+
overlapping: testCollisionSupport2D(a, b, out),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns false for an unregistered kind rather than throwing', () => {
|
|
150
|
+
expect(
|
|
151
|
+
testCollisionSupportOverlap2D({ kind: 'acme.capsule' } as unknown as CollisionShape2D, {
|
|
152
|
+
kind: 'circle',
|
|
153
|
+
radius: 1,
|
|
154
|
+
x: 0,
|
|
155
|
+
y: 0,
|
|
156
|
+
}),
|
|
157
|
+
).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// A small deterministic generator. Reproducibility is the point: a differential test that shuffles
|
|
162
|
+
// differently each run reports a failure nobody can reproduce from the output.
|
|
163
|
+
function createSeededRandom(seed: number): () => number {
|
|
164
|
+
let state = seed >>> 0;
|
|
165
|
+
return () => {
|
|
166
|
+
state = (state * 1664525 + 1013904223) >>> 0;
|
|
167
|
+
return state / 0x100000000;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createRandomShape(random: () => number): CollisionShape2D {
|
|
172
|
+
const spread = 4;
|
|
173
|
+
const x = (random() - 0.5) * spread;
|
|
174
|
+
const y = (random() - 0.5) * spread;
|
|
175
|
+
switch (Math.floor(random() * 4)) {
|
|
176
|
+
case 0:
|
|
177
|
+
return { kind: 'circle', radius: 0.25 + random() * 1.25, x, y };
|
|
178
|
+
case 1: {
|
|
179
|
+
const halfW = 0.25 + random() * 1.25;
|
|
180
|
+
const halfH = 0.25 + random() * 1.25;
|
|
181
|
+
return { kind: 'aabb', maxX: x + halfW, maxY: y + halfH, minX: x - halfW, minY: y - halfH };
|
|
182
|
+
}
|
|
183
|
+
case 2:
|
|
184
|
+
return {
|
|
185
|
+
halfH: 0.25 + random() * 1.25,
|
|
186
|
+
halfW: 0.25 + random() * 1.25,
|
|
187
|
+
kind: 'obb',
|
|
188
|
+
rotation: random() * Math.PI * 2,
|
|
189
|
+
x,
|
|
190
|
+
y,
|
|
191
|
+
};
|
|
192
|
+
default: {
|
|
193
|
+
// A convex polygon by construction: vertices on an ellipse at angles that advance by random but
|
|
194
|
+
// strictly positive steps summing to EXACTLY one turn.
|
|
195
|
+
//
|
|
196
|
+
// Normalizing the steps is the whole trick, and skipping it is a real trap — stepping by a random
|
|
197
|
+
// multiple of `2*pi/count` lets the total overshoot 2*pi, so the last vertices wrap past the first
|
|
198
|
+
// and the polygon self-intersects. The incumbent documents that it does not answer for a
|
|
199
|
+
// non-convex polygon, so every such shape becomes a mismatch that looks exactly like a solver bug.
|
|
200
|
+
const radiusX = 0.3 + random() * 1.2;
|
|
201
|
+
const radiusY = 0.3 + random() * 1.2;
|
|
202
|
+
const count = 3 + Math.floor(random() * 4);
|
|
203
|
+
const steps: number[] = [];
|
|
204
|
+
let total = 0;
|
|
205
|
+
for (let i = 0; i < count; i += 1) {
|
|
206
|
+
const step = 0.4 + random();
|
|
207
|
+
steps.push(step);
|
|
208
|
+
total += step;
|
|
209
|
+
}
|
|
210
|
+
const points: number[] = [];
|
|
211
|
+
let angle = random() * Math.PI * 2;
|
|
212
|
+
for (let i = 0; i < count; i += 1) {
|
|
213
|
+
points.push(x + Math.cos(angle) * radiusX, y + Math.sin(angle) * radiusY);
|
|
214
|
+
angle += (steps[i] / total) * Math.PI * 2;
|
|
215
|
+
}
|
|
216
|
+
return { kind: 'polygon', points };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|