@forgeax/engine-scene 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +55 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-owner.integration.test.d.ts +2 -0
  5. package/dist/__tests__/asset-owner.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/components.unit.test.d.ts +2 -0
  7. package/dist/__tests__/components.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/diagnostic-type-surface.test-d.d.ts +3 -0
  9. package/dist/__tests__/diagnostic-type-surface.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/direct-child-of-import.unit.test.d.ts +2 -0
  11. package/dist/__tests__/direct-child-of-import.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +8 -0
  13. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -0
  14. package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/hierarchy-projection.unit.test.d.ts +2 -0
  17. package/dist/__tests__/hierarchy-projection.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/plugin.integration.test.d.ts +2 -0
  19. package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
  20. package/dist/__tests__/propagation.unit.test.d.ts +2 -0
  21. package/dist/__tests__/propagation.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/transform-change-journal.unit.test.d.ts +2 -0
  23. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +1 -0
  24. package/dist/assets/instantiate-scene.d.ts +7 -0
  25. package/dist/assets/instantiate-scene.d.ts.map +1 -0
  26. package/dist/assets/publication-observer.d.ts +13 -0
  27. package/dist/assets/publication-observer.d.ts.map +1 -0
  28. package/dist/assets/scene-decoder.d.ts +8 -0
  29. package/dist/assets/scene-decoder.d.ts.map +1 -0
  30. package/dist/assets/scene-projection.d.ts +70 -0
  31. package/dist/assets/scene-projection.d.ts.map +1 -0
  32. package/dist/components/child-of.d.ts +31 -0
  33. package/dist/components/child-of.d.ts.map +1 -0
  34. package/dist/components/children.d.ts +48 -0
  35. package/dist/components/children.d.ts.map +1 -0
  36. package/dist/components/morph-weights.d.ts +5 -0
  37. package/dist/components/morph-weights.d.ts.map +1 -0
  38. package/dist/components/name.d.ts +4 -0
  39. package/dist/components/name.d.ts.map +1 -0
  40. package/dist/components/transform.d.ts +52 -0
  41. package/dist/components/transform.d.ts.map +1 -0
  42. package/dist/errors.d.ts +22 -0
  43. package/dist/errors.d.ts.map +1 -0
  44. package/dist/index.d.ts +15 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.mjs +2167 -0
  47. package/dist/index.mjs.map +1 -0
  48. package/dist/instances/collect-profile.d.ts +7 -0
  49. package/dist/instances/collect-profile.d.ts.map +1 -0
  50. package/dist/instances/externalization.d.ts +14 -0
  51. package/dist/instances/externalization.d.ts.map +1 -0
  52. package/dist/instances/scene-instances.d.ts +349 -0
  53. package/dist/instances/scene-instances.d.ts.map +1 -0
  54. package/dist/plugin.d.ts +3 -0
  55. package/dist/plugin.d.ts.map +1 -0
  56. package/dist/systems/hierarchy-projection.d.ts +16 -0
  57. package/dist/systems/hierarchy-projection.d.ts.map +1 -0
  58. package/dist/systems/index.d.ts +3 -0
  59. package/dist/systems/index.d.ts.map +1 -0
  60. package/dist/systems/propagate-transforms.d.ts +15 -0
  61. package/dist/systems/propagate-transforms.d.ts.map +1 -0
  62. package/package.json +59 -0
  63. package/src/__tests__/asset-owner.integration.test.ts +245 -0
  64. package/src/__tests__/components.unit.test.ts +26 -0
  65. package/src/__tests__/diagnostic-type-surface.test-d.ts +11 -0
  66. package/src/__tests__/direct-child-of-import.unit.test.ts +13 -0
  67. package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +15 -0
  68. package/src/__tests__/hierarchy-projection-contract.unit.test.ts +45 -0
  69. package/src/__tests__/hierarchy-projection.unit.test.ts +92 -0
  70. package/src/__tests__/plugin.integration.test.ts +23 -0
  71. package/src/__tests__/propagation.unit.test.ts +163 -0
  72. package/src/__tests__/transform-change-journal.unit.test.ts +105 -0
  73. package/src/assets/instantiate-scene.ts +14 -0
  74. package/src/assets/publication-observer.ts +24 -0
  75. package/src/assets/scene-decoder.ts +153 -0
  76. package/src/assets/scene-projection.ts +644 -0
  77. package/src/components/child-of.ts +90 -0
  78. package/src/components/children.ts +113 -0
  79. package/src/components/morph-weights.ts +6 -0
  80. package/src/components/name.ts +22 -0
  81. package/src/components/transform.ts +89 -0
  82. package/src/errors.ts +34 -0
  83. package/src/index.ts +78 -0
  84. package/src/instances/collect-profile.ts +10 -0
  85. package/src/instances/externalization.ts +137 -0
  86. package/src/instances/scene-instances.ts +1565 -0
  87. package/src/plugin.ts +28 -0
  88. package/src/systems/hierarchy-projection.ts +134 -0
  89. package/src/systems/index.ts +11 -0
  90. package/src/systems/propagate-transforms.ts +376 -0
@@ -0,0 +1,245 @@
1
+ import { defineComponent, World } from '@forgeax/engine-ecs';
2
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
3
+ import { err, ok, type SceneAsset } from '@forgeax/engine-types';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { sceneAssetDecoder } from '../assets/scene-decoder';
6
+ import {
7
+ projectSceneAsset,
8
+ SCENE_ASSET_MESH_DEFAULT_RESOLVER_RESOURCE_KEY,
9
+ type SceneAssetMeshDefaultResolver,
10
+ } from '../assets/scene-projection';
11
+ import { ChildOf, Children } from '../components/children';
12
+ import {
13
+ worldInstantiateScene,
14
+ worldInstantiateSceneFlat,
15
+ worldSetSceneInstantiateHook,
16
+ } from '../instances/scene-instances';
17
+
18
+ const SceneInstance = defineComponent('SceneInstance', {
19
+ source: { type: 'shared<SceneAsset>' },
20
+ mapping: { type: 'array<entity>' },
21
+ state: { type: 'unique<SceneInstanceState>' },
22
+ });
23
+ const SceneMarker = defineComponent('M2SceneOwnerMarker', { value: 'i32' });
24
+ const SceneAssetRefs = defineComponent('M2SceneAssetRefs', {
25
+ mesh: 'shared<MeshAsset>',
26
+ materials: 'array<shared<MaterialAsset>>',
27
+ });
28
+ void SceneAssetRefs;
29
+
30
+ function registerSceneWorld(world: World): void {
31
+ for (const component of [SceneInstance, ChildOf, Children, SceneMarker, SceneAssetRefs]) {
32
+ world.components.register(component).unwrap();
33
+ }
34
+ }
35
+
36
+ function scene(): SceneAsset {
37
+ return {
38
+ kind: 'scene',
39
+ entities: [
40
+ {
41
+ localId: 0 as never,
42
+ components: { M2SceneOwnerMarker: { value: 7 } },
43
+ },
44
+ ],
45
+ };
46
+ }
47
+
48
+ describe('scene owner direct contract', () => {
49
+ it('instantiates a real SceneAsset through a World and keeps the payload identity', () => {
50
+ const world = new World();
51
+ registerSceneWorld(world);
52
+ const handle = world.allocSharedRef('SceneAsset', scene());
53
+
54
+ const result = worldInstantiateSceneFlat(world, handle);
55
+
56
+ expect(result.ok).toBe(true);
57
+ expect(world.sharedRefs.resolve(handle)).toMatchObject({ ok: true, value: { kind: 'scene' } });
58
+ if (!result.ok) return;
59
+ expect(result.value.roots).toHaveLength(1);
60
+ expect(world.get(result.value.roots[0] as never, SceneMarker)).toMatchObject({
61
+ ok: true,
62
+ value: { value: 7 },
63
+ });
64
+ });
65
+
66
+ it('rolls back a failed scene mount without leaving a partial entity', () => {
67
+ const world = new World();
68
+ registerSceneWorld(world);
69
+ const invalid = world.allocSharedRef('SceneAsset', {
70
+ kind: 'scene',
71
+ entities: [{ localId: 0 as never, components: { MissingComponent: { value: 1 } } }],
72
+ } as SceneAsset);
73
+
74
+ const result = worldInstantiateSceneFlat(world, invalid);
75
+
76
+ expect(result.ok).toBe(false);
77
+ const query = world.query({ read: [SceneMarker] });
78
+ expect(query.ok ? Array.from(query.value) : []).toHaveLength(0);
79
+ });
80
+
81
+ it('rolls back the materialized subtree when an owner hook fails', () => {
82
+ const world = new World();
83
+ registerSceneWorld(world);
84
+ const handle = world.allocSharedRef('SceneAsset', scene());
85
+ const expected = { code: 'scene-owner-hook-failed' };
86
+ worldSetSceneInstantiateHook(world, () => ({ ok: false, error: expected }));
87
+
88
+ const result = worldInstantiateScene(world, handle);
89
+
90
+ expect(result).toEqual({ ok: false, error: expected });
91
+ const query = world.query({ read: [SceneMarker] });
92
+ expect(query.ok ? Array.from(query.value) : []).toHaveLength(0);
93
+ });
94
+
95
+ it('keeps Pack refs[] indices for World-local shared-field projection', async () => {
96
+ const decoded = await sceneAssetDecoder.decode({
97
+ envelope: {
98
+ guid: 'scene-guid',
99
+ kind: 'scene',
100
+ refs: ['mesh-guid', 'material-guid'],
101
+ artifacts: {},
102
+ payload: {
103
+ kind: 'scene',
104
+ entities: [
105
+ {
106
+ localId: 0 as never,
107
+ components: {
108
+ M2SceneAssetRefs: { mesh: 0, materials: [1] },
109
+ },
110
+ },
111
+ ],
112
+ } as unknown as SceneAsset,
113
+ },
114
+ artifacts: { read: async () => err({ code: 'asset-artifact-missing' } as never) },
115
+ signal: new AbortController().signal,
116
+ });
117
+
118
+ expect(decoded.ok).toBe(true);
119
+ if (!decoded.ok) return;
120
+ expect(decoded.value.entities[0]?.components.M2SceneAssetRefs).toEqual({
121
+ mesh: 0,
122
+ materials: [1],
123
+ });
124
+
125
+ const world = new World();
126
+ registerSceneWorld(world);
127
+ const projected = await projectSceneAsset(world, decoded.value, async (guid, kind) =>
128
+ ok({ kind, guid }),
129
+ );
130
+ expect(projected.ok).toBe(true);
131
+ if (!projected.ok) return;
132
+ const fields = projected.value.entities[0]?.components.M2SceneAssetRefs as Record<
133
+ string,
134
+ unknown
135
+ >;
136
+ expect(world.sharedRefs.resolve(fields.mesh as never)).toMatchObject({
137
+ value: { kind: 'mesh', guid: 'mesh-guid' },
138
+ });
139
+ expect(world.sharedRefs.resolve((fields.materials as unknown[])[0] as never)).toMatchObject({
140
+ value: { kind: 'material', guid: 'material-guid' },
141
+ });
142
+ });
143
+
144
+ it('projects GUID fields and nested mounts into World-owned shared refs', async () => {
145
+ const world = new World();
146
+ registerSceneWorld(world);
147
+ const child: SceneAsset = {
148
+ kind: 'scene',
149
+ entities: [{ localId: 0 as never, components: { M2SceneOwnerMarker: { value: 1 } } }],
150
+ };
151
+ const root: SceneAsset = {
152
+ kind: 'scene',
153
+ entities: [
154
+ {
155
+ localId: 0 as never,
156
+ components: {
157
+ M2SceneAssetRefs: { mesh: 'mesh-guid', materials: ['material-guid'] },
158
+ },
159
+ },
160
+ ],
161
+ mounts: [
162
+ {
163
+ localId: 1 as never,
164
+ source: 'child-guid',
165
+ memberFirst: 2 as never,
166
+ memberCount: 1,
167
+ components: { M2SceneOwnerMarker: { value: 2 } },
168
+ },
169
+ ],
170
+ };
171
+ const payloads: Record<string, unknown> = {
172
+ 'mesh-guid': { kind: 'mesh' },
173
+ 'material-guid': { kind: 'material' },
174
+ 'child-guid': child,
175
+ };
176
+
177
+ const projected = await projectSceneAsset(world, root, async (guid, kind) => {
178
+ const payload = payloads[guid];
179
+ if (payload === undefined) return err({ code: 'asset-not-found' } as never);
180
+ expect((payload as { readonly kind: string }).kind).toBe(kind);
181
+ return ok(payload);
182
+ });
183
+
184
+ expect(projected.ok).toBe(true);
185
+ if (!projected.ok) return;
186
+ const fields = projected.value.entities[0]?.components.M2SceneAssetRefs as Record<
187
+ string,
188
+ unknown
189
+ >;
190
+ expect(typeof fields.mesh).toBe('number');
191
+ expect(Array.isArray(fields.materials)).toBe(true);
192
+ const mountSource = projected.value.mounts?.[0]?.source;
193
+ expect(typeof mountSource).toBe('number');
194
+ expect(world.sharedRefs.resolve(fields.mesh as never)).toMatchObject({
195
+ value: { kind: 'mesh' },
196
+ });
197
+ expect(world.sharedRefs.resolve(mountSource as never)).toMatchObject({
198
+ value: { kind: 'scene' },
199
+ });
200
+
201
+ const sceneHandle = world.allocSharedRef('SceneAsset', projected.value);
202
+ const instantiated = worldInstantiateScene(world, sceneHandle);
203
+ expect(instantiated.ok).toBe(true);
204
+ });
205
+
206
+ it('projects mesh-owned material defaults into the World render view', async () => {
207
+ const world = new World();
208
+ registerSceneWorld(world);
209
+ const materialGuid = '019eb2cf-0001-7000-8000-deadbeef0002';
210
+ const parsedGuid = AssetGuid.parse(materialGuid);
211
+ expect(parsedGuid.ok).toBe(true);
212
+ if (!parsedGuid.ok) return;
213
+
214
+ const root: SceneAsset = {
215
+ kind: 'scene',
216
+ entities: [
217
+ {
218
+ localId: 0 as never,
219
+ components: { M2SceneAssetRefs: { mesh: 'mesh-guid', materials: [] } },
220
+ },
221
+ ],
222
+ };
223
+ const payloads: Record<string, unknown> = {
224
+ 'mesh-guid': {
225
+ kind: 'mesh',
226
+ materialSlots: [{ slotName: 'Default', defaultMaterial: parsedGuid.value }],
227
+ },
228
+ [materialGuid]: { kind: 'material' },
229
+ };
230
+
231
+ const projected = await projectSceneAsset(world, root, async (guid, kind) => {
232
+ const payload = payloads[guid.toLowerCase()];
233
+ if (payload === undefined) return err({ code: 'asset-not-found' } as never);
234
+ expect((payload as { readonly kind: string }).kind).toBe(kind);
235
+ return ok(payload);
236
+ });
237
+
238
+ expect(projected.ok).toBe(true);
239
+ if (!projected.ok) return;
240
+ const resolver = world.getResource<SceneAssetMeshDefaultResolver>(
241
+ SCENE_ASSET_MESH_DEFAULT_RESOLVER_RESOURCE_KEY,
242
+ );
243
+ expect(resolver.resolveMeshDefaultMaterial(materialGuid)).toBeGreaterThan(0);
244
+ });
245
+ });
@@ -0,0 +1,26 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ChildOf, Children, Name, Transform } from '../index';
4
+
5
+ describe('scene component roster', () => {
6
+ it('exports exactly the four scene components', () => {
7
+ expect(Object.keys({ Name, Transform, ChildOf, Children }).sort()).toEqual([
8
+ 'ChildOf',
9
+ 'Children',
10
+ 'Name',
11
+ 'Transform',
12
+ ]);
13
+ });
14
+
15
+ it('registers and spawns in a headless world', () => {
16
+ const world = new World();
17
+ const entity = world
18
+ .spawn(
19
+ { component: Name, data: { value: 'root' } },
20
+ { component: Transform, data: {} },
21
+ { component: Children, data: {} },
22
+ )
23
+ .unwrap();
24
+ expect(world.get(entity, Name).unwrap().value).toBe('root');
25
+ });
26
+ });
@@ -0,0 +1,11 @@
1
+ import type * as SceneSurface from '../index';
2
+
3
+ type DiagnosticSurface = Pick<SceneSurface.SceneHierarchyDiagnostic, 'code' | 'detail'>;
4
+ const diagnosticSurface: DiagnosticSurface | undefined = undefined;
5
+ void diagnosticSurface;
6
+
7
+ // These names were duplicate projections of the SceneError code/detail owners.
8
+ // @ts-expect-error hierarchy diagnostics use SceneErrorCode directly.
9
+ export type RemovedDiagnosticCode = SceneSurface['SceneHierarchyDiagnosticCode'];
10
+ // @ts-expect-error hierarchy diagnostics use SceneErrorDetail directly.
11
+ export type RemovedDiagnosticDetail = SceneSurface['SceneHierarchyDiagnosticDetail'];
@@ -0,0 +1,13 @@
1
+ import { componentSchema } from '@forgeax/engine-ecs/internal';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ describe('scene ChildOf module', () => {
5
+ it('registers its Children mirror when imported directly', async () => {
6
+ vi.resetModules();
7
+ const { ChildOf } = await import('../components/child-of');
8
+ const { Children } = await import('../components/children');
9
+
10
+ expect(ChildOf.name).toBe('ChildOf');
11
+ expect(componentSchema(Children).entities).toBe('array<entity>');
12
+ });
13
+ });
@@ -0,0 +1,15 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { ChildOf } from '../../index';
3
+
4
+ /**
5
+ * Scene-owned malformed-edge fixture. The public relationship write accepts
6
+ * stale handles as data; the projection then owns the liveness diagnostic.
7
+ * Keeping this fixture on `world.set` avoids a private ECS graph seam.
8
+ */
9
+ export function setMalformedParentEdge(
10
+ world: World,
11
+ child: EntityHandle,
12
+ parent: EntityHandle,
13
+ ): void {
14
+ world.set(child, ChildOf, { parent }).unwrap();
15
+ }
@@ -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
+ });