@flighthq/node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/boundsRectangle.d.ts +26 -0
  2. package/dist/boundsRectangle.d.ts.map +1 -0
  3. package/dist/boundsRectangle.js +160 -0
  4. package/dist/boundsRectangle.js.map +1 -0
  5. package/dist/hasAppearance.d.ts +3 -0
  6. package/dist/hasAppearance.d.ts.map +1 -0
  7. package/dist/hasAppearance.js +6 -0
  8. package/dist/hasAppearance.js.map +1 -0
  9. package/dist/hasBoundsRectangle.d.ts +5 -0
  10. package/dist/hasBoundsRectangle.d.ts.map +1 -0
  11. package/dist/hasBoundsRectangle.js +9 -0
  12. package/dist/hasBoundsRectangle.js.map +1 -0
  13. package/dist/hasClip.d.ts +3 -0
  14. package/dist/hasClip.d.ts.map +1 -0
  15. package/dist/hasClip.js +4 -0
  16. package/dist/hasClip.js.map +1 -0
  17. package/dist/hasMaterial.d.ts +3 -0
  18. package/dist/hasMaterial.d.ts.map +1 -0
  19. package/dist/hasMaterial.js +5 -0
  20. package/dist/hasMaterial.js.map +1 -0
  21. package/dist/hasTransform2d.d.ts +4 -0
  22. package/dist/hasTransform2d.d.ts.map +1 -0
  23. package/dist/hasTransform2d.js +19 -0
  24. package/dist/hasTransform2d.js.map +1 -0
  25. package/dist/hasTransform3d.d.ts +5 -0
  26. package/dist/hasTransform3d.d.ts.map +1 -0
  27. package/dist/hasTransform3d.js +8 -0
  28. package/dist/hasTransform3d.js.map +1 -0
  29. package/dist/hierarchy.d.ts +127 -0
  30. package/dist/hierarchy.d.ts.map +1 -0
  31. package/dist/hierarchy.js +396 -0
  32. package/dist/hierarchy.js.map +1 -0
  33. package/dist/index.d.ts +15 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +15 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/node.d.ts +21 -0
  38. package/dist/node.d.ts.map +1 -0
  39. package/dist/node.js +109 -0
  40. package/dist/node.js.map +1 -0
  41. package/dist/revision.d.ts +40 -0
  42. package/dist/revision.d.ts.map +1 -0
  43. package/dist/revision.js +85 -0
  44. package/dist/revision.js.map +1 -0
  45. package/dist/transform2d.d.ts +16 -0
  46. package/dist/transform2d.d.ts.map +1 -0
  47. package/dist/transform2d.js +99 -0
  48. package/dist/transform2d.js.map +1 -0
  49. package/dist/transform3d.d.ts +6 -0
  50. package/dist/transform3d.d.ts.map +1 -0
  51. package/dist/transform3d.js +45 -0
  52. package/dist/transform3d.js.map +1 -0
  53. package/dist/traversal.d.ts +51 -0
  54. package/dist/traversal.d.ts.map +1 -0
  55. package/dist/traversal.js +130 -0
  56. package/dist/traversal.js.map +1 -0
  57. package/dist/viewport.d.ts +8 -0
  58. package/dist/viewport.d.ts.map +1 -0
  59. package/dist/viewport.js +78 -0
  60. package/dist/viewport.js.map +1 -0
  61. package/package.json +40 -0
  62. package/src/boundsRectangle.test.ts +497 -0
  63. package/src/hasAppearance.test.ts +47 -0
  64. package/src/hasBoundsRectangle.test.ts +57 -0
  65. package/src/hasClip.test.ts +27 -0
  66. package/src/hasMaterial.test.ts +30 -0
  67. package/src/hasTransform2d.test.ts +67 -0
  68. package/src/hasTransform3d.test.ts +37 -0
  69. package/src/hierarchy.test.ts +973 -0
  70. package/src/node.test.ts +265 -0
  71. package/src/revision.test.ts +211 -0
  72. package/src/transform2d.test.ts +391 -0
  73. package/src/transform3d.test.ts +202 -0
  74. package/src/traversal.test.ts +233 -0
  75. package/src/viewport.test.ts +228 -0
@@ -0,0 +1,391 @@
1
+ import { getEntityRuntime } from '@flighthq/entity';
2
+ import { cloneMatrix, createMatrix, createVector2, equalsMatrix, multiplyMatrix, setMatrix } from '@flighthq/geometry';
3
+ import { addNodeChild, createNode } from '@flighthq/node';
4
+ import type { HasTransform2D, HasTransform2DRuntime, Matrix, Node, NodeRuntime } from '@flighthq/types';
5
+
6
+ import { initTransform2DRuntimeTrait, initTransform2DTrait } from './hasTransform2d';
7
+ import { invalidateNodeLocalTransform } from './revision';
8
+ import {
9
+ convertNodeVector2GlobalToLocal,
10
+ convertNodeVector2LocalToGlobal,
11
+ ensureNodeLocalTransformMatrix,
12
+ ensureNodeWorldTransformMatrix,
13
+ getNodeLocalTransformMatrix,
14
+ getNodeWorldTransformMatrix,
15
+ } from './transform2d';
16
+
17
+ function createTestNode(): TestNode {
18
+ const node = createNode(TestKind) as TestNode;
19
+ const runtime = getEntityRuntime(node);
20
+ initTransform2DTrait(node);
21
+ initTransform2DRuntimeTrait(runtime as HasTransform2DRuntime);
22
+ return node;
23
+ }
24
+
25
+ let node: TestNode;
26
+ beforeEach(() => {
27
+ node = createTestNode();
28
+ });
29
+
30
+ describe('convertNodeVector2GlobalToLocal', () => {
31
+ let obj: TestNode;
32
+
33
+ beforeEach(() => {
34
+ obj = createTestNode();
35
+ addNodeChild(createTestNode(), obj);
36
+ obj.x = 10;
37
+ obj.y = 20;
38
+ obj.scaleX = 2;
39
+ obj.scaleY = 2;
40
+ obj.rotation = 0;
41
+ invalidateNodeLocalTransform(obj);
42
+ });
43
+
44
+ it('writes into the provided output Vector2', () => {
45
+ const out = createVector2();
46
+ const world = createVector2(14, 24);
47
+
48
+ convertNodeVector2GlobalToLocal(out, obj, world);
49
+
50
+ expect(out.x).toBeCloseTo(2);
51
+ expect(out.y).toBeCloseTo(2);
52
+ });
53
+
54
+ it('reuses the output object', () => {
55
+ const out = createVector2(999, 999);
56
+ convertNodeVector2GlobalToLocal(out, obj, createVector2(10, 20));
57
+
58
+ expect(out).toEqual(expect.objectContaining({ x: 0, y: 0 }));
59
+ });
60
+
61
+ it('updates the world transform before conversion', () => {
62
+ // const spy = vi.spyOn(obj, updateWorldTransform);
63
+ // globalToLocal(createVector2(), obj, createVector2());
64
+ // expect(spy).toHaveBeenCalled();
65
+ // spy.mockRestore();
66
+ });
67
+
68
+ it('allows vector-like objects', () => {
69
+ const out = { x: 0, y: 0 };
70
+ const world = { x: 14, y: 24 };
71
+
72
+ convertNodeVector2GlobalToLocal(out, obj, world);
73
+
74
+ expect(out.x).toBeCloseTo(2);
75
+ expect(out.y).toBeCloseTo(2);
76
+ });
77
+ });
78
+
79
+ describe('convertNodeVector2LocalToGlobal', () => {
80
+ let obj: TestNode;
81
+
82
+ beforeEach(() => {
83
+ obj = createTestNode();
84
+ });
85
+
86
+ it('writes to out parameter', () => {
87
+ const local = createVector2(5, 5);
88
+ const out = createVector2();
89
+
90
+ convertNodeVector2LocalToGlobal(out, obj, local);
91
+
92
+ expect(out.x).toBe(5);
93
+ expect(out.y).toBe(5);
94
+ expect(out).not.toBe(local); // out is a separate object
95
+ });
96
+
97
+ it('respects world transform', () => {
98
+ obj.x = 50;
99
+ obj.y = 30;
100
+ invalidateNodeLocalTransform(obj);
101
+
102
+ const local = createVector2(10, 20);
103
+ const out = createVector2();
104
+
105
+ convertNodeVector2LocalToGlobal(out, obj, local);
106
+
107
+ expect(out.x).toBe(60); // 50 + 10
108
+ expect(out.y).toBe(50); // 30 + 20
109
+ });
110
+
111
+ it('produces independent results from multiple points', () => {
112
+ obj.x = 1;
113
+ obj.y = 2;
114
+ invalidateNodeLocalTransform(obj);
115
+
116
+ const p1 = createVector2(1, 1);
117
+ const p2 = createVector2(2, 2);
118
+
119
+ const g1 = createVector2();
120
+ convertNodeVector2LocalToGlobal(g1, obj, p1);
121
+ const g2 = createVector2();
122
+ convertNodeVector2LocalToGlobal(g2, obj, p2);
123
+
124
+ expect(g1.x).toBe(2);
125
+ expect(g1.y).toBe(3);
126
+ expect(g2.x).toBe(3);
127
+ expect(g2.y).toBe(4);
128
+ });
129
+
130
+ it('allows vector-like objects', () => {
131
+ const local = { x: 5, y: 5 };
132
+ const out = { x: 0, y: 0 };
133
+
134
+ convertNodeVector2LocalToGlobal(out, obj, local);
135
+
136
+ expect(out.x).toBe(5);
137
+ expect(out.y).toBe(5);
138
+ expect(out).not.toBe(local); // out is a separate object
139
+ });
140
+ });
141
+
142
+ describe('ensureNodeLocalTransformMatrix', () => {
143
+ it('computes local transform the first time', () => {
144
+ const runtime = getEntityRuntime(node) as HasTransform2DRuntime;
145
+ expect(runtime.localTransform2D).toBeNull();
146
+ ensureNodeLocalTransformMatrix(node);
147
+ expect(runtime.localTransform2D).not.toBeNull();
148
+ });
149
+
150
+ it('recomputes if the local transform ID has changed', () => {
151
+ const runtime = getEntityRuntime(node) as NodeRuntime<HasTransform2D> & HasTransform2DRuntime;
152
+ ensureNodeLocalTransformMatrix(node);
153
+ const cache = cloneAndInvalidateMatrix(runtime.localTransform2D);
154
+ runtime.localTransformId++;
155
+ ensureNodeLocalTransformMatrix(node);
156
+ expect(equalsMatrix(runtime.localTransform2D, cache)).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe('ensureNodeWorldTransformMatrix', () => {
161
+ it('computes world transform the first time', () => {
162
+ const runtime = getEntityRuntime(node) as NodeRuntime<HasTransform2D> & HasTransform2DRuntime;
163
+ expect(runtime.worldTransform2D).toBeNull();
164
+ ensureNodeWorldTransformMatrix(node);
165
+ expect(runtime.worldTransform2D).not.toBeNull();
166
+ });
167
+
168
+ it('computes world transform for a parent for the first time', () => {
169
+ const parent = createTestNode();
170
+ addNodeChild(parent, node);
171
+ const parentState = getEntityRuntime(node) as HasTransform2DRuntime;
172
+ expect(parentState.worldTransform2D).toBeNull();
173
+ ensureNodeWorldTransformMatrix(node);
174
+ expect(parentState.worldTransform2D).not.toBeNull();
175
+ });
176
+
177
+ it('recomputes if the local transform ID has changed', () => {
178
+ const runtime = getEntityRuntime(node) as NodeRuntime<HasTransform2D> & HasTransform2DRuntime;
179
+ ensureNodeWorldTransformMatrix(node);
180
+ const cache = cloneAndInvalidateMatrix(runtime.worldTransform2D);
181
+ runtime.localTransformId++;
182
+ ensureNodeWorldTransformMatrix(node);
183
+ expect(equalsMatrix(runtime.worldTransform2D, cache)).toBe(true);
184
+ });
185
+
186
+ it('recomputes if the parent transform ID has changed', () => {
187
+ const parent = createTestNode();
188
+ addNodeChild(parent, node);
189
+ const runtime = getEntityRuntime(node) as HasTransform2DRuntime;
190
+ const parentState = getEntityRuntime(parent) as NodeRuntime<HasTransform2D> & HasTransform2DRuntime;
191
+ ensureNodeWorldTransformMatrix(node);
192
+ const cache = cloneAndInvalidateMatrix(runtime.worldTransform2D);
193
+ parentState.worldTransformId++;
194
+ ensureNodeWorldTransformMatrix(node);
195
+ expect(equalsMatrix(runtime.worldTransform2D, cache)).toBe(true);
196
+ });
197
+ });
198
+
199
+ describe('getNodeLocalTransformMatrix', () => {
200
+ it('ensures local transform', () => {
201
+ const runtime = getEntityRuntime(node) as HasTransform2DRuntime;
202
+ expect(runtime.localTransform2D).toBeNull();
203
+ getNodeLocalTransformMatrix(node);
204
+ expect(runtime.localTransform2D).not.toBeNull();
205
+ });
206
+
207
+ it('returns local transform', () => {
208
+ const transform = getNodeLocalTransformMatrix(node);
209
+ expect(transform).equals((getEntityRuntime(node) as HasTransform2DRuntime).localTransform2D);
210
+ });
211
+
212
+ it('offsets translation by the pivot so the pivot point lands at (x, y)', () => {
213
+ node.x = 100;
214
+ node.y = 50;
215
+ node.pivotX = 10;
216
+ node.pivotY = 20;
217
+ const transform = getNodeLocalTransformMatrix(node);
218
+ expect(transform.tx).toBeCloseTo(90); // 100 - 1 * 10
219
+ expect(transform.ty).toBeCloseTo(30); // 50 - 1 * 20
220
+ });
221
+
222
+ it('scales the pivot offset with scaleX/scaleY', () => {
223
+ node.scaleX = 2;
224
+ node.scaleY = 3;
225
+ node.pivotX = 10;
226
+ node.pivotY = 10;
227
+ const transform = getNodeLocalTransformMatrix(node);
228
+ expect(transform.tx).toBeCloseTo(-20); // 0 - 2 * 10
229
+ expect(transform.ty).toBeCloseTo(-30); // 0 - 3 * 10
230
+ });
231
+ });
232
+
233
+ describe('getNodeWorldTransformMatrix', () => {
234
+ it('ensures world transform', () => {
235
+ const runtime = getEntityRuntime(node) as HasTransform2DRuntime;
236
+ expect(runtime.worldTransform2D).toBeNull();
237
+ getNodeWorldTransformMatrix(node);
238
+ expect(runtime.worldTransform2D).not.toBeNull();
239
+ });
240
+
241
+ it('returns local transform', () => {
242
+ const transform = getNodeWorldTransformMatrix(node);
243
+ expect(transform).equals((getEntityRuntime(node) as HasTransform2DRuntime).worldTransform2D);
244
+ });
245
+ });
246
+
247
+ describe('skew', () => {
248
+ const DEG_TO_RAD = Math.PI / 180;
249
+
250
+ it('produces the same matrix with skewX=0 and skewY=0 as without skew', () => {
251
+ const withSkew = createTestNode();
252
+ withSkew.rotation = 30;
253
+ withSkew.scaleX = 2;
254
+ withSkew.scaleY = 3;
255
+ withSkew.skewX = 0;
256
+ withSkew.skewY = 0;
257
+ invalidateNodeLocalTransform(withSkew);
258
+
259
+ const withoutSkew = createTestNode();
260
+ withoutSkew.rotation = 30;
261
+ withoutSkew.scaleX = 2;
262
+ withoutSkew.scaleY = 3;
263
+ invalidateNodeLocalTransform(withoutSkew);
264
+
265
+ const mSkew = getNodeLocalTransformMatrix(withSkew);
266
+ const mNoSkew = getNodeLocalTransformMatrix(withoutSkew);
267
+ expect(mSkew.a).toBeCloseTo(mNoSkew.a);
268
+ expect(mSkew.b).toBeCloseTo(mNoSkew.b);
269
+ expect(mSkew.c).toBeCloseTo(mNoSkew.c);
270
+ expect(mSkew.d).toBeCloseTo(mNoSkew.d);
271
+ expect(mSkew.tx).toBeCloseTo(mNoSkew.tx);
272
+ expect(mSkew.ty).toBeCloseTo(mNoSkew.ty);
273
+ });
274
+
275
+ it('applies isolated skewX to c and d cells without affecting a and b', () => {
276
+ node.skewX = 45;
277
+ node.skewY = 0;
278
+ node.scaleX = 1;
279
+ node.scaleY = 1;
280
+ node.rotation = 0;
281
+ invalidateNodeLocalTransform(node);
282
+
283
+ const m = getNodeLocalTransformMatrix(node);
284
+ const radX = 45 * DEG_TO_RAD;
285
+ expect(m.a).toBeCloseTo(1);
286
+ expect(m.b).toBeCloseTo(0);
287
+ expect(m.c).toBeCloseTo(-Math.sin(radX));
288
+ expect(m.d).toBeCloseTo(Math.cos(radX));
289
+ });
290
+
291
+ it('applies isolated skewY to a and b cells without affecting c and d', () => {
292
+ node.skewX = 0;
293
+ node.skewY = 30;
294
+ node.scaleX = 1;
295
+ node.scaleY = 1;
296
+ node.rotation = 0;
297
+ invalidateNodeLocalTransform(node);
298
+
299
+ const m = getNodeLocalTransformMatrix(node);
300
+ const radY = 30 * DEG_TO_RAD;
301
+ expect(m.a).toBeCloseTo(Math.cos(radY));
302
+ expect(m.b).toBeCloseTo(Math.sin(radY));
303
+ expect(m.c).toBeCloseTo(0);
304
+ expect(m.d).toBeCloseTo(1);
305
+ });
306
+
307
+ it('computes all four matrix cells with combined skew and rotation', () => {
308
+ node.rotation = 45;
309
+ node.skewX = 15;
310
+ node.skewY = 20;
311
+ node.scaleX = 1;
312
+ node.scaleY = 1;
313
+ invalidateNodeLocalTransform(node);
314
+
315
+ const m = getNodeLocalTransformMatrix(node);
316
+ const radY = (45 + 20) * DEG_TO_RAD;
317
+ const radX = (45 + 15) * DEG_TO_RAD;
318
+ expect(m.a).toBeCloseTo(Math.cos(radY));
319
+ expect(m.b).toBeCloseTo(Math.sin(radY));
320
+ expect(m.c).toBeCloseTo(-Math.sin(radX));
321
+ expect(m.d).toBeCloseTo(Math.cos(radX));
322
+ });
323
+
324
+ it('propagates skew through world transform', () => {
325
+ const parent = createTestNode();
326
+ parent.skewX = 10;
327
+ parent.skewY = 20;
328
+ parent.scaleX = 2;
329
+ parent.scaleY = 1;
330
+ invalidateNodeLocalTransform(parent);
331
+
332
+ const child = createTestNode();
333
+ child.x = 50;
334
+ child.scaleX = 1;
335
+ child.scaleY = 1;
336
+ invalidateNodeLocalTransform(child);
337
+
338
+ addNodeChild(parent, child);
339
+
340
+ const parentLocal = getNodeLocalTransformMatrix(parent);
341
+ const childLocal = getNodeLocalTransformMatrix(child);
342
+ const expected = createMatrix();
343
+ multiplyMatrix(expected, parentLocal, childLocal);
344
+
345
+ const world = getNodeWorldTransformMatrix(child);
346
+ expect(world.a).toBeCloseTo(expected.a);
347
+ expect(world.b).toBeCloseTo(expected.b);
348
+ expect(world.c).toBeCloseTo(expected.c);
349
+ expect(world.d).toBeCloseTo(expected.d);
350
+ expect(world.tx).toBeCloseTo(expected.tx);
351
+ expect(world.ty).toBeCloseTo(expected.ty);
352
+ });
353
+
354
+ it('computes correct tx/ty when both pivot and skew are nonzero', () => {
355
+ node.skewX = 30;
356
+ node.skewY = 45;
357
+ node.scaleX = 2;
358
+ node.scaleY = 3;
359
+ node.pivotX = 10;
360
+ node.pivotY = 20;
361
+ node.x = 100;
362
+ node.y = 50;
363
+ node.rotation = 0;
364
+ invalidateNodeLocalTransform(node);
365
+
366
+ const m = getNodeLocalTransformMatrix(node);
367
+ const radY = 45 * DEG_TO_RAD;
368
+ const radX = 30 * DEG_TO_RAD;
369
+ const a = Math.cos(radY) * 2;
370
+ const b = Math.sin(radY) * 2;
371
+ const c = -Math.sin(radX) * 3;
372
+ const d = Math.cos(radX) * 3;
373
+ expect(m.a).toBeCloseTo(a);
374
+ expect(m.b).toBeCloseTo(b);
375
+ expect(m.c).toBeCloseTo(c);
376
+ expect(m.d).toBeCloseTo(d);
377
+ expect(m.tx).toBeCloseTo(100 - (a * 10 + c * 20));
378
+ expect(m.ty).toBeCloseTo(50 - (b * 10 + d * 20));
379
+ });
380
+ });
381
+
382
+ function cloneAndInvalidateMatrix(matrix: Matrix | null): Matrix | null {
383
+ if (matrix === null) return null;
384
+ const clone = cloneMatrix(matrix);
385
+ setMatrix(matrix, -1, -1, -1, -1, -1, -1);
386
+ return clone;
387
+ }
388
+
389
+ type TestNode = Node<HasTransform2D> & HasTransform2D;
390
+
391
+ const TestKind = 'Test';
@@ -0,0 +1,202 @@
1
+ import { setMatrix4Identity } from '@flighthq/geometry';
2
+ import type { HasTransform3D, HasTransform3DRuntime, NodeRuntime, Transform3DNode, Vector3Like } from '@flighthq/types';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { initTransform3DRuntimeTrait, initTransform3DTrait } from './hasTransform3d';
6
+ import { addNodeChild } from './hierarchy';
7
+ import { createNode, getNodeRuntime } from './node';
8
+ import { invalidateNodeLocalTransform } from './revision';
9
+ import {
10
+ convertNodeVector3GlobalToLocal,
11
+ convertNodeVector3LocalToGlobal,
12
+ ensureNodeWorldTransformMatrix4,
13
+ getNodeWorldTransformMatrix4,
14
+ } from './transform3d';
15
+
16
+ const TestNodeKind = 'TestNode';
17
+
18
+ interface TestTraits extends HasTransform3D {}
19
+ type TestNode = Transform3DNode<TestTraits>;
20
+
21
+ function createTestNode(): TestNode {
22
+ const node = createNode<TestTraits>(TestNodeKind);
23
+ initTransform3DTrait(node);
24
+ initTransform3DRuntimeTrait(getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime);
25
+ return node as TestNode;
26
+ }
27
+
28
+ function vec(): Vector3Like {
29
+ return { x: 0, y: 0, z: 0 } as Vector3Like;
30
+ }
31
+
32
+ describe('convertNodeVector3GlobalToLocal', () => {
33
+ it('returns the point unchanged for an identity root node', () => {
34
+ const node = createTestNode();
35
+ const out = vec();
36
+ convertNodeVector3GlobalToLocal(out, node, { x: 4, y: 5, z: 6 } as Vector3Like);
37
+ expect(out.x).toBeCloseTo(4);
38
+ expect(out.y).toBeCloseTo(5);
39
+ expect(out.z).toBeCloseTo(6);
40
+ });
41
+
42
+ it('inverts the node translation', () => {
43
+ const node = createTestNode();
44
+ node.localMatrix.m[12] = 10;
45
+ node.localMatrix.m[13] = 20;
46
+ node.localMatrix.m[14] = 30;
47
+ invalidateNodeLocalTransform(node);
48
+
49
+ const out = vec();
50
+ convertNodeVector3GlobalToLocal(out, node, { x: 11, y: 21, z: 31 } as Vector3Like);
51
+ expect(out.x).toBeCloseTo(1);
52
+ expect(out.y).toBeCloseTo(1);
53
+ expect(out.z).toBeCloseTo(1);
54
+ });
55
+
56
+ it('round-trips with convertNodeVector3LocalToGlobal', () => {
57
+ const parent = createTestNode();
58
+ const child = createTestNode();
59
+ addNodeChild(parent, child);
60
+
61
+ parent.localMatrix.m[12] = 7;
62
+ parent.localMatrix.m[13] = -2;
63
+ invalidateNodeLocalTransform(parent);
64
+ child.localMatrix.m[14] = 4;
65
+ invalidateNodeLocalTransform(child);
66
+
67
+ const local = { x: 1.5, y: 2.5, z: 3.5 } as Vector3Like;
68
+ const world = vec();
69
+ convertNodeVector3LocalToGlobal(world, child, local);
70
+
71
+ const back = vec();
72
+ convertNodeVector3GlobalToLocal(back, child, world);
73
+ expect(back.x).toBeCloseTo(local.x);
74
+ expect(back.y).toBeCloseTo(local.y);
75
+ expect(back.z).toBeCloseTo(local.z);
76
+ });
77
+ });
78
+
79
+ describe('convertNodeVector3LocalToGlobal', () => {
80
+ it('returns the point unchanged for an identity root node', () => {
81
+ const node = createTestNode();
82
+ const out = vec();
83
+ convertNodeVector3LocalToGlobal(out, node, { x: 1, y: 2, z: 3 } as Vector3Like);
84
+ expect(out.x).toBeCloseTo(1);
85
+ expect(out.y).toBeCloseTo(2);
86
+ expect(out.z).toBeCloseTo(3);
87
+ });
88
+
89
+ it('applies the node translation to a local point', () => {
90
+ const node = createTestNode();
91
+ node.localMatrix.m[12] = 10;
92
+ node.localMatrix.m[13] = 20;
93
+ node.localMatrix.m[14] = 30;
94
+ invalidateNodeLocalTransform(node);
95
+
96
+ const out = vec();
97
+ convertNodeVector3LocalToGlobal(out, node, { x: 1, y: 1, z: 1 } as Vector3Like);
98
+ expect(out.x).toBeCloseTo(11);
99
+ expect(out.y).toBeCloseTo(21);
100
+ expect(out.z).toBeCloseTo(31);
101
+ });
102
+
103
+ it('composes parent and child translations', () => {
104
+ const parent = createTestNode();
105
+ const child = createTestNode();
106
+ addNodeChild(parent, child);
107
+
108
+ parent.localMatrix.m[12] = 5;
109
+ invalidateNodeLocalTransform(parent);
110
+ child.localMatrix.m[12] = 3;
111
+ invalidateNodeLocalTransform(child);
112
+
113
+ const out = vec();
114
+ convertNodeVector3LocalToGlobal(out, child, { x: 0, y: 0, z: 0 } as Vector3Like);
115
+ expect(out.x).toBeCloseTo(8);
116
+ });
117
+ });
118
+
119
+ describe('ensureNodeWorldTransformMatrix4', () => {
120
+ it('computes the world matrix for a root node', () => {
121
+ const node = createTestNode();
122
+ node.localMatrix.m[12] = 5;
123
+ invalidateNodeLocalTransform(node);
124
+ ensureNodeWorldTransformMatrix4(node);
125
+ const runtime = getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime;
126
+ expect(runtime.worldMatrix).not.toBeNull();
127
+ expect(runtime.worldMatrix!.m[12]).toBeCloseTo(5);
128
+ });
129
+
130
+ it('reuses a cached world matrix when no invalidation has occurred', () => {
131
+ const node = createTestNode();
132
+ ensureNodeWorldTransformMatrix4(node);
133
+ const runtime = getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime;
134
+ const first = runtime.worldMatrix;
135
+ ensureNodeWorldTransformMatrix4(node);
136
+ expect(runtime.worldMatrix).toBe(first);
137
+ });
138
+ });
139
+
140
+ describe('getNodeWorldTransformMatrix4', () => {
141
+ it('returns the world matrix for a node', () => {
142
+ const node = createTestNode();
143
+ node.localMatrix.m[12] = 3;
144
+ invalidateNodeLocalTransform(node);
145
+ const m = getNodeWorldTransformMatrix4(node);
146
+ expect(m.m[12]).toBeCloseTo(3);
147
+ });
148
+
149
+ it('composes parent and child local matrices', () => {
150
+ const parent = createTestNode();
151
+ const child = createTestNode();
152
+ addNodeChild(parent, child);
153
+ parent.localMatrix.m[12] = 10;
154
+ invalidateNodeLocalTransform(parent);
155
+ child.localMatrix.m[12] = 5;
156
+ invalidateNodeLocalTransform(child);
157
+ const m = getNodeWorldTransformMatrix4(child);
158
+ expect(m.m[12]).toBeCloseTo(15);
159
+ });
160
+
161
+ it('world matrix is recomputed after localMatrix changes', () => {
162
+ const node = createTestNode();
163
+ invalidateNodeLocalTransform(node);
164
+ ensureNodeWorldTransformMatrix4(node);
165
+ const runtime = getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime;
166
+ const first = runtime.worldTransformId;
167
+
168
+ node.localMatrix.m[12] = 99;
169
+ invalidateNodeLocalTransform(node);
170
+ ensureNodeWorldTransformMatrix4(node);
171
+ const second = runtime.worldTransformId;
172
+
173
+ expect(second).not.toBe(first);
174
+ });
175
+
176
+ it('world matrix is cached when nothing changes', () => {
177
+ const node = createTestNode();
178
+ ensureNodeWorldTransformMatrix4(node);
179
+ const runtime = getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime;
180
+ const id1 = runtime.worldTransformId;
181
+ ensureNodeWorldTransformMatrix4(node);
182
+ const id2 = runtime.worldTransformId;
183
+ expect(id1).toBe(id2);
184
+ });
185
+
186
+ it('child world matrix updates when parent localMatrix changes', () => {
187
+ const parent = createTestNode();
188
+ const child = createTestNode();
189
+ addNodeChild(parent, child);
190
+
191
+ setMatrix4Identity(parent.localMatrix);
192
+ setMatrix4Identity(child.localMatrix);
193
+ invalidateNodeLocalTransform(parent);
194
+ invalidateNodeLocalTransform(child);
195
+
196
+ parent.localMatrix.m[12] = 7;
197
+ invalidateNodeLocalTransform(parent);
198
+
199
+ const world = getNodeWorldTransformMatrix4(child);
200
+ expect(world.m[12]).toBeCloseTo(7);
201
+ });
202
+ });