@flighthq/geometry 0.3.0 → 0.3.1-next.1041.35b950c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/aabb.d.ts +1 -0
  2. package/dist/aabb.d.ts.map +1 -1
  3. package/dist/aabb.js +58 -38
  4. package/dist/aabb.js.map +1 -1
  5. package/dist/boundingSphere.d.ts.map +1 -1
  6. package/dist/boundingSphere.js +3 -2
  7. package/dist/boundingSphere.js.map +1 -1
  8. package/dist/capsule.d.ts +6 -5
  9. package/dist/capsule.d.ts.map +1 -1
  10. package/dist/capsule.js +38 -15
  11. package/dist/capsule.js.map +1 -1
  12. package/dist/contract.d.ts +1 -0
  13. package/dist/contract.d.ts.map +1 -1
  14. package/dist/contract.js +1 -0
  15. package/dist/contract.js.map +1 -1
  16. package/dist/enableGeometryPoolGuards.d.ts +4 -0
  17. package/dist/enableGeometryPoolGuards.d.ts.map +1 -0
  18. package/dist/enableGeometryPoolGuards.js +32 -0
  19. package/dist/enableGeometryPoolGuards.js.map +1 -0
  20. package/dist/frustum.d.ts +2 -1
  21. package/dist/frustum.d.ts.map +1 -1
  22. package/dist/frustum.js +4 -1
  23. package/dist/frustum.js.map +1 -1
  24. package/dist/geometryPoolGuards.d.ts +6 -0
  25. package/dist/geometryPoolGuards.d.ts.map +1 -0
  26. package/dist/geometryPoolGuards.js +7 -0
  27. package/dist/geometryPoolGuards.js.map +1 -0
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/matrix.d.ts +12 -9
  33. package/dist/matrix.d.ts.map +1 -1
  34. package/dist/matrix.js +103 -106
  35. package/dist/matrix.js.map +1 -1
  36. package/dist/matrix3.d.ts +17 -2
  37. package/dist/matrix3.d.ts.map +1 -1
  38. package/dist/matrix3.js +88 -44
  39. package/dist/matrix3.js.map +1 -1
  40. package/dist/matrix3Pool.d.ts.map +1 -1
  41. package/dist/matrix3Pool.js +6 -0
  42. package/dist/matrix3Pool.js.map +1 -1
  43. package/dist/matrix4.d.ts +23 -10
  44. package/dist/matrix4.d.ts.map +1 -1
  45. package/dist/matrix4.js +74 -53
  46. package/dist/matrix4.js.map +1 -1
  47. package/dist/matrix4Pool.d.ts.map +1 -1
  48. package/dist/matrix4Pool.js +6 -0
  49. package/dist/matrix4Pool.js.map +1 -1
  50. package/dist/matrixPool.d.ts.map +1 -1
  51. package/dist/matrixPool.js +6 -0
  52. package/dist/matrixPool.js.map +1 -1
  53. package/dist/obb.d.ts +4 -2
  54. package/dist/obb.d.ts.map +1 -1
  55. package/dist/obb.js +164 -88
  56. package/dist/obb.js.map +1 -1
  57. package/dist/quaternion.d.ts +7 -4
  58. package/dist/quaternion.d.ts.map +1 -1
  59. package/dist/quaternion.js +40 -25
  60. package/dist/quaternion.js.map +1 -1
  61. package/dist/quaternionPool.d.ts.map +1 -1
  62. package/dist/quaternionPool.js +6 -0
  63. package/dist/quaternionPool.js.map +1 -1
  64. package/dist/ray3d.d.ts +5 -4
  65. package/dist/ray3d.d.ts.map +1 -1
  66. package/dist/ray3d.js +23 -8
  67. package/dist/ray3d.js.map +1 -1
  68. package/dist/rectangle.d.ts.map +1 -1
  69. package/dist/rectangle.js +66 -43
  70. package/dist/rectangle.js.map +1 -1
  71. package/dist/rectanglePool.d.ts.map +1 -1
  72. package/dist/rectanglePool.js +6 -0
  73. package/dist/rectanglePool.js.map +1 -1
  74. package/dist/transform2d.d.ts.map +1 -1
  75. package/dist/transform2d.js +5 -2
  76. package/dist/transform2d.js.map +1 -1
  77. package/dist/typedarray.js +3 -6
  78. package/dist/typedarray.js.map +1 -1
  79. package/dist/vector2.d.ts.map +1 -1
  80. package/dist/vector2.js +44 -26
  81. package/dist/vector2.js.map +1 -1
  82. package/dist/vector2Pool.d.ts.map +1 -1
  83. package/dist/vector2Pool.js +6 -0
  84. package/dist/vector2Pool.js.map +1 -1
  85. package/dist/vector3.d.ts +2 -2
  86. package/dist/vector3.d.ts.map +1 -1
  87. package/dist/vector3.js +55 -37
  88. package/dist/vector3.js.map +1 -1
  89. package/dist/vector3Pool.d.ts.map +1 -1
  90. package/dist/vector3Pool.js +6 -0
  91. package/dist/vector3Pool.js.map +1 -1
  92. package/dist/vector4.d.ts +2 -2
  93. package/dist/vector4.d.ts.map +1 -1
  94. package/dist/vector4.js +66 -48
  95. package/dist/vector4.js.map +1 -1
  96. package/dist/vector4Pool.d.ts.map +1 -1
  97. package/dist/vector4Pool.js +6 -0
  98. package/dist/vector4Pool.js.map +1 -1
  99. package/package.json +4 -3
  100. package/src/aabb.test.ts +18 -0
  101. package/src/boundingSphere.test.ts +39 -0
  102. package/src/capsule.test.ts +158 -0
  103. package/src/enableGeometryPoolGuards.test.ts +121 -0
  104. package/src/frustum.test.ts +38 -0
  105. package/src/geometryPoolGuards.test.ts +16 -0
  106. package/src/matrix.test.ts +52 -6
  107. package/src/matrix3.test.ts +46 -6
  108. package/src/matrix3Pool.test.ts +11 -0
  109. package/src/matrix4.test.ts +553 -135
  110. package/src/matrix4Pool.test.ts +11 -0
  111. package/src/matrixPool.test.ts +11 -0
  112. package/src/obb.test.ts +277 -0
  113. package/src/plane.test.ts +20 -0
  114. package/src/quaternion.test.ts +222 -4
  115. package/src/quaternionPool.test.ts +12 -0
  116. package/src/ray3d.test.ts +204 -0
  117. package/src/rectangle.test.ts +58 -14
  118. package/src/rectanglePool.test.ts +11 -0
  119. package/src/transform2d.test.ts +38 -0
  120. package/src/vector2.test.ts +170 -0
  121. package/src/vector2Pool.test.ts +11 -0
  122. package/src/vector3.test.ts +210 -0
  123. package/src/vector3Pool.test.ts +11 -0
  124. package/src/vector4.test.ts +231 -0
  125. package/src/vector4Pool.test.ts +11 -0
@@ -62,6 +62,49 @@ describe('getClosestPointOnCapsule', () => {
62
62
  expect(p.x).toBeCloseTo(1, 5);
63
63
  expect(p.y).toBeCloseTo(0, 5);
64
64
  });
65
+
66
+ it('answers a point on the axis with a surface point, whichever way the axis runs', () => {
67
+ // A point on the axis has no single nearest surface point, so the answer is a pick from the
68
+ // ring around it — but it still has to be a point on the surface. Each capsule below is
69
+ // centered on the origin and queried at the origin, so a correct answer sits one radius out
70
+ // and square to the axis; an answer pointing along the axis is inside the capsule, not on it.
71
+ const out = createVector3();
72
+
73
+ getClosestPointOnCapsule(out, createCapsule(-5, 0, 0, 5, 0, 0, 1), createVector3(0, 0, 0));
74
+ expect(Math.hypot(out.x, out.y, out.z)).toBeCloseTo(1, 5);
75
+ expect(out.x).toBeCloseTo(0, 5);
76
+
77
+ getClosestPointOnCapsule(out, createCapsule(0, -5, 0, 0, 5, 0, 1), createVector3(0, 0, 0));
78
+ expect(Math.hypot(out.x, out.y, out.z)).toBeCloseTo(1, 5);
79
+ expect(out.y).toBeCloseTo(0, 5);
80
+
81
+ getClosestPointOnCapsule(out, createCapsule(0, 0, -5, 0, 0, 5, 1), createVector3(0, 0, 0));
82
+ expect(Math.hypot(out.x, out.y, out.z)).toBeCloseTo(1, 5);
83
+ expect(out.z).toBeCloseTo(0, 5);
84
+
85
+ // A capsule with no length is a sphere: its center has a whole sphere of nearest points and
86
+ // every direction is square to a zero-length axis, so only the distance is fixed.
87
+ getClosestPointOnCapsule(out, createCapsule(0, 0, 0, 0, 0, 0, 1), createVector3(0, 0, 0));
88
+ expect(Math.hypot(out.x, out.y, out.z)).toBeCloseTo(1, 5);
89
+ });
90
+
91
+ it('answers a point on a diagonal axis with a surface point too', () => {
92
+ // The axis here lines up with no world direction, so the perpendicular is a genuine cross
93
+ // product rather than a swap of coordinates. A correct answer is one radius from the axis
94
+ // point, which for a query at the middle of a centered capsule means square to the axis.
95
+ const evenlyDiagonal = createCapsule(-3, -3, -3, 3, 3, 3, 2);
96
+ const out = createVector3();
97
+ getClosestPointOnCapsule(out, evenlyDiagonal, createVector3(0, 0, 0));
98
+ expect(Math.hypot(out.x, out.y, out.z)).toBeCloseTo(2, 5);
99
+ expect(out.x + out.y + out.z).toBeCloseTo(0, 5);
100
+
101
+ // An axis leaning mostly along x, least along z, so the direction it is crossed with differs
102
+ // again — every ordering of the axis components has to produce a perpendicular.
103
+ const leaningDiagonal = createCapsule(-3, -2, -1, 3, 2, 1, 2);
104
+ getClosestPointOnCapsule(out, leaningDiagonal, createVector3(0, 0, 0));
105
+ expect(Math.hypot(out.x, out.y, out.z)).toBeCloseTo(2, 5);
106
+ expect(out.x * 3 + out.y * 2 + out.z * 1).toBeCloseTo(0, 5);
107
+ });
65
108
  });
66
109
 
67
110
  describe('intersectRay3DCapsule', () => {
@@ -93,6 +136,60 @@ describe('intersectRay3DCapsule', () => {
93
136
  const t = intersectRay3DCapsule(ray, c);
94
137
  expect(t).toBeCloseTo(4, 5);
95
138
  });
139
+
140
+ it('returns -1 for a ray with no direction', () => {
141
+ // Matches intersectRay3DSphere: a zero-length direction is not a ray and never hits, even
142
+ // from a point inside the capsule.
143
+ const c = createCapsule(0, -1, 0, 0, 1, 0, 1);
144
+ expect(intersectRay3DCapsule(createRay3D(0, 0, 0, 0, 0, 0), c)).toBe(-1);
145
+ expect(intersectRay3DCapsule(createRay3D(9, 9, 9, 0, 0, 0), c)).toBe(-1);
146
+ });
147
+
148
+ it('returns -1 for an empty capsule with a negative radius', () => {
149
+ const empty = createCapsule(0, -1, 0, 0, 1, 0, -1);
150
+ expect(intersectRay3DCapsule(createRay3D(5, 0, 0, -1, 0, 0), empty)).toBe(-1);
151
+ });
152
+
153
+ it('returns -1 for a ray that passes wide of the capsule', () => {
154
+ // Parallel to nothing and outside the cylinder radius, so neither the body nor either cap has
155
+ // a real intersection at all — a different miss from a ray aimed away from a capsule in reach.
156
+ const c = createCapsule(0, -1, 0, 0, 1, 0, 1);
157
+ expect(intersectRay3DCapsule(createRay3D(5, 0, 5, -1, 0, 0), c)).toBe(-1);
158
+ });
159
+
160
+ it('hits a cap when the ray runs along the capsule axis', () => {
161
+ // Straight down the axis the body test has nothing to solve — the ray never crosses the
162
+ // cylinder wall — so the answer has to come from the cap.
163
+ const c = createCapsule(0, -1, 0, 0, 1, 0, 1);
164
+ expect(intersectRay3DCapsule(createRay3D(0, 5, 0, 0, -1, 0), c)).toBeCloseTo(3, 5);
165
+ });
166
+
167
+ it('returns 0 for a ray starting inside a cap and pointing away', () => {
168
+ // Inside the rounded end, heading out. The near root of the cap sphere is behind the origin
169
+ // and the far one ahead, which is the definition of starting inside.
170
+ const c = createCapsule(0, -1, 0, 0, 1, 0, 1);
171
+ expect(intersectRay3DCapsule(createRay3D(0, 1.5, 0, 0, 1, 0), c)).toBe(0);
172
+ });
173
+
174
+ it('falls back to a cap when the body hit lands past the end of the axis', () => {
175
+ // These rays cross the infinite cylinder, but above or below the segment the cylinder is not
176
+ // capsule any more, so the body hit must be discarded and the rounded end answered instead.
177
+ // The capsule spans y in [-1, 1] with radius 1; a ray at y = 1.8 crosses the end sphere at
178
+ // x = sqrt(1 - 0.8^2) = 0.6, so it enters 4.4 along from x = 5.
179
+ const c = createCapsule(0, -1, 0, 0, 1, 0, 1);
180
+ expect(intersectRay3DCapsule(createRay3D(5, 1.8, 0, -1, 0, 0), c)).toBeCloseTo(4.4, 5);
181
+ expect(intersectRay3DCapsule(createRay3D(5, -1.8, 0, -1, 0, 0), c)).toBeCloseTo(4.4, 5);
182
+
183
+ // Beyond the reach of the cap, the same arrangement is a clean miss.
184
+ expect(intersectRay3DCapsule(createRay3D(5, 2.5, 0, -1, 0, 0), c)).toBe(-1);
185
+ });
186
+
187
+ it('returns -1 from inside the infinite cylinder but past the end cap', () => {
188
+ // The origin is in line with the capsule body yet beyond its end, so being inside the endless
189
+ // cylinder means nothing: from a point outside the capsule heading away, there is no hit.
190
+ const c = createCapsule(0, -1, 0, 0, 1, 0, 1);
191
+ expect(intersectRay3DCapsule(createRay3D(0, 3, 0, 1, 1, 0), c)).toBe(-1);
192
+ });
96
193
  });
97
194
 
98
195
  describe('isCapsuleIntersectingCapsule', () => {
@@ -114,6 +211,33 @@ describe('isCapsuleIntersectingCapsule', () => {
114
211
  expect(isCapsuleIntersectingCapsule(a, empty)).toBe(false);
115
212
  expect(isCapsuleIntersectingCapsule(empty, a)).toBe(false);
116
213
  });
214
+
215
+ it('switches over at the true axis distance for every segment arrangement', () => {
216
+ // The segment-to-segment distance behind this test has a separate branch for each way two
217
+ // segments can be arranged — both degenerate to points, one degenerate, parallel, skew, and
218
+ // the two cases where the nearest approach falls beyond an endpoint of the second segment.
219
+ // Each is checked against a distance measured by walking both segments, so a branch that
220
+ // returns the wrong closest pair shows up as an answer that flips at the wrong radius.
221
+ const arrangements: Array<[string, readonly number[], readonly number[]]> = [
222
+ ['two points', [1, 2, 3, 1, 2, 3], [4, 6, 3, 4, 6, 3]],
223
+ ['point and segment', [0, 5, 0, 0, 5, 0], [-10, 0, 0, 10, 0, 0]],
224
+ ['segment and point', [-10, 0, 0, 10, 0, 0], [0, 5, 0, 0, 5, 0]],
225
+ ['parallel', [0, 0, 0, 0, 10, 0], [3, 0, 0, 3, 10, 0]],
226
+ ['skew, nearest approach inside both', [-5, 0, 0, 5, 0, 0], [0, -5, 2, 0, 5, 2]],
227
+ ['nearest approach before the second start', [0, 0, 0, 10, 0, 0], [5, 5, 3, 5, 15, 3]],
228
+ ['nearest approach past the second end', [0, 0, 0, 10, 0, 0], [5, -15, 3, 5, -5, 3]],
229
+ ];
230
+
231
+ for (const [name, first, second] of arrangements) {
232
+ const gap = measureSegmentDistance(first, second);
233
+ const tooShort = createCapsule(...(first as SegmentArguments), gap * 0.45);
234
+ const longEnough = createCapsule(...(first as SegmentArguments), gap * 0.55);
235
+ const reach = createCapsule(...(second as SegmentArguments), gap * 0.45);
236
+
237
+ expect([name, isCapsuleIntersectingCapsule(tooShort, reach)]).toEqual([name, false]);
238
+ expect([name, isCapsuleIntersectingCapsule(longEnough, reach)]).toEqual([name, true]);
239
+ }
240
+ });
117
241
  });
118
242
 
119
243
  describe('isCapsuleIntersectingSphere', () => {
@@ -140,6 +264,14 @@ describe('isCapsuleIntersectingSphere', () => {
140
264
  const s = createBoundingSphere(0, 0, 0, 1);
141
265
  expect(isCapsuleIntersectingSphere(c, s)).toBe(false);
142
266
  });
267
+
268
+ it('treats a capsule with no length as a sphere', () => {
269
+ // Both endpoints in the same place leaves no axis to project onto, and the test reduces to
270
+ // two spheres: they touch when the centers are closer than the radii add up to.
271
+ const point = createCapsule(0, 0, 0, 0, 0, 0, 1);
272
+ expect(isCapsuleIntersectingSphere(point, createBoundingSphere(2.5, 0, 0, 1))).toBe(false);
273
+ expect(isCapsuleIntersectingSphere(point, createBoundingSphere(1.5, 0, 0, 1))).toBe(true);
274
+ });
143
275
  });
144
276
 
145
277
  describe('setCapsule', () => {
@@ -155,3 +287,29 @@ describe('setCapsule', () => {
155
287
  expect(c.radius).toBe(7);
156
288
  });
157
289
  });
290
+
291
+ // The six coordinates of a capsule axis: start x, y, z then end x, y, z.
292
+ type SegmentArguments = [number, number, number, number, number, number];
293
+
294
+ // The smallest distance between two segments, found by walking both of them. Deliberately not the
295
+ // closed-form solution the source uses — it is slow and approximate, but it is arrived at
296
+ // independently, so it can disagree with the formula it checks.
297
+ function measureSegmentDistance(first: readonly number[], second: readonly number[]): number {
298
+ const steps = 600;
299
+ let smallest = Number.POSITIVE_INFINITY;
300
+ for (let i = 0; i <= steps; i++) {
301
+ const s = i / steps;
302
+ const px = first[0] + s * (first[3] - first[0]);
303
+ const py = first[1] + s * (first[4] - first[1]);
304
+ const pz = first[2] + s * (first[5] - first[2]);
305
+ for (let j = 0; j <= steps; j++) {
306
+ const t = j / steps;
307
+ const qx = second[0] + t * (second[3] - second[0]);
308
+ const qy = second[1] + t * (second[4] - second[1]);
309
+ const qz = second[2] + t * (second[5] - second[2]);
310
+ const distance = Math.hypot(px - qx, py - qy, pz - qz);
311
+ if (distance < smallest) smallest = distance;
312
+ }
313
+ }
314
+ return smallest;
315
+ }
@@ -0,0 +1,121 @@
1
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
+ import type { LogEntry } from '@flighthq/types/contract';
3
+
4
+ import {
5
+ areGeometryPoolGuardsEnabled,
6
+ disableGeometryPoolGuards,
7
+ enableGeometryPoolGuards,
8
+ } from './enableGeometryPoolGuards';
9
+ import { acquireMatrix3, clearMatrix3Pool, releaseMatrix3 } from './matrix3Pool';
10
+ import { acquireMatrix4, clearMatrix4Pool, releaseMatrix4 } from './matrix4Pool';
11
+ import { acquireMatrix, clearMatrixPool, releaseMatrix } from './matrixPool';
12
+ import { acquireQuaternion, clearQuaternionPool, releaseQuaternion } from './quaternionPool';
13
+ import { acquireRectangle, clearRectanglePool, releaseRectangle } from './rectanglePool';
14
+ import { acquireVector2, clearVector2Pool, releaseVector2 } from './vector2Pool';
15
+ import { acquireVector3, clearVector3Pool, releaseVector3 } from './vector3Pool';
16
+ import { acquireVector4, clearVector4Pool, releaseVector4 } from './vector4Pool';
17
+
18
+ function captureLog(run: () => void): readonly LogEntry[] {
19
+ const sink = createMemoryLogSink(16);
20
+ addLogSink(sink.sink);
21
+ try {
22
+ run();
23
+ return getMemoryLogSinkEntries(sink);
24
+ } finally {
25
+ removeLogSink(sink.sink);
26
+ }
27
+ }
28
+
29
+ function clearPools(): void {
30
+ clearMatrixPool();
31
+ clearMatrix3Pool();
32
+ clearMatrix4Pool();
33
+ clearQuaternionPool();
34
+ clearRectanglePool();
35
+ clearVector2Pool();
36
+ clearVector3Pool();
37
+ clearVector4Pool();
38
+ }
39
+
40
+ function doubleRelease<Value>(acquire: () => Value, release: (value: Value) => void): void {
41
+ const value = acquire();
42
+ release(value);
43
+ release(value);
44
+ }
45
+
46
+ function messageOf(entry: Readonly<LogEntry>): string {
47
+ const data = entry.data;
48
+ return typeof data === 'string' ? data : String(data.message);
49
+ }
50
+
51
+ beforeEach(clearPools);
52
+
53
+ afterEach(() => {
54
+ disableGeometryPoolGuards();
55
+ clearPools();
56
+ });
57
+
58
+ describe('areGeometryPoolGuardsEnabled', () => {
59
+ it('reports whether the pool release diagnostic is installed', () => {
60
+ expect(areGeometryPoolGuardsEnabled()).toBe(false);
61
+ enableGeometryPoolGuards();
62
+ expect(areGeometryPoolGuardsEnabled()).toBe(true);
63
+ disableGeometryPoolGuards();
64
+ expect(areGeometryPoolGuardsEnabled()).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('disableGeometryPoolGuards', () => {
69
+ it('restores the production-default silent release path', () => {
70
+ enableGeometryPoolGuards();
71
+ disableGeometryPoolGuards();
72
+ const entries = captureLog(() => doubleRelease(acquireVector2, releaseVector2));
73
+ expect(entries).toHaveLength(0);
74
+ });
75
+ });
76
+
77
+ describe('enableGeometryPoolGuards', () => {
78
+ // ONE firing test deliberately consumes all eight process-lifetime logOnce keys.
79
+ it('warns once for a double release from every geometry pool and stays silent for valid brackets', () => {
80
+ enableGeometryPoolGuards();
81
+
82
+ const pairedEntries = captureLog(() => {
83
+ releaseMatrix(acquireMatrix());
84
+ releaseMatrix3(acquireMatrix3());
85
+ releaseMatrix4(acquireMatrix4());
86
+ releaseQuaternion(acquireQuaternion());
87
+ releaseRectangle(acquireRectangle());
88
+ releaseVector2(acquireVector2());
89
+ releaseVector3(acquireVector3());
90
+ releaseVector4(acquireVector4());
91
+ });
92
+ expect(pairedEntries).toHaveLength(0);
93
+
94
+ const entries = captureLog(() => {
95
+ doubleRelease(acquireMatrix, releaseMatrix);
96
+ doubleRelease(acquireMatrix3, releaseMatrix3);
97
+ doubleRelease(acquireMatrix4, releaseMatrix4);
98
+ doubleRelease(acquireQuaternion, releaseQuaternion);
99
+ doubleRelease(acquireRectangle, releaseRectangle);
100
+ doubleRelease(acquireVector2, releaseVector2);
101
+ doubleRelease(acquireVector3, releaseVector3);
102
+ doubleRelease(acquireVector4, releaseVector4);
103
+ });
104
+
105
+ expect(entries).toHaveLength(8);
106
+ expect(entries.every((entry) => entry.channel === 'geometry')).toBe(true);
107
+ const messages = entries.map(messageOf);
108
+ for (const releaseFunction of [
109
+ 'releaseMatrix',
110
+ 'releaseMatrix3',
111
+ 'releaseMatrix4',
112
+ 'releaseQuaternion',
113
+ 'releaseRectangle',
114
+ 'releaseVector2',
115
+ 'releaseVector3',
116
+ 'releaseVector4',
117
+ ]) {
118
+ expect(messages.some((message) => message.includes(releaseFunction))).toBe(true);
119
+ }
120
+ });
121
+ });
@@ -79,6 +79,19 @@ describe('getFrustumCorners', () => {
79
79
  expect(isFinite(corners[0].x)).toBe(true);
80
80
  expect(isFinite(corners[1].x)).toBe(true);
81
81
  });
82
+
83
+ it('writes finite corners for a matrix that divides a corner by zero', () => {
84
+ // The perspective divide has no answer when the matrix sends a corner to a w of zero — a
85
+ // point at infinity. Passing the coordinates through undivided keeps the corner finite, so a
86
+ // caller fitting bounds around these corners gets a wrong box rather than an infinite one.
87
+ const corners = Array.from({ length: 8 }, () => createVector3());
88
+ getFrustumCorners(corners, createMatrix4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0));
89
+ for (const corner of corners) {
90
+ expect(Number.isFinite(corner.x)).toBe(true);
91
+ expect(Number.isFinite(corner.y)).toBe(true);
92
+ expect(Number.isFinite(corner.z)).toBe(true);
93
+ }
94
+ });
82
95
  });
83
96
 
84
97
  describe('isFrustumContainingPoint', () => {
@@ -119,6 +132,16 @@ describe('isFrustumIntersectingAabb', () => {
119
132
  setFrustumFromMatrix4(f, createTestViewProjection());
120
133
  expect(isFrustumIntersectingAabb(f, createAabb(50, 50, -1, 60, 60, 1))).toBe(false);
121
134
  });
135
+
136
+ it.each([
137
+ ['x', createAabb(0.5, -0.5, -0.5, -0.5, 0.5, 0.5)],
138
+ ['y', createAabb(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5)],
139
+ ['z', createAabb(-0.5, -0.5, 0.5, 0.5, 0.5, -0.5)],
140
+ ])('rejects an AABB empty on %s', (_axis, empty) => {
141
+ const f = createFrustum();
142
+ setFrustumFromMatrix4(f, createMatrix4());
143
+ expect(isFrustumIntersectingAabb(f, empty)).toBe(false);
144
+ });
122
145
  });
123
146
 
124
147
  describe('isFrustumIntersectingSphere', () => {
@@ -157,4 +180,19 @@ describe('setFrustumFromMatrix4', () => {
157
180
  const len = Math.sqrt(near.a * near.a + near.b * near.b + near.c * near.c);
158
181
  expect(len).toBeCloseTo(1, 5);
159
182
  });
183
+
184
+ it('writes finite coefficients for a matrix with no frustum in it', () => {
185
+ // Every plane comes out of a matrix row, and a row of zeros has no direction to scale to unit
186
+ // length. The coefficients stay as they are rather than dividing by zero, so a caller that
187
+ // culls against this frustum gets consistent rejections instead of NaN comparisons, which are
188
+ // false in both directions and would let a box pass and fail the same test.
189
+ const f = createFrustum();
190
+ setFrustumFromMatrix4(f, createMatrix4(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
191
+ for (const plane of [f.near, f.far, f.left, f.right, f.top, f.bottom]) {
192
+ expect(Number.isFinite(plane.a)).toBe(true);
193
+ expect(Number.isFinite(plane.b)).toBe(true);
194
+ expect(Number.isFinite(plane.c)).toBe(true);
195
+ expect(Number.isFinite(plane.d)).toBe(true);
196
+ }
197
+ });
160
198
  });
@@ -0,0 +1,16 @@
1
+ import { geometryPoolReleaseGuard, setGeometryPoolReleaseGuard } from './geometryPoolGuards';
2
+
3
+ afterEach(() => {
4
+ setGeometryPoolReleaseGuard(null);
5
+ });
6
+
7
+ describe('setGeometryPoolReleaseGuard', () => {
8
+ it('installs and removes the internal release callback', () => {
9
+ const guard = (): void => {};
10
+ setGeometryPoolReleaseGuard(guard);
11
+ expect(geometryPoolReleaseGuard).toBe(guard);
12
+
13
+ setGeometryPoolReleaseGuard(null);
14
+ expect(geometryPoolReleaseGuard).toBeNull();
15
+ });
16
+ });
@@ -68,6 +68,12 @@ describe('copyMatrix', () => {
68
68
  expect(m2.tx).toBe(6);
69
69
  expect(m2.ty).toBe(7);
70
70
  });
71
+
72
+ it('supports out === source', () => {
73
+ const matrix = createMatrix(2, 3, 4, 5, 6, 7);
74
+ copyMatrix(matrix, matrix);
75
+ expect(matrix).toMatchObject({ a: 2, b: 3, c: 4, d: 5, tx: 6, ty: 7 });
76
+ });
71
77
  });
72
78
 
73
79
  describe('copyMatrixColumnFromVector3', () => {
@@ -95,10 +101,11 @@ describe('copyMatrixColumnFromVector3', () => {
95
101
  expect(m.ty).toBe(6);
96
102
  });
97
103
 
98
- it('should throw when column is greater than 2', () => {
104
+ it('throws when column is outside 0 through 2', () => {
99
105
  const m = createMatrix();
100
106
  const v = createVector3();
101
- expect(() => copyMatrixColumnFromVector3(m, 3, v)).toThrow();
107
+ expect(() => copyMatrixColumnFromVector3(m, -1, v)).toThrow(RangeError);
108
+ expect(() => copyMatrixColumnFromVector3(m, 3, v)).toThrow(RangeError);
102
109
  });
103
110
  });
104
111
 
@@ -130,10 +137,11 @@ describe('copyMatrixColumnToVector3', () => {
130
137
  expect(v.z).toBe(1);
131
138
  });
132
139
 
133
- it('should throw when column is greater than 2', () => {
140
+ it('throws when column is outside 0 through 2', () => {
134
141
  const m = createMatrix();
135
142
  const v = createVector3();
136
- expect(() => copyMatrixColumnToVector3(v, 3, m)).toThrow();
143
+ expect(() => copyMatrixColumnToVector3(v, -1, m)).toThrow(RangeError);
144
+ expect(() => copyMatrixColumnToVector3(v, 3, m)).toThrow(RangeError);
137
145
  });
138
146
  });
139
147
 
@@ -156,10 +164,17 @@ describe('copyMatrixRowFromVector3', () => {
156
164
  expect(m.ty).toBe(6);
157
165
  });
158
166
 
159
- it('should throw when row is greater than 2', () => {
167
+ it('leaves the fixed identity row unchanged for row 2', () => {
168
+ const m = createMatrix(1, 2, 3, 4, 5, 6);
169
+ copyMatrixRowFromVector3(m, 2, createVector3(7, 8, 9));
170
+ expect(m).toMatchObject({ a: 1, b: 2, c: 3, d: 4, tx: 5, ty: 6 });
171
+ });
172
+
173
+ it('throws when row is outside 0 through 2', () => {
160
174
  const m = createMatrix();
161
175
  const v = createVector3();
162
- expect(() => copyMatrixRowFromVector3(m, 3, v)).toThrow();
176
+ expect(() => copyMatrixRowFromVector3(m, -1, v)).toThrow(RangeError);
177
+ expect(() => copyMatrixRowFromVector3(m, 3, v)).toThrow(RangeError);
163
178
  });
164
179
  });
165
180
 
@@ -190,6 +205,13 @@ describe('copyMatrixRowToVector3', () => {
190
205
  expect(v.y).toBe(0);
191
206
  expect(v.z).toBe(1);
192
207
  });
208
+
209
+ it('throws when row is outside 0 through 2', () => {
210
+ const m = createMatrix();
211
+ const v = createVector3();
212
+ expect(() => copyMatrixRowToVector3(v, -1, m)).toThrow(RangeError);
213
+ expect(() => copyMatrixRowToVector3(v, 3, m)).toThrow(RangeError);
214
+ });
193
215
  });
194
216
 
195
217
  describe('createGradientTransformMatrix', () => {
@@ -472,6 +494,12 @@ describe('inverseMatrixTransformVectorXY', () => {
472
494
  });
473
495
 
474
496
  describe('matrixTransformBounds', () => {
497
+ it('maps coincident corners to a zero-size rectangle at the matrix translation', () => {
498
+ const out = createRectangle(1, 2, 3, 4);
499
+ matrixTransformBounds(out, createMatrix(2, 0, 0, 3, 7, 11), 5, 5, 5, 5);
500
+ expect(out).toMatchObject({ x: 7, y: 11, width: 0, height: 0 });
501
+ });
502
+
475
503
  it('should work when ax > bx or ay > by (flipped input)', () => {
476
504
  const m = createMatrix();
477
505
  const out = createRectangle();
@@ -499,6 +527,18 @@ describe('matrixTransformBounds', () => {
499
527
  expect(out.height).toBeCloseTo(10);
500
528
  });
501
529
 
530
+ it('tracks the fourth corner as a new minimum after positive skew', () => {
531
+ const out = createRectangle();
532
+ matrixTransformBounds(out, createMatrix(1, 1, 1, 1), 0, 1, 1, 0);
533
+ expect(out).toMatchObject({ x: 0, y: 0, width: 2, height: 2 });
534
+ });
535
+
536
+ it('tracks the second and fourth corners after a reflected skew', () => {
537
+ const out = createRectangle();
538
+ matrixTransformBounds(out, createMatrix(-1, -1, 1, 1), 0, 0, 1, 1);
539
+ expect(out).toMatchObject({ x: -1, y: -1, width: 2, height: 2 });
540
+ });
541
+
502
542
  it('should handle flipped input coordinates', () => {
503
543
  const rect = createRectangle(10, 20, -10, -20);
504
544
  const mat = createMatrix();
@@ -1079,6 +1119,12 @@ describe('setMatrixIdentity', () => {
1079
1119
  });
1080
1120
 
1081
1121
  describe('setTransformMatrix', () => {
1122
+ it('builds scale and translation without rotation', () => {
1123
+ const out = createMatrix(9, 9, 9, 9, 9, 9);
1124
+ setTransformMatrix(out, 2, 4, 0, 10, 100);
1125
+ expect(out).toMatchObject({ a: 2, b: 0, c: 0, d: 4, tx: 10, ty: 100 });
1126
+ });
1127
+
1082
1128
  it('should apply rotate, scale and translation', () => {
1083
1129
  const m1 = createMatrix();
1084
1130
  rotateMatrix(m1, m1, 45);
@@ -87,6 +87,12 @@ describe('copyMatrix3', () => {
87
87
  expect(getMatrix3Element(m2, 2, 1)).toBe(9);
88
88
  expect(getMatrix3Element(m2, 2, 2)).toBe(10);
89
89
  });
90
+
91
+ it('supports out === source', () => {
92
+ const matrix = createMatrix3(2, 3, 4, 5, 6, 7, 8, 9, 10);
93
+ copyMatrix3(matrix, matrix);
94
+ expect(Array.from(matrix.m)).toEqual([2, 5, 8, 3, 6, 9, 4, 7, 10]);
95
+ });
90
96
  });
91
97
 
92
98
  describe('copyMatrix3ColumnFromVector3', () => {
@@ -117,10 +123,11 @@ describe('copyMatrix3ColumnFromVector3', () => {
117
123
  expect(getMatrix3Element(m, 2, 2)).toBe(7);
118
124
  });
119
125
 
120
- it('should throw when column is greater than 2', () => {
126
+ it('throws when column is outside 0 through 2', () => {
121
127
  const m = createMatrix3();
122
128
  const v = createVector3();
123
- expect(() => copyMatrix3ColumnFromVector3(m, 3, v)).toThrow();
129
+ expect(() => copyMatrix3ColumnFromVector3(m, -1, v)).toThrow(RangeError);
130
+ expect(() => copyMatrix3ColumnFromVector3(m, 3, v)).toThrow(RangeError);
124
131
  });
125
132
  });
126
133
 
@@ -152,10 +159,11 @@ describe('copyMatrix3ColumnToVector3', () => {
152
159
  expect(v.z).toBe(9);
153
160
  });
154
161
 
155
- it('should throw when column is greater than 2', () => {
162
+ it('throws when column is outside 0 through 2', () => {
156
163
  const m = createMatrix3();
157
164
  const v = createVector3();
158
- expect(() => copyMatrix3ColumnToVector3(v, 3, m)).toThrow();
165
+ expect(() => copyMatrix3ColumnToVector3(v, -1, m)).toThrow(RangeError);
166
+ expect(() => copyMatrix3ColumnToVector3(v, 3, m)).toThrow(RangeError);
159
167
  });
160
168
 
161
169
  it('should allow matrix-like and vector-like objects', () => {
@@ -188,10 +196,11 @@ describe('copyMatrix3RowFromVector3', () => {
188
196
  expect(getMatrix3Element(m, 1, 2)).toBe(6);
189
197
  });
190
198
 
191
- it('should throw when row is greater than 2', () => {
199
+ it('throws when row is outside 0 through 2', () => {
192
200
  const m = createMatrix3();
193
201
  const v = createVector3();
194
- expect(() => copyMatrix3RowFromVector3(m, 3, v)).toThrow();
202
+ expect(() => copyMatrix3RowFromVector3(m, -1, v)).toThrow(RangeError);
203
+ expect(() => copyMatrix3RowFromVector3(m, 3, v)).toThrow(RangeError);
195
204
  });
196
205
 
197
206
  it('should allow matrix-like and vector-like objects', () => {
@@ -232,6 +241,13 @@ describe('copyMatrix3RowToVector3', () => {
232
241
  expect(v.z).toBe(1);
233
242
  });
234
243
 
244
+ it('throws when row is outside 0 through 2', () => {
245
+ const m = createMatrix3();
246
+ const v = createVector3();
247
+ expect(() => copyMatrix3RowToVector3(v, -1, m)).toThrow(RangeError);
248
+ expect(() => copyMatrix3RowToVector3(v, 3, m)).toThrow(RangeError);
249
+ });
250
+
235
251
  it('should allow matrix-like and vector-like objects', () => {
236
252
  // Column-major storage of rows [1,2,3],[4,5,6],[7,8,9]; row 0 = (1,2,3).
237
253
  const m = { m: new Float32Array([1, 4, 7, 2, 5, 8, 3, 6, 9]) };
@@ -458,6 +474,18 @@ describe('isAffineMatrix3', () => {
458
474
  });
459
475
 
460
476
  describe('multiplyMatrix3', () => {
477
+ it('multiplies a non-affine matrix into distinct and aliased outputs', () => {
478
+ const a = createMatrix3(1, 2, 3, 4, 5, 6, 7, 8, 10);
479
+ const b = createMatrix3(2, 0, 1, 1, 3, 0, 0, 2, 4);
480
+ const expected = createMatrix3(4, 12, 13, 13, 27, 28, 22, 44, 47);
481
+ const out = createMatrix3();
482
+ multiplyMatrix3(out, a, b);
483
+ expect(equalsMatrix3(out, expected)).toBe(true);
484
+
485
+ multiplyMatrix3(a, a, b);
486
+ expect(equalsMatrix3(a, expected)).toBe(true);
487
+ });
488
+
461
489
  it('should support out === a', () => {
462
490
  const a = createMatrix3(2, 3, 4, 5, 7, 8, 0, 0, 1);
463
491
  const b = createMatrix3(11, 13, 17, 19, 23, 29, 0, 0, 1);
@@ -556,6 +584,12 @@ describe('scaleMatrix3', () => {
556
584
  expect(out.m[0]).toBe(2); // (0,0)
557
585
  expect(out.m[4]).toBe(3); // (1,1)
558
586
  });
587
+
588
+ it('supports out === source', () => {
589
+ const matrix = createMatrix3(1, 2, 3, 4, 5, 6, 7, 8, 9);
590
+ scaleMatrix3(matrix, matrix, 2, 3);
591
+ expect(Array.from(matrix.m)).toEqual([2, 8, 14, 6, 15, 24, 3, 6, 9]);
592
+ });
559
593
  });
560
594
 
561
595
  describe('setMatrix3', () => {
@@ -766,6 +800,12 @@ describe('translateMatrix3', () => {
766
800
  expect(out.m[6]).toBe(2); // (0,2) tx
767
801
  expect(out.m[7]).toBe(3); // (1,2) ty
768
802
  });
803
+
804
+ it('supports out === source', () => {
805
+ const matrix = createMatrix3(1, 2, 3, 4, 5, 6, 0, 0, 1);
806
+ translateMatrix3(matrix, matrix, 2, 3);
807
+ expect(Array.from(matrix.m)).toEqual([1, 4, 0, 2, 5, 0, 11, 29, 1]);
808
+ });
769
809
  });
770
810
 
771
811
  describe('transposeMatrix3', () => {
@@ -1,3 +1,4 @@
1
+ import { attachEntityBinding, getEntityBinding } from '@flighthq/entity/contract';
1
2
  import { getMatrix3Element } from '@flighthq/geometry/contract';
2
3
  import type { Matrix3 } from '@flighthq/types/contract';
3
4
 
@@ -67,6 +68,16 @@ describe('clearMatrix3Pool', () => {
67
68
  });
68
69
 
69
70
  describe('releaseMatrix3', () => {
71
+ it('detaches the previous owner binding before the matrix is reused', () => {
72
+ const m = acquireMatrix3();
73
+ attachEntityBinding(m, { owner: 'first' });
74
+ releaseMatrix3(m);
75
+
76
+ const reused = acquireMatrix3();
77
+ expect(reused).toBe(m);
78
+ expect(getEntityBinding(reused)).toBeNull();
79
+ });
80
+
70
81
  it('handles null safely', () => {
71
82
  expect(() => releaseMatrix3(null as unknown as Matrix3)).not.toThrow();
72
83
  });