@flighthq/quadbatch 0.3.0-edge.1458.0c01b63

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,906 @@
1
+ import { createRectangle, createVector2 } from '@flighthq/geometry/contract';
2
+ import { getNodeLocalBoundsRectangle, getNodeLocalBoundsRevision } from '@flighthq/node/contract';
3
+ import { connectSignal } from '@flighthq/signals/contract';
4
+ import type {
5
+ QuadBatch,
6
+ QuadTransformType,
7
+ Texture2D,
8
+ TextureAtlas,
9
+ TextureAtlasRegion,
10
+ } from '@flighthq/types/contract';
11
+ import { QuadBatchKind } from '@flighthq/types/contract';
12
+
13
+ import {
14
+ appendQuadBatchInstance,
15
+ clearQuadBatch,
16
+ cloneQuadBatch,
17
+ compactQuadBatch,
18
+ computeQuadBatchLocalBoundsRectangle,
19
+ createQuadBatch,
20
+ createQuadBatchData,
21
+ createQuadBatchRuntime,
22
+ createQuadBatchSignals,
23
+ enableQuadBatchSignals,
24
+ getQuadBatchCapacity,
25
+ getQuadBatchInstanceId,
26
+ getQuadBatchInstanceTransform,
27
+ getQuadBatchRuntime,
28
+ getQuadBatchSignals,
29
+ getQuadBatchTransformStride,
30
+ hitTestQuadBatchPoint,
31
+ hitTestQuadBatchPointExact,
32
+ hitTestQuadBatchPointExactXY,
33
+ hitTestQuadBatchPointXY,
34
+ iterateQuadBatchInstances,
35
+ QUAD_BATCH_DELETED_ID,
36
+ removeQuadBatchInstance,
37
+ reserveQuadBatch,
38
+ resizeQuadBatch,
39
+ setQuadBatchInstance,
40
+ setQuadBatchInstanceTint,
41
+ setQuadBatchInstanceMatrix,
42
+ setQuadBatchInstanceRange,
43
+ setQuadBatchLocalBoundsRectangle,
44
+ setQuadBatchTransformType,
45
+ } from './quadBatch';
46
+
47
+ function makeQuadAtlas(...regions: TextureAtlasRegion[]): TextureAtlas {
48
+ return { texture: null, regions } as TextureAtlas;
49
+ }
50
+
51
+ function makeQuadRegion(id = 0, width = 32, height = 32): TextureAtlasRegion {
52
+ return { id, x: 0, y: 0, width, height, pivotX: null, pivotY: null } as TextureAtlasRegion;
53
+ }
54
+
55
+ describe('appendQuadBatchInstance', () => {
56
+ it('appends an instance and returns its index', () => {
57
+ const qb = createQuadBatch();
58
+ const idx = appendQuadBatchInstance(qb, 3, 10, 20);
59
+ expect(idx).toBe(0);
60
+ expect(qb.data.instanceCount).toBe(1);
61
+ expect(qb.data.ids[0]).toBe(3);
62
+ expect(qb.data.transforms[0]).toBe(10);
63
+ expect(qb.data.transforms[1]).toBe(20);
64
+ });
65
+
66
+ it('returns sequential indices for multiple appends', () => {
67
+ const qb = createQuadBatch();
68
+ expect(appendQuadBatchInstance(qb, 0, 0, 0)).toBe(0);
69
+ expect(appendQuadBatchInstance(qb, 1, 5, 5)).toBe(1);
70
+ expect(qb.data.instanceCount).toBe(2);
71
+ });
72
+
73
+ it('auto-grows capacity', () => {
74
+ const qb = createQuadBatch();
75
+ for (let i = 0; i < 10; i++) appendQuadBatchInstance(qb, i, i, i);
76
+ expect(qb.data.instanceCount).toBe(10);
77
+ expect(getQuadBatchCapacity(qb)).toBeGreaterThanOrEqual(10);
78
+ });
79
+ });
80
+
81
+ describe('clearQuadBatch', () => {
82
+ it('sets instanceCount to 0 and keeps capacity', () => {
83
+ const qb = createQuadBatch();
84
+ reserveQuadBatch(qb, 50);
85
+ qb.data.instanceCount = 10;
86
+ const capacityBefore = getQuadBatchCapacity(qb);
87
+ clearQuadBatch(qb);
88
+ expect(qb.data.instanceCount).toBe(0);
89
+ expect(getQuadBatchCapacity(qb)).toBe(capacityBefore);
90
+ });
91
+ });
92
+
93
+ describe('cloneQuadBatch', () => {
94
+ it('copies atlas, count, transformType, and instance data', () => {
95
+ const atlas = makeQuadAtlas(makeQuadRegion(0));
96
+ const source = createQuadBatch({ data: { atlas } });
97
+ appendQuadBatchInstance(source, 0, 10, 20);
98
+ const clone = cloneQuadBatch(source);
99
+ expect(clone).not.toBe(source);
100
+ expect(clone.data.atlas).toBe(atlas);
101
+ expect(clone.data.instanceCount).toBe(1);
102
+ expect(clone.data.transformType).toBe('vector2');
103
+ expect(getQuadBatchInstanceId(clone, 0)).toBe(0);
104
+ expect(clone.data.transforms[0]).toBe(10);
105
+ expect(clone.data.transforms[1]).toBe(20);
106
+ });
107
+
108
+ it('clones typed arrays so mutations do not leak back', () => {
109
+ const source = createQuadBatch();
110
+ appendQuadBatchInstance(source, 1, 0, 0);
111
+ const clone = cloneQuadBatch(source);
112
+ expect(clone.data.ids).not.toBe(source.data.ids);
113
+ expect(clone.data.transforms).not.toBe(source.data.transforms);
114
+ setQuadBatchInstance(clone, 0, 5, 99, 99);
115
+ expect(getQuadBatchInstanceId(source, 0)).toBe(1);
116
+ expect(source.data.transforms[0]).toBe(0);
117
+ });
118
+
119
+ it('copies materialData when present', () => {
120
+ const source = createQuadBatch();
121
+ appendQuadBatchInstance(source, 0, 0, 0);
122
+ source.data.materialData = [{ tag: 'a' }];
123
+ const clone = cloneQuadBatch(source);
124
+ expect(clone.data.materialData).not.toBe(source.data.materialData);
125
+ expect(clone.data.materialData).toEqual(source.data.materialData);
126
+ });
127
+ });
128
+
129
+ describe('compactQuadBatch', () => {
130
+ it('no-ops on an empty batch', () => {
131
+ const qb = createQuadBatch();
132
+ compactQuadBatch(qb);
133
+ expect(qb.data.instanceCount).toBe(0);
134
+ });
135
+
136
+ it('removes sentinel-id entries and preserves order', () => {
137
+ const qb = createQuadBatch();
138
+ appendQuadBatchInstance(qb, 10, 1, 1);
139
+ appendQuadBatchInstance(qb, 11, 2, 2);
140
+ appendQuadBatchInstance(qb, 12, 3, 3);
141
+ qb.data.ids[1] = QUAD_BATCH_DELETED_ID;
142
+ compactQuadBatch(qb);
143
+ expect(qb.data.instanceCount).toBe(2);
144
+ expect(getQuadBatchInstanceId(qb, 0)).toBe(10);
145
+ expect(getQuadBatchInstanceId(qb, 1)).toBe(12);
146
+ expect(qb.data.transforms[2]).toBe(3); // third instance's x moved to slot 1
147
+ });
148
+
149
+ it('leaves a fully-live buffer unchanged', () => {
150
+ const qb = createQuadBatch();
151
+ appendQuadBatchInstance(qb, 1, 0, 0);
152
+ appendQuadBatchInstance(qb, 2, 0, 0);
153
+ compactQuadBatch(qb);
154
+ expect(qb.data.instanceCount).toBe(2);
155
+ });
156
+ });
157
+
158
+ describe('computeQuadBatchLocalBoundsRectangle', () => {
159
+ it('returns zero bounds when atlas is null', () => {
160
+ const quadBatch = createQuadBatch();
161
+ const out = createRectangle(1, 2, 3, 4);
162
+ computeQuadBatchLocalBoundsRectangle(out, quadBatch);
163
+ expect(out.x).toBe(0);
164
+ expect(out.y).toBe(0);
165
+ expect(out.width).toBe(0);
166
+ expect(out.height).toBe(0);
167
+ });
168
+
169
+ it('returns zero bounds when instanceCount is 0', () => {
170
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
171
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
172
+ const quadBatch = createQuadBatch({ data: { atlas } });
173
+ const out = createRectangle(1, 2, 3, 4);
174
+ computeQuadBatchLocalBoundsRectangle(out, quadBatch);
175
+ expect(out.width).toBe(0);
176
+ expect(out.height).toBe(0);
177
+ });
178
+
179
+ it('computes AABB over all quads for vector2 transforms', () => {
180
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 16, pivotX: null, pivotY: null } as TextureAtlasRegion;
181
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
182
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 2 } });
183
+ quadBatch.data.ids = new Uint16Array([0, 0]);
184
+ quadBatch.data.transforms = new Float32Array([10, 20, 50, 100]);
185
+ const out = createRectangle();
186
+ computeQuadBatchLocalBoundsRectangle(out, quadBatch);
187
+ expect(out.x).toBe(10);
188
+ expect(out.y).toBe(20);
189
+ expect(out.width).toBe(72); // 50+32 - 10
190
+ expect(out.height).toBe(96); // 100+16 - 20
191
+ });
192
+
193
+ it('skips quads with out-of-range region ids for vector2 transforms', () => {
194
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
195
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
196
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 2 } });
197
+ quadBatch.data.ids = new Uint16Array([0, 99]);
198
+ quadBatch.data.transforms = new Float32Array([0, 0, 1000, 1000]);
199
+ const out = createRectangle();
200
+ computeQuadBatchLocalBoundsRectangle(out, quadBatch);
201
+ expect(out.width).toBe(32);
202
+ expect(out.height).toBe(32);
203
+ });
204
+
205
+ it('computes AABB over all quads for matrix3x2 transforms', () => {
206
+ const region = { id: 0, x: 0, y: 0, width: 10, height: 10, pivotX: null, pivotY: null } as TextureAtlasRegion;
207
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
208
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1, transformType: 'matrix3x2' } });
209
+ quadBatch.data.ids = new Uint16Array([0]);
210
+ // identity matrix at offset (5, 5): [1, 0, 0, 1, 5, 5]
211
+ quadBatch.data.transforms = new Float32Array([1, 0, 0, 1, 5, 5]);
212
+ const out = createRectangle();
213
+ computeQuadBatchLocalBoundsRectangle(out, quadBatch);
214
+ expect(out.x).toBe(5);
215
+ expect(out.y).toBe(5);
216
+ expect(out.width).toBe(10);
217
+ expect(out.height).toBe(10);
218
+ });
219
+
220
+ it('does not store the result on the batch', () => {
221
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 16, pivotX: null, pivotY: null } as TextureAtlasRegion;
222
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
223
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1 } });
224
+ quadBatch.data.ids = new Uint16Array([0]);
225
+ quadBatch.data.transforms = new Float32Array([10, 20]);
226
+ const revisionBefore = getNodeLocalBoundsRevision(quadBatch);
227
+ computeQuadBatchLocalBoundsRectangle(createRectangle(), quadBatch);
228
+ expect(getNodeLocalBoundsRevision(quadBatch)).toBe(revisionBefore);
229
+ expect(getNodeLocalBoundsRectangle(quadBatch).width).toBe(0);
230
+ });
231
+ });
232
+
233
+ describe('createQuadBatch', () => {
234
+ let quadBatch: QuadBatch;
235
+
236
+ beforeEach(() => {
237
+ quadBatch = createQuadBatch();
238
+ });
239
+
240
+ it('initializes default values', () => {
241
+ expect(quadBatch.data.atlas).toBeNull();
242
+ expect(quadBatch.data.ids).toStrictEqual(new Uint16Array());
243
+ expect(quadBatch.data.instanceCount).toBe(0);
244
+ expect(quadBatch.data.transforms).toStrictEqual(new Float32Array());
245
+ expect(quadBatch.data.transformType).toBe('vector2');
246
+ expect(quadBatch.kind).toBe(QuadBatchKind);
247
+ });
248
+
249
+ it('allows pre-defined values', () => {
250
+ const base = {
251
+ data: {
252
+ atlas: {} as TextureAtlas,
253
+ ids: new Uint16Array(),
254
+ instanceCount: 1000,
255
+ transforms: new Float32Array(),
256
+ transformType: 'matrix3x2' as QuadTransformType,
257
+ },
258
+ };
259
+ const obj = createQuadBatch(base);
260
+ expect(obj.data.atlas).toStrictEqual(base.data.atlas);
261
+ expect(obj.data.ids).toStrictEqual(base.data.ids);
262
+ expect(obj.data.instanceCount).toStrictEqual(base.data.instanceCount);
263
+ expect(obj.data.transforms).toStrictEqual(base.data.transforms);
264
+ expect(obj.data.transformType).toStrictEqual(base.data.transformType);
265
+ });
266
+
267
+ it('returns a new object for better hidden-class performance', () => {
268
+ const base = {};
269
+ const obj = createQuadBatch(base);
270
+ expect(obj).not.toStrictEqual(base);
271
+ });
272
+ });
273
+
274
+ describe('createQuadBatchData', () => {
275
+ it('returns default values', () => {
276
+ const data = createQuadBatchData();
277
+ expect(data.atlas).toBeNull();
278
+ expect(data.instanceCount).toBe(0);
279
+ expect(data.ids).toBeInstanceOf(Uint16Array);
280
+ expect(data.materialData).toBeNull();
281
+ expect(data.transforms).toBeInstanceOf(Float32Array);
282
+ expect(data.transformType).toBe('vector2');
283
+ });
284
+
285
+ it('allows pre-defined values', () => {
286
+ const data = createQuadBatchData({ instanceCount: 10 });
287
+ expect(data.instanceCount).toBe(10);
288
+ });
289
+ });
290
+
291
+ describe('createQuadBatchRuntime', () => {
292
+ it('returns a non-null runtime', () => {
293
+ const runtime = createQuadBatchRuntime();
294
+ expect(runtime).not.toBeNull();
295
+ });
296
+ });
297
+
298
+ describe('createQuadBatchSignals', () => {
299
+ it('creates a signals group with the three quad-batch signals', () => {
300
+ const signals = createQuadBatchSignals();
301
+ let appended = -1;
302
+ let removed: readonly number[] = [];
303
+ let cleared = false;
304
+ connectSignal(signals.onInstanceAppended, (index) => {
305
+ appended = index;
306
+ });
307
+ connectSignal(signals.onInstanceRemoved, (index, swapSource) => {
308
+ removed = [index, swapSource];
309
+ });
310
+ connectSignal(signals.onCleared, () => {
311
+ cleared = true;
312
+ });
313
+ signals.onInstanceAppended.emit(4);
314
+ signals.onInstanceRemoved.emit(1, 2);
315
+ signals.onCleared.emit();
316
+ expect(appended).toBe(4);
317
+ expect(removed).toEqual([1, 2]);
318
+ expect(cleared).toBe(true);
319
+ });
320
+ });
321
+
322
+ describe('enableQuadBatchSignals', () => {
323
+ it('creates and attaches signals on first call', () => {
324
+ const qb = createQuadBatch();
325
+ expect(getQuadBatchSignals(qb)).toBeNull();
326
+ const signals = enableQuadBatchSignals(qb);
327
+ expect(signals).not.toBeNull();
328
+ expect(getQuadBatchSignals(qb)).toBe(signals);
329
+ });
330
+
331
+ it('returns the same group on repeated calls', () => {
332
+ const qb = createQuadBatch();
333
+ expect(enableQuadBatchSignals(qb)).toBe(enableQuadBatchSignals(qb));
334
+ });
335
+
336
+ it('makes appendQuadBatchInstance fire onInstanceAppended', () => {
337
+ const qb = createQuadBatch();
338
+ const signals = enableQuadBatchSignals(qb);
339
+ let received = -1;
340
+ connectSignal(signals.onInstanceAppended, (index) => {
341
+ received = index;
342
+ });
343
+ const idx = appendQuadBatchInstance(qb, 0, 0, 0);
344
+ expect(received).toBe(idx);
345
+ });
346
+
347
+ it('makes removeQuadBatchInstance fire onInstanceRemoved with the swap source', () => {
348
+ const qb = createQuadBatch();
349
+ appendQuadBatchInstance(qb, 0, 0, 0);
350
+ appendQuadBatchInstance(qb, 1, 0, 0);
351
+ const signals = enableQuadBatchSignals(qb);
352
+ let received: readonly number[] = [];
353
+ connectSignal(signals.onInstanceRemoved, (index, swapSource) => {
354
+ received = [index, swapSource];
355
+ });
356
+ removeQuadBatchInstance(qb, 0);
357
+ expect(received).toEqual([0, 1]);
358
+ });
359
+
360
+ it('makes clearQuadBatch fire onCleared', () => {
361
+ const qb = createQuadBatch();
362
+ const signals = enableQuadBatchSignals(qb);
363
+ let cleared = false;
364
+ connectSignal(signals.onCleared, () => {
365
+ cleared = true;
366
+ });
367
+ clearQuadBatch(qb);
368
+ expect(cleared).toBe(true);
369
+ });
370
+ });
371
+
372
+ describe('getQuadBatchCapacity', () => {
373
+ it('returns 0 for a new quad batch', () => {
374
+ const quadBatch = createQuadBatch();
375
+ expect(getQuadBatchCapacity(quadBatch)).toBe(0);
376
+ });
377
+
378
+ it('returns the current capacity', () => {
379
+ const quadBatch = createQuadBatch();
380
+ quadBatch.data.ids = new Uint16Array(20);
381
+ quadBatch.data.transforms = new Float32Array(40); // 20 * 2
382
+ expect(getQuadBatchCapacity(quadBatch)).toBe(20);
383
+ });
384
+
385
+ it('returns the lowest value if arrays are misaligned in size', () => {
386
+ const quadBatch = createQuadBatch();
387
+ quadBatch.data.ids = new Uint16Array(10);
388
+ quadBatch.data.transforms = new Float32Array(40); // 20 * 2
389
+ expect(getQuadBatchCapacity(quadBatch)).toBe(10);
390
+ quadBatch.data.ids = new Uint16Array(100);
391
+ expect(getQuadBatchCapacity(quadBatch)).toBe(20);
392
+ });
393
+ });
394
+
395
+ describe('getQuadBatchInstanceId', () => {
396
+ it('returns the id at a valid index', () => {
397
+ const qb = createQuadBatch();
398
+ resizeQuadBatch(qb, 3);
399
+ qb.data.ids[1] = 7;
400
+ expect(getQuadBatchInstanceId(qb, 1)).toBe(7);
401
+ });
402
+
403
+ it('returns -1 for out-of-range index', () => {
404
+ const qb = createQuadBatch();
405
+ resizeQuadBatch(qb, 2);
406
+ expect(getQuadBatchInstanceId(qb, -1)).toBe(-1);
407
+ expect(getQuadBatchInstanceId(qb, 2)).toBe(-1);
408
+ });
409
+ });
410
+
411
+ describe('getQuadBatchInstanceTransform', () => {
412
+ it('writes x/y for vector2 batch', () => {
413
+ const qb = createQuadBatch();
414
+ resizeQuadBatch(qb, 1);
415
+ qb.data.transforms[0] = 15;
416
+ qb.data.transforms[1] = 25;
417
+ const out = createVector2();
418
+ const result = getQuadBatchInstanceTransform(out, qb, 0);
419
+ expect(result).toBe(true);
420
+ expect(out.x).toBe(15);
421
+ expect(out.y).toBe(25);
422
+ });
423
+
424
+ it('writes tx/ty (offset 4/5) for matrix3x2 batch', () => {
425
+ const qb = createQuadBatch({ data: { transformType: 'matrix3x2' } });
426
+ resizeQuadBatch(qb, 1);
427
+ // [a, b, c, d, tx, ty]
428
+ qb.data.transforms.set([1, 0, 0, 1, 55, 66]);
429
+ const out = createVector2();
430
+ getQuadBatchInstanceTransform(out, qb, 0);
431
+ expect(out.x).toBe(55);
432
+ expect(out.y).toBe(66);
433
+ });
434
+
435
+ it('returns false and does not write for out-of-range index', () => {
436
+ const qb = createQuadBatch();
437
+ resizeQuadBatch(qb, 1);
438
+ const out = createVector2(99, 99);
439
+ const result = getQuadBatchInstanceTransform(out, qb, 5);
440
+ expect(result).toBe(false);
441
+ expect(out.x).toBe(99);
442
+ expect(out.y).toBe(99);
443
+ });
444
+ });
445
+
446
+ describe('getQuadBatchRuntime', () => {
447
+ it('returns the runtime for a QuadBatch', () => {
448
+ const quadBatch = createQuadBatch();
449
+ const runtime = getQuadBatchRuntime(quadBatch);
450
+ expect(runtime).not.toBeNull();
451
+ });
452
+ });
453
+
454
+ describe('getQuadBatchSignals', () => {
455
+ it('returns null before signals are enabled', () => {
456
+ const qb = createQuadBatch();
457
+ expect(getQuadBatchSignals(qb)).toBeNull();
458
+ });
459
+
460
+ it('returns the group after enabling', () => {
461
+ const qb = createQuadBatch();
462
+ const signals = enableQuadBatchSignals(qb);
463
+ expect(getQuadBatchSignals(qb)).toBe(signals);
464
+ });
465
+ });
466
+
467
+ describe('getQuadBatchTransformStride', () => {
468
+ it('returns 2 if transform type is vector2', () => {
469
+ expect(getQuadBatchTransformStride('vector2')).toBe(2);
470
+ });
471
+
472
+ it('returns 2 if transform type is vector2', () => {
473
+ expect(getQuadBatchTransformStride('matrix3x2')).toBe(6);
474
+ });
475
+ });
476
+
477
+ describe('hitTestQuadBatchPoint', () => {
478
+ it('delegates to hitTestQuadBatchPointXY', () => {
479
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
480
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
481
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1 } });
482
+ quadBatch.data.ids = new Uint16Array([0]);
483
+ quadBatch.data.transforms = new Float32Array([10, 20]);
484
+ expect(hitTestQuadBatchPoint(quadBatch, { x: 15, y: 25 })).toBe(0);
485
+ expect(hitTestQuadBatchPoint(quadBatch, { x: 5, y: 5 })).toBe(-1);
486
+ });
487
+ });
488
+
489
+ describe('hitTestQuadBatchPointExact', () => {
490
+ it('delegates to hitTestQuadBatchPointExactXY', () => {
491
+ const atlas = makeQuadAtlas(makeQuadRegion(0));
492
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1 } });
493
+ quadBatch.data.ids = new Uint16Array([0]);
494
+ quadBatch.data.transforms = new Float32Array([10, 20]);
495
+ expect(hitTestQuadBatchPointExact(quadBatch, { x: 15, y: 25 })).toBe(0);
496
+ expect(hitTestQuadBatchPointExact(quadBatch, { x: 5, y: 5 })).toBe(-1);
497
+ });
498
+ });
499
+
500
+ describe('hitTestQuadBatchPointExactXY', () => {
501
+ it('returns -1 when atlas is null or empty', () => {
502
+ expect(hitTestQuadBatchPointExactXY(createQuadBatch(), 0, 0)).toBe(-1);
503
+ const atlas = makeQuadAtlas(makeQuadRegion(0));
504
+ expect(hitTestQuadBatchPointExactXY(createQuadBatch({ data: { atlas } }), 0, 0)).toBe(-1);
505
+ });
506
+
507
+ it('hits an axis-aligned vector2 quad like the AABB variant', () => {
508
+ const atlas = makeQuadAtlas(makeQuadRegion(0, 32, 32));
509
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1 } });
510
+ quadBatch.data.ids = new Uint16Array([0]);
511
+ quadBatch.data.transforms = new Float32Array([10, 20]);
512
+ expect(hitTestQuadBatchPointExactXY(quadBatch, 15, 25)).toBe(0);
513
+ expect(hitTestQuadBatchPointExactXY(quadBatch, 5, 5)).toBe(-1);
514
+ });
515
+
516
+ it('rejects an AABB corner that lies outside a rotated quad', () => {
517
+ // 45-degree rotation of a 100x100 quad. cos45 = sin45 ≈ 0.7071.
518
+ const s = Math.SQRT1_2;
519
+ const atlas = makeQuadAtlas(makeQuadRegion(0, 100, 100));
520
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1, transformType: 'matrix3x2' } });
521
+ quadBatch.data.ids = new Uint16Array([0]);
522
+ // [a, b, c, d, tx, ty] = rotate 45 about origin, no translation.
523
+ quadBatch.data.transforms = new Float32Array([s, s, -s, s, 0, 0]);
524
+ // The rotated diamond reaches x in [-70.7, 70.7], y in [0, 141.4]. The point (60, 5)
525
+ // is inside that AABB but outside the diamond near the right tip.
526
+ expect(hitTestQuadBatchPointExactXY(quadBatch, 60, 5)).toBe(-1);
527
+ // The AABB variant over-reports and treats it as a hit.
528
+ expect(hitTestQuadBatchPointXY(quadBatch, 60, 5)).toBe(0);
529
+ // The diamond center is a genuine hit for both.
530
+ expect(hitTestQuadBatchPointExactXY(quadBatch, 0, 70)).toBe(0);
531
+ });
532
+
533
+ it('returns -1 for out-of-range region ids', () => {
534
+ const atlas = makeQuadAtlas(makeQuadRegion(0));
535
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1, transformType: 'matrix3x2' } });
536
+ quadBatch.data.ids = new Uint16Array([99]);
537
+ quadBatch.data.transforms = new Float32Array([1, 0, 0, 1, 0, 0]);
538
+ expect(hitTestQuadBatchPointExactXY(quadBatch, 5, 5)).toBe(-1);
539
+ });
540
+ });
541
+
542
+ describe('hitTestQuadBatchPointXY', () => {
543
+ it('returns -1 when atlas is null', () => {
544
+ const quadBatch = createQuadBatch();
545
+ expect(hitTestQuadBatchPointXY(quadBatch, 0, 0)).toBe(-1);
546
+ });
547
+
548
+ it('returns -1 when instanceCount is 0', () => {
549
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
550
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
551
+ const quadBatch = createQuadBatch({ data: { atlas } });
552
+ expect(hitTestQuadBatchPointXY(quadBatch, 0, 0)).toBe(-1);
553
+ });
554
+
555
+ it('returns the index of a hit quad for vector2 transforms', () => {
556
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
557
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
558
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 2 } });
559
+ quadBatch.data.ids = new Uint16Array([0, 0]);
560
+ quadBatch.data.transforms = new Float32Array([10, 20, 100, 200]);
561
+ expect(hitTestQuadBatchPointXY(quadBatch, 15, 25)).toBe(0);
562
+ expect(hitTestQuadBatchPointXY(quadBatch, 110, 210)).toBe(1);
563
+ });
564
+
565
+ it('returns -1 when point is outside all quads for vector2 transforms', () => {
566
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
567
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
568
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1 } });
569
+ quadBatch.data.ids = new Uint16Array([0]);
570
+ quadBatch.data.transforms = new Float32Array([10, 20]);
571
+ expect(hitTestQuadBatchPointXY(quadBatch, 0, 0)).toBe(-1);
572
+ expect(hitTestQuadBatchPointXY(quadBatch, 42, 20)).toBe(-1);
573
+ });
574
+
575
+ it('returns the first hit index when quads overlap for vector2 transforms', () => {
576
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
577
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
578
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 2 } });
579
+ quadBatch.data.ids = new Uint16Array([0, 0]);
580
+ quadBatch.data.transforms = new Float32Array([0, 0, 0, 0]);
581
+ expect(hitTestQuadBatchPointXY(quadBatch, 10, 10)).toBe(0);
582
+ });
583
+
584
+ it('returns -1 for out-of-range region id for vector2 transforms', () => {
585
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
586
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
587
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1 } });
588
+ quadBatch.data.ids = new Uint16Array([99]);
589
+ quadBatch.data.transforms = new Float32Array([0, 0]);
590
+ expect(hitTestQuadBatchPointXY(quadBatch, 5, 5)).toBe(-1);
591
+ });
592
+
593
+ it('returns the index of a hit quad for matrix3x2 transforms', () => {
594
+ const region = { id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null } as TextureAtlasRegion;
595
+ const atlas = { texture: null, regions: [region] } as TextureAtlas;
596
+ const quadBatch = createQuadBatch({ data: { atlas, instanceCount: 1, transformType: 'matrix3x2' } });
597
+ quadBatch.data.ids = new Uint16Array([0]);
598
+ // identity matrix at offset (50, 60): [1, 0, 0, 1, 50, 60]
599
+ quadBatch.data.transforms = new Float32Array([1, 0, 0, 1, 50, 60]);
600
+ expect(hitTestQuadBatchPointXY(quadBatch, 60, 70)).toBe(0);
601
+ expect(hitTestQuadBatchPointXY(quadBatch, 40, 50)).toBe(-1);
602
+ });
603
+ });
604
+
605
+ describe('iterateQuadBatchInstances', () => {
606
+ it('visits each live instance in order with id and transform view', () => {
607
+ const qb = createQuadBatch();
608
+ appendQuadBatchInstance(qb, 7, 1, 2);
609
+ appendQuadBatchInstance(qb, 8, 3, 4);
610
+ const seen: Array<{ index: number; id: number; x: number; y: number }> = [];
611
+ iterateQuadBatchInstances(qb, (index, id, transforms) => {
612
+ seen.push({ index, id, x: transforms[0], y: transforms[1] });
613
+ });
614
+ expect(seen).toEqual([
615
+ { index: 0, id: 7, x: 1, y: 2 },
616
+ { index: 1, id: 8, x: 3, y: 4 },
617
+ ]);
618
+ });
619
+
620
+ it('provides a stride-length view for matrix3x2 batches', () => {
621
+ const qb = createQuadBatch({ data: { transformType: 'matrix3x2' } });
622
+ resizeQuadBatch(qb, 1);
623
+ qb.data.ids[0] = 2;
624
+ qb.data.transforms.set([1, 0, 0, 1, 9, 10]);
625
+ let length = -1;
626
+ iterateQuadBatchInstances(qb, (_index, _id, transforms) => {
627
+ length = transforms.length;
628
+ });
629
+ expect(length).toBe(6);
630
+ });
631
+
632
+ it('does not invoke the visitor for an empty batch', () => {
633
+ const qb = createQuadBatch();
634
+ let calls = 0;
635
+ iterateQuadBatchInstances(qb, () => {
636
+ calls++;
637
+ });
638
+ expect(calls).toBe(0);
639
+ });
640
+ });
641
+
642
+ describe('removeQuadBatchInstance', () => {
643
+ it('swap-removes the target instance with the last', () => {
644
+ const qb = createQuadBatch();
645
+ appendQuadBatchInstance(qb, 0, 0, 0);
646
+ appendQuadBatchInstance(qb, 1, 10, 10);
647
+ appendQuadBatchInstance(qb, 2, 20, 20);
648
+ removeQuadBatchInstance(qb, 0);
649
+ expect(qb.data.instanceCount).toBe(2);
650
+ // The last instance (id=2) should now be at index 0
651
+ expect(getQuadBatchInstanceId(qb, 0)).toBe(2);
652
+ });
653
+
654
+ it('removes the last instance directly', () => {
655
+ const qb = createQuadBatch();
656
+ appendQuadBatchInstance(qb, 5, 50, 50);
657
+ removeQuadBatchInstance(qb, 0);
658
+ expect(qb.data.instanceCount).toBe(0);
659
+ });
660
+
661
+ it('no-ops for out-of-range index', () => {
662
+ const qb = createQuadBatch();
663
+ appendQuadBatchInstance(qb, 0, 0, 0);
664
+ removeQuadBatchInstance(qb, -1);
665
+ removeQuadBatchInstance(qb, 1);
666
+ expect(qb.data.instanceCount).toBe(1);
667
+ });
668
+ });
669
+
670
+ describe('reserveQuadBatch', () => {
671
+ it('allocates if capacity is larger', () => {
672
+ const quadBatch = createQuadBatch();
673
+ reserveQuadBatch(quadBatch, 100);
674
+ expect(quadBatch.data.ids.length).toBe(100);
675
+ expect(quadBatch.data.transforms.length).toBe(100 * 2); // vector2
676
+ });
677
+
678
+ it('does not allocate if capacity is less than', () => {
679
+ const quadBatch = createQuadBatch();
680
+ quadBatch.data.ids = new Uint16Array(100);
681
+ quadBatch.data.transforms = new Float32Array(2 * 100);
682
+ const { ids, transforms } = quadBatch.data;
683
+ reserveQuadBatch(quadBatch, 50);
684
+ expect(quadBatch.data.ids).toStrictEqual(ids);
685
+ expect(quadBatch.data.transforms).toStrictEqual(transforms);
686
+ });
687
+
688
+ it('does not allocate if capacity is equal', () => {
689
+ const quadBatch = createQuadBatch();
690
+ quadBatch.data.ids = new Uint16Array(100);
691
+ quadBatch.data.transforms = new Float32Array(2 * 100);
692
+ const { ids, transforms } = quadBatch.data;
693
+ reserveQuadBatch(quadBatch, 100);
694
+ expect(quadBatch.data.ids).toStrictEqual(ids);
695
+ expect(quadBatch.data.transforms).toStrictEqual(transforms);
696
+ });
697
+ });
698
+
699
+ describe('resizeQuadBatch', () => {
700
+ it('allocates if instance count is greater than capacity', () => {
701
+ const quadBatch = createQuadBatch();
702
+ resizeQuadBatch(quadBatch, 100);
703
+ expect(quadBatch.data.ids.length).toBe(100);
704
+ expect(quadBatch.data.transforms.length).toBe(100 * 2);
705
+ });
706
+
707
+ it('sets instance count', () => {
708
+ const quadBatch = createQuadBatch();
709
+ resizeQuadBatch(quadBatch, 100);
710
+ expect(quadBatch.data.instanceCount).toBe(100);
711
+ });
712
+
713
+ it('keeps 300k untinted atlas instances on one texture and no material array', () => {
714
+ const texture = {} as Texture2D;
715
+ const atlas = makeQuadAtlas(makeQuadRegion());
716
+ atlas.texture = texture;
717
+ const quadBatch = createQuadBatch({ data: { atlas } });
718
+
719
+ resizeQuadBatch(quadBatch, 300_000);
720
+
721
+ expect(quadBatch.data.instanceCount).toBe(300_000);
722
+ expect(quadBatch.data.materialData).toBeNull();
723
+ expect(quadBatch.data.atlas?.texture).toBe(texture);
724
+ expect(quadBatch.data.ids).toBeInstanceOf(Uint16Array);
725
+ expect(quadBatch.data.transforms).toBeInstanceOf(Float32Array);
726
+ });
727
+
728
+ it('trusts instance count and does not allocate if shrinking', () => {
729
+ const quadBatch = createQuadBatch();
730
+ expect(quadBatch.data.ids.length).toBe(0);
731
+ expect(quadBatch.data.transforms.length).toBe(0);
732
+ quadBatch.data.instanceCount = 200;
733
+ resizeQuadBatch(quadBatch, 100);
734
+ expect(quadBatch.data.ids.length).toBe(0);
735
+ expect(quadBatch.data.transforms.length).toBe(0);
736
+ expect(quadBatch.data.instanceCount).toBe(100);
737
+ });
738
+ });
739
+
740
+ describe('setQuadBatchInstance', () => {
741
+ it('writes id and x/y into the given index', () => {
742
+ const qb = createQuadBatch();
743
+ resizeQuadBatch(qb, 3);
744
+ setQuadBatchInstance(qb, 1, 7, 100, 200);
745
+ expect(getQuadBatchInstanceId(qb, 1)).toBe(7);
746
+ const out = createVector2();
747
+ getQuadBatchInstanceTransform(out, qb, 1);
748
+ expect(out.x).toBe(100);
749
+ expect(out.y).toBe(200);
750
+ });
751
+
752
+ it('no-ops for out-of-range index', () => {
753
+ const qb = createQuadBatch();
754
+ resizeQuadBatch(qb, 1);
755
+ qb.data.ids[0] = 0;
756
+ setQuadBatchInstance(qb, -1, 9, 0, 0);
757
+ setQuadBatchInstance(qb, 1, 9, 0, 0);
758
+ expect(qb.data.ids[0]).toBe(0);
759
+ });
760
+ });
761
+
762
+ describe('setQuadBatchInstanceMatrix', () => {
763
+ it('writes id and all 6 matrix components', () => {
764
+ const qb = createQuadBatch({ data: { transformType: 'matrix3x2' } });
765
+ resizeQuadBatch(qb, 1);
766
+ setQuadBatchInstanceMatrix(qb, 0, 3, 1, 2, 3, 4, 5, 6);
767
+ expect(qb.data.ids[0]).toBe(3);
768
+ expect(qb.data.transforms[0]).toBe(1); // a
769
+ expect(qb.data.transforms[1]).toBe(2); // b
770
+ expect(qb.data.transforms[2]).toBe(3); // c
771
+ expect(qb.data.transforms[3]).toBe(4); // d
772
+ expect(qb.data.transforms[4]).toBe(5); // tx
773
+ expect(qb.data.transforms[5]).toBe(6); // ty
774
+ });
775
+
776
+ it('no-ops for out-of-range index', () => {
777
+ const qb = createQuadBatch({ data: { transformType: 'matrix3x2' } });
778
+ resizeQuadBatch(qb, 1);
779
+ qb.data.ids[0] = 0;
780
+ setQuadBatchInstanceMatrix(qb, 1, 9, 1, 0, 0, 1, 0, 0);
781
+ expect(qb.data.ids[0]).toBe(0);
782
+ });
783
+ });
784
+
785
+ describe('setQuadBatchInstanceRange', () => {
786
+ it('writes contiguous vector2 transforms from a source array', () => {
787
+ const qb = createQuadBatch();
788
+ resizeQuadBatch(qb, 3);
789
+ setQuadBatchInstanceRange(qb, 1, 2, new Float32Array([10, 20, 30, 40]));
790
+ const out = createVector2();
791
+ getQuadBatchInstanceTransform(out, qb, 1);
792
+ expect(out.x).toBe(10);
793
+ expect(out.y).toBe(20);
794
+ getQuadBatchInstanceTransform(out, qb, 2);
795
+ expect(out.x).toBe(30);
796
+ expect(out.y).toBe(40);
797
+ });
798
+
799
+ it('writes matrix3x2 transforms using the 6-float stride', () => {
800
+ const qb = createQuadBatch({ data: { transformType: 'matrix3x2' } });
801
+ resizeQuadBatch(qb, 1);
802
+ setQuadBatchInstanceRange(qb, 0, 1, new Float32Array([1, 0, 0, 1, 7, 8]));
803
+ expect(qb.data.transforms[4]).toBe(7);
804
+ expect(qb.data.transforms[5]).toBe(8);
805
+ });
806
+
807
+ it('no-ops when the range exceeds instanceCount', () => {
808
+ const qb = createQuadBatch();
809
+ resizeQuadBatch(qb, 2);
810
+ setQuadBatchInstanceRange(qb, 1, 5, new Float32Array([99, 99, 99, 99, 99, 99, 99, 99, 99, 99]));
811
+ expect(qb.data.transforms[2]).toBe(0);
812
+ });
813
+
814
+ it('no-ops for a non-positive count or negative start', () => {
815
+ const qb = createQuadBatch();
816
+ resizeQuadBatch(qb, 2);
817
+ setQuadBatchInstanceRange(qb, 0, 0, new Float32Array([1, 2]));
818
+ setQuadBatchInstanceRange(qb, -1, 1, new Float32Array([1, 2]));
819
+ expect(qb.data.transforms[0]).toBe(0);
820
+ });
821
+ });
822
+
823
+ describe('setQuadBatchInstanceTint', () => {
824
+ it('lazily allocates per-item data and stores packed RGBA', () => {
825
+ const batch = createQuadBatch({ data: { instanceCount: 3 } });
826
+ expect(batch.data.materialData).toBeNull();
827
+ setQuadBatchInstanceTint(batch, 1, 0x12345678);
828
+ expect(batch.data.materialData).toEqual([null, { tint: 0x12345678 }, null]);
829
+ });
830
+
831
+ it('ignores an out-of-range instance without allocating', () => {
832
+ const batch = createQuadBatch({ data: { instanceCount: 1 } });
833
+ setQuadBatchInstanceTint(batch, 2, 0xffffffff);
834
+ expect(batch.data.materialData).toBeNull();
835
+ });
836
+ });
837
+
838
+ describe('setQuadBatchLocalBoundsRectangle', () => {
839
+ it('makes getNodeLocalBoundsRectangle reflect the new bounds', () => {
840
+ const quadBatch = createQuadBatch();
841
+ setQuadBatchLocalBoundsRectangle(quadBatch, createRectangle(10, 20, 32, 16));
842
+ const local = getNodeLocalBoundsRectangle(quadBatch);
843
+ expect(local.x).toBe(10);
844
+ expect(local.y).toBe(20);
845
+ expect(local.width).toBe(32);
846
+ expect(local.height).toBe(16);
847
+ });
848
+
849
+ it('copies the rect so later mutations to the source do not affect stored bounds', () => {
850
+ const quadBatch = createQuadBatch();
851
+ const rect = createRectangle(10, 20, 32, 16);
852
+ setQuadBatchLocalBoundsRectangle(quadBatch, rect);
853
+ rect.width = 999;
854
+ expect(getNodeLocalBoundsRectangle(quadBatch).width).toBe(32);
855
+ });
856
+
857
+ it('invalidates local bounds', () => {
858
+ const quadBatch = createQuadBatch();
859
+ const before = getNodeLocalBoundsRevision(quadBatch);
860
+ setQuadBatchLocalBoundsRectangle(quadBatch, createRectangle());
861
+ expect(getNodeLocalBoundsRevision(quadBatch)).not.toBe(before);
862
+ });
863
+
864
+ it('returns zero bounds via getNodeLocalBoundsRectangle before any set', () => {
865
+ const quadBatch = createQuadBatch();
866
+ const local = getNodeLocalBoundsRectangle(quadBatch);
867
+ expect(local.width).toBe(0);
868
+ expect(local.height).toBe(0);
869
+ });
870
+ });
871
+
872
+ describe('setQuadBatchTransformType', () => {
873
+ it('no-ops when the new type matches the current type', () => {
874
+ const qb = createQuadBatch();
875
+ appendQuadBatchInstance(qb, 0, 1, 2);
876
+ const transforms = qb.data.transforms;
877
+ setQuadBatchTransformType(qb, 'vector2');
878
+ expect(qb.data.transformType).toBe('vector2');
879
+ expect(qb.data.transforms).toBe(transforms);
880
+ });
881
+
882
+ it('expands vector2 to matrix3x2 as identity + translation', () => {
883
+ const qb = createQuadBatch();
884
+ appendQuadBatchInstance(qb, 0, 5, 6);
885
+ appendQuadBatchInstance(qb, 1, 7, 8);
886
+ setQuadBatchTransformType(qb, 'matrix3x2');
887
+ expect(qb.data.transformType).toBe('matrix3x2');
888
+ // First instance: [1, 0, 0, 1, 5, 6]
889
+ expect(Array.from(qb.data.transforms.subarray(0, 6))).toEqual([1, 0, 0, 1, 5, 6]);
890
+ // Second instance: [1, 0, 0, 1, 7, 8]
891
+ expect(Array.from(qb.data.transforms.subarray(6, 12))).toEqual([1, 0, 0, 1, 7, 8]);
892
+ });
893
+
894
+ it('collapses matrix3x2 to vector2 keeping only translation', () => {
895
+ const qb = createQuadBatch({ data: { transformType: 'matrix3x2' } });
896
+ resizeQuadBatch(qb, 2);
897
+ setQuadBatchInstanceMatrix(qb, 0, 0, 2, 0, 0, 2, 10, 20);
898
+ setQuadBatchInstanceMatrix(qb, 1, 1, 2, 0, 0, 2, 30, 40);
899
+ setQuadBatchTransformType(qb, 'vector2');
900
+ expect(qb.data.transformType).toBe('vector2');
901
+ expect(qb.data.transforms[0]).toBe(10);
902
+ expect(qb.data.transforms[1]).toBe(20);
903
+ expect(qb.data.transforms[2]).toBe(30);
904
+ expect(qb.data.transforms[3]).toBe(40);
905
+ });
906
+ });