@forgeax/engine-scene 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +55 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-owner.integration.test.d.ts +2 -0
- package/dist/__tests__/asset-owner.integration.test.d.ts.map +1 -0
- package/dist/__tests__/components.unit.test.d.ts +2 -0
- package/dist/__tests__/components.unit.test.d.ts.map +1 -0
- package/dist/__tests__/diagnostic-type-surface.test-d.d.ts +3 -0
- package/dist/__tests__/diagnostic-type-surface.test-d.d.ts.map +1 -0
- package/dist/__tests__/direct-child-of-import.unit.test.d.ts +2 -0
- package/dist/__tests__/direct-child-of-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +8 -0
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -0
- package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/hierarchy-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/plugin.integration.test.d.ts +2 -0
- package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
- package/dist/__tests__/propagation.unit.test.d.ts +2 -0
- package/dist/__tests__/propagation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/transform-change-journal.unit.test.d.ts +2 -0
- package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts +8 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -0
- package/dist/collect-subtree.d.ts +4 -0
- package/dist/collect-subtree.d.ts.map +1 -0
- package/dist/components/child-of.d.ts +31 -0
- package/dist/components/child-of.d.ts.map +1 -0
- package/dist/components/children.d.ts +48 -0
- package/dist/components/children.d.ts.map +1 -0
- package/dist/components/morph-weights.d.ts +5 -0
- package/dist/components/morph-weights.d.ts.map +1 -0
- package/dist/components/name.d.ts +4 -0
- package/dist/components/name.d.ts.map +1 -0
- package/dist/components/transform.d.ts +52 -0
- package/dist/components/transform.d.ts.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1731 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instances/collect-profile.d.ts +7 -0
- package/dist/instances/collect-profile.d.ts.map +1 -0
- package/dist/instances/externalization.d.ts +14 -0
- package/dist/instances/externalization.d.ts.map +1 -0
- package/dist/instances/scene-instances.d.ts +350 -0
- package/dist/instances/scene-instances.d.ts.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/systems/hierarchy-projection.d.ts +16 -0
- package/dist/systems/hierarchy-projection.d.ts.map +1 -0
- package/dist/systems/index.d.ts +3 -0
- package/dist/systems/index.d.ts.map +1 -0
- package/dist/systems/propagate-transforms.d.ts +15 -0
- package/dist/systems/propagate-transforms.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/asset-owner.integration.test.ts +57 -0
- package/src/__tests__/components.unit.test.ts +26 -0
- package/src/__tests__/diagnostic-type-surface.test-d.ts +11 -0
- package/src/__tests__/direct-child-of-import.unit.test.ts +13 -0
- package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +15 -0
- package/src/__tests__/hierarchy-projection-contract.unit.test.ts +45 -0
- package/src/__tests__/hierarchy-projection.unit.test.ts +92 -0
- package/src/__tests__/plugin.integration.test.ts +23 -0
- package/src/__tests__/propagation.unit.test.ts +163 -0
- package/src/__tests__/transform-change-journal.unit.test.ts +105 -0
- package/src/assets/scene-decoder.ts +153 -0
- package/src/collect-subtree.ts +30 -0
- package/src/components/child-of.ts +90 -0
- package/src/components/children.ts +113 -0
- package/src/components/morph-weights.ts +6 -0
- package/src/components/name.ts +22 -0
- package/src/components/transform.ts +89 -0
- package/src/errors.ts +34 -0
- package/src/index.ts +65 -0
- package/src/instances/collect-profile.ts +10 -0
- package/src/instances/externalization.ts +137 -0
- package/src/instances/scene-instances.ts +1564 -0
- package/src/plugin.ts +28 -0
- package/src/systems/hierarchy-projection.ts +134 -0
- package/src/systems/index.ts +11 -0
- package/src/systems/propagate-transforms.ts +376 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ChildOf, Transform } from '../index';
|
|
4
|
+
import { projectHierarchy, type SceneHierarchyDiagnostic } from '../systems';
|
|
5
|
+
import { setMalformedParentEdge } from './fixtures/malformed-hierarchy-edge';
|
|
6
|
+
|
|
7
|
+
describe('scene hierarchy projection contract', () => {
|
|
8
|
+
it('exposes stable parent edges and diagnostic detail without throwing', () => {
|
|
9
|
+
const world = new World();
|
|
10
|
+
const parent = world.spawn({ component: Transform, data: {} }).unwrap();
|
|
11
|
+
const child = world
|
|
12
|
+
.spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent } })
|
|
13
|
+
.unwrap();
|
|
14
|
+
|
|
15
|
+
const snapshot = projectHierarchy(world);
|
|
16
|
+
|
|
17
|
+
expect(snapshot.parentOf.get(child)).toBe(parent);
|
|
18
|
+
expect(snapshot.diagnostics).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('keeps every diagnostic machine-readable and deterministically ordered', () => {
|
|
22
|
+
const world = new World();
|
|
23
|
+
const first = world.spawn({ component: Transform, data: {} }).unwrap();
|
|
24
|
+
const second = world
|
|
25
|
+
.spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent: first } })
|
|
26
|
+
.unwrap();
|
|
27
|
+
const third = world
|
|
28
|
+
.spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent: second } })
|
|
29
|
+
.unwrap();
|
|
30
|
+
setMalformedParentEdge(world, second, third);
|
|
31
|
+
|
|
32
|
+
const diagnostics: readonly SceneHierarchyDiagnostic[] = projectHierarchy(world).diagnostics;
|
|
33
|
+
|
|
34
|
+
expect(diagnostics.map((item) => item.detail.entity)).toEqual(
|
|
35
|
+
[...diagnostics.map((item) => item.detail.entity)].sort((a, b) => a - b),
|
|
36
|
+
);
|
|
37
|
+
for (const diagnostic of diagnostics) {
|
|
38
|
+
expect(['hierarchy-cycle', 'hierarchy-broken']).toContain(diagnostic.code);
|
|
39
|
+
expect(diagnostic.expected).toEqual(expect.any(String));
|
|
40
|
+
expect(diagnostic.hint).toEqual(expect.any(String));
|
|
41
|
+
expect(diagnostic.detail.entity).toEqual(expect.any(Number));
|
|
42
|
+
expect(diagnostic.detail.parent).toEqual(expect.any(Number));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ChildOf, Transform } from '../index';
|
|
4
|
+
import { projectHierarchy } from '../systems';
|
|
5
|
+
import { setMalformedParentEdge } from './fixtures/malformed-hierarchy-edge';
|
|
6
|
+
|
|
7
|
+
function childOf(world: World, parent: EntityHandle): EntityHandle {
|
|
8
|
+
return world
|
|
9
|
+
.spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent } })
|
|
10
|
+
.unwrap();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('scene hierarchy projection', () => {
|
|
14
|
+
it('keeps a live parent edge even when the parent has no Transform', () => {
|
|
15
|
+
const world = new World();
|
|
16
|
+
const parent = world.spawn().unwrap();
|
|
17
|
+
const child = childOf(world, parent);
|
|
18
|
+
|
|
19
|
+
const snapshot = projectHierarchy(world);
|
|
20
|
+
|
|
21
|
+
expect(snapshot.getParent(child)).toBe(parent);
|
|
22
|
+
expect(snapshot.diagnostics).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('cuts a stale parent edge and reports a machine-readable diagnostic', () => {
|
|
26
|
+
const world = new World();
|
|
27
|
+
const parent = world.spawn().unwrap();
|
|
28
|
+
const child = childOf(world, parent);
|
|
29
|
+
const staleParent = world.spawn().unwrap();
|
|
30
|
+
world.despawn(staleParent).unwrap();
|
|
31
|
+
setMalformedParentEdge(world, child, staleParent);
|
|
32
|
+
|
|
33
|
+
const snapshot = projectHierarchy(world);
|
|
34
|
+
const diagnostic = snapshot.diagnostics[0];
|
|
35
|
+
|
|
36
|
+
expect(snapshot.getParent(child)).toBeUndefined();
|
|
37
|
+
expect(diagnostic?.code).toBe('hierarchy-broken');
|
|
38
|
+
expect(diagnostic?.detail.entity).toBe(child);
|
|
39
|
+
expect(diagnostic?.detail.parent).toBe(staleParent);
|
|
40
|
+
expect(diagnostic?.expected).toContain('live entity');
|
|
41
|
+
expect(diagnostic?.hint.length).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('terminates self and multi-member cycles and sorts diagnostics by entity', () => {
|
|
45
|
+
const world = new World();
|
|
46
|
+
const self = childOf(world, world.spawn().unwrap());
|
|
47
|
+
const a = childOf(world, world.spawn().unwrap());
|
|
48
|
+
const b = childOf(world, world.spawn().unwrap());
|
|
49
|
+
const c = childOf(world, world.spawn().unwrap());
|
|
50
|
+
setMalformedParentEdge(world, self, self);
|
|
51
|
+
setMalformedParentEdge(world, a, b);
|
|
52
|
+
setMalformedParentEdge(world, b, c);
|
|
53
|
+
setMalformedParentEdge(world, c, a);
|
|
54
|
+
|
|
55
|
+
const snapshot = projectHierarchy(world);
|
|
56
|
+
const cycleDiagnostics = snapshot.diagnostics.filter((item) => item.code === 'hierarchy-cycle');
|
|
57
|
+
const diagnosticEntities = cycleDiagnostics.map((item) => item.detail.entity);
|
|
58
|
+
|
|
59
|
+
expect(snapshot.getParent(self)).toBeUndefined();
|
|
60
|
+
expect(snapshot.getParent(a)).toBeUndefined();
|
|
61
|
+
expect(snapshot.getParent(b)).toBeUndefined();
|
|
62
|
+
expect(snapshot.getParent(c)).toBeUndefined();
|
|
63
|
+
expect(cycleDiagnostics).toHaveLength(4);
|
|
64
|
+
expect(diagnosticEntities).toEqual([...diagnosticEntities].sort((x, y) => x - y));
|
|
65
|
+
expect(cycleDiagnostics.every((item) => item.detail.parent !== undefined)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not invent a parent edge for a root entity', () => {
|
|
69
|
+
const world = new World();
|
|
70
|
+
const root = world.spawn().unwrap();
|
|
71
|
+
|
|
72
|
+
expect(projectHierarchy(world).getParent(root)).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reuses a stable World projection and invalidates it after a hierarchy mutation', () => {
|
|
76
|
+
const world = new World();
|
|
77
|
+
const firstParent = world.spawn().unwrap();
|
|
78
|
+
const secondParent = world.spawn().unwrap();
|
|
79
|
+
const child = childOf(world, firstParent);
|
|
80
|
+
|
|
81
|
+
const first = projectHierarchy(world);
|
|
82
|
+
expect(projectHierarchy(world)).toBe(first);
|
|
83
|
+
expect(first.getParent(child)).toBe(firstParent);
|
|
84
|
+
|
|
85
|
+
world.set(child, ChildOf, { parent: secondParent }).unwrap();
|
|
86
|
+
|
|
87
|
+
const second = projectHierarchy(world);
|
|
88
|
+
expect(second).not.toBe(first);
|
|
89
|
+
expect(second.getParent(child)).toBe(secondParent);
|
|
90
|
+
expect(projectHierarchy(world)).toBe(second);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createWorldContext, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ChildOf, scenePlugin, Transform } from '../index';
|
|
4
|
+
|
|
5
|
+
describe('scenePlugin', () => {
|
|
6
|
+
it('installs propagation without render or animation capabilities', async () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
const ctx = await createWorldContext(world, [scenePlugin()]);
|
|
9
|
+
const root = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
|
|
10
|
+
const child = world
|
|
11
|
+
.spawn(
|
|
12
|
+
{ component: Transform, data: { pos: [1, 0, 0] } },
|
|
13
|
+
{ component: ChildOf, data: { parent: root } },
|
|
14
|
+
)
|
|
15
|
+
.unwrap();
|
|
16
|
+
world.update(1 / 60).unwrap();
|
|
17
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
|
|
18
|
+
await ctx.fiber.dispose();
|
|
19
|
+
expect(world.inspect().systems.some((system) => system.name === 'propagateTransforms')).toBe(
|
|
20
|
+
false,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { FixedUpdate, Update, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ChildOf, propagateTransforms, Transform } from '../index';
|
|
4
|
+
import { registerPropagateTransforms } from '../systems';
|
|
5
|
+
|
|
6
|
+
describe('scene propagation', () => {
|
|
7
|
+
it('registers ordinary transform owners without exposing terminal publication as a schedule', () => {
|
|
8
|
+
const world = new World();
|
|
9
|
+
registerPropagateTransforms(world);
|
|
10
|
+
|
|
11
|
+
expect(
|
|
12
|
+
world
|
|
13
|
+
.inspect()
|
|
14
|
+
.schedules.find((entry) => entry.schedule === Update)
|
|
15
|
+
?.systems.map((system) => system.name),
|
|
16
|
+
).toContain('propagateTransforms');
|
|
17
|
+
expect(
|
|
18
|
+
world
|
|
19
|
+
.inspect()
|
|
20
|
+
.schedules.find((entry) => entry.schedule === FixedUpdate)
|
|
21
|
+
?.systems.map((system) => system.name),
|
|
22
|
+
).toContain('propagateTransformsFixed');
|
|
23
|
+
expect(world.inspect().schedules.map((entry) => entry.schedule.name)).not.toContain(
|
|
24
|
+
'FramePublish',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
it('propagates a root and child in a headless world', () => {
|
|
28
|
+
const world = new World();
|
|
29
|
+
registerPropagateTransforms(world);
|
|
30
|
+
const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
31
|
+
const child = world
|
|
32
|
+
.spawn(
|
|
33
|
+
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
34
|
+
{ component: ChildOf, data: { parent: root } },
|
|
35
|
+
)
|
|
36
|
+
.unwrap();
|
|
37
|
+
expect(world.update(1 / 60).ok).toBe(true);
|
|
38
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('cascades linked children when a parent is despawned', () => {
|
|
42
|
+
const world = new World();
|
|
43
|
+
registerPropagateTransforms(world);
|
|
44
|
+
const parent = world.spawn({ component: Transform, data: {} }).unwrap();
|
|
45
|
+
const child = world
|
|
46
|
+
.spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent } })
|
|
47
|
+
.unwrap();
|
|
48
|
+
world.despawn(parent);
|
|
49
|
+
const result = world.update(1 / 60);
|
|
50
|
+
expect(result.ok).toBe(true);
|
|
51
|
+
expect(world.get(child, Transform).ok).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps equal entity handles isolated across Worlds', () => {
|
|
55
|
+
const first = new World();
|
|
56
|
+
const second = new World();
|
|
57
|
+
const firstRoot = first.spawn({ component: Transform, data: { pos: [3, 0, 0] } }).unwrap();
|
|
58
|
+
const secondRoot = second.spawn({ component: Transform, data: { pos: [7, 0, 0] } }).unwrap();
|
|
59
|
+
|
|
60
|
+
expect(firstRoot).toBe(secondRoot);
|
|
61
|
+
expect(propagateTransforms(first).ok).toBe(true);
|
|
62
|
+
expect(propagateTransforms(second).ok).toBe(true);
|
|
63
|
+
expect(first.get(firstRoot, Transform).unwrap().world[12]).toBeCloseTo(3);
|
|
64
|
+
expect(second.get(secondRoot, Transform).unwrap().world[12]).toBeCloseTo(7);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reuses a stable propagation result and invalidates it after a local edit', () => {
|
|
68
|
+
const world = new World();
|
|
69
|
+
const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
70
|
+
const child = world
|
|
71
|
+
.spawn(
|
|
72
|
+
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
73
|
+
{ component: ChildOf, data: { parent: root } },
|
|
74
|
+
)
|
|
75
|
+
.unwrap();
|
|
76
|
+
|
|
77
|
+
const first = propagateTransforms(world);
|
|
78
|
+
const second = propagateTransforms(world);
|
|
79
|
+
expect(first).toBe(second);
|
|
80
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(5);
|
|
81
|
+
|
|
82
|
+
world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
|
|
83
|
+
const third = propagateTransforms(world);
|
|
84
|
+
expect(third).not.toBe(second);
|
|
85
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('incrementally recomputes an edited transform and its descendants', () => {
|
|
89
|
+
const world = new World();
|
|
90
|
+
const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
91
|
+
const child = world
|
|
92
|
+
.spawn(
|
|
93
|
+
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
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
|
+
|
|
105
|
+
propagateTransforms(world).unwrap();
|
|
106
|
+
world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
|
|
107
|
+
propagateTransforms(world).unwrap();
|
|
108
|
+
|
|
109
|
+
expect(world.get(root, Transform).unwrap().world[12]).toBeCloseTo(2);
|
|
110
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
|
|
111
|
+
expect(world.get(grandchild, Transform).unwrap().world[12]).toBeCloseTo(14);
|
|
112
|
+
expect(world.get(sibling, Transform).unwrap().world[12]).toBeCloseTo(20);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('expands a generic ChildOf mutation from the validated hierarchy snapshot', () => {
|
|
116
|
+
const world = new World();
|
|
117
|
+
const firstParent = world.spawn({ component: Transform, data: { pos: [10, 0, 0] } }).unwrap();
|
|
118
|
+
const secondParent = world.spawn({ component: Transform, data: { pos: [100, 0, 0] } }).unwrap();
|
|
119
|
+
const child = world
|
|
120
|
+
.spawn(
|
|
121
|
+
{ component: Transform, data: { pos: [1, 0, 0] } },
|
|
122
|
+
{ component: ChildOf, data: { parent: firstParent } },
|
|
123
|
+
)
|
|
124
|
+
.unwrap();
|
|
125
|
+
const grandchild = world
|
|
126
|
+
.spawn(
|
|
127
|
+
{ component: Transform, data: { pos: [2, 0, 0] } },
|
|
128
|
+
{ component: ChildOf, data: { parent: child } },
|
|
129
|
+
)
|
|
130
|
+
.unwrap();
|
|
131
|
+
|
|
132
|
+
propagateTransforms(world).unwrap();
|
|
133
|
+
world.set(child, ChildOf, { parent: secondParent }).unwrap();
|
|
134
|
+
propagateTransforms(world).unwrap();
|
|
135
|
+
|
|
136
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(101);
|
|
137
|
+
expect(world.get(grandchild, Transform).unwrap().world[12]).toBeCloseTo(103);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('publishes transform writes when propagation is explicitly ordered after gameplay', () => {
|
|
141
|
+
const world = new World();
|
|
142
|
+
registerPropagateTransforms(world);
|
|
143
|
+
const root = world.spawn({ component: Transform, data: { pos: [2, 0, 0] } }).unwrap();
|
|
144
|
+
const child = world
|
|
145
|
+
.spawn(
|
|
146
|
+
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
147
|
+
{ component: ChildOf, data: { parent: root } },
|
|
148
|
+
)
|
|
149
|
+
.unwrap();
|
|
150
|
+
world
|
|
151
|
+
.addSystem(Update, {
|
|
152
|
+
name: 'late-pose',
|
|
153
|
+
before: ['propagateTransforms'],
|
|
154
|
+
queries: [],
|
|
155
|
+
fn: () => world.set(child, Transform, { pos: [8, 0, 0] }),
|
|
156
|
+
})
|
|
157
|
+
.unwrap();
|
|
158
|
+
|
|
159
|
+
world.update(0).unwrap();
|
|
160
|
+
|
|
161
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(10);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createWorldProjection } from '@forgeax/engine-ecs/projection';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ChildOf, propagateTransforms, Transform } from '../index';
|
|
5
|
+
|
|
6
|
+
describe('transform change journal', () => {
|
|
7
|
+
it('drains a large derived publication without rebuilding on the next pass', () => {
|
|
8
|
+
const world = new World();
|
|
9
|
+
for (let index = 0; index <= 65_536; index += 1) {
|
|
10
|
+
world.spawn({ component: Transform, data: {} }).unwrap();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const first = propagateTransforms(world);
|
|
14
|
+
const second = propagateTransforms(world);
|
|
15
|
+
|
|
16
|
+
expect(second).toBe(first);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('publishes exactly the recomputed subtree and stays empty on no-change propagation', () => {
|
|
20
|
+
const world = new World();
|
|
21
|
+
const root = world.spawn({ component: Transform, data: { pos: [1, 0, 0] } }).unwrap();
|
|
22
|
+
const child = world
|
|
23
|
+
.spawn(
|
|
24
|
+
{ component: Transform, data: { pos: [2, 0, 0] } },
|
|
25
|
+
{ component: ChildOf, data: { parent: root } },
|
|
26
|
+
)
|
|
27
|
+
.unwrap();
|
|
28
|
+
const grandchild = world
|
|
29
|
+
.spawn(
|
|
30
|
+
{ component: Transform, data: { pos: [3, 0, 0] } },
|
|
31
|
+
{ component: ChildOf, data: { parent: child } },
|
|
32
|
+
)
|
|
33
|
+
.unwrap();
|
|
34
|
+
const sibling = world.spawn({ component: Transform, data: { pos: [4, 0, 0] } }).unwrap();
|
|
35
|
+
|
|
36
|
+
propagateTransforms(world).unwrap();
|
|
37
|
+
const projection = createWorldProjection(world, { components: [Transform] });
|
|
38
|
+
projection.poll();
|
|
39
|
+
propagateTransforms(world).unwrap();
|
|
40
|
+
expect(projection.poll()).toEqual({ status: 'delta', cursor: projection.cursor, changes: [] });
|
|
41
|
+
|
|
42
|
+
world.set(child, Transform, { pos: [8, 0, 0] }).unwrap();
|
|
43
|
+
propagateTransforms(world).unwrap();
|
|
44
|
+
const changed = projection.poll();
|
|
45
|
+
expect(changed.status).toBe('delta');
|
|
46
|
+
if (changed.status !== 'delta') return;
|
|
47
|
+
expect(
|
|
48
|
+
changed.changes
|
|
49
|
+
.filter(
|
|
50
|
+
(record) => record.kind === 'derived-component-changed' && record.component === Transform,
|
|
51
|
+
)
|
|
52
|
+
.map((record) => record.entity),
|
|
53
|
+
).toEqual([child, grandchild]);
|
|
54
|
+
expect(changed.changes.some((record) => record.entity === sibling)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not propagate again from derived Transform records', () => {
|
|
58
|
+
const world = new World();
|
|
59
|
+
world.spawn({ component: Transform, data: { pos: [1, 0, 0] } }).unwrap();
|
|
60
|
+
|
|
61
|
+
propagateTransforms(world).unwrap();
|
|
62
|
+
const projection = createWorldProjection(world, { components: [Transform] });
|
|
63
|
+
projection.poll();
|
|
64
|
+
|
|
65
|
+
propagateTransforms(world).unwrap();
|
|
66
|
+
|
|
67
|
+
expect(projection.poll()).toEqual({ status: 'delta', cursor: projection.cursor, changes: [] });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('uses the final authored Transform state and skips an unchanged final state', () => {
|
|
71
|
+
const world = new World();
|
|
72
|
+
const root = world.spawn({ component: Transform, data: { pos: [1, 0, 0] } }).unwrap();
|
|
73
|
+
const child = world
|
|
74
|
+
.spawn(
|
|
75
|
+
{ component: Transform, data: { pos: [2, 0, 0] } },
|
|
76
|
+
{ component: ChildOf, data: { parent: root } },
|
|
77
|
+
)
|
|
78
|
+
.unwrap();
|
|
79
|
+
|
|
80
|
+
propagateTransforms(world).unwrap();
|
|
81
|
+
const projection = createWorldProjection(world, { components: [Transform] });
|
|
82
|
+
projection.poll();
|
|
83
|
+
|
|
84
|
+
world.set(root, Transform, { pos: [5, 0, 0] }).unwrap();
|
|
85
|
+
world.set(root, Transform, { pos: [1, 0, 0] }).unwrap();
|
|
86
|
+
propagateTransforms(world).unwrap();
|
|
87
|
+
const unchanged = projection.poll();
|
|
88
|
+
expect(unchanged.status).toBe('delta');
|
|
89
|
+
if (unchanged.status !== 'delta') return;
|
|
90
|
+
expect(
|
|
91
|
+
unchanged.changes.filter((record) => record.kind === 'derived-component-changed'),
|
|
92
|
+
).toEqual([]);
|
|
93
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(3);
|
|
94
|
+
|
|
95
|
+
world.set(root, Transform, { pos: [5, 0, 0] }).unwrap();
|
|
96
|
+
world.set(root, Transform, { pos: [7, 0, 0] }).unwrap();
|
|
97
|
+
propagateTransforms(world).unwrap();
|
|
98
|
+
const changed = projection.poll();
|
|
99
|
+
expect(changed.status).toBe('delta');
|
|
100
|
+
if (changed.status !== 'delta') return;
|
|
101
|
+
const derived = changed.changes.filter((record) => record.kind === 'derived-component-changed');
|
|
102
|
+
expect(derived.map((record) => record.entity)).toEqual([root, child]);
|
|
103
|
+
expect(world.get(child, Transform).unwrap().world[12]).toBeCloseTo(9);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AssetDecoder,
|
|
3
|
+
type AssetDecoderContribution,
|
|
4
|
+
type AssetKind,
|
|
5
|
+
type AssetLoadError,
|
|
6
|
+
err,
|
|
7
|
+
ok,
|
|
8
|
+
type Result,
|
|
9
|
+
type SceneAsset,
|
|
10
|
+
type SceneEntity,
|
|
11
|
+
type SceneInstanceMount,
|
|
12
|
+
} from '@forgeax/engine-types';
|
|
13
|
+
|
|
14
|
+
export const sceneAssetKind: AssetKind<SceneAsset, 'scene'> = {
|
|
15
|
+
kind: 'scene',
|
|
16
|
+
} as AssetKind<SceneAsset, 'scene'>;
|
|
17
|
+
|
|
18
|
+
function invalidScene(guid: string, reason: string): Result<SceneAsset, AssetLoadError> {
|
|
19
|
+
return err({
|
|
20
|
+
code: 'asset-package-invalid',
|
|
21
|
+
expected: 'a scene payload with an entities array',
|
|
22
|
+
hint: 'recook the SceneAsset and publish its complete envelope',
|
|
23
|
+
detail: { guid, reason },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SceneWireRefResult =
|
|
28
|
+
| { readonly ok: true; readonly value: SceneAsset }
|
|
29
|
+
| { readonly ok: false; readonly reason: string };
|
|
30
|
+
|
|
31
|
+
// The Pack envelope owns refs[] while the decoded SceneAsset remains the
|
|
32
|
+
// portable payload. Keep that wire-only fact beside the decoded object so the
|
|
33
|
+
// World-local projection can interpret shared-field indices without putting a
|
|
34
|
+
// component registry or World into the decoder contract.
|
|
35
|
+
const sceneWireRefs = new WeakMap<object, readonly string[]>();
|
|
36
|
+
|
|
37
|
+
/** @internal Read the Pack refs[] retained for a decoded SceneAsset payload. */
|
|
38
|
+
export function sceneAssetWireRefs(asset: SceneAsset): readonly string[] | undefined {
|
|
39
|
+
return sceneWireRefs.get(asset);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveWireRef(
|
|
43
|
+
refs: readonly string[],
|
|
44
|
+
value: number,
|
|
45
|
+
location: string,
|
|
46
|
+
): { readonly ok: true; readonly value: string } | { readonly ok: false; readonly reason: string } {
|
|
47
|
+
const guid = refs[value];
|
|
48
|
+
if (!Number.isInteger(value) || value < 0 || guid === undefined) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: `${location} references refs[${value}], but refs contains ${refs.length} entries`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, value: guid };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveMounts(
|
|
58
|
+
mounts: readonly SceneInstanceMount[] | undefined,
|
|
59
|
+
refs: readonly string[],
|
|
60
|
+
):
|
|
61
|
+
| { readonly ok: true; readonly value: readonly SceneInstanceMount[] | undefined }
|
|
62
|
+
| { readonly ok: false; readonly reason: string } {
|
|
63
|
+
if (mounts === undefined) return { ok: true, value: undefined };
|
|
64
|
+
const resolved: SceneInstanceMount[] = [];
|
|
65
|
+
for (const mount of mounts) {
|
|
66
|
+
if (typeof mount.source !== 'number' || !Number.isInteger(mount.source)) {
|
|
67
|
+
resolved.push(mount);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const ref = resolveWireRef(refs, mount.source, `mount ${mount.localId} source`);
|
|
71
|
+
if (!ref.ok) return ref;
|
|
72
|
+
resolved.push({ ...mount, source: ref.value });
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, value: resolved };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveSkinGuids(
|
|
78
|
+
skinGuids: readonly (number | string)[] | undefined,
|
|
79
|
+
refs: readonly string[],
|
|
80
|
+
):
|
|
81
|
+
| { readonly ok: true; readonly value: readonly string[] | undefined }
|
|
82
|
+
| { readonly ok: false; readonly reason: string } {
|
|
83
|
+
if (skinGuids === undefined) return { ok: true, value: undefined };
|
|
84
|
+
const resolved: string[] = [];
|
|
85
|
+
for (let index = 0; index < skinGuids.length; index += 1) {
|
|
86
|
+
const value = skinGuids[index];
|
|
87
|
+
if (typeof value === 'string') {
|
|
88
|
+
resolved.push(value);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
92
|
+
return { ok: false, reason: `skinGuids[${index}] is not a GUID or refs index` };
|
|
93
|
+
}
|
|
94
|
+
const ref = resolveWireRef(refs, value, `skinGuids[${index}]`);
|
|
95
|
+
if (!ref.ok) return ref;
|
|
96
|
+
resolved.push(ref.value);
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, value: resolved };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): SceneWireRefResult {
|
|
102
|
+
const entities: SceneEntity[] = [];
|
|
103
|
+
for (const entity of payload.entities) {
|
|
104
|
+
const components: Record<string, Record<string, unknown>> = {};
|
|
105
|
+
for (const [componentName, rawFields] of Object.entries(entity.components)) {
|
|
106
|
+
// Component schema lookup is World-local after the ECS core reduction.
|
|
107
|
+
// The runtime projection owns the World-local schema and converts
|
|
108
|
+
// authored GUID fields into World.sharedRefs handles. Keep this loader
|
|
109
|
+
// boundary POD-only instead of consulting a removed process-global ECS
|
|
110
|
+
// component registry.
|
|
111
|
+
components[componentName] = { ...(rawFields as Record<string, unknown>) };
|
|
112
|
+
}
|
|
113
|
+
entities.push({ localId: entity.localId, components });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mounts = resolveMounts(payload.mounts, refs);
|
|
117
|
+
if (!mounts.ok) return mounts;
|
|
118
|
+
const skinGuids = resolveSkinGuids(
|
|
119
|
+
payload.skinGuids as readonly (number | string)[] | undefined,
|
|
120
|
+
refs,
|
|
121
|
+
);
|
|
122
|
+
if (!skinGuids.ok) return skinGuids;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
value: {
|
|
127
|
+
kind: 'scene',
|
|
128
|
+
entities,
|
|
129
|
+
...(mounts.value === undefined ? {} : { mounts: mounts.value }),
|
|
130
|
+
...(skinGuids.value === undefined ? {} : { skinGuids: skinGuids.value }),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Scene owns structural validation; World-local projection resolves shared refs. */
|
|
136
|
+
export const sceneAssetDecoder: AssetDecoder<SceneAsset> = {
|
|
137
|
+
async decode({ envelope }): Promise<Result<SceneAsset, AssetLoadError>> {
|
|
138
|
+
const payload = envelope.payload;
|
|
139
|
+
if (payload.kind !== 'scene' || !Array.isArray(payload.entities)) {
|
|
140
|
+
return invalidScene(envelope.guid, 'scene payload is missing entities');
|
|
141
|
+
}
|
|
142
|
+
const resolved = resolveSceneWireRefs(payload, envelope.refs);
|
|
143
|
+
if (!resolved.ok) return invalidScene(envelope.guid, resolved.reason);
|
|
144
|
+
sceneWireRefs.set(resolved.value, Object.freeze([...envelope.refs]));
|
|
145
|
+
return ok(resolved.value);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const sceneAssetContribution: AssetDecoderContribution<SceneAsset, 'scene'> = {
|
|
150
|
+
kind: sceneAssetKind,
|
|
151
|
+
decoder: sceneAssetDecoder,
|
|
152
|
+
consumer: 'Scene',
|
|
153
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Scene-owned hierarchy traversal shared by scene collection and render hooks.
|
|
2
|
+
|
|
3
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
4
|
+
|
|
5
|
+
import { Children } from './components/children';
|
|
6
|
+
|
|
7
|
+
/** Walk a Children hierarchy breadth-first, reusing an optional visited set. */
|
|
8
|
+
export function collectSubtree(
|
|
9
|
+
world: World,
|
|
10
|
+
spawnRoot: EntityHandle,
|
|
11
|
+
visited?: Set<number>,
|
|
12
|
+
): Set<number> {
|
|
13
|
+
if (visited === undefined) visited = new Set<number>();
|
|
14
|
+
if (visited.has(spawnRoot as number)) return visited;
|
|
15
|
+
const queue: number[] = [spawnRoot as number];
|
|
16
|
+
visited.add(spawnRoot as number);
|
|
17
|
+
while (queue.length > 0) {
|
|
18
|
+
const current = queue.shift() as number;
|
|
19
|
+
const children = world.get(current as EntityHandle, Children);
|
|
20
|
+
if (!children.ok) continue;
|
|
21
|
+
const entities = children.value.entities as ArrayLike<number>;
|
|
22
|
+
for (let index = 0; index < entities.length; index += 1) {
|
|
23
|
+
const child = entities[index] as number;
|
|
24
|
+
if (visited.has(child)) continue;
|
|
25
|
+
visited.add(child);
|
|
26
|
+
queue.push(child);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return visited;
|
|
30
|
+
}
|