@flighthq/node 0.3.0-next.906.07cea63 → 0.3.1-next.1041.35b950c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boundsRectangle.d.ts +11 -0
- package/dist/boundsRectangle.d.ts.map +1 -1
- package/dist/boundsRectangle.js +110 -12
- package/dist/boundsRectangle.js.map +1 -1
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -1
- package/dist/enableNodeGuards.d.ts +4 -0
- package/dist/enableNodeGuards.d.ts.map +1 -0
- package/dist/enableNodeGuards.js +29 -0
- package/dist/enableNodeGuards.js.map +1 -0
- package/dist/hasAppearance.d.ts +2 -1
- package/dist/hasAppearance.d.ts.map +1 -1
- package/dist/hasAppearance.js +10 -0
- package/dist/hasAppearance.js.map +1 -1
- package/dist/hasBoundsRectangle.d.ts +1 -1
- package/dist/hasBoundsRectangle.d.ts.map +1 -1
- package/dist/hasBoundsRectangle.js +5 -0
- package/dist/hasBoundsRectangle.js.map +1 -1
- package/dist/hasTransform3d.d.ts.map +1 -1
- package/dist/hasTransform3d.js +11 -4
- package/dist/hasTransform3d.js.map +1 -1
- package/dist/hierarchy.d.ts +15 -7
- package/dist/hierarchy.d.ts.map +1 -1
- package/dist/hierarchy.js +65 -28
- package/dist/hierarchy.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +2 -0
- package/dist/node.js.map +1 -1
- package/dist/nodeColorAdjustment.d.ts.map +1 -1
- package/dist/nodeColorAdjustment.js +1 -7
- package/dist/nodeColorAdjustment.js.map +1 -1
- package/dist/nodeOrderList.d.ts +88 -0
- package/dist/nodeOrderList.d.ts.map +1 -0
- package/dist/nodeOrderList.js +257 -0
- package/dist/nodeOrderList.js.map +1 -0
- package/dist/nodeTransform2d.d.ts.map +1 -1
- package/dist/nodeTransform2d.js +1 -1
- package/dist/nodeTransform2d.js.map +1 -1
- package/dist/revision.d.ts +5 -2
- package/dist/revision.d.ts.map +1 -1
- package/dist/revision.js +17 -5
- package/dist/revision.js.map +1 -1
- package/dist/stageFit.d.ts.map +1 -1
- package/dist/stageFit.js +8 -4
- package/dist/stageFit.js.map +1 -1
- package/dist/traversal.d.ts +1 -1
- package/dist/traversal.d.ts.map +1 -1
- package/dist/traversal.js +1 -1
- package/dist/traversal.js.map +1 -1
- package/package.json +11 -7
- package/src/boundsRectangle.test.ts +378 -7
- package/src/enableNodeGuards.test.ts +78 -0
- package/src/hasAppearance.test.ts +33 -3
- package/src/hasBoundsRectangle.test.ts +8 -0
- package/src/hasTransform2d.test.ts +6 -0
- package/src/hasTransform3d.test.ts +40 -8
- package/src/hierarchy.test.ts +298 -4
- package/src/node.test.ts +11 -1
- package/src/nodeColorAdjustment.test.ts +11 -0
- package/src/nodeOrderList.test.ts +683 -0
- package/src/nodeTransform2d.test.ts +45 -0
- package/src/revision.test.ts +51 -0
- package/src/stageFit.test.ts +56 -1
- package/src/traversal.test.ts +32 -3
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
initTransform2DRuntimeTrait,
|
|
14
14
|
initTransform2DTrait,
|
|
15
15
|
invalidateNodeLocalTransform,
|
|
16
|
+
removeNodeChild,
|
|
17
|
+
setNodeEnabled,
|
|
16
18
|
} from '@flighthq/node/contract';
|
|
17
19
|
import type {
|
|
18
20
|
HasBoundsRectangle,
|
|
@@ -26,6 +28,7 @@ import type {
|
|
|
26
28
|
|
|
27
29
|
import {
|
|
28
30
|
computeNodeBoundsRectangle,
|
|
31
|
+
computeNodeRootLocalBoundsRectangle,
|
|
29
32
|
ensureNodeLocalBoundsRectangle,
|
|
30
33
|
ensureNodeParentBoundsRectangle,
|
|
31
34
|
ensureNodeWorldBoundsRectangle,
|
|
@@ -41,8 +44,10 @@ import { initBoundsRectangleRuntimeTrait, initBoundsRectangleTrait } from './has
|
|
|
41
44
|
|
|
42
45
|
function getEntityRuntime(
|
|
43
46
|
source: TestNode,
|
|
44
|
-
): NodeRuntime<HasBoundsRectangle & HasTransform2D> & HasBoundsRectangleRuntime {
|
|
45
|
-
return _getRuntime(source) as NodeRuntime<HasBoundsRectangle & HasTransform2D> &
|
|
47
|
+
): NodeRuntime<HasBoundsRectangle & HasTransform2D> & HasBoundsRectangleRuntime & HasTransform2DRuntime {
|
|
48
|
+
return _getRuntime(source) as NodeRuntime<HasBoundsRectangle & HasTransform2D> &
|
|
49
|
+
HasBoundsRectangleRuntime &
|
|
50
|
+
HasTransform2DRuntime;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
function createTestNode(): TestNode {
|
|
@@ -89,6 +94,14 @@ describe('computeNodeBoundsRectangle', () => {
|
|
|
89
94
|
expect(equalsRectangle(out, { x: 5, y: 5, width: 100, height: 100 })).toBe(true);
|
|
90
95
|
});
|
|
91
96
|
|
|
97
|
+
it('defaults a null target coordinate space to the source', () => {
|
|
98
|
+
const out = createRectangle();
|
|
99
|
+
|
|
100
|
+
computeNodeBoundsRectangle(out, child, null);
|
|
101
|
+
|
|
102
|
+
expect(equalsRectangle(out, { x: 5, y: 5, width: 100, height: 100 })).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
92
105
|
it('should compute bounds relative to parent', () => {
|
|
93
106
|
const out = createRectangle();
|
|
94
107
|
computeNodeBoundsRectangle(out, child, root);
|
|
@@ -98,12 +111,21 @@ describe('computeNodeBoundsRectangle', () => {
|
|
|
98
111
|
expect(out.height).toBeCloseTo(100);
|
|
99
112
|
});
|
|
100
113
|
|
|
114
|
+
it('uses parent-space bounds directly when the parent is nested', () => {
|
|
115
|
+
const out = createRectangle();
|
|
116
|
+
|
|
117
|
+
computeNodeBoundsRectangle(out, grandChild, child);
|
|
118
|
+
|
|
119
|
+
expect(equalsRectangle(out, getNodeParentBoundsRectangle(grandChild))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
101
122
|
it('should compute bounds relative to nested child', () => {
|
|
102
123
|
const out = createRectangle();
|
|
103
124
|
computeNodeBoundsRectangle(out, root, grandChild);
|
|
104
|
-
expect(out.
|
|
105
|
-
expect(out.
|
|
106
|
-
|
|
125
|
+
expect(out.x).toBeCloseTo(-100);
|
|
126
|
+
expect(out.y).toBeCloseTo(-100);
|
|
127
|
+
expect(out.width).toBeCloseTo(205);
|
|
128
|
+
expect(out.height).toBeCloseTo(205);
|
|
107
129
|
});
|
|
108
130
|
|
|
109
131
|
it('should account for scaling in parent transforms', () => {
|
|
@@ -152,14 +174,129 @@ describe('computeNodeBoundsRectangle', () => {
|
|
|
152
174
|
expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
|
|
153
175
|
});
|
|
154
176
|
|
|
177
|
+
it('converts through world space for a nested unrelated target', () => {
|
|
178
|
+
const unrelatedRoot = createTestNode();
|
|
179
|
+
const unrelatedTarget = createTestNode();
|
|
180
|
+
addNodeChild(unrelatedRoot, unrelatedTarget);
|
|
181
|
+
const out = createRectangle();
|
|
182
|
+
|
|
183
|
+
computeNodeBoundsRectangle(out, grandChild, unrelatedTarget);
|
|
184
|
+
|
|
185
|
+
expect(equalsRectangle(out, getNodeWorldBoundsRectangle(grandChild))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
155
188
|
it('should return world bounds if the target coordinate space is root', () => {
|
|
156
189
|
const out = createRectangle();
|
|
157
190
|
computeNodeBoundsRectangle(out, child, root);
|
|
158
191
|
expect(equalsRectangle(out, getNodeWorldBoundsRectangle(child))).toBe(true);
|
|
159
192
|
});
|
|
193
|
+
|
|
194
|
+
it('removes a detached target transform from target-relative bounds', () => {
|
|
195
|
+
root.x = 40;
|
|
196
|
+
root.y = -25;
|
|
197
|
+
invalidateNodeLocalTransform(root);
|
|
198
|
+
const out = createRectangle();
|
|
199
|
+
|
|
200
|
+
computeNodeBoundsRectangle(out, child, root);
|
|
201
|
+
|
|
202
|
+
expect(out.x).toBeCloseTo(105);
|
|
203
|
+
expect(out.y).toBeCloseTo(105);
|
|
204
|
+
expect(out.width).toBeCloseTo(100);
|
|
205
|
+
expect(out.height).toBeCloseTo(100);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('computeNodeRootLocalBoundsRectangle', () => {
|
|
210
|
+
it('ignores the detached root transform while preserving negative local extents', () => {
|
|
211
|
+
const root = createTestNode();
|
|
212
|
+
root.x = 120;
|
|
213
|
+
root.y = -35;
|
|
214
|
+
root.rotation = 37;
|
|
215
|
+
root.scaleX = 2;
|
|
216
|
+
root.scaleY = 0.5;
|
|
217
|
+
setRectangle(getNodeLocalBoundsRectangle(root), -10, -5, 30, 20);
|
|
218
|
+
|
|
219
|
+
const out = createRectangle();
|
|
220
|
+
computeNodeRootLocalBoundsRectangle(out, root);
|
|
221
|
+
|
|
222
|
+
expect(out).toMatchObject({ x: -10, y: -5, width: 30, height: 20 });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('composes nested transforms relative to an identity root', () => {
|
|
226
|
+
const root = createTestNode();
|
|
227
|
+
const child = createTestNode();
|
|
228
|
+
root.x = 500;
|
|
229
|
+
root.rotation = 90;
|
|
230
|
+
child.x = 20;
|
|
231
|
+
child.y = 3;
|
|
232
|
+
child.scaleX = 2;
|
|
233
|
+
child.scaleY = 3;
|
|
234
|
+
setRectangle(getNodeLocalBoundsRectangle(root), 0, 0, 10, 10);
|
|
235
|
+
setRectangle(getNodeLocalBoundsRectangle(child), -5, -2, 4, 8);
|
|
236
|
+
addNodeChild(root, child);
|
|
237
|
+
|
|
238
|
+
const out = createRectangle();
|
|
239
|
+
computeNodeRootLocalBoundsRectangle(out, root);
|
|
240
|
+
|
|
241
|
+
expect(out.x).toBeCloseTo(0);
|
|
242
|
+
expect(out.y).toBeCloseTo(-3);
|
|
243
|
+
expect(out.width).toBeCloseTo(18);
|
|
244
|
+
expect(out.height).toBeCloseTo(24);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('composes transforms at every descendant depth', () => {
|
|
248
|
+
const root = createTestNode();
|
|
249
|
+
const child = createTestNode();
|
|
250
|
+
const grandchild = createTestNode();
|
|
251
|
+
setRectangle(getNodeLocalBoundsRectangle(root), 0, 0, 1, 1);
|
|
252
|
+
setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 1, 1);
|
|
253
|
+
setRectangle(getNodeLocalBoundsRectangle(grandchild), 0, 0, 2, 2);
|
|
254
|
+
child.x = 10;
|
|
255
|
+
grandchild.x = 20;
|
|
256
|
+
addNodeChild(root, child);
|
|
257
|
+
addNodeChild(child, grandchild);
|
|
258
|
+
|
|
259
|
+
const out = createRectangle();
|
|
260
|
+
computeNodeRootLocalBoundsRectangle(out, root);
|
|
261
|
+
|
|
262
|
+
expect(out).toMatchObject({ x: 0, y: 0, width: 32, height: 2 });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('excludes disabled descendants', () => {
|
|
266
|
+
const root = createTestNode();
|
|
267
|
+
const child = createTestNode();
|
|
268
|
+
setRectangle(getNodeLocalBoundsRectangle(root), 0, 0, 10, 10);
|
|
269
|
+
setRectangle(getNodeLocalBoundsRectangle(child), 0, 0, 20, 20);
|
|
270
|
+
child.x = 100;
|
|
271
|
+
child.enabled = false;
|
|
272
|
+
addNodeChild(root, child);
|
|
273
|
+
|
|
274
|
+
const out = createRectangle();
|
|
275
|
+
computeNodeRootLocalBoundsRectangle(out, root);
|
|
276
|
+
|
|
277
|
+
expect(out).toMatchObject({ x: 0, y: 0, width: 10, height: 10 });
|
|
278
|
+
});
|
|
160
279
|
});
|
|
161
280
|
|
|
162
281
|
describe('ensureNodeLocalBoundsRectangle', () => {
|
|
282
|
+
it('recalculates when the kind-owned validity hook rejects an unchanged revision', () => {
|
|
283
|
+
const object = createTestNode();
|
|
284
|
+
const runtime = getEntityRuntime(object);
|
|
285
|
+
let input = { width: 10 };
|
|
286
|
+
let stampedInput = input;
|
|
287
|
+
runtime.computeLocalBoundsRectangle = (out: Rectangle) => {
|
|
288
|
+
out.width = input.width;
|
|
289
|
+
stampedInput = input;
|
|
290
|
+
};
|
|
291
|
+
runtime.isLocalBoundsRectangleValid = () => stampedInput === input;
|
|
292
|
+
ensureNodeLocalBoundsRectangle(object);
|
|
293
|
+
|
|
294
|
+
input = { width: 20 };
|
|
295
|
+
ensureNodeLocalBoundsRectangle(object);
|
|
296
|
+
|
|
297
|
+
expect(runtime.localBoundsRectangle?.width).toBe(20);
|
|
298
|
+
});
|
|
299
|
+
|
|
163
300
|
it('should ensure localBoundsRectangle is defined', () => {
|
|
164
301
|
const object = createTestNode();
|
|
165
302
|
const runtime = getEntityRuntime(object);
|
|
@@ -258,6 +395,20 @@ describe('ensureNodeWorldBoundsRectangle', () => {
|
|
|
258
395
|
expect(runtime.worldBoundsRectangle).not.toBeNull();
|
|
259
396
|
});
|
|
260
397
|
|
|
398
|
+
it('recomputes when bounds are cached before the world matrix exists', () => {
|
|
399
|
+
const object = createTestNode();
|
|
400
|
+
const runtime = getEntityRuntime(object);
|
|
401
|
+
getNodeLocalBoundsRectangle(object);
|
|
402
|
+
runtime.worldBoundsRectangle = createRectangle(5, 5, 10, 10);
|
|
403
|
+
runtime.worldBoundsUsingLocalBoundsId = runtime.localBoundsId;
|
|
404
|
+
expect(runtime.worldMatrix).toBeNull();
|
|
405
|
+
|
|
406
|
+
ensureNodeWorldBoundsRectangle(object);
|
|
407
|
+
|
|
408
|
+
expect(runtime.worldMatrix).not.toBeNull();
|
|
409
|
+
expect(runtime.worldBoundsRectangle).toMatchObject({ x: 0, y: 0, width: 0, height: 0 });
|
|
410
|
+
});
|
|
411
|
+
|
|
261
412
|
it('should not recalculate if localBoundsId and worldTransformId are unchanged', () => {
|
|
262
413
|
const object = createTestNode();
|
|
263
414
|
const runtime = getEntityRuntime(object);
|
|
@@ -439,8 +590,85 @@ describe('getNodeWorldBoundsRectangle', () => {
|
|
|
439
590
|
invalidateNodeLocalTransform(child);
|
|
440
591
|
|
|
441
592
|
const bounds = getNodeWorldBoundsRectangle(parent);
|
|
442
|
-
expect(bounds
|
|
443
|
-
|
|
593
|
+
expect(bounds).toMatchObject({ x: 0, y: 0, width: 300, height: 200 });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('refreshes cached aggregate bounds when a child moves', () => {
|
|
597
|
+
const parent = createTestNode();
|
|
598
|
+
const child = createTestNode();
|
|
599
|
+
addNodeChild(parent, child);
|
|
600
|
+
setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
|
|
601
|
+
setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 0, 0, 20, 20);
|
|
602
|
+
child.x = 30;
|
|
603
|
+
invalidateNodeLocalTransform(child);
|
|
604
|
+
expect(getNodeWorldBoundsRectangle(parent)).toMatchObject({ x: 0, y: 0, width: 50, height: 20 });
|
|
605
|
+
|
|
606
|
+
child.x = 80;
|
|
607
|
+
invalidateNodeLocalTransform(child);
|
|
608
|
+
|
|
609
|
+
expect(getNodeWorldBoundsRectangle(parent)).toMatchObject({ x: 0, y: 0, width: 100, height: 20 });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('consults a descendant kind-owned bounds validity hook after the aggregate is cached', () => {
|
|
613
|
+
const parent = createTestNode();
|
|
614
|
+
const child = createTestNode();
|
|
615
|
+
const runtime = getEntityRuntime(child);
|
|
616
|
+
let input = { width: 20 };
|
|
617
|
+
let stampedInput = input;
|
|
618
|
+
runtime.computeLocalBoundsRectangle = (out: Rectangle) => {
|
|
619
|
+
setRectangle(out, 100, 0, input.width, 10);
|
|
620
|
+
stampedInput = input;
|
|
621
|
+
};
|
|
622
|
+
runtime.isLocalBoundsRectangleValid = () => stampedInput === input;
|
|
623
|
+
addNodeChild(parent, child);
|
|
624
|
+
setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
|
|
625
|
+
expect(getNodeWorldBoundsRectangle(parent).width).toBe(120);
|
|
626
|
+
|
|
627
|
+
input = { width: 50 };
|
|
628
|
+
|
|
629
|
+
expect(getNodeWorldBoundsRectangle(parent).width).toBe(150);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('refreshes cached aggregate bounds when a child is removed', () => {
|
|
633
|
+
const parent = createTestNode();
|
|
634
|
+
const child = createTestNode();
|
|
635
|
+
addNodeChild(parent, child);
|
|
636
|
+
setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
|
|
637
|
+
setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 100, 0, 200, 200);
|
|
638
|
+
expect(getNodeWorldBoundsRectangle(parent).width).toBe(300);
|
|
639
|
+
|
|
640
|
+
removeNodeChild(parent, child);
|
|
641
|
+
|
|
642
|
+
expect(getNodeWorldBoundsRectangle(parent)).toMatchObject({ x: 0, y: 0, width: 10, height: 10 });
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('refreshes cached aggregate bounds when a child is disabled or re-enabled', () => {
|
|
646
|
+
const parent = createTestNode();
|
|
647
|
+
const child = createTestNode();
|
|
648
|
+
addNodeChild(parent, child);
|
|
649
|
+
setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
|
|
650
|
+
setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 100, 0, 200, 200);
|
|
651
|
+
expect(getNodeWorldBoundsRectangle(parent).width).toBe(300);
|
|
652
|
+
|
|
653
|
+
setNodeEnabled(child, false);
|
|
654
|
+
expect(getNodeWorldBoundsRectangle(parent).width).toBe(10);
|
|
655
|
+
|
|
656
|
+
setNodeEnabled(child, true);
|
|
657
|
+
expect(getNodeWorldBoundsRectangle(parent).width).toBe(300);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('ignores an enabled child with zero-area world bounds', () => {
|
|
661
|
+
const parent = createTestNode();
|
|
662
|
+
const child = createTestNode();
|
|
663
|
+
addNodeChild(parent, child);
|
|
664
|
+
setRectangle(getNodeLocalBoundsRectangle(parent) as Rectangle, 0, 0, 10, 10);
|
|
665
|
+
setRectangle(getNodeLocalBoundsRectangle(child) as Rectangle, 0, 0, 0, 5);
|
|
666
|
+
child.x = 100;
|
|
667
|
+
invalidateNodeLocalTransform(child);
|
|
668
|
+
|
|
669
|
+
const bounds = getNodeWorldBoundsRectangle(parent);
|
|
670
|
+
|
|
671
|
+
expect(bounds).toMatchObject({ x: 0, y: 0, width: 10, height: 10 });
|
|
444
672
|
});
|
|
445
673
|
});
|
|
446
674
|
|
|
@@ -461,6 +689,74 @@ describe('setNodeHeight', () => {
|
|
|
461
689
|
setNodeHeight(node, 100);
|
|
462
690
|
expect(node.scaleY).toBe(0);
|
|
463
691
|
});
|
|
692
|
+
|
|
693
|
+
it('keeps a finite scale when the current height is zero', () => {
|
|
694
|
+
const node = createTestNode();
|
|
695
|
+
setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 0);
|
|
696
|
+
|
|
697
|
+
setNodeHeight(node, 100);
|
|
698
|
+
|
|
699
|
+
expect(node.scaleY).toBe(1);
|
|
700
|
+
expect(Number.isFinite(node.scaleY)).toBe(true);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
for (const [name, transform] of sizeRoundTripTransforms) {
|
|
704
|
+
it(`round-trips parent-space height through ${name}`, () => {
|
|
705
|
+
const parent = createTestNode();
|
|
706
|
+
const node = createTestNode();
|
|
707
|
+
addNodeChild(parent, node);
|
|
708
|
+
setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
|
|
709
|
+
Object.assign(node, transform);
|
|
710
|
+
invalidateNodeLocalTransform(node);
|
|
711
|
+
const sign = Math.sign(node.scaleY);
|
|
712
|
+
|
|
713
|
+
setNodeHeight(node, 100);
|
|
714
|
+
|
|
715
|
+
expect(getNodeHeight(node)).toBeCloseTo(100, 10);
|
|
716
|
+
expect(Math.sign(node.scaleY)).toBe(sign);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
it('declines an unsatisfiable height below the fixed x-axis contribution', () => {
|
|
721
|
+
const parent = createTestNode();
|
|
722
|
+
const node = createTestNode();
|
|
723
|
+
addNodeChild(parent, node);
|
|
724
|
+
setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
|
|
725
|
+
node.rotation = 30;
|
|
726
|
+
invalidateNodeLocalTransform(node);
|
|
727
|
+
const scaleY = node.scaleY;
|
|
728
|
+
|
|
729
|
+
setNodeHeight(node, 4);
|
|
730
|
+
|
|
731
|
+
expect(node.scaleY).toBe(scaleY);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('does not mutate at a singular rotation and still solves near it', () => {
|
|
735
|
+
const singularParent = createTestNode();
|
|
736
|
+
const singular = createTestNode();
|
|
737
|
+
addNodeChild(singularParent, singular);
|
|
738
|
+
setRectangle(getNodeLocalBoundsRectangle(singular) as Rectangle, 0, 0, 10, 20);
|
|
739
|
+
singular.rotation = 90;
|
|
740
|
+
singular.scaleY = 2;
|
|
741
|
+
invalidateNodeLocalTransform(singular);
|
|
742
|
+
|
|
743
|
+
setNodeHeight(singular, 50);
|
|
744
|
+
|
|
745
|
+
expect(singular.scaleY).toBe(2);
|
|
746
|
+
expect(Number.isFinite(singular.scaleY)).toBe(true);
|
|
747
|
+
|
|
748
|
+
const nearParent = createTestNode();
|
|
749
|
+
const near = createTestNode();
|
|
750
|
+
addNodeChild(nearParent, near);
|
|
751
|
+
setRectangle(getNodeLocalBoundsRectangle(near) as Rectangle, 0, 0, 10, 20);
|
|
752
|
+
near.rotation = 89.999;
|
|
753
|
+
invalidateNodeLocalTransform(near);
|
|
754
|
+
|
|
755
|
+
setNodeHeight(near, 50);
|
|
756
|
+
|
|
757
|
+
expect(Number.isFinite(near.scaleY)).toBe(true);
|
|
758
|
+
expect(getNodeHeight(near)).toBeCloseTo(50, 8);
|
|
759
|
+
});
|
|
464
760
|
});
|
|
465
761
|
|
|
466
762
|
describe('setNodeWidth', () => {
|
|
@@ -480,6 +776,74 @@ describe('setNodeWidth', () => {
|
|
|
480
776
|
setNodeWidth(node, 100);
|
|
481
777
|
expect(node.scaleX).toBe(0);
|
|
482
778
|
});
|
|
779
|
+
|
|
780
|
+
it('keeps a finite scale when the current width is zero', () => {
|
|
781
|
+
const node = createTestNode();
|
|
782
|
+
setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 0, 10);
|
|
783
|
+
|
|
784
|
+
setNodeWidth(node, 100);
|
|
785
|
+
|
|
786
|
+
expect(node.scaleX).toBe(1);
|
|
787
|
+
expect(Number.isFinite(node.scaleX)).toBe(true);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
for (const [name, transform] of sizeRoundTripTransforms) {
|
|
791
|
+
it(`round-trips parent-space width through ${name}`, () => {
|
|
792
|
+
const parent = createTestNode();
|
|
793
|
+
const node = createTestNode();
|
|
794
|
+
addNodeChild(parent, node);
|
|
795
|
+
setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
|
|
796
|
+
Object.assign(node, transform);
|
|
797
|
+
invalidateNodeLocalTransform(node);
|
|
798
|
+
const sign = Math.sign(node.scaleX);
|
|
799
|
+
|
|
800
|
+
setNodeWidth(node, 100);
|
|
801
|
+
|
|
802
|
+
expect(getNodeWidth(node)).toBeCloseTo(100, 10);
|
|
803
|
+
expect(Math.sign(node.scaleX)).toBe(sign);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
it('declines an unsatisfiable width below the fixed y-axis contribution', () => {
|
|
808
|
+
const parent = createTestNode();
|
|
809
|
+
const node = createTestNode();
|
|
810
|
+
addNodeChild(parent, node);
|
|
811
|
+
setRectangle(getNodeLocalBoundsRectangle(node) as Rectangle, 0, 0, 10, 20);
|
|
812
|
+
node.rotation = 30;
|
|
813
|
+
invalidateNodeLocalTransform(node);
|
|
814
|
+
const scaleX = node.scaleX;
|
|
815
|
+
|
|
816
|
+
setNodeWidth(node, 5);
|
|
817
|
+
|
|
818
|
+
expect(node.scaleX).toBe(scaleX);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('does not mutate at a singular rotation and still solves near it', () => {
|
|
822
|
+
const singularParent = createTestNode();
|
|
823
|
+
const singular = createTestNode();
|
|
824
|
+
addNodeChild(singularParent, singular);
|
|
825
|
+
setRectangle(getNodeLocalBoundsRectangle(singular) as Rectangle, 0, 0, 10, 20);
|
|
826
|
+
singular.rotation = 90;
|
|
827
|
+
singular.scaleX = 2;
|
|
828
|
+
invalidateNodeLocalTransform(singular);
|
|
829
|
+
|
|
830
|
+
setNodeWidth(singular, 50);
|
|
831
|
+
|
|
832
|
+
expect(singular.scaleX).toBe(2);
|
|
833
|
+
expect(Number.isFinite(singular.scaleX)).toBe(true);
|
|
834
|
+
|
|
835
|
+
const nearParent = createTestNode();
|
|
836
|
+
const near = createTestNode();
|
|
837
|
+
addNodeChild(nearParent, near);
|
|
838
|
+
setRectangle(getNodeLocalBoundsRectangle(near) as Rectangle, 0, 0, 10, 20);
|
|
839
|
+
near.rotation = 89.999;
|
|
840
|
+
invalidateNodeLocalTransform(near);
|
|
841
|
+
|
|
842
|
+
setNodeWidth(near, 50);
|
|
843
|
+
|
|
844
|
+
expect(Number.isFinite(near.scaleX)).toBe(true);
|
|
845
|
+
expect(getNodeWidth(near)).toBeCloseTo(50, 8);
|
|
846
|
+
});
|
|
483
847
|
});
|
|
484
848
|
|
|
485
849
|
function cloneAndInvalidateRect(rect: Rectangle): Rectangle {
|
|
@@ -494,4 +858,11 @@ function invalidateRect(rect: Rectangle | null): void {
|
|
|
494
858
|
|
|
495
859
|
type TestNode = Node<HasTransform2D & HasBoundsRectangle> & HasTransform2D & HasBoundsRectangle;
|
|
496
860
|
|
|
861
|
+
const sizeRoundTripTransforms = [
|
|
862
|
+
['rotation', { rotation: 30, scaleX: 1, scaleY: 1, skewX: 0, skewY: 0 }],
|
|
863
|
+
['skew and non-uniform scale', { rotation: 10, scaleX: 0.7, scaleY: 2.3, skewX: 25, skewY: -15 }],
|
|
864
|
+
['mirrored x scale', { rotation: -25, scaleX: -1.4, scaleY: 0.6, skewX: -10, skewY: 20 }],
|
|
865
|
+
['mirrored y scale', { rotation: 40, scaleX: 1.8, scaleY: -1.2, skewX: 12, skewY: -18 }],
|
|
866
|
+
] as const;
|
|
867
|
+
|
|
497
868
|
const TestKind = 'Test';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getEntityRuntime as getRawEntityRuntime } from '@flighthq/entity/contract';
|
|
2
|
+
import { setLogSink } from '@flighthq/log/contract';
|
|
3
|
+
import type { HasTransform2D, HasTransform2DRuntime, LogEntry, Node } from '@flighthq/types/contract';
|
|
4
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { areNodeGuardsEnabled, disableNodeGuards, enableNodeGuards } from './enableNodeGuards';
|
|
7
|
+
import { initTransform2DRuntimeTrait, initTransform2DTrait } from './hasTransform2d';
|
|
8
|
+
import { reparentNode } from './hierarchy';
|
|
9
|
+
import { createNode } from './node';
|
|
10
|
+
import { invalidateNodeLocalTransform } from './revision';
|
|
11
|
+
|
|
12
|
+
describe('areNodeGuardsEnabled', () => {
|
|
13
|
+
test('reports whether the guards are installed', () => {
|
|
14
|
+
expect(areNodeGuardsEnabled()).toBe(false);
|
|
15
|
+
enableNodeGuards();
|
|
16
|
+
expect(areNodeGuardsEnabled()).toBe(true);
|
|
17
|
+
disableNodeGuards();
|
|
18
|
+
expect(areNodeGuardsEnabled()).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('disableNodeGuards', () => {
|
|
23
|
+
test('leaves the core silent again once removed', () => {
|
|
24
|
+
enableNodeGuards();
|
|
25
|
+
disableNodeGuards();
|
|
26
|
+
expect(captureLog(() => reparentNode(transformNode(), collapsedParent('gone')))).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('enableNodeGuards', () => {
|
|
31
|
+
// `logOnce` keys on the parent name and has no reset, so each triggering test must use a DISTINCT
|
|
32
|
+
// name — a repeat would be deduplicated away and assert nothing while still looking green.
|
|
33
|
+
test('warns, naming both nodes, when a reparent is declined', () => {
|
|
34
|
+
enableNodeGuards();
|
|
35
|
+
const child = transformNode();
|
|
36
|
+
child.name = 'child';
|
|
37
|
+
const entries = captureLog(() => reparentNode(child, collapsedParent('collapsed-warns')));
|
|
38
|
+
expect(entries).toHaveLength(1);
|
|
39
|
+
expect(entries[0].data).toMatchObject({ child: 'child', newParent: 'collapsed-warns' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('stays silent when the reparent succeeds', () => {
|
|
43
|
+
enableNodeGuards();
|
|
44
|
+
const healthy = transformNode();
|
|
45
|
+
invalidateNodeLocalTransform(healthy);
|
|
46
|
+
expect(captureLog(() => reparentNode(transformNode(), healthy))).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
disableNodeGuards();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function captureLog(run: () => void): LogEntry[] {
|
|
55
|
+
const entries: LogEntry[] = [];
|
|
56
|
+
setLogSink((entry) => entries.push(entry));
|
|
57
|
+
try {
|
|
58
|
+
run();
|
|
59
|
+
} finally {
|
|
60
|
+
setLogSink(null);
|
|
61
|
+
}
|
|
62
|
+
return entries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collapsedParent(name: string): Node<HasTransform2D> & HasTransform2D {
|
|
66
|
+
const parent = transformNode();
|
|
67
|
+
parent.name = name;
|
|
68
|
+
parent.scaleX = 0;
|
|
69
|
+
invalidateNodeLocalTransform(parent);
|
|
70
|
+
return parent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function transformNode(): Node<HasTransform2D> & HasTransform2D {
|
|
74
|
+
const node = createNode('NodeGuardTest') as Node<HasTransform2D> & HasTransform2D;
|
|
75
|
+
initTransform2DTrait(node);
|
|
76
|
+
initTransform2DRuntimeTrait(getRawEntityRuntime(node) as HasTransform2DRuntime);
|
|
77
|
+
return node;
|
|
78
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { HasAppearance } from '@flighthq/types/contract';
|
|
1
|
+
import type { HasAppearance, HasAppearanceRuntime, NodeRuntime } from '@flighthq/types/contract';
|
|
2
2
|
|
|
3
|
-
import { initAppearanceTrait } from './hasAppearance';
|
|
4
|
-
import { createNode } from './node';
|
|
3
|
+
import { initAppearanceRuntimeTrait, initAppearanceTrait } from './hasAppearance';
|
|
4
|
+
import { createNode, createNodeRuntime } from './node';
|
|
5
5
|
|
|
6
6
|
const TestKind = 'Test';
|
|
7
7
|
|
|
@@ -10,6 +10,36 @@ function makeTarget(): HasAppearance {
|
|
|
10
10
|
return node;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
describe('initAppearanceRuntimeTrait', () => {
|
|
14
|
+
let runtime: NodeRuntime<HasAppearance> & HasAppearanceRuntime;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
runtime = createNodeRuntime() as NodeRuntime<HasAppearance> & HasAppearanceRuntime;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('initializes default values', () => {
|
|
21
|
+
initAppearanceRuntimeTrait(runtime);
|
|
22
|
+
|
|
23
|
+
expect(runtime.worldAlpha).toBeNull();
|
|
24
|
+
expect(runtime.worldAlphaUsingAppearanceId).toStrictEqual(-1);
|
|
25
|
+
expect(runtime.worldAlphaUsingParentAppearanceId).toStrictEqual(-1);
|
|
26
|
+
expect(runtime.worldAppearanceId).toStrictEqual(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('resets a resolved cache back to the unresolved state', () => {
|
|
30
|
+
runtime.worldAlpha = 0.25;
|
|
31
|
+
runtime.worldAlphaUsingAppearanceId = 7;
|
|
32
|
+
runtime.worldAlphaUsingParentAppearanceId = 9;
|
|
33
|
+
runtime.worldAppearanceId = 11;
|
|
34
|
+
initAppearanceRuntimeTrait(runtime);
|
|
35
|
+
|
|
36
|
+
expect(runtime.worldAlpha).toBeNull();
|
|
37
|
+
expect(runtime.worldAlphaUsingAppearanceId).toStrictEqual(-1);
|
|
38
|
+
expect(runtime.worldAlphaUsingParentAppearanceId).toStrictEqual(-1);
|
|
39
|
+
expect(runtime.worldAppearanceId).toStrictEqual(0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
13
43
|
describe('initAppearanceTrait', () => {
|
|
14
44
|
it('sets default values when called with no options', () => {
|
|
15
45
|
const target = makeTarget();
|
|
@@ -34,6 +34,14 @@ describe('initBoundsRectangleRuntimeTrait', () => {
|
|
|
34
34
|
expect(runtime.localBoundsRectangle).toBeNull();
|
|
35
35
|
expect(runtime.worldBoundsRectangle).toBeNull();
|
|
36
36
|
expect(runtime.computeLocalBoundsRectangle).toStrictEqual(defaultComputeLocalBoundsRectangle);
|
|
37
|
+
expect(runtime.isLocalBoundsRectangleValid).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('installs a kind-owned local-bounds validity hook', () => {
|
|
41
|
+
const isLocalBoundsRectangleValid = vi.fn(() => true);
|
|
42
|
+
initBoundsRectangleRuntimeTrait(runtime, { isLocalBoundsRectangleValid });
|
|
43
|
+
|
|
44
|
+
expect(runtime.isLocalBoundsRectangleValid).toBe(isLocalBoundsRectangleValid);
|
|
37
45
|
});
|
|
38
46
|
});
|
|
39
47
|
|
|
@@ -34,6 +34,8 @@ describe('initTransform2DTrait', () => {
|
|
|
34
34
|
it('initializes default values', () => {
|
|
35
35
|
initTransform2DTrait(node);
|
|
36
36
|
|
|
37
|
+
expect(node.pivotX).toStrictEqual(0);
|
|
38
|
+
expect(node.pivotY).toStrictEqual(0);
|
|
37
39
|
expect(node.rotation).toStrictEqual(0);
|
|
38
40
|
expect(node.scaleX).toStrictEqual(1);
|
|
39
41
|
expect(node.scaleY).toStrictEqual(1);
|
|
@@ -45,6 +47,8 @@ describe('initTransform2DTrait', () => {
|
|
|
45
47
|
|
|
46
48
|
it('allows pre-defined values', () => {
|
|
47
49
|
const base = {
|
|
50
|
+
pivotX: 4,
|
|
51
|
+
pivotY: 8,
|
|
48
52
|
scaleX: 2,
|
|
49
53
|
scaleY: 3,
|
|
50
54
|
skewX: 15,
|
|
@@ -54,6 +58,8 @@ describe('initTransform2DTrait', () => {
|
|
|
54
58
|
y: 200,
|
|
55
59
|
};
|
|
56
60
|
initTransform2DTrait(node, base);
|
|
61
|
+
expect(node.pivotX).toStrictEqual(base.pivotX);
|
|
62
|
+
expect(node.pivotY).toStrictEqual(base.pivotY);
|
|
57
63
|
expect(node.scaleX).toStrictEqual(base.scaleX);
|
|
58
64
|
expect(node.scaleY).toStrictEqual(base.scaleY);
|
|
59
65
|
expect(node.skewX).toStrictEqual(base.skewX);
|