@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
@@ -1,3 +1,4 @@
1
+ import { attachEntityBinding, getEntityBinding } from '@flighthq/entity/contract';
1
2
  import {
2
3
  acquireIdentityQuaternion,
3
4
  acquireQuaternion,
@@ -55,6 +56,17 @@ describe('clearQuaternionPool', () => {
55
56
  });
56
57
 
57
58
  describe('releaseQuaternion', () => {
59
+ it('detaches the previous owner binding before the quaternion is reused', () => {
60
+ clearQuaternionPool();
61
+ const q = acquireQuaternion();
62
+ attachEntityBinding(q, { owner: 'first' });
63
+ releaseQuaternion(q);
64
+
65
+ const reused = acquireQuaternion();
66
+ expect(reused).toBe(q);
67
+ expect(getEntityBinding(reused)).toBeNull();
68
+ });
69
+
58
70
  it('ignores a falsy value', () => {
59
71
  clearQuaternionPool();
60
72
 
package/src/ray3d.test.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createAabb } from './aabb';
2
2
  import { createBoundingSphere } from './boundingSphere';
3
+ import { createCapsule, intersectRay3DCapsule } from './capsule';
4
+ import { createObb, intersectRay3DObb } from './obb';
3
5
  import { createPlane } from './plane';
4
6
  import {
5
7
  createRay3D,
@@ -73,6 +75,55 @@ describe('getClosestPointBetweenRay3Ds', () => {
73
75
  // Gap between the parallel rays is 2 along Y.
74
76
  expect(Math.hypot(pa.x - pb.x, pa.y - pb.y, pa.z - pb.z)).toBeCloseTo(2, 6);
75
77
  });
78
+
79
+ it('clamps to the origin of the first ray when its nearest approach is behind it', () => {
80
+ // Ray a runs +X from the origin; ray b is a vertical line well behind a's start. The nearest
81
+ // point on a's infinite line is at x = -5, which is not on the ray, so a's origin is the answer.
82
+ const a = createRay3D(0, 0, 0, 1, 0, 0);
83
+ const b = createRay3D(-5, 1, 0, 0, 0, 1);
84
+ const pa = createVector3();
85
+ const pb = createVector3();
86
+ getClosestPointBetweenRay3Ds(pa, pb, a, b);
87
+ expect(pa.x).toBeCloseTo(0, 6);
88
+ expect(pa.y).toBeCloseTo(0, 6);
89
+ expect(pa.z).toBeCloseTo(0, 6);
90
+ expect(pb.x).toBeCloseTo(-5, 6);
91
+ expect(pb.y).toBeCloseTo(1, 6);
92
+ expect(pb.z).toBeCloseTo(0, 6);
93
+ });
94
+
95
+ it('clamps both rays to their origins when each nearest approach is behind the other', () => {
96
+ // Both rays head away, so both clamps fire: the second to its origin, and then the first,
97
+ // re-derived from that, to its own.
98
+ const a = createRay3D(5, 0, 0, 1, 0, 0);
99
+ const b = createRay3D(0, 3, 0, 0, 1, 0);
100
+ const pa = createVector3();
101
+ const pb = createVector3();
102
+ getClosestPointBetweenRay3Ds(pa, pb, a, b);
103
+ expect(pa.x).toBeCloseTo(5, 6);
104
+ expect(pa.y).toBeCloseTo(0, 6);
105
+ expect(pb.x).toBeCloseTo(0, 6);
106
+ expect(pb.y).toBeCloseTo(3, 6);
107
+ });
108
+
109
+ it('writes finite points when either direction has no length', () => {
110
+ // A direction of zero length gives the solver a zero denominator on both the second and the
111
+ // first ray in turn. Without its guards this writes NaN into the caller's vectors, which is
112
+ // worse than a poor answer because it spreads silently through whatever consumes it.
113
+ const pa = createVector3();
114
+ const pb = createVector3();
115
+
116
+ getClosestPointBetweenRay3Ds(pa, pb, createRay3D(0, 0, 0, 1, 0, 0), createRay3D(5, 1, 0, 0, 0, 0));
117
+ expect(Number.isFinite(pa.x) && Number.isFinite(pa.y) && Number.isFinite(pa.z)).toBe(true);
118
+ expect(pb.x).toBe(5);
119
+ expect(pb.y).toBe(1);
120
+
121
+ getClosestPointBetweenRay3Ds(pa, pb, createRay3D(0, 0, 0, 0, 0, 0), createRay3D(0, 0, 5, 0, 0, 1));
122
+ expect(pa.x).toBe(0);
123
+ expect(pa.y).toBe(0);
124
+ expect(pa.z).toBe(0);
125
+ expect(Number.isFinite(pb.x) && Number.isFinite(pb.y) && Number.isFinite(pb.z)).toBe(true);
126
+ });
76
127
  });
77
128
 
78
129
  describe('getClosestPointOnRay3D', () => {
@@ -110,6 +161,17 @@ describe('getClosestPointOnRay3D', () => {
110
161
  expect(p.y).toBeCloseTo(4, 6);
111
162
  expect(p.z).toBeCloseTo(0, 6);
112
163
  });
164
+
165
+ it('writes the origin for a ray with no direction', () => {
166
+ // A ray with no direction reaches nowhere, so its origin is the only point it has. Dividing
167
+ // by the zero length instead would put NaN in the caller's vector.
168
+ const ray = createRay3D(1, 2, 3, 0, 0, 0);
169
+ const out = createVector3();
170
+ getClosestPointOnRay3D(out, ray, createVector3(9, 9, 9));
171
+ expect(out.x).toBe(1);
172
+ expect(out.y).toBe(2);
173
+ expect(out.z).toBe(3);
174
+ });
113
175
  });
114
176
 
115
177
  describe('getRay3DPointAt', () => {
@@ -172,6 +234,52 @@ describe('intersectRay3DAabb', () => {
172
234
  const t = intersectRay3DAabb(ray, aabb);
173
235
  expect(t).toBeCloseTo(4);
174
236
  });
237
+
238
+ it.each([
239
+ ['x', createRay3D(5, 1.5, 1.5, -1, 0, 0), createAabb(2, 1, 1, 1, 2, 2)],
240
+ ['y', createRay3D(1.5, 5, 1.5, 0, -1, 0), createAabb(1, 2, 1, 2, 1, 2)],
241
+ ['z', createRay3D(1.5, 1.5, 5, 0, 0, -1), createAabb(1, 1, 2, 2, 2, 1)],
242
+ ])('returns -1 for an AABB empty on %s', (_axis, ray, empty) => {
243
+ expect(intersectRay3DAabb(ray, empty)).toBe(-1);
244
+ });
245
+
246
+ it('returns -1 for a ray with no direction, inside the box or out', () => {
247
+ // A zero-length direction is not a ray, so it never hits anything — the same answer the
248
+ // sphere, capsule, plane and triangle tests give, and not a containment test in disguise.
249
+ const aabb = createAabb(-1, -1, -1, 1, 1, 1);
250
+ expect(intersectRay3DAabb(createRay3D(0, 0, 0, 0, 0, 0), aabb)).toBe(-1);
251
+ expect(intersectRay3DAabb(createRay3D(5, 0, 0, 0, 0, 0), aabb)).toBe(-1);
252
+ });
253
+
254
+ it('enters each of the six sides at that side’s own distance', () => {
255
+ // One slab per axis, written out three times in the source rather than looped, and each one
256
+ // orders its own pair of crossings. A bound picked up from the block above only shows up on
257
+ // the axis it was mistyped on, and only in one direction along it — so the box has six
258
+ // different distances to its sides and each ray is checked against the one it should hit.
259
+ // A cube would hide exactly the mistake this is here to catch.
260
+ const lopsided = createAabb(-1, -4, -6, 2, 3, 5);
261
+ for (const [ox, oy, oz, dx, dy, dz, entry] of RAYS_INTO_LOPSIDED_BOX) {
262
+ expect(intersectRay3DAabb(createRay3D(ox, oy, oz, dx, dy, dz), lopsided)).toBeCloseTo(entry, 6);
263
+ }
264
+ });
265
+
266
+ it('returns -1 for a ray aimed away from the box along any axis', () => {
267
+ // Same six positions, turned around. The box is behind the origin, so both crossings are
268
+ // behind it and the slab that owns that axis is the one that has to reject.
269
+ const lopsided = createAabb(-1, -4, -6, 2, 3, 5);
270
+ for (const [ox, oy, oz, dx, dy, dz] of RAYS_INTO_LOPSIDED_BOX) {
271
+ expect(intersectRay3DAabb(createRay3D(ox, oy, oz, -dx, -dy, -dz), lopsided)).toBe(-1);
272
+ }
273
+ });
274
+
275
+ it('returns -1 for a ray running alongside the box on any axis', () => {
276
+ // No motion on one axis and outside the box on it: the ray stays level with the box forever
277
+ // and never enters, which each slab has to catch without a crossing to compute.
278
+ const lopsided = createAabb(-1, -4, -6, 2, 3, 5);
279
+ expect(intersectRay3DAabb(createRay3D(10, 0, 0, 0, 1, 0), lopsided)).toBe(-1);
280
+ expect(intersectRay3DAabb(createRay3D(0, 10, 0, 0, 0, 1), lopsided)).toBe(-1);
281
+ expect(intersectRay3DAabb(createRay3D(0, 0, 10, 1, 0, 0), lopsided)).toBe(-1);
282
+ });
175
283
  });
176
284
 
177
285
  describe('intersectRay3DPlane', () => {
@@ -193,6 +301,17 @@ describe('intersectRay3DPlane', () => {
193
301
  const ray = createRay3D(0, 0, 3, 0, 0, 1); // pointing away from z=0
194
302
  expect(intersectRay3DPlane(ray, plane)).toBe(-1);
195
303
  });
304
+
305
+ it.each([1, 1e-6, 1e-12])('is invariant when the plane equation is scaled by %s', (scale) => {
306
+ const plane = createPlane(0, 0, scale, 3 * scale);
307
+ const ray = createRay3D(0, 0, -6, 0, 0, 2);
308
+ expect(intersectRay3DPlane(ray, plane)).toBeCloseTo(1.5, 10);
309
+ });
310
+
311
+ it('returns -1 for a zero normal or zero direction', () => {
312
+ expect(intersectRay3DPlane(createRay3D(0, 0, -3, 0, 0, 1), createPlane())).toBe(-1);
313
+ expect(intersectRay3DPlane(createRay3D(0, 0, -3, 0, 0, 0), createPlane(0, 0, 1, 0))).toBe(-1);
314
+ });
196
315
  });
197
316
 
198
317
  describe('intersectRay3DSphere', () => {
@@ -220,6 +339,18 @@ describe('intersectRay3DSphere', () => {
220
339
  const ray = createRay3D(0, 0, -5, 0, 0, 1);
221
340
  expect(intersectRay3DSphere(ray, sphere)).toBe(-1);
222
341
  });
342
+
343
+ it('returns -1 when the sphere is squarely behind the ray', () => {
344
+ // Aimed away rather than off to one side: the line through the ray does cross the sphere, so
345
+ // this is not the miss the other test covers — both crossings are simply behind the origin.
346
+ const sphere = createBoundingSphere(0, 0, 0, 1);
347
+ expect(intersectRay3DSphere(createRay3D(5, 0, 0, 1, 0, 0), sphere)).toBe(-1);
348
+ });
349
+
350
+ it('returns -1 for a ray with no direction', () => {
351
+ const sphere = createBoundingSphere(0, 0, 0, 1);
352
+ expect(intersectRay3DSphere(createRay3D(0, 0, 0, 0, 0, 0), sphere)).toBe(-1);
353
+ });
223
354
  });
224
355
 
225
356
  describe('intersectRay3DTriangle', () => {
@@ -255,6 +386,66 @@ describe('intersectRay3DTriangle', () => {
255
386
  const ray = createRay3D(0, 0, -1, 0, 0, 1);
256
387
  expect(intersectRay3DTriangle(ray, p, p, p)).toBe(-1);
257
388
  });
389
+
390
+ it.each([1, 1e-6, 1e-12])('accepts a non-normalized direction scaled by %s', (scale) => {
391
+ const ray = createRay3D(0, 0.3, -3, 0, 0, scale);
392
+ expect(intersectRay3DTriangle(ray, a, b, c) * scale).toBeCloseTo(3, 10);
393
+ });
394
+
395
+ it.each([1, 1e-3, 1e-6])('does not treat a triangle scaled by %s as degenerate', (scale) => {
396
+ const scaledA = { x: -scale, y: 0, z: 0 };
397
+ const scaledB = { x: scale, y: 0, z: 0 };
398
+ const scaledC = { x: 0, y: scale, z: 0 };
399
+ const ray = createRay3D(0, 0.3 * scale, -3, 0, 0, 1);
400
+ expect(intersectRay3DTriangle(ray, scaledA, scaledB, scaledC)).toBeCloseTo(3, 10);
401
+ });
402
+
403
+ it('returns -1 for a zero direction', () => {
404
+ expect(intersectRay3DTriangle(createRay3D(0, 0.3, -3, 0, 0, 0), a, b, c)).toBe(-1);
405
+ });
406
+
407
+ it('returns -1 for a nonzero direction nearly parallel to the triangle', () => {
408
+ const nearlyParallel = createRay3D(-1, 0.3, -1e-12, 1, 0, 1e-12);
409
+ expect(intersectRay3DTriangle(nearlyParallel, a, b, c)).toBe(-1);
410
+ });
411
+
412
+ it('returns -1 for points inside the triangle’s span but past its far edge', () => {
413
+ // The triangle covers x in [-1, 1] along the base and rises to (0, 1). A ray through
414
+ // (0.9, 0.9) lands in the corner the hypotenuse cuts off: within the reach of both edges
415
+ // measured from the first corner, but outside the triangle itself.
416
+ const ray = createRay3D(0.9, 0.9, -3, 0, 0, 1);
417
+ expect(intersectRay3DTriangle(ray, a, b, c)).toBe(-1);
418
+ });
419
+
420
+ it('returns -1 for points on the far side of the base', () => {
421
+ const ray = createRay3D(0, -0.5, -3, 0, 0, 1);
422
+ expect(intersectRay3DTriangle(ray, a, b, c)).toBe(-1);
423
+ });
424
+
425
+ it('returns -1 for points beyond either end of the first edge', () => {
426
+ // Off the end of the base in each direction. These are rejected on the first barycentric
427
+ // coordinate, before the second is worked out at all — a different rejection from the two
428
+ // above, and the one that stops a point way off the triangle being measured against its far
429
+ // edge as though it were nearby.
430
+ expect(intersectRay3DTriangle(createRay3D(3, 0, -3, 0, 0, 1), a, b, c)).toBe(-1);
431
+ expect(intersectRay3DTriangle(createRay3D(0, 3, -3, 0, 0, 1), a, b, c)).toBe(-1);
432
+ });
433
+ });
434
+
435
+ describe('ray-shape direction scale parity', () => {
436
+ it.each([1, 1e-6, 1e-12])('preserves the hit point across all six shapes at scale %s', (scale) => {
437
+ const ray = createRay3D(5, 0, 0, -scale, 0, 0);
438
+ const hits = [
439
+ intersectRay3DAabb(ray, createAabb(-1, -1, -1, 1, 1, 1)),
440
+ intersectRay3DSphere(ray, createBoundingSphere(0, 0, 0, 1)),
441
+ intersectRay3DCapsule(ray, createCapsule(0, -1, 0, 0, 1, 0, 1)),
442
+ intersectRay3DObb(ray, createObb(0, 0, 0, 1, 1, 1, 0, 0, 0, 1)),
443
+ intersectRay3DPlane(ray, createPlane(1, 0, 0, -1)),
444
+ intersectRay3DTriangle(ray, { x: 1, y: -1, z: -1 }, { x: 1, y: 1, z: -1 }, { x: 1, y: 0, z: 1 }),
445
+ ];
446
+
447
+ for (const t of hits) expect(t * scale).toBeCloseTo(4, 10);
448
+ });
258
449
  });
259
450
 
260
451
  describe('setRay3D', () => {
@@ -295,3 +486,16 @@ describe('setRay3D', () => {
295
486
  expect(ray.direction.z).toBe(0);
296
487
  });
297
488
  });
489
+
490
+ // Six rays, one per side of the box spanning (-1, -4, -6) to (2, 3, 5), each starting 10 units out
491
+ // along an axis and aimed at it. The last number is where that ray meets the box: all six differ,
492
+ // so a slab reading another axis' bound gives a different answer rather than the same one.
493
+ // Reversing the direction of each turns the set into six rays aimed away from the box.
494
+ const RAYS_INTO_LOPSIDED_BOX: ReadonlyArray<readonly [number, number, number, number, number, number, number]> = [
495
+ [-10, 0, 0, 1, 0, 0, 9],
496
+ [10, 0, 0, -1, 0, 0, 8],
497
+ [0, -10, 0, 0, 1, 0, 6],
498
+ [0, 10, 0, 0, -1, 0, 7],
499
+ [0, 0, -10, 0, 0, 1, 4],
500
+ [0, 0, 10, 0, 0, -1, 5],
501
+ ];
@@ -291,6 +291,11 @@ describe('enclosesRectangle', () => {
291
291
  });
292
292
 
293
293
  describe('equalsRectangle', () => {
294
+ it('returns false when exactly one rectangle is absent', () => {
295
+ expect(equalsRectangle(r, null)).toBe(false);
296
+ expect(equalsRectangle(undefined, r)).toBe(false);
297
+ });
298
+
294
299
  it('returns true for identical rectangles', () => {
295
300
  expect(equalsRectangle(r, r)).toBe(true);
296
301
  expect(equalsRectangle(r, cloneRectangle(r))).toBe(true);
@@ -307,28 +312,61 @@ describe('equalsRectangle', () => {
307
312
  expect(equalsRectangle(r, r)).toBe(true);
308
313
  expect(equalsRectangle(r, r2)).toBe(true);
309
314
  });
315
+
316
+ it('compares every field, not just the position', () => {
317
+ // Four hand-written comparisons in a row: one left reading the wrong field agrees with the
318
+ // right answer on every pair that differs in more than one place, so each differs alone here.
319
+ const base = createRectangle(1, 2, 3, 4);
320
+ expect(equalsRectangle(base, createRectangle(9, 2, 3, 4))).toBe(false);
321
+ expect(equalsRectangle(base, createRectangle(1, 9, 3, 4))).toBe(false);
322
+ expect(equalsRectangle(base, createRectangle(1, 2, 9, 4))).toBe(false);
323
+ expect(equalsRectangle(base, createRectangle(1, 2, 3, 9))).toBe(false);
324
+ });
325
+
326
+ it('returns false when either side is missing', () => {
327
+ // The signature accepts null and undefined, so absence is an answerable input rather than
328
+ // misuse: nothing is equal to a rectangle, including another absence. Two absences are two
329
+ // unknowns, not one shared value — the same answer the three equalsVector* functions give.
330
+ const rect = createRectangle(1, 2, 3, 4);
331
+ expect(equalsRectangle(rect, null)).toBe(false);
332
+ expect(equalsRectangle(null, rect)).toBe(false);
333
+ expect(equalsRectangle(rect, undefined)).toBe(false);
334
+ expect(equalsRectangle(undefined, rect)).toBe(false);
335
+ expect(equalsRectangle(null, null)).toBe(false);
336
+ expect(equalsRectangle(undefined, undefined)).toBe(false);
337
+ expect(equalsRectangle(null, undefined)).toBe(false);
338
+ });
339
+
340
+ it('holds a rectangle equal to itself even with a NaN field', () => {
341
+ // Comparing field by field would say a rectangle carrying NaN differs from itself, and a
342
+ // caller watching for changes would then see one every time it looked.
343
+ const notANumber = createRectangle(NaN, 2, 3, 4);
344
+ expect(equalsRectangle(notANumber, notANumber)).toBe(true);
345
+ expect(equalsRectangle(notANumber, createRectangle(NaN, 2, 3, 4))).toBe(false);
346
+ });
310
347
  });
311
348
 
312
349
  describe('expandRectangleToPoint', () => {
313
- it('inflates rectangle by Vector2', () => {
350
+ it('expands a rectangle to contain an outside point', () => {
351
+ const source = createRectangle(10, 10, 5, 5);
314
352
  const result = createRectangle();
315
- expandRectangleToPoint(result, r, createVector2(1, 2));
316
- expect(result).not.toBe(r);
317
- expect(result.x).toBe(-1);
318
- expect(result.y).toBe(-2);
319
- expect(result.width).toBe(12);
320
- expect(result.height).toBe(24);
353
+ expandRectangleToPoint(result, source, createVector2(20, 12));
354
+ expect(result).not.toBe(source);
355
+ expect(result).toMatchObject({ x: 10, y: 10, width: 10, height: 5 });
321
356
  });
322
357
 
323
- it('allows rectangle- and point-like objects', () => {
324
- const r = { x: 0, y: 0, width: 10, height: 20 };
325
- const p = { x: 1, y: 2 };
358
+ it('normalizes flipped bounds while expanding', () => {
359
+ const r = { x: 10, y: 10, width: -5, height: -5 };
360
+ const p = { x: 12, y: 4 };
326
361
  const result = { x: 0, y: 0, width: 0, height: 0 };
327
362
  expandRectangleToPoint(result, r, p);
328
- expect(result.x).toBe(-1);
329
- expect(result.y).toBe(-2);
330
- expect(result.width).toBe(12);
331
- expect(result.height).toBe(24);
363
+ expect(result).toEqual({ x: 5, y: 4, width: 7, height: 6 });
364
+ });
365
+
366
+ it('supports out === sourceRect', () => {
367
+ const source = createRectangle(10, 10, 5, 5);
368
+ expandRectangleToPoint(source, source, createVector2(20, 12));
369
+ expect(source).toMatchObject({ x: 10, y: 10, width: 10, height: 5 });
332
370
  });
333
371
  });
334
372
 
@@ -835,6 +873,12 @@ describe('offsetRectangleByPoint', () => {
835
873
  expect(out.x).toBe(3);
836
874
  expect(out.y).toBe(4);
837
875
  });
876
+
877
+ it('supports out === source', () => {
878
+ const source = createRectangle(1, 2, 10, 20);
879
+ offsetRectangleByPoint(source, source, createVector2(3, 4));
880
+ expect(source).toMatchObject({ x: 4, y: 6, width: 10, height: 20 });
881
+ });
838
882
  });
839
883
 
840
884
  describe('setEmptyRectangle', () => {
@@ -1,3 +1,4 @@
1
+ import { attachEntityBinding, getEntityBinding } from '@flighthq/entity/contract';
1
2
  import type { Rectangle } from '@flighthq/types/contract';
2
3
 
3
4
  import { acquireEmptyRectangle, acquireRectangle, clearRectanglePool, releaseRectangle } from './rectanglePool';
@@ -65,6 +66,16 @@ describe('clearRectanglePool', () => {
65
66
  });
66
67
 
67
68
  describe('releaseRectangle', () => {
69
+ it('detaches the previous owner binding before the rectangle is reused', () => {
70
+ const r = acquireRectangle();
71
+ attachEntityBinding(r, { owner: 'first' });
72
+ releaseRectangle(r);
73
+
74
+ const reused = acquireRectangle();
75
+ expect(reused).toBe(r);
76
+ expect(getEntityBinding(reused)).toBeNull();
77
+ });
78
+
68
79
  it('handles null safely', () => {
69
80
  expect(() => releaseRectangle(null as unknown as Rectangle)).not.toThrow();
70
81
  });
@@ -81,4 +81,42 @@ describe('decomposeMatrixToTransform2D', () => {
81
81
  expect(out.scaleX).toBeCloseTo(1);
82
82
  expect(out.scaleY).toBeCloseTo(-1);
83
83
  });
84
+
85
+ it('round-trips a mirrored matrix through the forward build', () => {
86
+ expectRoundTrip(createMatrix(1, 0, 0, -1, 0, 0));
87
+ expectRoundTrip(createMatrix(-1, 0, 0, 1, 0, 0));
88
+ expectRoundTrip(createMatrix(0.5, 0.8, 0.8, -0.5, 3, 4));
89
+ });
90
+
91
+ // The failure set is the negative-determinant set on any input, not a mirror-specific shape, so the
92
+ // property is asserted over a grid spanning both determinant signs rather than at hand-picked cases.
93
+ it('round-trips every non-degenerate matrix in a grid spanning both determinant signs', () => {
94
+ const values = [-2, -1, -0.5, 0.5, 1, 2];
95
+ let negativeDeterminantCount = 0;
96
+ for (const a of values) {
97
+ for (const b of values) {
98
+ for (const c of values) {
99
+ for (const d of values) {
100
+ if (Math.abs(a * d - b * c) < 1e-9) continue;
101
+ if (a * d - b * c < 0) negativeDeterminantCount++;
102
+ expectRoundTrip(createMatrix(a, b, c, d, 0, 0));
103
+ }
104
+ }
105
+ }
106
+ }
107
+ // Guards the guard: a grid that stopped covering reflections would still pass every assertion above.
108
+ expect(negativeDeterminantCount).toBe(572);
109
+ });
84
110
  });
111
+
112
+ // Decomposing and rebuilding through the forward convention must reproduce the original cells. This is
113
+ // the losslessness `setNodeLocalMatrix` promises its callers, and it holds for both determinant signs.
114
+ function expectRoundTrip(source: Readonly<ReturnType<typeof createMatrix>>) {
115
+ const out = createTransform2D();
116
+ decomposeMatrixToTransform2D(out, source);
117
+ const rebuilt = buildMatrix(out.x, out.y, out.rotation, out.scaleX, out.scaleY, out.skewX, out.skewY);
118
+ expect(rebuilt.a).toBeCloseTo(source.a);
119
+ expect(rebuilt.b).toBeCloseTo(source.b);
120
+ expect(rebuilt.c).toBeCloseTo(source.c);
121
+ expect(rebuilt.d).toBeCloseTo(source.d);
122
+ }