@forgeax/engine-scene 0.1.20 → 0.1.23
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/README.md +37 -9
- package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
- package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
- package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
- package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
- package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
- package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
- package/dist/__tests__/structural.test.d.ts +2 -0
- package/dist/__tests__/structural.test.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
- package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
- package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
- package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
- package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
- package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
- package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -1
- package/dist/components/child-of.d.ts +15 -8
- package/dist/components/child-of.d.ts.map +1 -1
- package/dist/components/children.d.ts.map +1 -1
- package/dist/components/transform.d.ts +7 -43
- package/dist/components/transform.d.ts.map +1 -1
- package/dist/errors.d.ts +23 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +616 -372
- package/dist/index.mjs.map +1 -1
- package/dist/instances/binding.d.ts +29 -0
- package/dist/instances/binding.d.ts.map +1 -0
- package/dist/instances/externalization.d.ts.map +1 -1
- package/dist/instances/scene-instances.d.ts +5 -17
- package/dist/instances/scene-instances.d.ts.map +1 -1
- package/dist/instances/state.d.ts +28 -0
- package/dist/instances/state.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/systems/hierarchy-projection.d.ts.map +1 -1
- package/dist/systems/index.d.ts +1 -1
- package/dist/systems/index.d.ts.map +1 -1
- package/dist/systems/propagate-transforms.d.ts +1 -2
- package/dist/systems/propagate-transforms.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components.unit.test.ts +34 -2
- package/src/__tests__/flat-propagation.contract.test.ts +128 -0
- package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
- package/src/__tests__/flat-propagation.perf.test.ts +336 -0
- package/src/__tests__/plugin.integration.test.ts +2 -2
- package/src/__tests__/propagation.unit.test.ts +207 -91
- package/src/__tests__/scene-binding.integration.test.ts +124 -0
- package/src/__tests__/schedule-order.contract.test.ts +43 -0
- package/src/__tests__/structural.test.ts +23 -0
- package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
- package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
- package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
- package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
- package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
- package/src/assets/scene-decoder.ts +6 -1
- package/src/components/child-of.ts +15 -8
- package/src/components/children.ts +6 -0
- package/src/components/transform.ts +23 -81
- package/src/errors.ts +26 -2
- package/src/index.ts +10 -1
- package/src/instances/binding.ts +77 -0
- package/src/instances/externalization.ts +7 -1
- package/src/instances/scene-instances.ts +47 -76
- package/src/instances/state.ts +62 -0
- package/src/plugin.ts +9 -2
- package/src/systems/hierarchy-projection.ts +25 -8
- package/src/systems/index.ts +3 -0
- package/src/systems/propagate-transforms.ts +376 -245
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
- package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
|
@@ -1,163 +1,279 @@
|
|
|
1
|
-
import { FixedUpdate, Update, World } from '@forgeax/engine-ecs';
|
|
1
|
+
import { type EntityHandle, FixedUpdate, Update, World } from '@forgeax/engine-ecs';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { ChangeEpochExhaustedError } from '../../../ecs/src/errors';
|
|
4
|
+
import { worldInternal } from '../../../ecs/src/world-internal';
|
|
5
|
+
import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '../index';
|
|
4
6
|
import { registerPropagateTransforms } from '../systems';
|
|
5
7
|
|
|
8
|
+
function spawnTransform(world: World, pos: number[], parent?: EntityHandle) {
|
|
9
|
+
const components = [{ component: Transform, data: { pos } }];
|
|
10
|
+
if (parent === undefined) return world.spawn(...components).unwrap();
|
|
11
|
+
return world.spawn(...components, { component: ChildOf, data: { parent } }).unwrap();
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
describe('scene propagation', () => {
|
|
7
|
-
it('
|
|
15
|
+
it('materializes the derived world component from Transform at the ECS boundary', () => {
|
|
8
16
|
const world = new World();
|
|
9
|
-
|
|
17
|
+
const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
18
|
+
|
|
19
|
+
expect(world.get(entity, GlobalTransform).ok).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('materializes the transform pair when a hierarchy edge is the only authored component', () => {
|
|
23
|
+
const world = new World();
|
|
24
|
+
const parent = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
|
|
25
|
+
const child = world.spawn({ component: ChildOf, data: { parent } }).unwrap();
|
|
26
|
+
|
|
27
|
+
expect(world.get(child, Transform).ok).toBe(true);
|
|
28
|
+
expect(world.get(child, GlobalTransform).ok).toBe(true);
|
|
29
|
+
expect(propagateTransforms(world).ok).toBe(true);
|
|
30
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(4);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects a Transform-only entity with a structured pair error', () => {
|
|
34
|
+
const world = new World();
|
|
35
|
+
const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
36
|
+
world.removeComponent(entity, GlobalTransform).unwrap();
|
|
37
|
+
|
|
38
|
+
const result = propagateTransforms(world);
|
|
39
|
+
|
|
40
|
+
expect(result.ok).toBe(false);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
expect(result.error.code).toBe('hierarchy-broken');
|
|
43
|
+
expect(result.error.expected).toContain('GlobalTransform pair');
|
|
44
|
+
expect(result.error.detail?.entity).toBe(entity);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects a GlobalTransform-only entity with a structured pair error', () => {
|
|
49
|
+
const world = new World();
|
|
50
|
+
const entity = world.spawn({ component: GlobalTransform, data: {} }).unwrap();
|
|
51
|
+
|
|
52
|
+
const result = propagateTransforms(world);
|
|
10
53
|
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
expect(result.error.code).toBe('hierarchy-broken');
|
|
57
|
+
expect(result.error.expected).toContain('Transform pair');
|
|
58
|
+
expect(result.error.detail?.entity).toBe(entity);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('preserves the ECS range-publication error in flat propagation diagnostics', () => {
|
|
63
|
+
const world = new World();
|
|
64
|
+
spawnTransform(world, [2, 0, 0]);
|
|
65
|
+
const internal = world[worldInternal];
|
|
66
|
+
const originalMarkRange = internal.markComponentRangeChanged;
|
|
67
|
+
Object.defineProperty(internal, 'markComponentRangeChanged', {
|
|
68
|
+
configurable: true,
|
|
69
|
+
value: () => {
|
|
70
|
+
throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const result = propagateTransforms(world);
|
|
75
|
+
expect(result.ok).toBe(false);
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
expect(result.error.code).toBe('hierarchy-broken');
|
|
78
|
+
expect(result.error.detail?.kind).toBe('derived-write');
|
|
79
|
+
if (result.error.detail?.kind === 'derived-write') {
|
|
80
|
+
expect(result.error.detail.cause.code).toBe('change-epoch-exhausted');
|
|
81
|
+
expect(result.error.detail.cause.expected).toBe(
|
|
82
|
+
'mutationEpoch < Number.MAX_SAFE_INTEGER',
|
|
83
|
+
);
|
|
84
|
+
expect(result.error.detail.cause.hint).toContain('Rebuild the World');
|
|
85
|
+
expect(result.error.detail.cause.detail).toEqual({ epoch: Number.MAX_SAFE_INTEGER });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
Object.defineProperty(internal, 'markComponentRangeChanged', {
|
|
90
|
+
configurable: true,
|
|
91
|
+
value: originalMarkRange,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('registers the TransformPropagation owner in Update and FixedUpdate', () => {
|
|
97
|
+
const world = new World();
|
|
98
|
+
registerPropagateTransforms(world);
|
|
11
99
|
expect(
|
|
12
100
|
world
|
|
13
101
|
.inspect()
|
|
14
102
|
.schedules.find((entry) => entry.schedule === Update)
|
|
15
|
-
?.systems.map((
|
|
103
|
+
?.systems.map((s) => s.name),
|
|
16
104
|
).toContain('propagateTransforms');
|
|
17
105
|
expect(
|
|
18
106
|
world
|
|
19
107
|
.inspect()
|
|
20
108
|
.schedules.find((entry) => entry.schedule === FixedUpdate)
|
|
21
|
-
?.systems.map((
|
|
109
|
+
?.systems.map((s) => s.name),
|
|
22
110
|
).toContain('propagateTransformsFixed');
|
|
23
|
-
expect(world.inspect().schedules.map((entry) => entry.schedule.name)).not.toContain(
|
|
24
|
-
'FramePublish',
|
|
25
|
-
);
|
|
26
111
|
});
|
|
27
|
-
|
|
112
|
+
|
|
113
|
+
it('propagates a root and child into GlobalTransform', () => {
|
|
28
114
|
const world = new World();
|
|
29
115
|
registerPropagateTransforms(world);
|
|
30
|
-
const root = world
|
|
31
|
-
const child = world
|
|
32
|
-
.spawn(
|
|
33
|
-
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
34
|
-
{ component: ChildOf, data: { parent: root } },
|
|
35
|
-
)
|
|
36
|
-
.unwrap();
|
|
116
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
117
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
37
118
|
expect(world.update(1 / 60).ok).toBe(true);
|
|
38
|
-
expect(world.get(child,
|
|
119
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
|
|
39
120
|
});
|
|
40
121
|
|
|
41
122
|
it('cascades linked children when a parent is despawned', () => {
|
|
42
123
|
const world = new World();
|
|
43
124
|
registerPropagateTransforms(world);
|
|
44
|
-
const parent = world
|
|
45
|
-
const child = world
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
expect(result.ok).toBe(true);
|
|
125
|
+
const parent = spawnTransform(world, [0, 0, 0]);
|
|
126
|
+
const child = spawnTransform(world, [1, 0, 0], parent);
|
|
127
|
+
|
|
128
|
+
world.despawn(parent).unwrap();
|
|
129
|
+
|
|
130
|
+
expect(world.update(1 / 60).ok).toBe(true);
|
|
51
131
|
expect(world.get(child, Transform).ok).toBe(false);
|
|
52
132
|
});
|
|
53
133
|
|
|
54
134
|
it('keeps equal entity handles isolated across Worlds', () => {
|
|
55
135
|
const first = new World();
|
|
56
136
|
const second = new World();
|
|
57
|
-
const firstRoot = first
|
|
58
|
-
const secondRoot = second
|
|
59
|
-
|
|
137
|
+
const firstRoot = spawnTransform(first, [3, 0, 0]);
|
|
138
|
+
const secondRoot = spawnTransform(second, [7, 0, 0]);
|
|
60
139
|
expect(firstRoot).toBe(secondRoot);
|
|
61
140
|
expect(propagateTransforms(first).ok).toBe(true);
|
|
62
141
|
expect(propagateTransforms(second).ok).toBe(true);
|
|
63
|
-
expect(first.get(firstRoot,
|
|
64
|
-
expect(second.get(secondRoot,
|
|
142
|
+
expect(first.get(firstRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(3);
|
|
143
|
+
expect(second.get(secondRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(7);
|
|
65
144
|
});
|
|
66
145
|
|
|
67
|
-
it('
|
|
146
|
+
it('executes repeated propagation and preserves stable world semantics', () => {
|
|
68
147
|
const world = new World();
|
|
69
|
-
const root = world
|
|
70
|
-
const child = world
|
|
71
|
-
.spawn(
|
|
72
|
-
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
73
|
-
{ component: ChildOf, data: { parent: root } },
|
|
74
|
-
)
|
|
75
|
-
.unwrap();
|
|
148
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
149
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
76
150
|
|
|
77
151
|
const first = propagateTransforms(world);
|
|
78
152
|
const second = propagateTransforms(world);
|
|
79
|
-
expect(first).toBe(
|
|
80
|
-
expect(
|
|
153
|
+
expect(first.ok).toBe(true);
|
|
154
|
+
expect(second.ok).toBe(true);
|
|
155
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
|
|
156
|
+
|
|
157
|
+
const corruptedWorld = new Float32Array(16);
|
|
158
|
+
corruptedWorld[12] = -100;
|
|
159
|
+
world.set(child, GlobalTransform, { world: corruptedWorld }).unwrap();
|
|
160
|
+
const repaired = propagateTransforms(world);
|
|
161
|
+
expect(repaired.ok).toBe(true);
|
|
162
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
|
|
81
163
|
|
|
82
164
|
world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
|
|
83
165
|
const third = propagateTransforms(world);
|
|
84
|
-
expect(third).
|
|
85
|
-
expect(world.get(child,
|
|
166
|
+
expect(third.ok).toBe(true);
|
|
167
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
|
|
86
168
|
});
|
|
87
169
|
|
|
88
|
-
it('
|
|
170
|
+
it('recomputes the edited transform and its descendants', () => {
|
|
89
171
|
const world = new World();
|
|
90
|
-
const root = world
|
|
91
|
-
const child = world
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{ component: ChildOf, data: { parent: root } },
|
|
95
|
-
)
|
|
96
|
-
.unwrap();
|
|
97
|
-
const grandchild = world
|
|
98
|
-
.spawn(
|
|
99
|
-
{ component: Transform, data: { pos: [4, 0, 0] } },
|
|
100
|
-
{ component: ChildOf, data: { parent: child } },
|
|
101
|
-
)
|
|
102
|
-
.unwrap();
|
|
103
|
-
const sibling = world.spawn({ component: Transform, data: { pos: [20, 0, 0] } }).unwrap();
|
|
104
|
-
|
|
172
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
173
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
174
|
+
const grandchild = spawnTransform(world, [4, 0, 0], child);
|
|
175
|
+
const sibling = spawnTransform(world, [20, 0, 0]);
|
|
105
176
|
propagateTransforms(world).unwrap();
|
|
106
177
|
world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
|
|
107
178
|
propagateTransforms(world).unwrap();
|
|
179
|
+
expect(world.get(root, GlobalTransform).unwrap().world[12]).toBeCloseTo(2);
|
|
180
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
|
|
181
|
+
expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(14);
|
|
182
|
+
expect(world.get(sibling, GlobalTransform).unwrap().world[12]).toBeCloseTo(20);
|
|
183
|
+
});
|
|
108
184
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
185
|
+
it('recomputes a child after reparenting to a different live parent', () => {
|
|
186
|
+
const world = new World();
|
|
187
|
+
const firstParent = spawnTransform(world, [10, 0, 0]);
|
|
188
|
+
const secondParent = spawnTransform(world, [100, 0, 0]);
|
|
189
|
+
const child = spawnTransform(world, [1, 0, 0], firstParent);
|
|
190
|
+
propagateTransforms(world).unwrap();
|
|
191
|
+
world.set(child, ChildOf, { parent: secondParent }).unwrap();
|
|
192
|
+
propagateTransforms(world).unwrap();
|
|
193
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
|
|
113
194
|
});
|
|
114
195
|
|
|
115
|
-
it('
|
|
196
|
+
it('publishes a local fallback before returning a stale-parent error', () => {
|
|
116
197
|
const world = new World();
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
198
|
+
const parent = spawnTransform(world, [10, 0, 0]);
|
|
199
|
+
const child = spawnTransform(world, [1, 0, 0], parent);
|
|
200
|
+
propagateTransforms(world).unwrap();
|
|
201
|
+
const baseline = new Float32Array(world.get(child, GlobalTransform).unwrap().world);
|
|
202
|
+
|
|
203
|
+
const staleParent = spawnTransform(world, [50, 0, 0]);
|
|
204
|
+
world.despawn(staleParent).unwrap();
|
|
205
|
+
world.set(child, ChildOf, { parent: staleParent }).unwrap();
|
|
206
|
+
const fault = propagateTransforms(world);
|
|
207
|
+
|
|
208
|
+
expect(fault.ok).toBe(false);
|
|
209
|
+
if (!fault.ok) expect(fault.error.code).toBe('hierarchy-broken');
|
|
210
|
+
const fallback = world.get(child, GlobalTransform).unwrap().world;
|
|
211
|
+
expect(fallback[12]).toBeCloseTo(1);
|
|
212
|
+
expect(fallback[12]).not.toBeCloseTo(baseline[12] ?? Number.NaN);
|
|
213
|
+
|
|
214
|
+
world.set(child, ChildOf, { parent }).unwrap();
|
|
215
|
+
expect(propagateTransforms(world).ok).toBe(true);
|
|
216
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(baseline[12] ?? 0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('cuts every member of a cycle to a finite local root fallback', () => {
|
|
220
|
+
const world = new World();
|
|
221
|
+
const root = spawnTransform(world, [0, 0, 0]);
|
|
222
|
+
const first = spawnTransform(world, [1, 0, 0], root);
|
|
223
|
+
const second = spawnTransform(world, [2, 0, 0], first);
|
|
224
|
+
world.set(first, ChildOf, { parent: second }).unwrap();
|
|
225
|
+
|
|
226
|
+
const result = propagateTransforms(world);
|
|
227
|
+
|
|
228
|
+
expect(result.ok).toBe(false);
|
|
229
|
+
if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
|
|
230
|
+
for (const entity of [first, second]) {
|
|
231
|
+
const matrix = world.get(entity, GlobalTransform).unwrap().world;
|
|
232
|
+
expect([...matrix].every(Number.isFinite)).toBe(true);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('expands a generic ChildOf mutation and updates descendants', () => {
|
|
237
|
+
const world = new World();
|
|
238
|
+
const firstParent = spawnTransform(world, [10, 0, 0]);
|
|
239
|
+
const secondParent = spawnTransform(world, [100, 0, 0]);
|
|
240
|
+
const child = spawnTransform(world, [1, 0, 0], firstParent);
|
|
241
|
+
const grandchild = spawnTransform(world, [2, 0, 0], child);
|
|
131
242
|
|
|
132
243
|
propagateTransforms(world).unwrap();
|
|
133
244
|
world.set(child, ChildOf, { parent: secondParent }).unwrap();
|
|
134
245
|
propagateTransforms(world).unwrap();
|
|
135
246
|
|
|
136
|
-
expect(world.get(child,
|
|
137
|
-
expect(world.get(grandchild,
|
|
247
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
|
|
248
|
+
expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(103);
|
|
138
249
|
});
|
|
139
250
|
|
|
140
|
-
it('
|
|
251
|
+
it('returns a structured error for a parent cycle', () => {
|
|
252
|
+
const world = new World();
|
|
253
|
+
const root = spawnTransform(world, [1, 0, 0]);
|
|
254
|
+
const child = spawnTransform(world, [2, 0, 0], root);
|
|
255
|
+
const result = world.addComponent(root, { component: ChildOf, data: { parent: child } });
|
|
256
|
+
expect(result.ok).toBe(false);
|
|
257
|
+
if (!result.ok) {
|
|
258
|
+
expect(result.error.code).toBe('relationship-self-cycle');
|
|
259
|
+
expect(result.error.hint).toContain('descendant');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('publishes local writes before the same-tick world read', () => {
|
|
141
264
|
const world = new World();
|
|
142
265
|
registerPropagateTransforms(world);
|
|
143
|
-
const root = world
|
|
144
|
-
const child = world
|
|
145
|
-
.spawn(
|
|
146
|
-
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
147
|
-
{ component: ChildOf, data: { parent: root } },
|
|
148
|
-
)
|
|
149
|
-
.unwrap();
|
|
266
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
267
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
150
268
|
world
|
|
151
269
|
.addSystem(Update, {
|
|
152
270
|
name: 'late-pose',
|
|
153
|
-
before: ['propagateTransforms'],
|
|
154
271
|
queries: [],
|
|
272
|
+
before: ['propagateTransforms'],
|
|
155
273
|
fn: () => world.set(child, Transform, { pos: [8, 0, 0] }),
|
|
156
274
|
})
|
|
157
275
|
.unwrap();
|
|
158
|
-
|
|
159
276
|
world.update(0).unwrap();
|
|
160
|
-
|
|
161
|
-
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
|
|
277
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
|
|
162
278
|
});
|
|
163
279
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ok } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { sceneAssetDecoder } from '../assets/scene-decoder.js';
|
|
4
|
+
import {
|
|
5
|
+
resolveSceneEntity,
|
|
6
|
+
type SceneEntityRef,
|
|
7
|
+
sceneEntity,
|
|
8
|
+
validateSceneBindings,
|
|
9
|
+
} from '../instances/binding.js';
|
|
10
|
+
import { externalizeSceneAsset } from '../instances/externalization.js';
|
|
11
|
+
|
|
12
|
+
describe('SceneEntityRef instance binding', () => {
|
|
13
|
+
it('resolves by scene sourceKey and bindingKey, not display name', () => {
|
|
14
|
+
const ref: SceneEntityRef = sceneEntity('level/main', 'player');
|
|
15
|
+
const first = resolveSceneEntity(ref, {
|
|
16
|
+
sceneSourceKey: 'level/main',
|
|
17
|
+
bindings: new Map([['player', 11]]),
|
|
18
|
+
});
|
|
19
|
+
const second = resolveSceneEntity(ref, {
|
|
20
|
+
sceneSourceKey: 'level/main',
|
|
21
|
+
bindings: new Map([['player', 22]]),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(first).toEqual({ ok: true, value: 11 });
|
|
25
|
+
expect(second).toEqual({ ok: true, value: 22 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a missing or cross-instance binding with a closed error', () => {
|
|
29
|
+
const result = resolveSceneEntity(sceneEntity('level/main', 'camera'), {
|
|
30
|
+
sceneSourceKey: 'level/main',
|
|
31
|
+
bindings: new Map([['player', 11]]),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(result).toMatchObject({
|
|
35
|
+
ok: false,
|
|
36
|
+
error: {
|
|
37
|
+
code: 'scene-binding-missing',
|
|
38
|
+
expected: expect.stringContaining('bindingKey'),
|
|
39
|
+
hint: expect.stringContaining('declare'),
|
|
40
|
+
detail: { sceneSourceKey: 'level/main', bindingKey: 'camera' },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects a reference from another concrete SceneInstance', () => {
|
|
46
|
+
const ref = sceneEntity('level/other', 'player');
|
|
47
|
+
const result = resolveSceneEntity(ref, {
|
|
48
|
+
sceneSourceKey: 'level/main',
|
|
49
|
+
bindings: new Map([['player', 11]]),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result).toMatchObject({
|
|
53
|
+
ok: false,
|
|
54
|
+
error: {
|
|
55
|
+
code: 'scene-binding-wrong-instance',
|
|
56
|
+
detail: { sceneSourceKey: 'level/other', bindingKey: 'player' },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects duplicate declarations before an instance can publish bindings', () => {
|
|
62
|
+
const result = validateSceneBindings('level/main', ['player', 'player']);
|
|
63
|
+
|
|
64
|
+
expect(result).toMatchObject({
|
|
65
|
+
ok: false,
|
|
66
|
+
error: {
|
|
67
|
+
code: 'scene-binding-duplicate',
|
|
68
|
+
expected: expect.stringContaining('unique'),
|
|
69
|
+
hint: expect.stringContaining('rename'),
|
|
70
|
+
detail: { sceneSourceKey: 'level/main', bindingKey: 'player' },
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('keeps binding identity stable when the display name or source path changes', () => {
|
|
76
|
+
const ref = sceneEntity('level/main', 'player');
|
|
77
|
+
const result = resolveSceneEntity(ref, {
|
|
78
|
+
sceneSourceKey: 'level/main',
|
|
79
|
+
bindings: new Map([['player', 11]]),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ ok: true, value: 11 });
|
|
83
|
+
expect(ref).not.toHaveProperty('name');
|
|
84
|
+
expect(ref).not.toHaveProperty('path');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('preserves scene source and binding metadata through decode and externalization', async () => {
|
|
88
|
+
const envelope = {
|
|
89
|
+
guid: '00000000-0000-0000-0000-000000000021',
|
|
90
|
+
kind: 'scene',
|
|
91
|
+
payload: {
|
|
92
|
+
kind: 'scene' as const,
|
|
93
|
+
sourceKey: 'world/main',
|
|
94
|
+
entities: [{ localId: 0, bindingKey: 'player', components: {} }],
|
|
95
|
+
},
|
|
96
|
+
refs: [],
|
|
97
|
+
artifacts: {},
|
|
98
|
+
} as unknown as Parameters<typeof sceneAssetDecoder.decode>[0]['envelope'];
|
|
99
|
+
const decoded = await sceneAssetDecoder.decode({
|
|
100
|
+
envelope,
|
|
101
|
+
artifacts: { read: async () => ok(new Uint8Array()) },
|
|
102
|
+
signal: new AbortController().signal,
|
|
103
|
+
});
|
|
104
|
+
expect(decoded).toMatchObject({
|
|
105
|
+
ok: true,
|
|
106
|
+
value: {
|
|
107
|
+
sourceKey: 'world/main',
|
|
108
|
+
entities: [{ bindingKey: 'player' }],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
if (!decoded.ok) return;
|
|
112
|
+
|
|
113
|
+
const externalized = externalizeSceneAsset(decoded.value, () => ({}));
|
|
114
|
+
expect(externalized).toMatchObject({
|
|
115
|
+
ok: true,
|
|
116
|
+
value: {
|
|
117
|
+
payload: {
|
|
118
|
+
sourceKey: 'world/main',
|
|
119
|
+
entities: [{ bindingKey: 'player' }],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { FixedUpdate, Update, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { GlobalTransform, Transform } from '../index';
|
|
4
|
+
import { registerPropagateTransforms } from '../systems';
|
|
5
|
+
|
|
6
|
+
describe('TransformPropagation schedule contract', () => {
|
|
7
|
+
it('exposes one ordering token in both Update and FixedUpdate', () => {
|
|
8
|
+
const world = new World();
|
|
9
|
+
registerPropagateTransforms(world);
|
|
10
|
+
const inspection = world.inspect();
|
|
11
|
+
const update = inspection.schedules.find((entry) => entry.schedule === Update);
|
|
12
|
+
const fixed = inspection.schedules.find((entry) => entry.schedule === FixedUpdate);
|
|
13
|
+
|
|
14
|
+
expect(update?.systems.map((system) => system.name)).toContain('propagateTransforms');
|
|
15
|
+
expect(fixed?.systems.map((system) => system.name)).toContain('propagateTransformsFixed');
|
|
16
|
+
expect(inspection.schedules.map((entry) => entry.schedule.name)).toEqual(
|
|
17
|
+
expect.arrayContaining(['Update', 'FixedUpdate']),
|
|
18
|
+
);
|
|
19
|
+
expect(inspection.schedules.map((entry) => entry.schedule.name)).not.toContain('FramePublish');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps local and world query surfaces disjoint', () => {
|
|
23
|
+
const world = new World();
|
|
24
|
+
const entity = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
|
|
25
|
+
registerPropagateTransforms(world);
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
world
|
|
29
|
+
.query({ read: [Transform] })
|
|
30
|
+
.unwrap()
|
|
31
|
+
.at(entity)
|
|
32
|
+
?.get(Transform).pos[0],
|
|
33
|
+
).toBe(4);
|
|
34
|
+
expect(
|
|
35
|
+
world
|
|
36
|
+
.query({ read: [GlobalTransform] })
|
|
37
|
+
.unwrap()
|
|
38
|
+
.at(entity),
|
|
39
|
+
).toBeDefined();
|
|
40
|
+
expect(world.update(0).ok).toBe(true);
|
|
41
|
+
expect(world.get(entity, GlobalTransform).unwrap().world[12]).toBeCloseTo(4);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @ts-expect-error The package test tsconfig intentionally omits Node globals.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('SceneInstance structure characterization', () => {
|
|
6
|
+
it('records the current instance state surface', async () => {
|
|
7
|
+
const source = readFileSync(
|
|
8
|
+
new URL('../instances/scene-instances.ts', import.meta.url),
|
|
9
|
+
'utf8',
|
|
10
|
+
);
|
|
11
|
+
const snapshot = {
|
|
12
|
+
lines: source.split('\n').length - 1,
|
|
13
|
+
exportedFunctions: (source.match(/^export function\b/gm) ?? []).length,
|
|
14
|
+
hasStatePayload: source.includes('export interface SceneInstanceStatePayload'),
|
|
15
|
+
hasWorldStateWeakMap: source.includes('const sceneWorldStates = new WeakMap'),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
expect(snapshot.lines).toBeLessThan(1564);
|
|
19
|
+
expect(snapshot.exportedFunctions).toBe(27);
|
|
20
|
+
expect(snapshot.hasStatePayload).toBe(false);
|
|
21
|
+
expect(snapshot.hasWorldStateWeakMap).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|