@flighthq/node 0.2.0 → 0.2.1-next.410.a23f189

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 (47) hide show
  1. package/dist/boundsRectangle.d.ts.map +1 -1
  2. package/dist/boundsRectangle.js +9 -9
  3. package/dist/boundsRectangle.js.map +1 -1
  4. package/dist/hasAppearance.d.ts.map +1 -1
  5. package/dist/hasAppearance.js +0 -1
  6. package/dist/hasAppearance.js.map +1 -1
  7. package/dist/hasBlendMode.d.ts +3 -0
  8. package/dist/hasBlendMode.d.ts.map +1 -0
  9. package/dist/hasBlendMode.js +4 -0
  10. package/dist/hasBlendMode.js.map +1 -0
  11. package/dist/hasTransform2d.js +2 -2
  12. package/dist/hasTransform2d.js.map +1 -1
  13. package/dist/hasTransform3d.d.ts.map +1 -1
  14. package/dist/hasTransform3d.js +7 -3
  15. package/dist/hasTransform3d.js.map +1 -1
  16. package/dist/hierarchy.js +4 -4
  17. package/dist/hierarchy.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/node.d.ts.map +1 -1
  23. package/dist/node.js +3 -0
  24. package/dist/node.js.map +1 -1
  25. package/dist/revision.d.ts.map +1 -1
  26. package/dist/revision.js +16 -5
  27. package/dist/revision.js.map +1 -1
  28. package/dist/transform2d.d.ts +8 -5
  29. package/dist/transform2d.d.ts.map +1 -1
  30. package/dist/transform2d.js +53 -20
  31. package/dist/transform2d.js.map +1 -1
  32. package/dist/transform3d.d.ts +10 -3
  33. package/dist/transform3d.d.ts.map +1 -1
  34. package/dist/transform3d.js +79 -13
  35. package/dist/transform3d.js.map +1 -1
  36. package/dist/viewport.d.ts.map +1 -1
  37. package/dist/viewport.js +3 -2
  38. package/dist/viewport.js.map +1 -1
  39. package/package.json +5 -5
  40. package/src/boundsRectangle.test.ts +2 -2
  41. package/src/hasAppearance.test.ts +0 -10
  42. package/src/hasBlendMode.test.ts +25 -0
  43. package/src/hasTransform2d.test.ts +2 -2
  44. package/src/hasTransform3d.test.ts +18 -19
  45. package/src/hierarchy.test.ts +13 -13
  46. package/src/transform2d.test.ts +113 -51
  47. package/src/transform3d.test.ts +302 -100
@@ -1,16 +1,31 @@
1
- import { setMatrix4Identity } from '@flighthq/geometry';
1
+ import {
2
+ composeMatrix4,
3
+ createMatrix4,
4
+ createQuaternion,
5
+ createTransform3D,
6
+ createVector3,
7
+ multiplyMatrix4,
8
+ setQuaternionFromAxisAngle,
9
+ } from '@flighthq/geometry';
2
10
  import type { HasTransform3D, HasTransform3DRuntime, NodeRuntime, Transform3DNode, Vector3Like } from '@flighthq/types';
3
11
  import { describe, expect, it } from 'vitest';
4
12
 
5
13
  import { initTransform3DRuntimeTrait, initTransform3DTrait } from './hasTransform3d';
6
- import { addNodeChild } from './hierarchy';
14
+ import { addNodeChild, removeNodeChild } from './hierarchy';
7
15
  import { createNode, getNodeRuntime } from './node';
8
16
  import { invalidateNodeLocalTransform } from './revision';
9
17
  import {
10
18
  convertNodeVector3GlobalToLocal,
11
19
  convertNodeVector3LocalToGlobal,
12
- ensureNodeWorldTransformMatrix4,
13
- getNodeWorldTransformMatrix4,
20
+ ensureNodeLocalMatrix4,
21
+ ensureNodeWorldMatrix4,
22
+ getNodeLocalMatrix4,
23
+ getNodeTransform3D,
24
+ getNodeWorldMatrix4,
25
+ isNodeLocalMatrix4Detached,
26
+ setNodeLocalMatrix4,
27
+ setNodeTransform3D,
28
+ syncNodeTransform3DFromMatrix4,
14
29
  } from './transform3d';
15
30
 
16
31
  const TestNodeKind = 'TestNode';
@@ -25,6 +40,37 @@ function createTestNode(): TestNode {
25
40
  return node as TestNode;
26
41
  }
27
42
 
43
+ function setNodeTranslation(node: TestNode, x: number, y: number, z: number): void {
44
+ node.position.x = x;
45
+ node.position.y = y;
46
+ node.position.z = z;
47
+ invalidateNodeLocalTransform(node);
48
+ }
49
+
50
+ // Rotate about +Z so a child offset along +X swings toward +Y — an order-sensitive case pure
51
+ // translation cannot expose.
52
+ function setNodeRotationZ(node: TestNode, radians: number): void {
53
+ setQuaternionFromAxisAngle(node.rotation, _zAxis, radians);
54
+ invalidateNodeLocalTransform(node);
55
+ }
56
+
57
+ function setNodeScale(node: TestNode, x: number, y: number, z: number): void {
58
+ node.scale.x = x;
59
+ node.scale.y = y;
60
+ node.scale.z = z;
61
+ invalidateNodeLocalTransform(node);
62
+ }
63
+
64
+ const _zAxis = { x: 0, y: 0, z: 1 } as Vector3Like;
65
+
66
+ function translationMatrix(x: number, y: number, z: number) {
67
+ const m = createMatrix4();
68
+ m.m[12] = x;
69
+ m.m[13] = y;
70
+ m.m[14] = z;
71
+ return m;
72
+ }
73
+
28
74
  function vec(): Vector3Like {
29
75
  return { x: 0, y: 0, z: 0 } as Vector3Like;
30
76
  }
@@ -41,58 +87,19 @@ describe('convertNodeVector3GlobalToLocal', () => {
41
87
 
42
88
  it('inverts the node translation', () => {
43
89
  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
-
90
+ setNodeTranslation(node, 10, 20, 30);
49
91
  const out = vec();
50
92
  convertNodeVector3GlobalToLocal(out, node, { x: 11, y: 21, z: 31 } as Vector3Like);
51
93
  expect(out.x).toBeCloseTo(1);
52
94
  expect(out.y).toBeCloseTo(1);
53
95
  expect(out.z).toBeCloseTo(1);
54
96
  });
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
97
  });
78
98
 
79
99
  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
100
  it('applies the node translation to a local point', () => {
90
101
  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
-
102
+ setNodeTranslation(node, 10, 20, 30);
96
103
  const out = vec();
97
104
  convertNodeVector3LocalToGlobal(out, node, { x: 1, y: 1, z: 1 } as Vector3Like);
98
105
  expect(out.x).toBeCloseTo(11);
@@ -104,99 +111,294 @@ describe('convertNodeVector3LocalToGlobal', () => {
104
111
  const parent = createTestNode();
105
112
  const child = createTestNode();
106
113
  addNodeChild(parent, child);
107
-
108
- parent.localMatrix.m[12] = 5;
109
- invalidateNodeLocalTransform(parent);
110
- child.localMatrix.m[12] = 3;
111
- invalidateNodeLocalTransform(child);
112
-
114
+ setNodeTranslation(parent, 5, 0, 0);
115
+ setNodeTranslation(child, 3, 0, 0);
113
116
  const out = vec();
114
117
  convertNodeVector3LocalToGlobal(out, child, { x: 0, y: 0, z: 0 } as Vector3Like);
115
118
  expect(out.x).toBeCloseTo(8);
116
119
  });
117
120
  });
118
121
 
119
- describe('ensureNodeWorldTransformMatrix4', () => {
120
- it('computes the world matrix for a root node', () => {
122
+ describe('ensureNodeLocalMatrix4', () => {
123
+ it('composes the local matrix from translation/rotation/scale', () => {
121
124
  const node = createTestNode();
122
- node.localMatrix.m[12] = 5;
125
+ setNodeTranslation(node, 2, 3, 4);
126
+ node.scale.x = 5;
123
127
  invalidateNodeLocalTransform(node);
124
- ensureNodeWorldTransformMatrix4(node);
128
+ ensureNodeLocalMatrix4(node);
129
+ const m = getNodeLocalMatrix4(node);
130
+ expect(m.m[0]).toBeCloseTo(5);
131
+ expect(m.m[12]).toBeCloseTo(2);
132
+ expect(m.m[13]).toBeCloseTo(3);
133
+ expect(m.m[14]).toBeCloseTo(4);
134
+ });
135
+ });
136
+
137
+ describe('ensureNodeWorldMatrix4', () => {
138
+ it('computes the world matrix for a root node', () => {
139
+ const node = createTestNode();
140
+ setNodeTranslation(node, 5, 0, 0);
141
+ ensureNodeWorldMatrix4(node);
125
142
  const runtime = getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime;
126
- expect(runtime.worldMatrix).not.toBeNull();
127
- expect(runtime.worldMatrix!.m[12]).toBeCloseTo(5);
143
+ expect(runtime.worldMatrix4).not.toBeNull();
144
+ expect(runtime.worldMatrix4!.m[12]).toBeCloseTo(5);
128
145
  });
129
146
 
130
147
  it('reuses a cached world matrix when no invalidation has occurred', () => {
131
148
  const node = createTestNode();
132
- ensureNodeWorldTransformMatrix4(node);
149
+ ensureNodeWorldMatrix4(node);
133
150
  const runtime = getNodeRuntime(node) as NodeRuntime<TestTraits> & HasTransform3DRuntime;
134
- const first = runtime.worldMatrix;
135
- ensureNodeWorldTransformMatrix4(node);
136
- expect(runtime.worldMatrix).toBe(first);
151
+ const first = runtime.worldMatrix4;
152
+ ensureNodeWorldMatrix4(node);
153
+ expect(runtime.worldMatrix4).toBe(first);
154
+ });
155
+
156
+ it('propagates a mid-chain parent change down to an already-cached grandchild', () => {
157
+ const root = createTestNode();
158
+ const parent = createTestNode();
159
+ const child = createTestNode();
160
+ addNodeChild(root, parent);
161
+ addNodeChild(parent, child);
162
+ setNodeTranslation(root, 1, 0, 0);
163
+ setNodeTranslation(parent, 2, 0, 0);
164
+ setNodeTranslation(child, 3, 0, 0);
165
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(6);
166
+ // Mutating the middle node must invalidate the grandchild's cached world through the chain.
167
+ setNodeTranslation(parent, 20, 0, 0);
168
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(24);
137
169
  });
138
170
  });
139
171
 
140
- describe('getNodeWorldTransformMatrix4', () => {
141
- it('returns the world matrix for a node', () => {
172
+ describe('getNodeLocalMatrix4', () => {
173
+ it('reflects the authored translation', () => {
142
174
  const node = createTestNode();
143
- node.localMatrix.m[12] = 3;
144
- invalidateNodeLocalTransform(node);
145
- const m = getNodeWorldTransformMatrix4(node);
146
- expect(m.m[12]).toBeCloseTo(3);
175
+ setNodeTranslation(node, 3, 0, 0);
176
+ expect(getNodeLocalMatrix4(node).m[12]).toBeCloseTo(3);
147
177
  });
178
+ });
148
179
 
180
+ describe('getNodeTransform3D', () => {
181
+ it('reads the node TRS fields into a carrier', () => {
182
+ const node = createTestNode();
183
+ setNodeTranslation(node, 1, 2, 3);
184
+ node.scale.y = 4;
185
+ const out = createTransform3D();
186
+ getNodeTransform3D(out, node);
187
+ expect(out.position).toMatchObject({ x: 1, y: 2, z: 3 });
188
+ expect(out.scale.y).toBeCloseTo(4);
189
+ });
190
+ });
191
+
192
+ describe('getNodeWorldMatrix4', () => {
149
193
  it('composes parent and child local matrices', () => {
150
194
  const parent = createTestNode();
151
195
  const child = createTestNode();
152
196
  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);
197
+ setNodeTranslation(parent, 10, 0, 0);
198
+ setNodeTranslation(child, 5, 0, 0);
199
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(15);
159
200
  });
160
201
 
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;
202
+ it('updates when the parent transform changes', () => {
203
+ const parent = createTestNode();
204
+ const child = createTestNode();
205
+ addNodeChild(parent, child);
206
+ getNodeWorldMatrix4(child);
207
+ setNodeTranslation(parent, 7, 0, 0);
208
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(7);
209
+ });
167
210
 
168
- node.localMatrix.m[12] = 99;
169
- invalidateNodeLocalTransform(node);
170
- ensureNodeWorldTransformMatrix4(node);
171
- const second = runtime.worldTransformId;
211
+ it('rotates a child offset by the parent rotation (world = parentWorld × local)', () => {
212
+ const parent = createTestNode();
213
+ const child = createTestNode();
214
+ addNodeChild(parent, child);
215
+ setNodeRotationZ(parent, Math.PI / 2); // +90° about +Z maps +X -> +Y
216
+ setNodeTranslation(child, 1, 0, 0);
217
+ // The child sits at local +X; under the parent's rotation its world position swings to +Y.
218
+ // A reversed multiply order (local × parentWorld) would instead leave it at +X — this pins the order.
219
+ const world = getNodeWorldMatrix4(child);
220
+ expect(world.m[12]).toBeCloseTo(0);
221
+ expect(world.m[13]).toBeCloseTo(1);
222
+ expect(world.m[14]).toBeCloseTo(0);
223
+ });
224
+
225
+ it('composes parent translation after rotation onto a child offset', () => {
226
+ const parent = createTestNode();
227
+ const child = createTestNode();
228
+ addNodeChild(parent, child);
229
+ setNodeTranslation(parent, 10, 0, 0);
230
+ setNodeRotationZ(parent, Math.PI / 2);
231
+ setNodeTranslation(child, 1, 0, 0);
232
+ // parentWorld = T(10)·R(90°); child offset (1,0,0) rotates to (0,1,0) then translates to (10,1,0).
233
+ const world = getNodeWorldMatrix4(child);
234
+ expect(world.m[12]).toBeCloseTo(10);
235
+ expect(world.m[13]).toBeCloseTo(1);
236
+ expect(world.m[14]).toBeCloseTo(0);
237
+ });
238
+
239
+ it('scales a child offset and scale by the parent scale', () => {
240
+ const parent = createTestNode();
241
+ const child = createTestNode();
242
+ addNodeChild(parent, child);
243
+ setNodeScale(parent, 2, 2, 2);
244
+ setNodeTranslation(child, 3, 0, 0);
245
+ const world = getNodeWorldMatrix4(child);
246
+ expect(world.m[12]).toBeCloseTo(6); // parent scale multiplies the child's position
247
+ expect(world.m[0]).toBeCloseTo(2); // and its scale
248
+ });
249
+
250
+ it('composes a three-level translation/rotation/scale chain', () => {
251
+ const root = createTestNode();
252
+ const parent = createTestNode();
253
+ const child = createTestNode();
254
+ addNodeChild(root, parent);
255
+ addNodeChild(parent, child);
256
+
257
+ setNodeTranslation(root, 1, 2, 3);
258
+ setNodeRotationZ(root, Math.PI / 6);
259
+ setNodeScale(root, 2, 2, 2);
260
+ setNodeTranslation(parent, 4, 0, 0);
261
+ setNodeRotationZ(parent, Math.PI / 4);
262
+ setNodeTranslation(child, 0, 5, 0);
263
+ setNodeScale(child, 3, 1, 1);
264
+
265
+ // Oracle: world = local(root) × local(parent) × local(child), built from the same geometry
266
+ // primitives the implementation composes, so the test pins the wiring and order, not the math.
267
+ const localRoot = createMatrix4();
268
+ composeMatrix4(localRoot, root.position, root.rotation, root.scale);
269
+ const localParent = createMatrix4();
270
+ composeMatrix4(localParent, parent.position, parent.rotation, parent.scale);
271
+ const localChild = createMatrix4();
272
+ composeMatrix4(localChild, child.position, child.rotation, child.scale);
273
+ const rootTimesParent = createMatrix4();
274
+ multiplyMatrix4(rootTimesParent, localRoot, localParent);
275
+ const expected = createMatrix4();
276
+ multiplyMatrix4(expected, rootTimesParent, localChild);
277
+
278
+ const world = getNodeWorldMatrix4(child);
279
+ for (let i = 0; i < 16; i++) {
280
+ expect(world.m[i]).toBeCloseTo(expected.m[i]);
281
+ }
282
+ });
172
283
 
173
- expect(second).not.toBe(first);
284
+ it('propagates a grandparent change through an unchanged middle node to a grandchild', () => {
285
+ const root = createTestNode();
286
+ const mid = createTestNode();
287
+ const leaf = createTestNode();
288
+ addNodeChild(root, mid);
289
+ addNodeChild(mid, leaf);
290
+ setNodeTranslation(root, 1, 0, 0);
291
+ setNodeTranslation(leaf, 2, 0, 0);
292
+ // Prime the grandchild's cached world (root 1 + leaf 2 = 3), then move only the grandparent.
293
+ expect(getNodeWorldMatrix4(leaf).m[12]).toBeCloseTo(3);
294
+ setNodeTranslation(root, 100, 0, 0);
295
+ // The middle node does not change locally, but its world moves — the grandchild must follow.
296
+ expect(getNodeWorldMatrix4(leaf).m[12]).toBeCloseTo(102);
174
297
  });
175
298
 
176
- it('world matrix is cached when nothing changes', () => {
299
+ it('recomputes the world matrix after reparenting to a different parent', () => {
300
+ const parentA = createTestNode();
301
+ const parentB = createTestNode();
302
+ const child = createTestNode();
303
+ setNodeTranslation(parentA, 10, 0, 0);
304
+ setNodeTranslation(parentB, 0, 20, 0);
305
+ setNodeTranslation(child, 1, 0, 0);
306
+
307
+ addNodeChild(parentA, child);
308
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(11);
309
+ expect(getNodeWorldMatrix4(child).m[13]).toBeCloseTo(0);
310
+
311
+ // parentA and parentB carry the same local revision, so their composite worldTransformId
312
+ // collides; correct reparenting relies on invalidateNodeParentReference, not the id compare.
313
+ removeNodeChild(parentA, child);
314
+ addNodeChild(parentB, child);
315
+ const world = getNodeWorldMatrix4(child);
316
+ expect(world.m[12]).toBeCloseTo(1);
317
+ expect(world.m[13]).toBeCloseTo(20);
318
+ });
319
+ });
320
+
321
+ describe('isNodeLocalMatrix4Detached', () => {
322
+ it('is false for a TRS-authored node and true after a direct matrix set', () => {
177
323
  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);
324
+ setNodeTranslation(node, 1, 0, 0);
325
+ getNodeLocalMatrix4(node);
326
+ expect(isNodeLocalMatrix4Detached(node)).toBe(false);
327
+ setNodeLocalMatrix4(node, translationMatrix(9, 0, 0));
328
+ expect(isNodeLocalMatrix4Detached(node)).toBe(true);
329
+ });
330
+ });
331
+
332
+ describe('setNodeLocalMatrix4', () => {
333
+ it('sets the matrix directly and survives the local recompute', () => {
334
+ const node = createTestNode();
335
+ setNodeLocalMatrix4(node, translationMatrix(9, 8, 7));
336
+ // ensure must NOT recompose from the (dormant) TRS fields.
337
+ ensureNodeLocalMatrix4(node);
338
+ const m = getNodeLocalMatrix4(node);
339
+ expect(m.m[12]).toBeCloseTo(9);
340
+ expect(m.m[13]).toBeCloseTo(8);
341
+ expect(m.m[14]).toBeCloseTo(7);
184
342
  });
185
343
 
186
- it('child world matrix updates when parent localMatrix changes', () => {
344
+ it('propagates to the world matrix', () => {
187
345
  const parent = createTestNode();
188
346
  const child = createTestNode();
189
347
  addNodeChild(parent, child);
348
+ setNodeLocalMatrix4(parent, translationMatrix(4, 0, 0));
349
+ setNodeLocalMatrix4(child, translationMatrix(3, 0, 0));
350
+ expect(getNodeWorldMatrix4(child).m[12]).toBeCloseTo(7);
351
+ });
352
+
353
+ it('is overridden when a TRS write reclaims authority (last writer wins)', () => {
354
+ const node = createTestNode();
355
+ setNodeLocalMatrix4(node, translationMatrix(9, 0, 0));
356
+ setNodeTranslation(node, 2, 0, 0);
357
+ expect(isNodeLocalMatrix4Detached(node)).toBe(false);
358
+ expect(getNodeLocalMatrix4(node).m[12]).toBeCloseTo(2);
359
+ });
190
360
 
191
- setMatrix4Identity(parent.localMatrix);
192
- setMatrix4Identity(child.localMatrix);
193
- invalidateNodeLocalTransform(parent);
194
- invalidateNodeLocalTransform(child);
361
+ it('copies the source matrix rather than aliasing it', () => {
362
+ const node = createTestNode();
363
+ const source = translationMatrix(5, 0, 0);
364
+ setNodeLocalMatrix4(node, source);
365
+ source.m[12] = 999;
366
+ expect(getNodeLocalMatrix4(node).m[12]).toBeCloseTo(5);
367
+ });
368
+ });
195
369
 
196
- parent.localMatrix.m[12] = 7;
197
- invalidateNodeLocalTransform(parent);
370
+ describe('setNodeTransform3D', () => {
371
+ it('copies carrier TRS and rebuilds the matrix', () => {
372
+ const node = createTestNode();
373
+ const t = createTransform3D();
374
+ t.position = createVector3(6, 0, 0);
375
+ t.scale = createVector3(2, 2, 2);
376
+ setNodeTransform3D(node, t);
377
+ const m = getNodeLocalMatrix4(node);
378
+ expect(m.m[0]).toBeCloseTo(2);
379
+ expect(m.m[12]).toBeCloseTo(6);
380
+ });
381
+ });
382
+
383
+ describe('syncNodeTransform3DFromMatrix4', () => {
384
+ it('decomposes a directly-set matrix back into the TRS fields and clears detached', () => {
385
+ const node = createTestNode();
386
+ setNodeLocalMatrix4(node, translationMatrix(3, 4, 5));
387
+ syncNodeTransform3DFromMatrix4(node);
388
+ expect(isNodeLocalMatrix4Detached(node)).toBe(false);
389
+ expect(node.position.x).toBeCloseTo(3);
390
+ expect(node.position.y).toBeCloseTo(4);
391
+ expect(node.position.z).toBeCloseTo(5);
392
+ });
198
393
 
199
- const world = getNodeWorldTransformMatrix4(child);
200
- expect(world.m[12]).toBeCloseTo(7);
394
+ it('is non-destructive: the matrix cache is unchanged', () => {
395
+ const node = createTestNode();
396
+ node.rotation = createQuaternion();
397
+ setNodeLocalMatrix4(node, translationMatrix(3, 4, 5));
398
+ syncNodeTransform3DFromMatrix4(node);
399
+ const m = getNodeLocalMatrix4(node);
400
+ expect(m.m[12]).toBeCloseTo(3);
401
+ expect(m.m[13]).toBeCloseTo(4);
402
+ expect(m.m[14]).toBeCloseTo(5);
201
403
  });
202
404
  });