@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.
- 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/instantiate-scene.d.ts +7 -0
- package/dist/assets/instantiate-scene.d.ts.map +1 -0
- package/dist/assets/publication-observer.d.ts +13 -0
- package/dist/assets/publication-observer.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/assets/scene-projection.d.ts +70 -0
- package/dist/assets/scene-projection.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 +2167 -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 +349 -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 +59 -0
- package/src/__tests__/asset-owner.integration.test.ts +245 -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/instantiate-scene.ts +14 -0
- package/src/assets/publication-observer.ts +24 -0
- package/src/assets/scene-decoder.ts +153 -0
- package/src/assets/scene-projection.ts +644 -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 +78 -0
- package/src/instances/collect-profile.ts +10 -0
- package/src/instances/externalization.ts +137 -0
- package/src/instances/scene-instances.ts +1565 -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,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,14 @@
|
|
|
1
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { Handle, SceneAsset } from '@forgeax/engine-types';
|
|
3
|
+
import { worldInstantiateScene } from '../instances/scene-instances';
|
|
4
|
+
|
|
5
|
+
/** Scene-owned entry point for materialising a validated SceneAsset in a World. */
|
|
6
|
+
export function instantiateScene(
|
|
7
|
+
world: World,
|
|
8
|
+
handle: Handle<'SceneAsset', 'shared'>,
|
|
9
|
+
parent?: EntityHandle,
|
|
10
|
+
): ReturnType<typeof worldInstantiateScene> {
|
|
11
|
+
return worldInstantiateScene(world, handle, parent);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SceneInstanceAsset = SceneAsset;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type AssetPublicationTuple, err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
export interface ScenePublicationObservation {
|
|
4
|
+
readonly guid: string;
|
|
5
|
+
readonly generation: number;
|
|
6
|
+
readonly digest: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Read-only projection of a published scene envelope for consumers. */
|
|
10
|
+
export function observeScenePublication(
|
|
11
|
+
envelope: AssetPublicationTuple,
|
|
12
|
+
): Result<
|
|
13
|
+
ScenePublicationObservation,
|
|
14
|
+
{ readonly code: 'scene-publication-invalid'; readonly expected: string; readonly hint: string }
|
|
15
|
+
> {
|
|
16
|
+
if (!Number.isSafeInteger(envelope.generation) || envelope.generation < 1) {
|
|
17
|
+
return err({
|
|
18
|
+
code: 'scene-publication-invalid',
|
|
19
|
+
expected: 'a positive publication generation',
|
|
20
|
+
hint: 'publish the scene envelope with a monotonic generation',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return ok({ guid: envelope.scopeId, generation: envelope.generation, digest: envelope.digest });
|
|
24
|
+
}
|
|
@@ -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
|
+
};
|