@flighthq/collision 0.1.0

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.
@@ -0,0 +1,56 @@
1
+ import type { CollisionShape } from '@flighthq/types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { getCollisionShapeContainsPoint } from './pointContainment';
5
+
6
+ describe('getCollisionShapeContainsPoint', () => {
7
+ it('tests a circle inside, outside, and on the boundary (inclusive)', () => {
8
+ const circle: CollisionShape = { kind: 'circle', x: 0, y: 0, radius: 5 };
9
+ expect(getCollisionShapeContainsPoint(circle, 1, 1)).toBe(true);
10
+ expect(getCollisionShapeContainsPoint(circle, 5, 0)).toBe(true); // on the boundary
11
+ expect(getCollisionShapeContainsPoint(circle, 6, 0)).toBe(false);
12
+ });
13
+
14
+ it('tests an axis-aligned box, boundary-inclusive', () => {
15
+ const box: CollisionShape = { kind: 'aabb', minX: 0, minY: 0, maxX: 10, maxY: 10 };
16
+ expect(getCollisionShapeContainsPoint(box, 5, 5)).toBe(true);
17
+ expect(getCollisionShapeContainsPoint(box, 0, 10)).toBe(true); // corner
18
+ expect(getCollisionShapeContainsPoint(box, -1, 5)).toBe(false);
19
+ expect(getCollisionShapeContainsPoint(box, 11, 5)).toBe(false);
20
+ });
21
+
22
+ it('tests a rotated oriented box in its own frame', () => {
23
+ // 45-degree box, half-extents 2x2. A point on the world axis at distance 2.8 is inside
24
+ // (the box reaches ~2.83 along its diagonal) but the same distance is outside a smaller reach.
25
+ const obb: CollisionShape = { kind: 'obb', x: 0, y: 0, halfW: 2, halfH: 2, rotation: Math.PI / 4 };
26
+ expect(getCollisionShapeContainsPoint(obb, 0, 0)).toBe(true);
27
+ expect(getCollisionShapeContainsPoint(obb, 2.8, 0)).toBe(true); // toward a rotated corner
28
+ expect(getCollisionShapeContainsPoint(obb, 2.1, 2.1)).toBe(false); // just past a flat edge
29
+ });
30
+
31
+ it('tests a convex hexagon inside and outside', () => {
32
+ const hex: CollisionShape = { kind: 'polygon', points: [2, 0, 1, 2, -1, 2, -2, 0, -1, -2, 1, -2] };
33
+ expect(getCollisionShapeContainsPoint(hex, 0, 0)).toBe(true);
34
+ expect(getCollisionShapeContainsPoint(hex, 1.9, 0)).toBe(true);
35
+ expect(getCollisionShapeContainsPoint(hex, 3, 0)).toBe(false);
36
+ expect(getCollisionShapeContainsPoint(hex, 0, 3)).toBe(false);
37
+ });
38
+
39
+ it('tests a point lying on a segment', () => {
40
+ const segment: CollisionShape = { kind: 'segment', x0: 0, y0: 0, x1: 10, y1: 0 };
41
+ expect(getCollisionShapeContainsPoint(segment, 5, 0)).toBe(true);
42
+ expect(getCollisionShapeContainsPoint(segment, 5, 0.1)).toBe(false);
43
+ expect(getCollisionShapeContainsPoint(segment, 11, 0)).toBe(false);
44
+ });
45
+
46
+ it('tests coincidence with a point collider', () => {
47
+ const point: CollisionShape = { kind: 'point', x: 3, y: 4 };
48
+ expect(getCollisionShapeContainsPoint(point, 3, 4)).toBe(true);
49
+ expect(getCollisionShapeContainsPoint(point, 3, 5)).toBe(false);
50
+ });
51
+
52
+ it('returns false for an unknown kind', () => {
53
+ const shape = { kind: 'acme.capsule', x: 0, y: 0 } as unknown as CollisionShape;
54
+ expect(getCollisionShapeContainsPoint(shape, 0, 0)).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,108 @@
1
+ import type { CollisionAabb, CollisionCircle, CollisionObb, CollisionPolygon, CollisionSegment } from '@flighthq/types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ testSegmentAabbCollision,
6
+ testSegmentCircleCollision,
7
+ testSegmentObbCollision,
8
+ testSegmentPolygonCollision,
9
+ testSegmentSegmentCollision,
10
+ } from './segmentCollision';
11
+
12
+ function segment(x0: number, y0: number, x1: number, y1: number): CollisionSegment {
13
+ return { x0, y0, x1, y1 };
14
+ }
15
+
16
+ describe('testSegmentAabbCollision', () => {
17
+ const box: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
18
+
19
+ it('is true for a segment crossing the box', () => {
20
+ expect(testSegmentAabbCollision(segment(-5, 5, 15, 5), box)).toBe(true);
21
+ });
22
+
23
+ it('is true for a segment fully inside the box', () => {
24
+ expect(testSegmentAabbCollision(segment(2, 2, 8, 8), box)).toBe(true);
25
+ });
26
+
27
+ it('is false for a segment that misses the box', () => {
28
+ expect(testSegmentAabbCollision(segment(-5, -5, -1, -1), box)).toBe(false);
29
+ });
30
+
31
+ it('is false for a segment running parallel outside the box', () => {
32
+ expect(testSegmentAabbCollision(segment(-5, 20, 15, 20), box)).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe('testSegmentCircleCollision', () => {
37
+ const circle: CollisionCircle = { x: 0, y: 0, radius: 2 };
38
+
39
+ it('is true for a segment passing through the circle', () => {
40
+ expect(testSegmentCircleCollision(segment(-10, 0, 10, 0), circle)).toBe(true);
41
+ });
42
+
43
+ it('is true for a segment exactly tangent to the circle (inclusive)', () => {
44
+ expect(testSegmentCircleCollision(segment(-10, 2, 10, 2), circle)).toBe(true);
45
+ });
46
+
47
+ it('is true when an endpoint sits inside the circle', () => {
48
+ expect(testSegmentCircleCollision(segment(0, 0, 10, 0), circle)).toBe(true);
49
+ });
50
+
51
+ it('is false for a segment that misses the circle', () => {
52
+ expect(testSegmentCircleCollision(segment(-10, 5, 10, 5), circle)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('testSegmentObbCollision', () => {
57
+ const obb: CollisionObb = { x: 0, y: 0, halfW: 3, halfH: 3, rotation: Math.PI / 4 };
58
+
59
+ it('is true for a segment crossing the rotated box', () => {
60
+ expect(testSegmentObbCollision(segment(-10, 0, 10, 0), obb)).toBe(true);
61
+ });
62
+
63
+ it('is false for a segment above the rotated box', () => {
64
+ expect(testSegmentObbCollision(segment(-10, 10, 10, 10), obb)).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('testSegmentPolygonCollision', () => {
69
+ const square: CollisionPolygon = { points: [0, 0, 10, 0, 10, 10, 0, 10] };
70
+
71
+ it('is true for a segment crossing the polygon', () => {
72
+ expect(testSegmentPolygonCollision(segment(-5, 5, 15, 5), square)).toBe(true);
73
+ });
74
+
75
+ it('is true when an endpoint is inside the polygon', () => {
76
+ expect(testSegmentPolygonCollision(segment(5, 5, 20, 20), square)).toBe(true);
77
+ });
78
+
79
+ it('is false for a segment that misses the polygon', () => {
80
+ expect(testSegmentPolygonCollision(segment(-5, -5, -1, -1), square)).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('testSegmentSegmentCollision', () => {
85
+ it('is true for two crossing segments', () => {
86
+ expect(testSegmentSegmentCollision(segment(0, 0, 10, 10), segment(0, 10, 10, 0))).toBe(true);
87
+ });
88
+
89
+ it('is true for segments touching at a shared endpoint (inclusive)', () => {
90
+ expect(testSegmentSegmentCollision(segment(0, 0, 5, 0), segment(5, 0, 5, 5))).toBe(true);
91
+ });
92
+
93
+ it('is true for overlapping collinear segments', () => {
94
+ expect(testSegmentSegmentCollision(segment(0, 0, 10, 0), segment(5, 0, 15, 0))).toBe(true);
95
+ });
96
+
97
+ it('is false for disjoint collinear segments', () => {
98
+ expect(testSegmentSegmentCollision(segment(0, 0, 4, 0), segment(6, 0, 10, 0))).toBe(false);
99
+ });
100
+
101
+ it('is false for parallel non-collinear segments', () => {
102
+ expect(testSegmentSegmentCollision(segment(0, 0, 10, 0), segment(0, 1, 10, 1))).toBe(false);
103
+ });
104
+
105
+ it('is false for segments that miss each other', () => {
106
+ expect(testSegmentSegmentCollision(segment(0, 0, 1, 0), segment(5, 5, 6, 6))).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,329 @@
1
+ import type { CollisionAabb, CollisionCircle, CollisionObb, CollisionPolygon } from '@flighthq/types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createCollisionManifold } from './manifold';
5
+ import {
6
+ testAabbAabbCollision,
7
+ testAabbObbCollision,
8
+ testAabbPolygonCollision,
9
+ testCircleAabbCollision,
10
+ testCircleCircleCollision,
11
+ testCircleObbCollision,
12
+ testCirclePolygonCollision,
13
+ testObbObbCollision,
14
+ testObbPolygonCollision,
15
+ testPolygonPolygonCollision,
16
+ } from './shapeCollision';
17
+
18
+ // A square collider as a flat convex polygon, corners CCW from the min corner.
19
+ function square(minX: number, minY: number, size: number): CollisionPolygon {
20
+ return { points: [minX, minY, minX + size, minY, minX + size, minY + size, minX, minY + size] };
21
+ }
22
+
23
+ describe('testAabbAabbCollision', () => {
24
+ it('reports the least-penetration axis: boxes overlapping more on X separate along Y', () => {
25
+ const out = createCollisionManifold();
26
+ // A = 0..10 square; B spans x[2,8] (deep X overlap) but only y[8,10] of A (shallow Y overlap).
27
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
28
+ const b: CollisionAabb = { minX: 2, minY: 8, maxX: 8, maxY: 20 };
29
+ expect(testAabbAabbCollision(a, b, out)).toBe(true);
30
+ expect(out.overlapping).toBe(true);
31
+ expect(out.normalX).toBeCloseTo(0);
32
+ expect(out.normalY).toBeCloseTo(-1);
33
+ expect(out.depth).toBeCloseTo(2);
34
+ });
35
+
36
+ it('is separated when the boxes are disjoint', () => {
37
+ const out = createCollisionManifold();
38
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 1, maxY: 1 };
39
+ const b: CollisionAabb = { minX: 2, minY: 2, maxX: 3, maxY: 3 };
40
+ expect(testAabbAabbCollision(a, b, out)).toBe(false);
41
+ expect(out.overlapping).toBe(false);
42
+ expect(out.normalX).toBe(0);
43
+ expect(out.normalY).toBe(0);
44
+ expect(out.depth).toBe(0);
45
+ });
46
+
47
+ it('treats edge-touching as not overlapping (exclusive)', () => {
48
+ const out = createCollisionManifold();
49
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 1, maxY: 1 };
50
+ const b: CollisionAabb = { minX: 1, minY: 0, maxX: 2, maxY: 1 };
51
+ expect(testAabbAabbCollision(a, b, out)).toBe(false);
52
+ });
53
+
54
+ it('resolves containment with the exit-distance depth, not the intersection length', () => {
55
+ const out = createCollisionManifold();
56
+ // B fully inside A, closer to A's top face — MTV pushes A up through the nearest face by 5.
57
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
58
+ const b: CollisionAabb = { minX: 3, minY: 3, maxX: 6, maxY: 5 };
59
+ expect(testAabbAabbCollision(a, b, out)).toBe(true);
60
+ expect(out.normalX).toBeCloseTo(0);
61
+ expect(out.normalY).toBeCloseTo(1);
62
+ expect(out.depth).toBeCloseTo(5);
63
+ });
64
+
65
+ it('clears a reused manifold when a following pair misses', () => {
66
+ const out = createCollisionManifold();
67
+ testAabbAabbCollision({ minX: 0, minY: 0, maxX: 2, maxY: 2 }, { minX: 1, minY: 1, maxX: 3, maxY: 3 }, out);
68
+ expect(out.overlapping).toBe(true);
69
+ testAabbAabbCollision({ minX: 0, minY: 0, maxX: 1, maxY: 1 }, { minX: 5, minY: 5, maxX: 6, maxY: 6 }, out);
70
+ expect(out.overlapping).toBe(false);
71
+ expect(out.normalX).toBe(0);
72
+ expect(out.normalY).toBe(0);
73
+ expect(out.depth).toBe(0);
74
+ });
75
+ });
76
+
77
+ describe('testAabbObbCollision', () => {
78
+ it('overlaps an axis-aligned OBB and pushes the box off it', () => {
79
+ const out = createCollisionManifold();
80
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 4, maxY: 10 };
81
+ const b: CollisionObb = { x: 6, y: 5, halfW: 3, halfH: 3, rotation: 0 };
82
+ expect(testAabbObbCollision(a, b, out)).toBe(true);
83
+ expect(out.normalX).toBeCloseTo(-1);
84
+ expect(out.normalY).toBeCloseTo(0);
85
+ expect(out.depth).toBeCloseTo(1); // A right edge x=4, OBB left edge x=3 -> penetration 1
86
+ });
87
+
88
+ it('is separated when apart', () => {
89
+ const out = createCollisionManifold();
90
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 2, maxY: 2 };
91
+ const b: CollisionObb = { x: 10, y: 10, halfW: 1, halfH: 1, rotation: 0.5 };
92
+ expect(testAabbObbCollision(a, b, out)).toBe(false);
93
+ expect(out.overlapping).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('testAabbPolygonCollision', () => {
98
+ it('overlaps a convex polygon', () => {
99
+ const out = createCollisionManifold();
100
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 6, maxY: 6 };
101
+ expect(testAabbPolygonCollision(a, square(4, 0, 6), out)).toBe(true);
102
+ expect(out.normalX).toBeCloseTo(-1);
103
+ expect(out.depth).toBeCloseTo(2); // A right x=6, polygon left x=4 -> penetration 2
104
+ });
105
+
106
+ it('is separated when apart', () => {
107
+ const out = createCollisionManifold();
108
+ const a: CollisionAabb = { minX: 0, minY: 0, maxX: 2, maxY: 2 };
109
+ expect(testAabbPolygonCollision(a, square(10, 10, 4), out)).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('testCircleAabbCollision', () => {
114
+ it('overlaps with the closest-point normal when the center is outside the box', () => {
115
+ const out = createCollisionManifold();
116
+ const a: CollisionCircle = { x: 15, y: 5, radius: 7 };
117
+ const b: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
118
+ expect(testCircleAabbCollision(a, b, out)).toBe(true);
119
+ expect(out.normalX).toBeCloseTo(1);
120
+ expect(out.normalY).toBeCloseTo(0);
121
+ expect(out.depth).toBeCloseTo(2); // closest point (10,5), dist 5, radius 7 -> depth 2
122
+ });
123
+
124
+ it('is separated when the closest point is beyond the radius', () => {
125
+ const out = createCollisionManifold();
126
+ const a: CollisionCircle = { x: 20, y: 5, radius: 3 };
127
+ const b: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
128
+ expect(testCircleAabbCollision(a, b, out)).toBe(false);
129
+ });
130
+
131
+ it('treats exact touching as not overlapping (exclusive)', () => {
132
+ const out = createCollisionManifold();
133
+ const a: CollisionCircle = { x: 17, y: 5, radius: 7 }; // closest (10,5), dist 7 == radius
134
+ const b: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
135
+ expect(testCircleAabbCollision(a, b, out)).toBe(false);
136
+ });
137
+
138
+ it('pushes out through the nearest face when the center is inside the box', () => {
139
+ const out = createCollisionManifold();
140
+ const a: CollisionCircle = { x: 8, y: 5, radius: 1 }; // 2 from the right face
141
+ const b: CollisionAabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 };
142
+ expect(testCircleAabbCollision(a, b, out)).toBe(true);
143
+ expect(out.normalX).toBeCloseTo(1);
144
+ expect(out.normalY).toBeCloseTo(0);
145
+ expect(out.depth).toBeCloseTo(3); // 2 to the face + radius 1
146
+ });
147
+ });
148
+
149
+ describe('testCircleCircleCollision', () => {
150
+ it('gives depth 1 and a unit normal for two radius-1 circles centered 1 apart', () => {
151
+ const out = createCollisionManifold();
152
+ const a: CollisionCircle = { x: 0, y: 0, radius: 1 };
153
+ const b: CollisionCircle = { x: 1, y: 0, radius: 1 };
154
+ expect(testCircleCircleCollision(a, b, out)).toBe(true);
155
+ expect(out.normalX).toBeCloseTo(-1); // pushes A (left) off B (right)
156
+ expect(out.normalY).toBeCloseTo(0);
157
+ expect(out.depth).toBeCloseTo(1);
158
+ });
159
+
160
+ it('is separated when the centers are farther than the radius sum', () => {
161
+ const out = createCollisionManifold();
162
+ const a: CollisionCircle = { x: 0, y: 0, radius: 1 };
163
+ const b: CollisionCircle = { x: 3, y: 0, radius: 1 };
164
+ expect(testCircleCircleCollision(a, b, out)).toBe(false);
165
+ expect(out.depth).toBe(0);
166
+ });
167
+
168
+ it('treats exact touching (distance == radius sum) as not overlapping', () => {
169
+ const out = createCollisionManifold();
170
+ const a: CollisionCircle = { x: 0, y: 0, radius: 1 };
171
+ const b: CollisionCircle = { x: 2, y: 0, radius: 1 };
172
+ expect(testCircleCircleCollision(a, b, out)).toBe(false);
173
+ });
174
+
175
+ it('handles a fully contained circle', () => {
176
+ const out = createCollisionManifold();
177
+ const a: CollisionCircle = { x: 0, y: 0, radius: 5 };
178
+ const b: CollisionCircle = { x: 1, y: 0, radius: 1 };
179
+ expect(testCircleCircleCollision(a, b, out)).toBe(true);
180
+ expect(out.normalX).toBeCloseTo(-1);
181
+ expect(out.depth).toBeCloseTo(5); // radiusSum 6 - dist 1
182
+ });
183
+
184
+ it('falls back to a +X normal at full depth for concentric circles', () => {
185
+ const out = createCollisionManifold();
186
+ const a: CollisionCircle = { x: 2, y: 2, radius: 3 };
187
+ const b: CollisionCircle = { x: 2, y: 2, radius: 1 };
188
+ expect(testCircleCircleCollision(a, b, out)).toBe(true);
189
+ expect(out.normalX).toBe(1);
190
+ expect(out.normalY).toBe(0);
191
+ expect(out.depth).toBeCloseTo(4);
192
+ });
193
+ });
194
+
195
+ describe('testCircleObbCollision', () => {
196
+ it('matches circle-vs-AABB for a zero-rotation OBB', () => {
197
+ const out = createCollisionManifold();
198
+ const a: CollisionCircle = { x: 15, y: 5, radius: 7 };
199
+ const b: CollisionObb = { x: 5, y: 5, halfW: 5, halfH: 5, rotation: 0 };
200
+ expect(testCircleObbCollision(a, b, out)).toBe(true);
201
+ expect(out.normalX).toBeCloseTo(1);
202
+ expect(out.normalY).toBeCloseTo(0);
203
+ expect(out.depth).toBeCloseTo(2);
204
+ });
205
+
206
+ it('rotates the manifold normal back into world space for a rotated OBB', () => {
207
+ const out = createCollisionManifold();
208
+ // OBB rotated 90 degrees: its half-width (5) lies along world Y, half-height (1) along world X.
209
+ const a: CollisionCircle = { x: 3, y: 0, radius: 2.5 };
210
+ const b: CollisionObb = { x: 0, y: 0, halfW: 5, halfH: 1, rotation: Math.PI / 2 };
211
+ expect(testCircleObbCollision(a, b, out)).toBe(true);
212
+ expect(out.normalX).toBeCloseTo(1); // world +X face at x=1, circle center x=3 r=2.5
213
+ expect(out.normalY).toBeCloseTo(0);
214
+ expect(out.depth).toBeCloseTo(0.5); // (1 + 2.5) - 3
215
+ });
216
+
217
+ it('is separated when apart', () => {
218
+ const out = createCollisionManifold();
219
+ const a: CollisionCircle = { x: 20, y: 0, radius: 1 };
220
+ const b: CollisionObb = { x: 0, y: 0, halfW: 2, halfH: 2, rotation: 0.7 };
221
+ expect(testCircleObbCollision(a, b, out)).toBe(false);
222
+ });
223
+ });
224
+
225
+ describe('testCirclePolygonCollision', () => {
226
+ it('overlaps a square polygon on the edge nearest the circle', () => {
227
+ const out = createCollisionManifold();
228
+ const a: CollisionCircle = { x: 13, y: 5, radius: 5 };
229
+ expect(testCirclePolygonCollision(a, square(0, 0, 10), out)).toBe(true);
230
+ expect(out.normalX).toBeCloseTo(1);
231
+ expect(out.normalY).toBeCloseTo(0);
232
+ expect(out.depth).toBeCloseTo(2); // polygon right x=10, circle left x=8 -> penetration 2
233
+ });
234
+
235
+ it('uses the vertex axis to separate a circle just past a corner', () => {
236
+ const out = createCollisionManifold();
237
+ // Corner (10,10), circle center (13,13): corner distance sqrt(18) ~= 4.243 > radius 4.
238
+ // Edge normals alone (X and Y) would falsely report overlap; the vertex axis separates them.
239
+ const a: CollisionCircle = { x: 13, y: 13, radius: 4 };
240
+ expect(testCirclePolygonCollision(a, square(0, 0, 10), out)).toBe(false);
241
+ expect(out.overlapping).toBe(false);
242
+ });
243
+
244
+ it('reports overlap when the circle reaches past the corner', () => {
245
+ const out = createCollisionManifold();
246
+ const a: CollisionCircle = { x: 13, y: 13, radius: 5 }; // corner distance 4.243 < radius 5
247
+ expect(testCirclePolygonCollision(a, square(0, 0, 10), out)).toBe(true);
248
+ });
249
+
250
+ it('handles a circle fully contained in the polygon', () => {
251
+ const out = createCollisionManifold();
252
+ const a: CollisionCircle = { x: 5, y: 5, radius: 1 };
253
+ expect(testCirclePolygonCollision(a, square(0, 0, 10), out)).toBe(true);
254
+ expect(out.overlapping).toBe(true);
255
+ expect(out.depth).toBeCloseTo(6); // 5 to the nearest edge + radius 1
256
+ });
257
+
258
+ it('is separated when far from the polygon', () => {
259
+ const out = createCollisionManifold();
260
+ const a: CollisionCircle = { x: 30, y: 5, radius: 3 };
261
+ expect(testCirclePolygonCollision(a, square(0, 0, 10), out)).toBe(false);
262
+ });
263
+ });
264
+
265
+ describe('testObbObbCollision', () => {
266
+ it('overlaps an axis-aligned box with a 45-degree diamond and separates along X', () => {
267
+ const out = createCollisionManifold();
268
+ const a: CollisionObb = { x: 0, y: 0, halfW: 2, halfH: 2, rotation: 0 };
269
+ const b: CollisionObb = { x: 3, y: 0, halfW: 2, halfH: 2, rotation: Math.PI / 4 };
270
+ expect(testObbObbCollision(a, b, out)).toBe(true);
271
+ expect(out.normalX).toBeCloseTo(-1); // pushes A (left) off the diamond
272
+ expect(out.normalY).toBeCloseTo(0);
273
+ expect(out.depth).toBeCloseTo(1.8284, 3); // A right x=2, diamond left vertex x=3-2.828
274
+ });
275
+
276
+ it('is separated when apart', () => {
277
+ const out = createCollisionManifold();
278
+ const a: CollisionObb = { x: 0, y: 0, halfW: 1, halfH: 1, rotation: 0.3 };
279
+ const b: CollisionObb = { x: 8, y: 0, halfW: 1, halfH: 1, rotation: 1.1 };
280
+ expect(testObbObbCollision(a, b, out)).toBe(false);
281
+ });
282
+ });
283
+
284
+ describe('testObbPolygonCollision', () => {
285
+ it('overlaps a convex polygon', () => {
286
+ const out = createCollisionManifold();
287
+ const a: CollisionObb = { x: 0, y: 0, halfW: 3, halfH: 3, rotation: 0 };
288
+ expect(testObbPolygonCollision(a, square(2, -1, 4), out)).toBe(true);
289
+ expect(out.normalX).toBeCloseTo(-1);
290
+ expect(out.depth).toBeCloseTo(1); // OBB right x=3, polygon left x=2 -> penetration 1
291
+ });
292
+
293
+ it('is separated when apart', () => {
294
+ const out = createCollisionManifold();
295
+ const a: CollisionObb = { x: 0, y: 0, halfW: 1, halfH: 1, rotation: 0.4 };
296
+ expect(testObbPolygonCollision(a, square(10, 10, 3), out)).toBe(false);
297
+ });
298
+ });
299
+
300
+ describe('testPolygonPolygonCollision', () => {
301
+ it('reports the least-penetration axis for two overlapping squares', () => {
302
+ const out = createCollisionManifold();
303
+ // A = 0..10 square; B overlaps deeply on X (x[2,8]) but shallowly on Y (y[8,20]).
304
+ const a = square(0, 0, 10);
305
+ const b: CollisionPolygon = { points: [2, 8, 8, 8, 8, 20, 2, 20] };
306
+ expect(testPolygonPolygonCollision(a, b, out)).toBe(true);
307
+ expect(out.normalX).toBeCloseTo(0);
308
+ expect(out.normalY).toBeCloseTo(-1);
309
+ expect(out.depth).toBeCloseTo(2);
310
+ });
311
+
312
+ it('treats a concave-looking-but-convex polygon (a regular hexagon) correctly', () => {
313
+ const out = createCollisionManifold();
314
+ // A convex hexagon centered near the origin.
315
+ const hex: CollisionPolygon = { points: [2, 0, 1, 2, -1, 2, -2, 0, -1, -2, 1, -2] };
316
+ expect(testPolygonPolygonCollision(hex, square(0, 0, 3), out)).toBe(true);
317
+ expect(out.overlapping).toBe(true);
318
+ });
319
+
320
+ it('is separated when the polygons are disjoint', () => {
321
+ const out = createCollisionManifold();
322
+ expect(testPolygonPolygonCollision(square(0, 0, 2), square(5, 5, 2), out)).toBe(false);
323
+ });
324
+
325
+ it('treats edge-touching as not overlapping (exclusive)', () => {
326
+ const out = createCollisionManifold();
327
+ expect(testPolygonPolygonCollision(square(0, 0, 2), square(2, 0, 2), out)).toBe(false);
328
+ });
329
+ });
@@ -0,0 +1,60 @@
1
+ import type { CollisionShape } from '@flighthq/types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createCollisionManifold } from './manifold';
5
+ import { testCollision } from './testCollision';
6
+
7
+ describe('testCollision', () => {
8
+ it('dispatches a circle-circle pair to the same result as the direct test', () => {
9
+ const out = createCollisionManifold();
10
+ const a: CollisionShape = { kind: 'circle', x: 0, y: 0, radius: 1 };
11
+ const b: CollisionShape = { kind: 'circle', x: 1, y: 0, radius: 1 };
12
+ expect(testCollision(a, b, out)).toBe(true);
13
+ expect(out.normalX).toBeCloseTo(-1);
14
+ expect(out.depth).toBeCloseTo(1);
15
+ });
16
+
17
+ it('dispatches an aabb-circle pair in either argument order with a sign-consistent normal', () => {
18
+ const forward = createCollisionManifold();
19
+ const reversed = createCollisionManifold();
20
+ const circle: CollisionShape = { kind: 'circle', x: 15, y: 5, radius: 7 };
21
+ const box: CollisionShape = { kind: 'aabb', minX: 0, minY: 0, maxX: 10, maxY: 10 };
22
+
23
+ // circle vs box: pushes the circle (A) off the box -> +X.
24
+ expect(testCollision(circle, box, forward)).toBe(true);
25
+ expect(forward.normalX).toBeCloseTo(1);
26
+ expect(forward.depth).toBeCloseTo(2);
27
+
28
+ // box vs circle: pushes the box (A) off the circle -> the opposite direction, -X.
29
+ expect(testCollision(box, circle, reversed)).toBe(true);
30
+ expect(reversed.normalX).toBeCloseTo(-1);
31
+ expect(reversed.depth).toBeCloseTo(2);
32
+ });
33
+
34
+ it('dispatches an obb-polygon pair', () => {
35
+ const out = createCollisionManifold();
36
+ const obb: CollisionShape = { kind: 'obb', x: 0, y: 0, halfW: 3, halfH: 3, rotation: 0 };
37
+ const polygon: CollisionShape = { kind: 'polygon', points: [2, -1, 6, -1, 6, 3, 2, 3] };
38
+ expect(testCollision(obb, polygon, out)).toBe(true);
39
+ expect(out.normalX).toBeCloseTo(-1);
40
+ expect(out.depth).toBeCloseTo(1);
41
+ });
42
+
43
+ it('reports no overlap for disjoint shapes', () => {
44
+ const out = createCollisionManifold();
45
+ const a: CollisionShape = { kind: 'circle', x: 0, y: 0, radius: 1 };
46
+ const b: CollisionShape = { kind: 'aabb', minX: 10, minY: 10, maxX: 12, maxY: 12 };
47
+ expect(testCollision(a, b, out)).toBe(false);
48
+ expect(out.overlapping).toBe(false);
49
+ });
50
+
51
+ it('reports no manifold for the area-less segment and point kinds', () => {
52
+ const out = createCollisionManifold();
53
+ const segment: CollisionShape = { kind: 'segment', x0: 0, y0: 0, x1: 1, y1: 1 };
54
+ const point: CollisionShape = { kind: 'point', x: 0, y: 0 };
55
+ const circle: CollisionShape = { kind: 'circle', x: 0, y: 0, radius: 1 };
56
+ expect(testCollision(segment, circle, out)).toBe(false);
57
+ expect(testCollision(circle, point, out)).toBe(false);
58
+ expect(out.overlapping).toBe(false);
59
+ });
60
+ });