@flighthq/render 0.3.0-next.906.07cea63 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/contract.d.ts +6 -1
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +6 -1
  4. package/dist/contract.js.map +1 -1
  5. package/dist/enableColorAdjustmentGuards.js +6 -6
  6. package/dist/enableColorAdjustmentGuards.js.map +1 -1
  7. package/dist/enableColorAdjustments.d.ts +4 -0
  8. package/dist/enableColorAdjustments.d.ts.map +1 -0
  9. package/dist/enableColorAdjustments.js +110 -0
  10. package/dist/enableColorAdjustments.js.map +1 -0
  11. package/dist/enableSceneRenderGuards.d.ts +22 -0
  12. package/dist/enableSceneRenderGuards.d.ts.map +1 -0
  13. package/dist/enableSceneRenderGuards.js +36 -0
  14. package/dist/enableSceneRenderGuards.js.map +1 -0
  15. package/dist/explainScene2DCoverage.d.ts +4 -0
  16. package/dist/explainScene2DCoverage.d.ts.map +1 -0
  17. package/dist/explainScene2DCoverage.js +57 -0
  18. package/dist/explainScene2DCoverage.js.map +1 -0
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/renderProxy.d.ts.map +1 -1
  24. package/dist/renderProxy.js +34 -9
  25. package/dist/renderProxy.js.map +1 -1
  26. package/dist/renderRegistryGuards.d.ts +5 -0
  27. package/dist/renderRegistryGuards.d.ts.map +1 -0
  28. package/dist/renderRegistryGuards.js +87 -0
  29. package/dist/renderRegistryGuards.js.map +1 -0
  30. package/dist/renderRegistrySignals.d.ts +3 -0
  31. package/dist/renderRegistrySignals.d.ts.map +1 -0
  32. package/dist/renderRegistrySignals.js +19 -0
  33. package/dist/renderRegistrySignals.js.map +1 -0
  34. package/dist/renderState.d.ts +1 -0
  35. package/dist/renderState.d.ts.map +1 -1
  36. package/dist/renderState.js +26 -0
  37. package/dist/renderState.js.map +1 -1
  38. package/dist/renderTarget.d.ts +4 -2
  39. package/dist/renderTarget.d.ts.map +1 -1
  40. package/dist/renderTarget.js +52 -2
  41. package/dist/renderTarget.js.map +1 -1
  42. package/dist/renderTransform2d.d.ts.map +1 -1
  43. package/dist/renderTransform2d.js +4 -1
  44. package/dist/renderTransform2d.js.map +1 -1
  45. package/dist/renderer.d.ts +1 -0
  46. package/dist/renderer.d.ts.map +1 -1
  47. package/dist/renderer.js +16 -0
  48. package/dist/renderer.js.map +1 -1
  49. package/dist/scene2dWorkingColorSpace.d.ts +3 -0
  50. package/dist/scene2dWorkingColorSpace.d.ts.map +1 -0
  51. package/dist/scene2dWorkingColorSpace.js +19 -0
  52. package/dist/scene2dWorkingColorSpace.js.map +1 -0
  53. package/dist/sceneRender.d.ts +6 -2
  54. package/dist/sceneRender.d.ts.map +1 -1
  55. package/dist/sceneRender.js +47 -13
  56. package/dist/sceneRender.js.map +1 -1
  57. package/package.json +15 -12
  58. package/src/enableColorAdjustmentGuards.test.ts +6 -4
  59. package/src/enableColorAdjustments.test.ts +233 -0
  60. package/src/enableSceneRenderGuards.test.ts +96 -0
  61. package/src/explainScene2DCoverage.test.ts +113 -0
  62. package/src/renderProxy.test.ts +142 -11
  63. package/src/renderQueue.test.ts +1 -1
  64. package/src/renderRegistryGuards.test.ts +94 -0
  65. package/src/renderRegistrySignals.test.ts +15 -0
  66. package/src/renderState.test.ts +16 -1
  67. package/src/renderTarget.test.ts +113 -5
  68. package/src/renderTransform2d.test.ts +15 -1
  69. package/src/renderViewport.test.ts +1 -1
  70. package/src/renderer.test.ts +44 -1
  71. package/src/scene2dWorkingColorSpace.test.ts +11 -0
  72. package/src/sceneRender.test.ts +75 -2
  73. package/dist/renderColorScaleBias.d.ts +0 -3
  74. package/dist/renderColorScaleBias.d.ts.map +0 -1
  75. package/dist/renderColorScaleBias.js +0 -21
  76. package/dist/renderColorScaleBias.js.map +0 -1
  77. package/src/renderColorScaleBias.test.ts +0 -53
@@ -1,15 +1,19 @@
1
1
  import { createMatrix, createRectangle } from '@flighthq/geometry/contract';
2
2
  import {
3
3
  addNodeChild,
4
+ addNodeChildAt,
4
5
  getNodeAppearanceRevision,
6
+ getNodeChildrenRevision,
5
7
  getNodeLocalContentRevision,
6
8
  getNodeLocalTransformRevision,
9
+ getNodeParentReferenceRevision,
7
10
  invalidateNodeAppearance,
8
11
  invalidateNodeLocalTransform,
12
+ removeNodeChild,
9
13
  } from '@flighthq/node/contract';
10
14
  import { createDisplayObject, setNode2DClip } from '@flighthq/scene2d/contract';
11
- import { createSprite } from '@flighthq/sprite/contract';
12
- import type { ClipRegion } from '@flighthq/types/contract';
15
+ import { createSprite } from '@flighthq/scene2d/contract';
16
+ import type { ClipRegion, Node, RenderProxy, RenderProxy2D, RenderState } from '@flighthq/types/contract';
13
17
 
14
18
  import { registerRenderer } from './renderer';
15
19
  import {
@@ -44,6 +48,14 @@ function makeClipRegion(): ClipRegion {
44
48
  return { contours: null, rect: createRectangle(0, 0, 10, 10), winding: 'nonZero', version: 0 };
45
49
  }
46
50
 
51
+ function markRenderProxyClean<Traits extends object>(data: RenderProxy, source: Node<Traits>): void {
52
+ data.lastAppearanceId = getNodeAppearanceRevision(source);
53
+ data.lastChildrenId = getNodeChildrenRevision(source);
54
+ data.lastLocalContentId = getNodeLocalContentRevision(source);
55
+ data.lastLocalTransformId = getNodeLocalTransformRevision(source);
56
+ data.lastParentReferenceId = getNodeParentReferenceRevision(source);
57
+ }
58
+
47
59
  describe('createRenderProxy', () => {
48
60
  it('initializes default values', () => {
49
61
  const state = createRenderState();
@@ -55,7 +67,9 @@ describe('createRenderProxy', () => {
55
67
  expect(node.alpha).toBe(1);
56
68
  expect(node.appearanceFrameId).toBe(-1);
57
69
  expect(node.lastAppearanceId).toBe(-1);
70
+ expect(node.lastChildrenId).toBe(-1);
58
71
  expect(node.lastLocalTransformId).toBe(-1);
72
+ expect(node.lastParentReferenceId).toBe(-1);
59
73
  expect(node.transformFrameId).toBe(-1);
60
74
  expect(node.renderer).toBeNull();
61
75
  expect(node.rendererData).toBeNull();
@@ -258,9 +272,7 @@ describe('isRenderProxyDirty', () => {
258
272
  const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
259
273
  const source = createDisplayObject();
260
274
  const data = createRenderProxy2D(state, source);
261
- data.lastAppearanceId = getNodeAppearanceRevision(source);
262
- data.lastLocalContentId = getNodeLocalContentRevision(source);
263
- data.lastLocalTransformId = getNodeLocalTransformRevision(source);
275
+ markRenderProxyClean(data, source);
264
276
 
265
277
  expect(isRenderProxyDirty(state, source, data)).toBe(false);
266
278
  });
@@ -269,8 +281,7 @@ describe('isRenderProxyDirty', () => {
269
281
  const state = createRenderState();
270
282
  const source = createDisplayObject();
271
283
  const data = createRenderProxy2D(state, source);
272
- data.lastAppearanceId = getNodeAppearanceRevision(source);
273
- data.lastLocalTransformId = getNodeLocalTransformRevision(source);
284
+ markRenderProxyClean(data, source);
274
285
  invalidateNodeAppearance(source);
275
286
 
276
287
  expect(isRenderProxyDirty(state, source, data)).toBe(true);
@@ -280,8 +291,7 @@ describe('isRenderProxyDirty', () => {
280
291
  const state = createRenderState();
281
292
  const source = createDisplayObject();
282
293
  const data = createRenderProxy2D(state, source);
283
- data.lastAppearanceId = getNodeAppearanceRevision(source);
284
- data.lastLocalTransformId = getNodeLocalTransformRevision(source);
294
+ markRenderProxyClean(data, source);
285
295
  const parentData = createRenderProxy2D(state, createDisplayObject());
286
296
  parentData.transformFrameId = getRenderStateRuntime(state).currentFrameId;
287
297
 
@@ -293,8 +303,7 @@ describe('isRenderProxyDirty', () => {
293
303
  const source = createDisplayObject();
294
304
  const data = createRenderProxy2D(state, source);
295
305
  data.transform2D = createMatrix();
296
- data.lastAppearanceId = getNodeAppearanceRevision(source);
297
- data.lastLocalTransformId = getNodeLocalTransformRevision(source);
306
+ markRenderProxyClean(data, source);
298
307
  source.x = 10;
299
308
  invalidateNodeLocalTransform(source);
300
309
 
@@ -337,6 +346,89 @@ describe('isRenderProxyVisible', () => {
337
346
  });
338
347
 
339
348
  describe('prepareScene2DRender', () => {
349
+ it('dirties a warm proxy when it is reattached after another prepared child', () => {
350
+ const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
351
+ const root = createDisplayObject();
352
+ const childA = createDisplayObject();
353
+ const childB = createDisplayObject();
354
+ addNodeChild(root, childA);
355
+ expect(prepareScene2DRender(state, root)).toBe(true);
356
+ expect(prepareScene2DRender(state, root)).toBe(false);
357
+
358
+ removeNodeChild(root, childA);
359
+ addNodeChild(root, childB);
360
+ expect(prepareScene2DRender(state, root)).toBe(true);
361
+ expect(prepareScene2DRender(state, root)).toBe(false);
362
+
363
+ removeNodeChild(root, childB);
364
+ addNodeChild(root, childA);
365
+ expect(prepareScene2DRender(state, root)).toBe(true);
366
+ expect(prepareScene2DRender(state, root)).toBe(false);
367
+ });
368
+
369
+ it('dirties the tree when a prepared child is removed', () => {
370
+ const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
371
+ const root = createDisplayObject();
372
+ const child = createDisplayObject();
373
+ addNodeChild(root, child);
374
+ prepareScene2DRender(state, root);
375
+ expect(prepareScene2DRender(state, root)).toBe(false);
376
+
377
+ removeNodeChild(root, child);
378
+
379
+ expect(prepareScene2DRender(state, root)).toBe(true);
380
+ expect(prepareScene2DRender(state, root)).toBe(false);
381
+ });
382
+
383
+ it('dirties the tree when prepared siblings are reordered', () => {
384
+ const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
385
+ const root = createDisplayObject();
386
+ const childA = createDisplayObject();
387
+ const childB = createDisplayObject();
388
+ addNodeChild(root, childA);
389
+ addNodeChild(root, childB);
390
+ prepareScene2DRender(state, root);
391
+ expect(prepareScene2DRender(state, root)).toBe(false);
392
+
393
+ addNodeChildAt(root, childA, 1);
394
+
395
+ expect(prepareScene2DRender(state, root)).toBe(true);
396
+ expect(prepareScene2DRender(state, root)).toBe(false);
397
+ });
398
+
399
+ it('recomputes inherited transform and appearance when a warm child changes parent', () => {
400
+ const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
401
+ const root = createDisplayObject();
402
+ const parentA = createDisplayObject();
403
+ const parentB = createDisplayObject();
404
+ const child = createDisplayObject();
405
+ parentA.x = 10;
406
+ parentA.alpha = 0.5;
407
+ parentB.x = 100;
408
+ parentB.alpha = 0.25;
409
+ child.x = 3;
410
+ child.alpha = 0.5;
411
+ invalidateNodeLocalTransform(parentA);
412
+ invalidateNodeAppearance(parentA);
413
+ invalidateNodeLocalTransform(parentB);
414
+ invalidateNodeAppearance(parentB);
415
+ invalidateNodeLocalTransform(child);
416
+ invalidateNodeAppearance(child);
417
+ addNodeChild(root, parentA);
418
+ addNodeChild(root, parentB);
419
+ addNodeChild(parentA, child);
420
+ prepareScene2DRender(state, root);
421
+ expect(getOrCreateRenderProxy2D(state, child).transform2D.tx).toBe(13);
422
+ expect(getOrCreateRenderProxy2D(state, child).alpha).toBe(0.25);
423
+
424
+ addNodeChild(parentB, child);
425
+
426
+ expect(prepareScene2DRender(state, root)).toBe(true);
427
+ expect(getOrCreateRenderProxy2D(state, child).transform2D.tx).toBe(103);
428
+ expect(getOrCreateRenderProxy2D(state, child).alpha).toBe(0.125);
429
+ expect(prepareScene2DRender(state, root)).toBe(false);
430
+ });
431
+
340
432
  it('creates render nodes for all enabled nodes in the tree', () => {
341
433
  const state = createRenderState();
342
434
  const root = createDisplayObject();
@@ -389,6 +481,23 @@ describe('prepareScene2DRender', () => {
389
481
  expect(prepareScene2DRender(state, root)).toBe(false);
390
482
  });
391
483
 
484
+ it('returns true when the registered renderer reports an identity-tier change', () => {
485
+ const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
486
+ const root = createDisplayObject();
487
+ let rendererDirty = false;
488
+ registerRenderer(state, root.kind, {
489
+ createData: () => null,
490
+ isDirty: () => rendererDirty,
491
+ submit: vi.fn(),
492
+ });
493
+ prepareScene2DRender(state, root);
494
+ expect(prepareScene2DRender(state, root)).toBe(false);
495
+
496
+ rendererDirty = true;
497
+
498
+ expect(prepareScene2DRender(state, root)).toBe(true);
499
+ });
500
+
392
501
  it('accumulates clip depth down a clipped subtree', () => {
393
502
  const state = createRenderState();
394
503
  const root = createDisplayObject();
@@ -461,6 +570,28 @@ describe('updateRenderProxy2D', () => {
461
570
  updateRenderProxy2D(state, root, data, undefined);
462
571
  expect(data.clipDepth).toBe(1);
463
572
  });
573
+
574
+ it('runs the optional color resolver after material and before clip and revision bookkeeping', () => {
575
+ const state = createRenderState();
576
+ const root = createDisplayObject();
577
+ const material = { kind: 'TestMaterial' } as never;
578
+ root.material = material;
579
+ setNode2DClip(root, makeClipRegion());
580
+ const data = getOrCreateRenderProxy2D(state, root);
581
+ const resolver = vi.fn((_state: RenderState, resolvedData: RenderProxy) => {
582
+ expect(resolvedData.material).toBe(material);
583
+ expect((resolvedData as RenderProxy2D).clipDepth).toBe(0);
584
+ expect(resolvedData.lastChildrenId).toBe(-1);
585
+ });
586
+ getRenderStateRuntime(state).colorAdjustmentResolver = resolver;
587
+
588
+ updateRenderProxy2D(state, root, data, undefined);
589
+
590
+ expect(resolver).toHaveBeenCalledWith(state, data, undefined);
591
+ expect(resolver).toHaveBeenCalledTimes(1);
592
+ expect(data.clipDepth).toBe(1);
593
+ expect(data.lastChildrenId).toBe(getNodeChildrenRevision(root));
594
+ });
464
595
  });
465
596
 
466
597
  describe('updateRenderProxyRenderer', () => {
@@ -1,6 +1,6 @@
1
1
  import { addNodeChild } from '@flighthq/node/contract';
2
2
  import { createDisplayObject } from '@flighthq/scene2d/contract';
3
- import { createSprite } from '@flighthq/sprite/contract';
3
+ import { createSprite } from '@flighthq/scene2d/contract';
4
4
  import type { Renderer, RenderProxy, RenderQueueEntry } from '@flighthq/types/contract';
5
5
 
6
6
  import { registerRenderer } from './renderer';
@@ -0,0 +1,94 @@
1
+ import { addLogSink, createMemoryLogSink, getMemoryLogSinkEntries, removeLogSink } from '@flighthq/log/contract';
2
+ import { emitSignal } from '@flighthq/signals/contract';
3
+ import { RenderRegistry } from '@flighthq/types/contract';
4
+
5
+ import { registerRenderer } from './renderer';
6
+ import { createRenderProxy } from './renderProxy';
7
+ import {
8
+ areRenderRegistryGuardsEnabled,
9
+ enableRenderRegistryGuards,
10
+ explainRenderRegistryMisses,
11
+ } from './renderRegistryGuards';
12
+ import { enableRenderRegistrySignals } from './renderRegistrySignals';
13
+ import { createRenderState } from './renderState';
14
+
15
+ describe('areRenderRegistryGuardsEnabled', () => {
16
+ it('reports whether the state-local registry guard is enabled', () => {
17
+ const state = createRenderState();
18
+ expect(areRenderRegistryGuardsEnabled(state)).toBe(false);
19
+ enableRenderRegistryGuards(state);
20
+ expect(areRenderRegistryGuardsEnabled(state)).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe('enableRenderRegistryGuards', () => {
25
+ it('is idempotent', () => {
26
+ const state = createRenderState();
27
+ enableRenderRegistryGuards(state);
28
+ enableRenderRegistryGuards(state);
29
+ expect(areRenderRegistryGuardsEnabled(state)).toBe(true);
30
+ });
31
+
32
+ it('warns once per state, registry, and kind', () => {
33
+ const firstState = createRenderState();
34
+ const secondState = createRenderState();
35
+ const sink = createMemoryLogSink(4);
36
+ addLogSink(sink.sink);
37
+ enableRenderRegistryGuards(firstState);
38
+ enableRenderRegistryGuards(secondState);
39
+ try {
40
+ createRenderProxy(firstState, { kind: 'acme.Missing' } as never);
41
+ createRenderProxy(firstState, { kind: 'acme.Missing' } as never);
42
+ createRenderProxy(secondState, { kind: 'acme.Missing' } as never);
43
+
44
+ const entries = getMemoryLogSinkEntries(sink);
45
+ expect(entries).toHaveLength(2);
46
+ expect(entries[0]?.data).toMatchObject({
47
+ kind: 'acme.Missing',
48
+ message:
49
+ 'createRenderProxy: node kind has no registered renderer — call registerRenderer(state, kind, renderer)',
50
+ registry: RenderRegistry.NodeRenderer,
51
+ });
52
+ } finally {
53
+ removeLogSink(sink.sink);
54
+ }
55
+ });
56
+
57
+ it('stays silent when the node renderer is registered', () => {
58
+ const state = createRenderState();
59
+ const sink = createMemoryLogSink(1);
60
+ addLogSink(sink.sink);
61
+ enableRenderRegistryGuards(state);
62
+ registerRenderer(state, 'acme.Registered', { createData: () => null, submit: () => {} });
63
+ try {
64
+ createRenderProxy(state, { kind: 'acme.Registered' } as never);
65
+ expect(getMemoryLogSinkEntries(sink)).toHaveLength(0);
66
+ } finally {
67
+ removeLogSink(sink.sink);
68
+ }
69
+ });
70
+ });
71
+
72
+ describe('explainRenderRegistryMisses', () => {
73
+ it('returns each recorded registry and kind pair once in event order', () => {
74
+ const state = createRenderState();
75
+ enableRenderRegistryGuards(state);
76
+ const signals = enableRenderRegistrySignals(state);
77
+ emitSignal(signals.onRegistryMiss, RenderRegistry.TextureResolver, 'acme.Texture');
78
+ emitSignal(signals.onRegistryMiss, RenderRegistry.TextureResolver, 'acme.Texture');
79
+ emitSignal(signals.onRegistryMiss, RenderRegistry.ShapeCommandHandler, 'acme.ShapeCommand');
80
+
81
+ expect(explainRenderRegistryMisses(state)).toEqual({
82
+ misses: [
83
+ { kind: 'acme.Texture', registry: RenderRegistry.TextureResolver },
84
+ { kind: 'acme.ShapeCommand', registry: RenderRegistry.ShapeCommandHandler },
85
+ ],
86
+ status: 'misses-recorded',
87
+ });
88
+ });
89
+
90
+ it('returns a complete empty explanation before any miss is recorded', () => {
91
+ const state = createRenderState();
92
+ expect(explainRenderRegistryMisses(state)).toEqual({ misses: [], status: 'complete' });
93
+ });
94
+ });
@@ -0,0 +1,15 @@
1
+ import { enableRenderRegistrySignals } from './renderRegistrySignals';
2
+ import { createRenderState, getRenderStateRuntime } from './renderState';
3
+
4
+ describe('enableRenderRegistrySignals', () => {
5
+ it('allocates one state-local registry-miss seam and reuses it', () => {
6
+ const state = createRenderState();
7
+ expect(getRenderStateRuntime(state).registryMiss).toBeNull();
8
+
9
+ const signals = enableRenderRegistrySignals(state);
10
+
11
+ expect(signals.onRegistryMiss).toBeDefined();
12
+ expect(getRenderStateRuntime(state).registryMiss?.signals).toBe(signals);
13
+ expect(enableRenderRegistrySignals(state)).toBe(signals);
14
+ });
15
+ });
@@ -2,7 +2,7 @@ import { createMatrix } from '@flighthq/geometry/contract';
2
2
  import type { RenderState } from '@flighthq/types/contract';
3
3
  import { BlendMode, EntityRuntimeKey } from '@flighthq/types/contract';
4
4
 
5
- import { createRenderState, createRenderStateRuntime, getRenderStateRuntime } from './renderState';
5
+ import { createRenderState, createRenderStateRuntime, destroyRenderState, getRenderStateRuntime } from './renderState';
6
6
 
7
7
  describe('createRenderState', () => {
8
8
  let state: RenderState;
@@ -69,6 +69,7 @@ describe('createRenderState', () => {
69
69
  describe('createRenderStateRuntime', () => {
70
70
  it('initializes the machinery fields', () => {
71
71
  const runtime = createRenderStateRuntime();
72
+ expect(runtime.colorAdjustmentResolver).toBeNull();
72
73
  expect(runtime.currentFrameId).toStrictEqual(0);
73
74
  expect(runtime.renderProxyMap).toStrictEqual(new WeakMap());
74
75
  expect(runtime.renderProxyAdapterMap).toStrictEqual(new WeakMap());
@@ -85,6 +86,20 @@ describe('createRenderStateRuntime', () => {
85
86
  });
86
87
  });
87
88
 
89
+ describe('destroyRenderState', () => {
90
+ it('clears state-owned traversal and registration storage', () => {
91
+ const state = createRenderState();
92
+ const runtime = getRenderStateRuntime(state);
93
+ runtime.tempStack.push({} as never);
94
+ runtime.renderEffectPaddingResolverRegistry = new Map();
95
+
96
+ destroyRenderState(state);
97
+
98
+ expect(runtime.tempStack).toHaveLength(0);
99
+ expect(runtime.renderEffectPaddingResolverRegistry).toBeNull();
100
+ });
101
+ });
102
+
88
103
  describe('getRenderStateRuntime', () => {
89
104
  it('returns the runtime attached under EntityRuntimeKey', () => {
90
105
  const state = createRenderState();
@@ -1,10 +1,13 @@
1
- import { createMatrix, createRectangle } from '@flighthq/geometry/contract';
1
+ import { createMatrix, createRectangle, multiplyMatrix } from '@flighthq/geometry/contract';
2
+ import { getNodeLocalMatrix } from '@flighthq/node/contract';
2
3
  import { createDisplayObject } from '@flighthq/scene2d/contract';
3
4
 
4
5
  import {
5
- computeScene2DRenderTargetTransform,
6
6
  computeRenderCacheTransform,
7
7
  computeRenderTargetSize,
8
+ computeScene2DRenderTargetTransform,
9
+ explainRenderTargetAxes,
10
+ resolveRenderTargetDescriptor,
8
11
  } from './renderTarget';
9
12
 
10
13
  describe('computeRenderCacheTransform', () => {
@@ -40,6 +43,14 @@ describe('computeRenderTargetSize', () => {
40
43
  expect(result.height).toBe(88);
41
44
  });
42
45
 
46
+ it('adds directional padding on its corresponding sides', () => {
47
+ const result = computeRenderTargetSize(
48
+ { x: -3, y: 4, width: 100, height: 80 },
49
+ { bottom: 7, left: 2, right: 5, top: 3 },
50
+ );
51
+ expect(result).toEqual({ width: 107, height: 90 });
52
+ });
53
+
43
54
  it('respects minWidth and minHeight', () => {
44
55
  const result = computeRenderTargetSize({ x: 0, y: 0, width: 0, height: 0 }, 0, 32, 16);
45
56
  expect(result.width).toBe(32);
@@ -73,12 +84,109 @@ describe('computeScene2DRenderTargetTransform', () => {
73
84
  expect(out2.tx).not.toBe(out1.tx);
74
85
  });
75
86
 
76
- it('does not throw for a non-identity local transform', () => {
87
+ it('cancels a transformed detached root while retaining the content-origin translation', () => {
77
88
  const obj = createDisplayObject();
78
89
  obj.x = 50;
79
90
  obj.y = 30;
80
- const bounds = createRectangle(50, 30, 100, 80);
91
+ obj.rotation = 31;
92
+ obj.scaleX = 2;
93
+ obj.scaleY = 0.5;
94
+ const bounds = createRectangle(-12, -7, 100, 80);
81
95
  const out = createMatrix();
82
- expect(() => computeScene2DRenderTargetTransform(out, obj, bounds)).not.toThrow();
96
+ computeScene2DRenderTargetTransform(out, obj, bounds, 9, 4);
97
+ const composed = createMatrix();
98
+ multiplyMatrix(composed, out, getNodeLocalMatrix(obj));
99
+
100
+ expect(composed.a).toBeCloseTo(1);
101
+ expect(composed.b).toBeCloseTo(0);
102
+ expect(composed.c).toBeCloseTo(0);
103
+ expect(composed.d).toBeCloseTo(1);
104
+ expect(composed.tx).toBeCloseTo(21);
105
+ expect(composed.ty).toBeCloseTo(11);
106
+ });
107
+ });
108
+
109
+ describe('resolveRenderTargetDescriptor', () => {
110
+ it('resolves every optional target axis and clear policy', () => {
111
+ expect(resolveRenderTargetDescriptor({ width: 64, height: 48 })).toEqual({
112
+ width: 64,
113
+ height: 48,
114
+ format: 'rgba8',
115
+ colorAttachments: 1,
116
+ colorFormats: ['rgba8'],
117
+ sampleCount: 1,
118
+ depth: 'none',
119
+ colorSpace: 'srgb',
120
+ clearColors: [],
121
+ clearDepth: 1,
122
+ });
123
+ });
124
+
125
+ describe('explainRenderTargetAxes', () => {
126
+ it('reports every changed axis in stable descriptor order', () => {
127
+ const requested = resolveRenderTargetDescriptor({
128
+ width: 64,
129
+ height: 48,
130
+ format: 'rgba16f',
131
+ colorAttachments: 2,
132
+ sampleCount: 8,
133
+ depth: 'depth-stencil-sampled',
134
+ });
135
+ const effective = {
136
+ ...requested,
137
+ format: 'rgba8' as const,
138
+ colorFormats: ['rgba8', 'rgba8'] as const,
139
+ sampleCount: 4,
140
+ depth: 'depth-stencil' as const,
141
+ };
142
+
143
+ expect(explainRenderTargetAxes(requested, effective)).toEqual([
144
+ { axis: 'format', effective: 'rgba8', requested: 'rgba16f' },
145
+ { axis: 'colorFormats', effective: ['rgba8', 'rgba8'], requested: ['rgba16f', 'rgba16f'] },
146
+ { axis: 'sampleCount', effective: 4, requested: 8 },
147
+ { axis: 'depth', effective: 'depth-stencil', requested: 'depth-stencil-sampled' },
148
+ ]);
149
+ });
150
+
151
+ it('returns no differences for identical canonical axes', () => {
152
+ const axes = resolveRenderTargetDescriptor({ width: 64, height: 48 });
153
+ expect(explainRenderTargetAxes(axes, { ...axes, colorFormats: [...axes.colorFormats] })).toEqual([]);
154
+ });
155
+ });
156
+
157
+ it('normalizes dimensions, attachment count, and sample count once', () => {
158
+ expect(
159
+ resolveRenderTargetDescriptor({
160
+ width: 10.2,
161
+ height: 0,
162
+ colorAttachments: 1.2,
163
+ sampleCount: 3.1,
164
+ }),
165
+ ).toMatchObject({
166
+ width: 11,
167
+ height: 1,
168
+ colorAttachments: 2,
169
+ sampleCount: 4,
170
+ });
171
+ });
172
+
173
+ it('expands heterogeneous color formats across every requested attachment', () => {
174
+ const resolved = resolveRenderTargetDescriptor({
175
+ width: 64,
176
+ height: 48,
177
+ format: 'rgba16f',
178
+ colorAttachments: 3,
179
+ colorFormats: ['rgba8', 'rgba32f'],
180
+ });
181
+
182
+ expect(resolved.format).toBe('rgba8');
183
+ expect(resolved.colorFormats).toEqual(['rgba8', 'rgba32f', 'rgba16f']);
184
+ });
185
+
186
+ it('copies caller-owned clear arrays', () => {
187
+ const clearColors = [0xff0000ff];
188
+ const resolved = resolveRenderTargetDescriptor({ width: 64, height: 48, clearColors });
189
+ clearColors[0] = 0;
190
+ expect(resolved.clearColors).toEqual([0xff0000ff]);
83
191
  });
84
192
  });
@@ -1,3 +1,4 @@
1
+ import { createMatrix } from '@flighthq/geometry/contract';
1
2
  import { createDisplayObject } from '@flighthq/scene2d/contract';
2
3
 
3
4
  import { createRenderProxy2D } from './renderProxy';
@@ -13,13 +14,26 @@ describe('updateRenderProxy2DTransform', () => {
13
14
  });
14
15
 
15
16
  it('returns false when nothing changed', () => {
16
- const state = createRenderState();
17
+ const state = createRenderState({ sceneGraphSyncPolicy: 'requiresInvalidation' });
17
18
  const obj = createDisplayObject();
18
19
  const data = createRenderProxy2D(state, obj);
19
20
  updateRenderProxy2DTransform(state, data);
20
21
  expect(updateRenderProxy2DTransform(state, data)).toBe(false);
21
22
  });
22
23
 
24
+ it('refreshes an unchanged root node when the render-state transform changes', () => {
25
+ const state = createRenderState();
26
+ const obj = createDisplayObject();
27
+ const data = createRenderProxy2D(state, obj);
28
+
29
+ updateRenderProxy2DTransform(state, data);
30
+ state.renderTransform2D = createMatrix(2, 0, 0, 2, 17, 0);
31
+
32
+ expect(updateRenderProxy2DTransform(state, data)).toBe(true);
33
+ expect(data.transform2D.a).toBe(2);
34
+ expect(data.transform2D.tx).toBe(17);
35
+ });
36
+
23
37
  it('returns true when parent was updated this frame', () => {
24
38
  const state = createRenderState();
25
39
  getRenderStateRuntime(state).currentFrameId = 2;
@@ -5,7 +5,7 @@ import {
5
5
  invalidateNodeLocalBounds,
6
6
  invalidateNodeLocalTransform,
7
7
  } from '@flighthq/node/contract';
8
- import { createBitmap, createDisplayObject } from '@flighthq/scene2d/contract';
8
+ import { createSprite, createDisplayObject } from '@flighthq/scene2d/contract';
9
9
  import type { HasBoundsRectangleRuntime, RenderProxy2D, RenderViewport2D } from '@flighthq/types/contract';
10
10
 
11
11
  import { createRenderProxy2D, prepareScene2DRender } from './renderProxy';
@@ -1,7 +1,8 @@
1
- import type { Scene2DClipHooks, Renderer, RenderState } from '@flighthq/types/contract';
1
+ import type { CanvasShapeCommand, Scene2DClipHooks, Renderer, RenderState } from '@flighthq/types/contract';
2
2
 
3
3
  import {
4
4
  copyAllRenderersFromRenderState,
5
+ copyRenderStateRegistrations,
5
6
  copyRenderersFromRenderState,
6
7
  noopRendererData,
7
8
  registerRenderer,
@@ -68,6 +69,48 @@ describe('copyRenderersFromRenderState', () => {
68
69
  });
69
70
  });
70
71
 
72
+ describe('copyRenderStateRegistrations', () => {
73
+ it('snapshot-copies effect-padding registrations without aliasing the maps', () => {
74
+ const source = createRenderState();
75
+ const target = createRenderState();
76
+ const resolver = vi.fn();
77
+ const colorAdjustmentResolver = vi.fn();
78
+ getRenderStateRuntime(source).colorAdjustmentResolver = colorAdjustmentResolver;
79
+ getRenderStateRuntime(source).renderEffectPaddingResolverRegistry = new Map([['acme.Effect', resolver]]);
80
+
81
+ copyRenderStateRegistrations(target, source);
82
+
83
+ expect(getRenderStateRuntime(target).colorAdjustmentResolver).toBe(colorAdjustmentResolver);
84
+ expect(getRenderStateRuntime(target).renderEffectPaddingResolverRegistry).not.toBe(
85
+ getRenderStateRuntime(source).renderEffectPaddingResolverRegistry,
86
+ );
87
+ expect(getRenderStateRuntime(target).renderEffectPaddingResolverRegistry?.get('acme.Effect')).toBe(resolver);
88
+ });
89
+
90
+ it('snapshot-copies the shape-command registry without aliasing the map', () => {
91
+ const source = createRenderState();
92
+ const target = createRenderState();
93
+ const command = { key: 'beginFill', draw: vi.fn() } as unknown as CanvasShapeCommand;
94
+ getRenderStateRuntime(source).canvasShapeCommandRegistry = new Map([['beginFill', command]]);
95
+
96
+ copyRenderStateRegistrations(target, source);
97
+
98
+ expect(getRenderStateRuntime(target).canvasShapeCommandRegistry).not.toBe(
99
+ getRenderStateRuntime(source).canvasShapeCommandRegistry,
100
+ );
101
+ expect(getRenderStateRuntime(target).canvasShapeCommandRegistry?.get('beginFill')).toBe(command);
102
+ });
103
+
104
+ it('leaves the shape-command registry null when the source never registered one', () => {
105
+ const source = createRenderState();
106
+ const target = createRenderState();
107
+
108
+ copyRenderStateRegistrations(target, source);
109
+
110
+ expect(getRenderStateRuntime(target).canvasShapeCommandRegistry).toBeNull();
111
+ });
112
+ });
113
+
71
114
  describe('noopRendererData', () => {
72
115
  it('returns null', () => {
73
116
  const state = createRenderState();
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { SCENE2D_WORKING_COLOR_SPACE } from './scene2dWorkingColorSpace';
4
+
5
+ describe('SCENE2D_WORKING_COLOR_SPACE', () => {
6
+ // The 2D tower composites in the encoded domain — byte-through, no decode, no encode. Changing this
7
+ // is a rendered-output change for every 2D scene, not a refactor.
8
+ it('declares the 2D tower encoded, matching its byte-through pipeline', () => {
9
+ expect(SCENE2D_WORKING_COLOR_SPACE).toBe('srgb');
10
+ });
11
+ });