@forgeax/engine-scene 0.1.21 → 0.1.24
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 +82 -11
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +5 -3
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -1
- 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__/hierarchy-propagation.perf.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-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 +23 -14
- package/dist/components/child-of.d.ts.map +1 -1
- package/dist/components/children.d.ts +14 -13
- 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 +1122 -358
- 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 +6 -18
- 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 +22 -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__/fixtures/malformed-hierarchy-edge.ts +26 -4
- 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__/hierarchy-propagation.perf.test.ts +149 -0
- package/src/__tests__/plugin.integration.test.ts +2 -2
- package/src/__tests__/propagation.unit.test.ts +271 -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 +38 -27
- package/src/components/children.ts +36 -37
- 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 +51 -80
- 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 +1142 -240
- 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,343 @@
|
|
|
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';
|
|
7
|
+
import {
|
|
8
|
+
beginTransformPropagationTrace,
|
|
9
|
+
endTransformPropagationTrace,
|
|
10
|
+
} from '../systems/propagate-transforms';
|
|
11
|
+
import { setMalformedParentEdge } from './fixtures/malformed-hierarchy-edge';
|
|
12
|
+
|
|
13
|
+
function spawnTransform(world: World, pos: number[], parent?: EntityHandle) {
|
|
14
|
+
const components = [{ component: Transform, data: { pos } }];
|
|
15
|
+
if (parent === undefined) return world.spawn(...components).unwrap();
|
|
16
|
+
return world.spawn(...components, { component: ChildOf, data: { parent } }).unwrap();
|
|
17
|
+
}
|
|
5
18
|
|
|
6
19
|
describe('scene propagation', () => {
|
|
7
|
-
it('
|
|
20
|
+
it('materializes the derived world component from Transform at the ECS boundary', () => {
|
|
8
21
|
const world = new World();
|
|
9
|
-
|
|
22
|
+
const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
23
|
+
|
|
24
|
+
expect(world.get(entity, GlobalTransform).ok).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('materializes the transform pair when a hierarchy edge is the only authored component', () => {
|
|
28
|
+
const world = new World();
|
|
29
|
+
const parent = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
|
|
30
|
+
const child = world.spawn({ component: ChildOf, data: { parent } }).unwrap();
|
|
31
|
+
|
|
32
|
+
expect(world.get(child, Transform).ok).toBe(true);
|
|
33
|
+
expect(world.get(child, GlobalTransform).ok).toBe(true);
|
|
34
|
+
expect(propagateTransforms(world).ok).toBe(true);
|
|
35
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(4);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects a Transform-only entity with a structured pair error', () => {
|
|
39
|
+
const world = new World();
|
|
40
|
+
const entity = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
41
|
+
world.removeComponent(entity, GlobalTransform).unwrap();
|
|
42
|
+
|
|
43
|
+
const result = propagateTransforms(world);
|
|
44
|
+
|
|
45
|
+
expect(result.ok).toBe(false);
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
expect(result.error.code).toBe('hierarchy-broken');
|
|
48
|
+
expect(result.error.expected).toContain('GlobalTransform pair');
|
|
49
|
+
expect(result.error.detail?.entity).toBe(entity);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects a GlobalTransform-only entity with a structured pair error', () => {
|
|
54
|
+
const world = new World();
|
|
55
|
+
const entity = world.spawn({ component: GlobalTransform, data: {} }).unwrap();
|
|
56
|
+
|
|
57
|
+
const result = propagateTransforms(world);
|
|
58
|
+
|
|
59
|
+
expect(result.ok).toBe(false);
|
|
60
|
+
if (!result.ok) {
|
|
61
|
+
expect(result.error.code).toBe('hierarchy-broken');
|
|
62
|
+
expect(result.error.expected).toContain('Transform pair');
|
|
63
|
+
expect(result.error.detail?.entity).toBe(entity);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('preserves the ECS range-publication error in flat propagation diagnostics', () => {
|
|
68
|
+
const world = new World();
|
|
69
|
+
spawnTransform(world, [2, 0, 0]);
|
|
70
|
+
const internal = world[worldInternal];
|
|
71
|
+
const originalMarkRange = internal.markComponentRangeChanged;
|
|
72
|
+
Object.defineProperty(internal, 'markComponentRangeChanged', {
|
|
73
|
+
configurable: true,
|
|
74
|
+
value: () => {
|
|
75
|
+
throw new ChangeEpochExhaustedError(Number.MAX_SAFE_INTEGER);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
const result = propagateTransforms(world);
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
expect(result.error.code).toBe('hierarchy-broken');
|
|
83
|
+
expect(result.error.detail?.kind).toBe('derived-write');
|
|
84
|
+
if (result.error.detail?.kind === 'derived-write') {
|
|
85
|
+
expect(result.error.detail.cause.code).toBe('change-epoch-exhausted');
|
|
86
|
+
expect(result.error.detail.cause.expected).toBe(
|
|
87
|
+
'mutationEpoch < Number.MAX_SAFE_INTEGER',
|
|
88
|
+
);
|
|
89
|
+
expect(result.error.detail.cause.hint).toContain('Rebuild the World');
|
|
90
|
+
expect(result.error.detail.cause.detail).toEqual({ epoch: Number.MAX_SAFE_INTEGER });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
Object.defineProperty(internal, 'markComponentRangeChanged', {
|
|
95
|
+
configurable: true,
|
|
96
|
+
value: originalMarkRange,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
10
100
|
|
|
101
|
+
it('registers the TransformPropagation owner in Update and FixedUpdate', () => {
|
|
102
|
+
const world = new World();
|
|
103
|
+
registerPropagateTransforms(world);
|
|
11
104
|
expect(
|
|
12
105
|
world
|
|
13
106
|
.inspect()
|
|
14
107
|
.schedules.find((entry) => entry.schedule === Update)
|
|
15
|
-
?.systems.map((
|
|
108
|
+
?.systems.map((s) => s.name),
|
|
16
109
|
).toContain('propagateTransforms');
|
|
17
110
|
expect(
|
|
18
111
|
world
|
|
19
112
|
.inspect()
|
|
20
113
|
.schedules.find((entry) => entry.schedule === FixedUpdate)
|
|
21
|
-
?.systems.map((
|
|
114
|
+
?.systems.map((s) => s.name),
|
|
22
115
|
).toContain('propagateTransformsFixed');
|
|
23
|
-
expect(world.inspect().schedules.map((entry) => entry.schedule.name)).not.toContain(
|
|
24
|
-
'FramePublish',
|
|
25
|
-
);
|
|
26
116
|
});
|
|
27
|
-
|
|
117
|
+
|
|
118
|
+
it('propagates a root and child into GlobalTransform', () => {
|
|
28
119
|
const world = new World();
|
|
29
120
|
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();
|
|
121
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
122
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
37
123
|
expect(world.update(1 / 60).ok).toBe(true);
|
|
38
|
-
expect(world.get(child,
|
|
124
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
|
|
39
125
|
});
|
|
40
126
|
|
|
41
127
|
it('cascades linked children when a parent is despawned', () => {
|
|
42
128
|
const world = new World();
|
|
43
129
|
registerPropagateTransforms(world);
|
|
44
|
-
const parent = world
|
|
45
|
-
const child = world
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
expect(result.ok).toBe(true);
|
|
130
|
+
const parent = spawnTransform(world, [0, 0, 0]);
|
|
131
|
+
const child = spawnTransform(world, [1, 0, 0], parent);
|
|
132
|
+
|
|
133
|
+
world.despawn(parent).unwrap();
|
|
134
|
+
|
|
135
|
+
expect(world.update(1 / 60).ok).toBe(true);
|
|
51
136
|
expect(world.get(child, Transform).ok).toBe(false);
|
|
52
137
|
});
|
|
53
138
|
|
|
54
139
|
it('keeps equal entity handles isolated across Worlds', () => {
|
|
55
140
|
const first = new World();
|
|
56
141
|
const second = new World();
|
|
57
|
-
const firstRoot = first
|
|
58
|
-
const secondRoot = second
|
|
59
|
-
|
|
142
|
+
const firstRoot = spawnTransform(first, [3, 0, 0]);
|
|
143
|
+
const secondRoot = spawnTransform(second, [7, 0, 0]);
|
|
60
144
|
expect(firstRoot).toBe(secondRoot);
|
|
61
145
|
expect(propagateTransforms(first).ok).toBe(true);
|
|
62
146
|
expect(propagateTransforms(second).ok).toBe(true);
|
|
63
|
-
expect(first.get(firstRoot,
|
|
64
|
-
expect(second.get(secondRoot,
|
|
147
|
+
expect(first.get(firstRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(3);
|
|
148
|
+
expect(second.get(secondRoot, GlobalTransform).unwrap().world[12]).toBeCloseTo(7);
|
|
65
149
|
});
|
|
66
150
|
|
|
67
|
-
it('
|
|
151
|
+
it('executes repeated propagation and preserves stable world semantics', () => {
|
|
68
152
|
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();
|
|
153
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
154
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
76
155
|
|
|
77
156
|
const first = propagateTransforms(world);
|
|
78
157
|
const second = propagateTransforms(world);
|
|
79
|
-
expect(first).toBe(
|
|
80
|
-
expect(
|
|
158
|
+
expect(first.ok).toBe(true);
|
|
159
|
+
expect(second.ok).toBe(true);
|
|
160
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
|
|
161
|
+
|
|
162
|
+
const corruptedWorld = new Float32Array(16);
|
|
163
|
+
corruptedWorld[12] = -100;
|
|
164
|
+
world.set(child, GlobalTransform, { world: corruptedWorld }).unwrap();
|
|
165
|
+
const repaired = propagateTransforms(world);
|
|
166
|
+
expect(repaired.ok).toBe(true);
|
|
167
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(5);
|
|
81
168
|
|
|
82
169
|
world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
|
|
83
170
|
const third = propagateTransforms(world);
|
|
84
|
-
expect(third).
|
|
85
|
-
expect(world.get(child,
|
|
171
|
+
expect(third.ok).toBe(true);
|
|
172
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
|
|
86
173
|
});
|
|
87
174
|
|
|
88
|
-
it('
|
|
175
|
+
it('recomputes the edited transform and its descendants', () => {
|
|
89
176
|
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
|
-
|
|
177
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
178
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
179
|
+
const grandchild = spawnTransform(world, [4, 0, 0], child);
|
|
180
|
+
const sibling = spawnTransform(world, [20, 0, 0]);
|
|
105
181
|
propagateTransforms(world).unwrap();
|
|
106
182
|
world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
|
|
107
183
|
propagateTransforms(world).unwrap();
|
|
184
|
+
expect(world.get(root, GlobalTransform).unwrap().world[12]).toBeCloseTo(2);
|
|
185
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
|
|
186
|
+
expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(14);
|
|
187
|
+
expect(world.get(sibling, GlobalTransform).unwrap().world[12]).toBeCloseTo(20);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('recomputes a child after reparenting to a different live parent', () => {
|
|
191
|
+
const world = new World();
|
|
192
|
+
const firstParent = spawnTransform(world, [10, 0, 0]);
|
|
193
|
+
const secondParent = spawnTransform(world, [100, 0, 0]);
|
|
194
|
+
const child = spawnTransform(world, [1, 0, 0], firstParent);
|
|
195
|
+
propagateTransforms(world).unwrap();
|
|
196
|
+
world.set(child, ChildOf, { parent: secondParent }).unwrap();
|
|
197
|
+
propagateTransforms(world).unwrap();
|
|
198
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('recomputes a detached ChildOf entity as a flat root even when local data is unchanged', () => {
|
|
202
|
+
const world = new World();
|
|
203
|
+
const parent = spawnTransform(world, [10, 0, 0]);
|
|
204
|
+
const child = spawnTransform(world, [1, 0, 0], parent);
|
|
205
|
+
propagateTransforms(world).unwrap();
|
|
206
|
+
|
|
207
|
+
world.removeComponent(child, ChildOf).unwrap();
|
|
208
|
+
const result = propagateTransforms(world);
|
|
108
209
|
|
|
109
|
-
expect(
|
|
110
|
-
expect(world.get(child,
|
|
111
|
-
expect(world.get(grandchild, Transform).unwrap().world[12]).toBeCloseTo(14);
|
|
112
|
-
expect(world.get(sibling, Transform).unwrap().world[12]).toBeCloseTo(20);
|
|
210
|
+
expect(result.ok).toBe(true);
|
|
211
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(1);
|
|
113
212
|
});
|
|
114
213
|
|
|
115
|
-
it('
|
|
214
|
+
it('reuses one hierarchy root cursor and reports linear traversal counters', () => {
|
|
116
215
|
const world = new World();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
216
|
+
for (let index = 0; index < 32; index += 1) {
|
|
217
|
+
const root = spawnTransform(world, [index, 0, 0]);
|
|
218
|
+
spawnTransform(world, [1, 0, 0], root);
|
|
219
|
+
}
|
|
220
|
+
propagateTransforms(world).unwrap();
|
|
221
|
+
|
|
222
|
+
beginTransformPropagationTrace();
|
|
223
|
+
propagateTransforms(world).unwrap();
|
|
224
|
+
const trace = endTransformPropagationTrace();
|
|
225
|
+
|
|
226
|
+
expect(trace.hierarchyRootInvocations).toBeGreaterThan(0);
|
|
227
|
+
expect(trace.hierarchyRootCursorReuses).toBe(trace.hierarchyRootInvocations);
|
|
228
|
+
expect(trace.hierarchyRootCursorAllocations).toBe(0);
|
|
229
|
+
expect(trace.hierarchyRowsEvaluated).toBeGreaterThanOrEqual(32);
|
|
230
|
+
expect(trace.hierarchyEdgesVisited).toBeGreaterThanOrEqual(32);
|
|
231
|
+
expect(trace.hierarchyResidualParentProbes).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('publishes a local fallback before returning a stale-parent error', () => {
|
|
235
|
+
const world = new World();
|
|
236
|
+
const parent = spawnTransform(world, [10, 0, 0]);
|
|
237
|
+
const child = spawnTransform(world, [1, 0, 0], parent);
|
|
238
|
+
propagateTransforms(world).unwrap();
|
|
239
|
+
const baseline = new Float32Array(world.get(child, GlobalTransform).unwrap().world);
|
|
240
|
+
|
|
241
|
+
const staleParent = spawnTransform(world, [50, 0, 0]);
|
|
242
|
+
world.despawn(staleParent).unwrap();
|
|
243
|
+
// Public relationship writes reject stale targets before changing either
|
|
244
|
+
// side. Corrupt the source only through the test-owned internal fixture
|
|
245
|
+
// to exercise propagation's explicit malformed-input fallback.
|
|
246
|
+
setMalformedParentEdge(world, child, staleParent);
|
|
247
|
+
const fault = propagateTransforms(world);
|
|
248
|
+
|
|
249
|
+
expect(fault.ok).toBe(false);
|
|
250
|
+
if (!fault.ok) expect(fault.error.code).toBe('hierarchy-broken');
|
|
251
|
+
const fallback = world.get(child, GlobalTransform).unwrap().world;
|
|
252
|
+
expect(fallback[12]).toBeCloseTo(1);
|
|
253
|
+
expect(fallback[12]).not.toBeCloseTo(baseline[12] ?? Number.NaN);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('cuts every member of a cycle to a finite local root fallback', () => {
|
|
257
|
+
const world = new World();
|
|
258
|
+
const root = spawnTransform(world, [0, 0, 0]);
|
|
259
|
+
const first = spawnTransform(world, [1, 0, 0], root);
|
|
260
|
+
const second = spawnTransform(world, [2, 0, 0], first);
|
|
261
|
+
setMalformedParentEdge(world, first, second);
|
|
262
|
+
|
|
263
|
+
const result = propagateTransforms(world);
|
|
264
|
+
|
|
265
|
+
expect(result.ok).toBe(false);
|
|
266
|
+
if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
|
|
267
|
+
for (const entity of [first, second]) {
|
|
268
|
+
const matrix = world.get(entity, GlobalTransform).unwrap().world;
|
|
269
|
+
expect([...matrix].every(Number.isFinite)).toBe(true);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('bounds rootless-cycle parent probes to one residual pass', () => {
|
|
274
|
+
const world = new World();
|
|
275
|
+
const root = spawnTransform(world, [0, 0, 0]);
|
|
276
|
+
const nodes: EntityHandle[] = [];
|
|
277
|
+
let parent = root;
|
|
278
|
+
for (let index = 0; index < 48; index += 1) {
|
|
279
|
+
const node = spawnTransform(world, [1, 0, 0], parent);
|
|
280
|
+
nodes.push(node);
|
|
281
|
+
parent = node;
|
|
282
|
+
}
|
|
283
|
+
propagateTransforms(world).unwrap();
|
|
284
|
+
const first = nodes[0];
|
|
285
|
+
const last = nodes[nodes.length - 1];
|
|
286
|
+
if (first === undefined || last === undefined) throw new Error('expected cycle nodes');
|
|
287
|
+
setMalformedParentEdge(world, first, last);
|
|
288
|
+
|
|
289
|
+
beginTransformPropagationTrace();
|
|
290
|
+
const result = propagateTransforms(world);
|
|
291
|
+
const trace = endTransformPropagationTrace();
|
|
292
|
+
|
|
293
|
+
expect(result.ok).toBe(false);
|
|
294
|
+
if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
|
|
295
|
+
expect(trace.hierarchyResidualParentProbes).toBeGreaterThan(0);
|
|
296
|
+
expect(trace.hierarchyResidualParentProbes).toBeLessThanOrEqual(nodes.length + 1);
|
|
297
|
+
expect(trace.hierarchyEdgesVisited).toBeLessThanOrEqual(nodes.length + 1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('expands a generic ChildOf mutation and updates descendants', () => {
|
|
301
|
+
const world = new World();
|
|
302
|
+
const firstParent = spawnTransform(world, [10, 0, 0]);
|
|
303
|
+
const secondParent = spawnTransform(world, [100, 0, 0]);
|
|
304
|
+
const child = spawnTransform(world, [1, 0, 0], firstParent);
|
|
305
|
+
const grandchild = spawnTransform(world, [2, 0, 0], child);
|
|
131
306
|
|
|
132
307
|
propagateTransforms(world).unwrap();
|
|
133
308
|
world.set(child, ChildOf, { parent: secondParent }).unwrap();
|
|
134
309
|
propagateTransforms(world).unwrap();
|
|
135
310
|
|
|
136
|
-
expect(world.get(child,
|
|
137
|
-
expect(world.get(grandchild,
|
|
311
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
|
|
312
|
+
expect(world.get(grandchild, GlobalTransform).unwrap().world[12]).toBeCloseTo(103);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('returns a structured error for a parent cycle', () => {
|
|
316
|
+
const world = new World();
|
|
317
|
+
const root = spawnTransform(world, [1, 0, 0]);
|
|
318
|
+
const child = spawnTransform(world, [2, 0, 0], root);
|
|
319
|
+
const result = world.addComponent(root, { component: ChildOf, data: { parent: child } });
|
|
320
|
+
expect(result.ok).toBe(false);
|
|
321
|
+
if (!result.ok) {
|
|
322
|
+
expect(result.error.code).toBe('relationship-self-cycle');
|
|
323
|
+
expect(result.error.hint).toContain('descendant');
|
|
324
|
+
}
|
|
138
325
|
});
|
|
139
326
|
|
|
140
|
-
it('publishes
|
|
327
|
+
it('publishes local writes before the same-tick world read', () => {
|
|
141
328
|
const world = new World();
|
|
142
329
|
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();
|
|
330
|
+
const root = spawnTransform(world, [2, 0, 0]);
|
|
331
|
+
const child = spawnTransform(world, [3, 0, 0], root);
|
|
150
332
|
world
|
|
151
333
|
.addSystem(Update, {
|
|
152
334
|
name: 'late-pose',
|
|
153
|
-
before: ['propagateTransforms'],
|
|
154
335
|
queries: [],
|
|
336
|
+
before: ['propagateTransforms'],
|
|
155
337
|
fn: () => world.set(child, Transform, { pos: [8, 0, 0] }),
|
|
156
338
|
})
|
|
157
339
|
.unwrap();
|
|
158
|
-
|
|
159
340
|
world.update(0).unwrap();
|
|
160
|
-
|
|
161
|
-
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
|
|
341
|
+
expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(10);
|
|
162
342
|
});
|
|
163
343
|
});
|
|
@@ -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
|
+
});
|