@forgeax/engine-import 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 +226 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts +2 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/import-contract-migration.test.d.ts +2 -0
- package/dist/__tests__/import-contract-migration.test.d.ts.map +1 -0
- package/dist/__tests__/import-dependencies.test.d.ts +2 -0
- package/dist/__tests__/import-dependencies.test.d.ts.map +1 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts +2 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts.map +1 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/import.unit.test.d.ts +2 -0
- package/dist/__tests__/import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts +2 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts +2 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts.map +1 -0
- package/dist/import-product.d.ts +36 -0
- package/dist/import-product.d.ts.map +1 -0
- package/dist/import-runner.d.ts +133 -0
- package/dist/import-runner.d.ts.map +1 -0
- package/dist/importer-registry.d.ts +40 -0
- package/dist/importer-registry.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1607 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mesh-bin.d.ts +19 -0
- package/dist/mesh-bin.d.ts.map +1 -0
- package/dist/scriptable-pack-output-producers.d.ts +6 -0
- package/dist/scriptable-pack-output-producers.d.ts.map +1 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts +22 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts.map +1 -0
- package/dist/scriptable-pack.d.ts +153 -0
- package/dist/scriptable-pack.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/decode-image-owner.test-d.ts +53 -0
- package/src/__tests__/import-contract-migration.test.ts +190 -0
- package/src/__tests__/import-dependencies.test.ts +65 -0
- package/src/__tests__/import-finalizer-digest-retry.test.ts +113 -0
- package/src/__tests__/import-local-artifacts.test.ts +100 -0
- package/src/__tests__/import.unit.test.ts +429 -0
- package/src/__tests__/material-import-product.unit.test.ts +56 -0
- package/src/__tests__/mesh-bin-contract.test.ts +95 -0
- package/src/__tests__/mesh-bin-morph-v3.test.ts +44 -0
- package/src/__tests__/mesh-bin.test.ts +102 -0
- package/src/__tests__/scriptable-pack-output-producers.unit.test.ts +190 -0
- package/src/__tests__/scriptable-pack-production-producers.unit.test.ts +429 -0
- package/src/__tests__/scriptable-pack-staged-snapshot.unit.test.ts +258 -0
- package/src/__tests__/scriptable-pack.integration.test.ts +435 -0
- package/src/__tests__/source-overrides-import-context.unit.test.ts +92 -0
- package/src/import-product.ts +195 -0
- package/src/import-runner.ts +720 -0
- package/src/importer-registry.ts +80 -0
- package/src/index.ts +103 -0
- package/src/mesh-bin.ts +229 -0
- package/src/scriptable-pack-output-producers.ts +466 -0
- package/src/scriptable-pack-staged-snapshot.ts +161 -0
- package/src/scriptable-pack.ts +615 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { defineComponent } from '@forgeax/engine-ecs';
|
|
2
|
+
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
3
|
+
import type {
|
|
4
|
+
AnimationClip,
|
|
5
|
+
AnimationGraph,
|
|
6
|
+
Asset,
|
|
7
|
+
AssetGuid as AssetGuidType,
|
|
8
|
+
AudioClipAsset,
|
|
9
|
+
EquirectAsset,
|
|
10
|
+
FontAsset,
|
|
11
|
+
MaterialAsset,
|
|
12
|
+
MeshAsset,
|
|
13
|
+
ParticleEffectAsset,
|
|
14
|
+
RenderPipelineAsset,
|
|
15
|
+
SamplerAsset,
|
|
16
|
+
SceneAsset,
|
|
17
|
+
SkeletonAsset,
|
|
18
|
+
SkinAsset,
|
|
19
|
+
TextureAsset,
|
|
20
|
+
TilesetAsset,
|
|
21
|
+
VideoAsset,
|
|
22
|
+
} from '@forgeax/engine-types';
|
|
23
|
+
import { describe, expect, it } from 'vitest';
|
|
24
|
+
import { createStandardAssetOutputProducerRegistry } from '../scriptable-pack-output-producers.js';
|
|
25
|
+
|
|
26
|
+
function guid(value: string): AssetGuidType {
|
|
27
|
+
const parsed = AssetGuid.parse(value);
|
|
28
|
+
if (!parsed.ok) throw parsed.error;
|
|
29
|
+
return parsed.value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MATERIAL_GUID = guid('019ffa97-3000-7000-8000-000000000001');
|
|
33
|
+
const MESH_GUID = guid('019ffa97-3000-7000-8000-000000000002');
|
|
34
|
+
const ScriptableMeshFilter = defineComponent('ScriptablePackTestMeshFilter', {
|
|
35
|
+
assetHandle: 'shared<MeshAsset>',
|
|
36
|
+
});
|
|
37
|
+
const ScriptableMeshRenderer = defineComponent('ScriptablePackTestMeshRenderer', {
|
|
38
|
+
materials: 'array<shared<MaterialAsset>>',
|
|
39
|
+
});
|
|
40
|
+
const componentCatalog = new Map<
|
|
41
|
+
string,
|
|
42
|
+
typeof ScriptableMeshFilter | typeof ScriptableMeshRenderer
|
|
43
|
+
>([
|
|
44
|
+
[ScriptableMeshFilter.name, ScriptableMeshFilter],
|
|
45
|
+
[ScriptableMeshRenderer.name, ScriptableMeshRenderer],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const OUTPUT_GUID = guid('019ffa97-3000-7000-8000-000000000010');
|
|
49
|
+
const ATLAS_GUID = guid('019ffa97-3000-7000-8000-000000000011');
|
|
50
|
+
const SKELETON_GUID = guid('019ffa97-3000-7000-8000-000000000012');
|
|
51
|
+
|
|
52
|
+
function canonicalAttributes(vertexCount: number): MeshAsset['attributes'] {
|
|
53
|
+
return {
|
|
54
|
+
position: new Float32Array(vertexCount * 3),
|
|
55
|
+
normal: new Float32Array(vertexCount * 3),
|
|
56
|
+
uv: new Float32Array(vertexCount * 2),
|
|
57
|
+
tangent: new Float32Array(vertexCount * 4),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const particleProgram = {
|
|
62
|
+
format: 'forgeax-vfx-program-2',
|
|
63
|
+
fingerprint: 'sha256:scriptable-pack-matrix',
|
|
64
|
+
emitters: [
|
|
65
|
+
{
|
|
66
|
+
id: 'default',
|
|
67
|
+
module: 'matrix/default',
|
|
68
|
+
capacity: 16,
|
|
69
|
+
backend: { required: 'gpu' },
|
|
70
|
+
space: 'local',
|
|
71
|
+
schedule: {},
|
|
72
|
+
bounds: {},
|
|
73
|
+
renderers: [],
|
|
74
|
+
simulationWhenCulled: 'pause',
|
|
75
|
+
wgsl: 'fn main() {}',
|
|
76
|
+
reflection: {},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
const matrixAssets = [
|
|
82
|
+
{
|
|
83
|
+
kind: 'mesh',
|
|
84
|
+
vertices: new Float32Array(48),
|
|
85
|
+
indices: new Uint16Array([0]),
|
|
86
|
+
attributes: canonicalAttributes(4),
|
|
87
|
+
submeshes: [],
|
|
88
|
+
materialSlots: [],
|
|
89
|
+
} satisfies MeshAsset,
|
|
90
|
+
{ kind: 'material', values: { roughness: 0.5 } } satisfies MaterialAsset,
|
|
91
|
+
{ kind: 'scene', entities: [] } satisfies SceneAsset,
|
|
92
|
+
{
|
|
93
|
+
kind: 'texture',
|
|
94
|
+
width: 1,
|
|
95
|
+
height: 1,
|
|
96
|
+
format: 'rgba8unorm',
|
|
97
|
+
data: new Uint8Array([255, 255, 255, 255]),
|
|
98
|
+
colorSpace: 'srgb',
|
|
99
|
+
mipmap: false,
|
|
100
|
+
} satisfies TextureAsset,
|
|
101
|
+
{
|
|
102
|
+
kind: 'equirect',
|
|
103
|
+
width: 1,
|
|
104
|
+
height: 1,
|
|
105
|
+
format: 'rgba16float',
|
|
106
|
+
data: new Uint8Array(8),
|
|
107
|
+
colorSpace: 'linear',
|
|
108
|
+
} satisfies EquirectAsset,
|
|
109
|
+
{ kind: 'sampler', magFilter: 'linear' } satisfies SamplerAsset,
|
|
110
|
+
{
|
|
111
|
+
kind: 'font',
|
|
112
|
+
atlas: ATLAS_GUID,
|
|
113
|
+
sampler: OUTPUT_GUID,
|
|
114
|
+
glyphs: {},
|
|
115
|
+
common: {
|
|
116
|
+
lineHeight: 1,
|
|
117
|
+
base: 1,
|
|
118
|
+
distanceRange: 4,
|
|
119
|
+
pxRange: 4,
|
|
120
|
+
atlasWidth: 1,
|
|
121
|
+
atlasHeight: 1,
|
|
122
|
+
},
|
|
123
|
+
} satisfies FontAsset,
|
|
124
|
+
{ kind: 'render-pipeline', pipelineId: 'forgeax::urp' } satisfies RenderPipelineAsset,
|
|
125
|
+
{
|
|
126
|
+
kind: 'tileset',
|
|
127
|
+
atlases: [AssetGuid.format(ATLAS_GUID)],
|
|
128
|
+
tileWidth: 1,
|
|
129
|
+
tileHeight: 1,
|
|
130
|
+
columns: 1,
|
|
131
|
+
rows: 1,
|
|
132
|
+
regions: [{ x: 0, y: 0, width: 1, height: 1 }],
|
|
133
|
+
tiles: [{ regionIndex: 0 }],
|
|
134
|
+
} satisfies TilesetAsset,
|
|
135
|
+
{ kind: 'video', url: 'https://example.test/scene.webm' } satisfies VideoAsset,
|
|
136
|
+
{
|
|
137
|
+
kind: 'skeleton',
|
|
138
|
+
inverseBindMatrices: new Float32Array(16),
|
|
139
|
+
jointCount: 1,
|
|
140
|
+
} satisfies SkeletonAsset,
|
|
141
|
+
{
|
|
142
|
+
kind: 'skin',
|
|
143
|
+
skeletonGuid: AssetGuid.format(SKELETON_GUID),
|
|
144
|
+
jointPaths: ['root'],
|
|
145
|
+
} satisfies SkinAsset,
|
|
146
|
+
{ kind: 'animation-clip', duration: 1, channels: [] } satisfies AnimationClip,
|
|
147
|
+
{
|
|
148
|
+
kind: 'animation-graph',
|
|
149
|
+
nodes: [{ type: 'clip', clip: AssetGuid.format(OUTPUT_GUID), weight: 1 }],
|
|
150
|
+
root: 0,
|
|
151
|
+
} satisfies AnimationGraph,
|
|
152
|
+
{
|
|
153
|
+
kind: 'audio',
|
|
154
|
+
sourceKey: 'audio/matrix',
|
|
155
|
+
mediaType: 'audio/wav',
|
|
156
|
+
bytes: new Uint8Array([82, 73, 70, 70]),
|
|
157
|
+
} satisfies AudioClipAsset,
|
|
158
|
+
{
|
|
159
|
+
kind: 'particle-effect',
|
|
160
|
+
schemaVersion: 2,
|
|
161
|
+
programFingerprint: particleProgram.fingerprint,
|
|
162
|
+
emitters: [{ id: 'default', capacity: 16 }],
|
|
163
|
+
program: particleProgram,
|
|
164
|
+
} satisfies ParticleEffectAsset,
|
|
165
|
+
] as const satisfies readonly Asset[];
|
|
166
|
+
|
|
167
|
+
describe('standard ScriptablePack output producers', () => {
|
|
168
|
+
void ScriptableMeshFilter;
|
|
169
|
+
void ScriptableMeshRenderer;
|
|
170
|
+
it('registers the production producer versions for all ordinary Asset kinds', () => {
|
|
171
|
+
const registry = createStandardAssetOutputProducerRegistry();
|
|
172
|
+
expect(registry.versions()).toEqual({
|
|
173
|
+
'animation-clip': 'ordinary-pod/1',
|
|
174
|
+
'animation-graph': 'ordinary-pod/1',
|
|
175
|
+
audio: 'ordinary-pod/1',
|
|
176
|
+
equirect: 'ordinary-pod/1',
|
|
177
|
+
font: 'ordinary-pod/1',
|
|
178
|
+
material: 'material-pack/1',
|
|
179
|
+
mesh: 'mesh-binary/4',
|
|
180
|
+
'particle-effect': 'ordinary-pod/1',
|
|
181
|
+
'render-pipeline': 'ordinary-pod/1',
|
|
182
|
+
sampler: 'ordinary-pod/1',
|
|
183
|
+
scene: 'scene-pack/2',
|
|
184
|
+
skeleton: 'ordinary-pod/1',
|
|
185
|
+
skin: 'ordinary-pod/1',
|
|
186
|
+
texture: 'ordinary-pod/1',
|
|
187
|
+
tileset: 'ordinary-pod/1',
|
|
188
|
+
video: 'ordinary-pod/1',
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('provides a real producer contract for every ordinary Asset kind', async () => {
|
|
193
|
+
const registry = createStandardAssetOutputProducerRegistry();
|
|
194
|
+
const expectedKinds = [
|
|
195
|
+
'mesh',
|
|
196
|
+
'material',
|
|
197
|
+
'scene',
|
|
198
|
+
'texture',
|
|
199
|
+
'equirect',
|
|
200
|
+
'sampler',
|
|
201
|
+
'font',
|
|
202
|
+
'render-pipeline',
|
|
203
|
+
'tileset',
|
|
204
|
+
'video',
|
|
205
|
+
'skeleton',
|
|
206
|
+
'skin',
|
|
207
|
+
'animation-clip',
|
|
208
|
+
'animation-graph',
|
|
209
|
+
'audio',
|
|
210
|
+
'particle-effect',
|
|
211
|
+
] as const;
|
|
212
|
+
|
|
213
|
+
expect(Object.keys(registry.versions()).sort()).toEqual([...expectedKinds].sort());
|
|
214
|
+
for (const [index, asset] of matrixAssets.entries()) {
|
|
215
|
+
const producer = registry.get(asset.kind);
|
|
216
|
+
expect(producer, `${asset.kind} producer`).toBeDefined();
|
|
217
|
+
if (producer === undefined) continue;
|
|
218
|
+
const result = await producer.produce({
|
|
219
|
+
guid: AssetGuid.format(OUTPUT_GUID),
|
|
220
|
+
sourceKey: `matrix/${index}/${asset.kind}`,
|
|
221
|
+
asset,
|
|
222
|
+
});
|
|
223
|
+
expect(result.ok, `${asset.kind} producer result`).toBe(true);
|
|
224
|
+
if (!result.ok) continue;
|
|
225
|
+
expect(result.value.payload.kind ?? asset.kind).toBe(asset.kind);
|
|
226
|
+
const hasPayload = Object.keys(result.value.payload).length > 0;
|
|
227
|
+
const hasDescriptor =
|
|
228
|
+
asset.kind === 'video' &&
|
|
229
|
+
'url' in asset &&
|
|
230
|
+
typeof asset.url === 'string' &&
|
|
231
|
+
asset.url.length > 0;
|
|
232
|
+
expect(
|
|
233
|
+
hasPayload ||
|
|
234
|
+
result.value.refs.length + Object.keys(result.value.artifacts).length > 0 ||
|
|
235
|
+
hasDescriptor,
|
|
236
|
+
`${asset.kind} producer facts`,
|
|
237
|
+
).toBe(true);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('publishes media and VFX producer facts without host execution', async () => {
|
|
242
|
+
const registry = createStandardAssetOutputProducerRegistry();
|
|
243
|
+
for (const kind of ['audio', 'video', 'particle-effect'] as const) {
|
|
244
|
+
const asset = matrixAssets.find((candidate) => candidate.kind === kind);
|
|
245
|
+
const producer = registry.get(kind);
|
|
246
|
+
expect(producer, `${kind} producer`).toBeDefined();
|
|
247
|
+
expect(asset, `${kind} fixture`).toBeDefined();
|
|
248
|
+
if (producer === undefined || asset === undefined) continue;
|
|
249
|
+
const result = await producer.produce({
|
|
250
|
+
guid: AssetGuid.format(OUTPUT_GUID),
|
|
251
|
+
sourceKey: `media/${kind}`,
|
|
252
|
+
asset,
|
|
253
|
+
});
|
|
254
|
+
expect(result.ok, `${kind} result`).toBe(true);
|
|
255
|
+
if (!result.ok) continue;
|
|
256
|
+
if (kind === 'video') {
|
|
257
|
+
expect(result.value.payload).toMatchObject({ kind, url: (asset as VideoAsset).url });
|
|
258
|
+
} else {
|
|
259
|
+
expect(Object.keys(result.value.artifacts)).not.toHaveLength(0);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('serializes a scene through component schemas and emits recursive GUID refs', async () => {
|
|
265
|
+
const registry = createStandardAssetOutputProducerRegistry();
|
|
266
|
+
const producer = registry.get('scene');
|
|
267
|
+
expect(producer).toBeDefined();
|
|
268
|
+
if (producer === undefined) return;
|
|
269
|
+
|
|
270
|
+
const scene: SceneAsset = {
|
|
271
|
+
kind: 'scene',
|
|
272
|
+
entities: [
|
|
273
|
+
{
|
|
274
|
+
localId: 0 as never,
|
|
275
|
+
components: {
|
|
276
|
+
ScriptablePackTestMeshFilter: { assetHandle: AssetGuid.format(MESH_GUID) },
|
|
277
|
+
ScriptablePackTestMeshRenderer: {
|
|
278
|
+
materials: [AssetGuid.format(MATERIAL_GUID)],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
};
|
|
284
|
+
const result = await producer.produce({
|
|
285
|
+
guid: '019ffa97-3000-7000-8000-000000000003',
|
|
286
|
+
sourceKey: 'scene/showcase',
|
|
287
|
+
asset: scene,
|
|
288
|
+
components: componentCatalog,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.ok).toBe(true);
|
|
292
|
+
if (!result.ok) return;
|
|
293
|
+
expect(result.value.payload).toMatchObject({
|
|
294
|
+
entities: [
|
|
295
|
+
{
|
|
296
|
+
localId: 0,
|
|
297
|
+
components: {
|
|
298
|
+
ScriptablePackTestMeshFilter: { assetHandle: 0 },
|
|
299
|
+
ScriptablePackTestMeshRenderer: { materials: [1] },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
expect(result.value.refs).toEqual([
|
|
305
|
+
{
|
|
306
|
+
guid: AssetGuid.format(MESH_GUID),
|
|
307
|
+
sourceField: {
|
|
308
|
+
componentName: 'ScriptablePackTestMeshFilter',
|
|
309
|
+
fieldName: 'assetHandle',
|
|
310
|
+
},
|
|
311
|
+
sceneEntityId: 0,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
guid: AssetGuid.format(MATERIAL_GUID),
|
|
315
|
+
sourceField: {
|
|
316
|
+
componentName: 'ScriptablePackTestMeshRenderer',
|
|
317
|
+
fieldName: 'materials',
|
|
318
|
+
arrayIndex: 0,
|
|
319
|
+
},
|
|
320
|
+
sceneEntityId: 0,
|
|
321
|
+
},
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('packs mesh bytes and projects material parent references', async () => {
|
|
326
|
+
const registry = createStandardAssetOutputProducerRegistry();
|
|
327
|
+
const mesh = registry.get('mesh');
|
|
328
|
+
const material = registry.get('material');
|
|
329
|
+
expect(mesh).toBeDefined();
|
|
330
|
+
expect(material).toBeDefined();
|
|
331
|
+
if (mesh === undefined || material === undefined) return;
|
|
332
|
+
|
|
333
|
+
const meshAsset: MeshAsset = {
|
|
334
|
+
kind: 'mesh',
|
|
335
|
+
vertices: new Float32Array(48),
|
|
336
|
+
indices: new Uint16Array([0]),
|
|
337
|
+
attributes: canonicalAttributes(4),
|
|
338
|
+
submeshes: [],
|
|
339
|
+
materialSlots: [{ slotName: 'Default', defaultMaterial: MATERIAL_GUID }],
|
|
340
|
+
};
|
|
341
|
+
const meshResult = await mesh.produce({
|
|
342
|
+
guid: AssetGuid.format(MESH_GUID),
|
|
343
|
+
sourceKey: 'mesh/generated',
|
|
344
|
+
asset: meshAsset,
|
|
345
|
+
});
|
|
346
|
+
expect(meshResult.ok).toBe(true);
|
|
347
|
+
if (meshResult.ok)
|
|
348
|
+
expect(meshResult.value.artifacts.body?.bytes.byteLength).toBeGreaterThan(28);
|
|
349
|
+
|
|
350
|
+
const materialAsset: MaterialAsset = { kind: 'material', parent: MATERIAL_GUID };
|
|
351
|
+
const materialResult = await material.produce({
|
|
352
|
+
guid: AssetGuid.format(MATERIAL_GUID),
|
|
353
|
+
sourceKey: 'material/generated',
|
|
354
|
+
asset: materialAsset,
|
|
355
|
+
});
|
|
356
|
+
expect(materialResult.ok).toBe(true);
|
|
357
|
+
if (materialResult.ok) {
|
|
358
|
+
expect(materialResult.value.payload).toMatchObject({ parent: 0 });
|
|
359
|
+
expect(materialResult.value.refs).toEqual([
|
|
360
|
+
{ guid: AssetGuid.format(MATERIAL_GUID), sourceField: { fieldName: 'parent' } },
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('keeps ordinary output identities and serialized bytes stable across builds', async () => {
|
|
366
|
+
const registry = createStandardAssetOutputProducerRegistry();
|
|
367
|
+
const mesh = registry.get('mesh');
|
|
368
|
+
const material = registry.get('material');
|
|
369
|
+
const scene = registry.get('scene');
|
|
370
|
+
expect(mesh).toBeDefined();
|
|
371
|
+
expect(material).toBeDefined();
|
|
372
|
+
expect(scene).toBeDefined();
|
|
373
|
+
if (mesh === undefined || material === undefined || scene === undefined) return;
|
|
374
|
+
|
|
375
|
+
const inputs = [
|
|
376
|
+
{
|
|
377
|
+
guid: AssetGuid.format(MESH_GUID),
|
|
378
|
+
sourceKey: 'mesh/generated',
|
|
379
|
+
asset: {
|
|
380
|
+
kind: 'mesh' as const,
|
|
381
|
+
vertices: new Float32Array([
|
|
382
|
+
0, 0, 0, 0, 0, 1, 0.5, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0.5, 1, 0, 0, 0, 1, 1, 0, 0, 0,
|
|
383
|
+
0, 1, 1, 1, 0, 0, 0, 1,
|
|
384
|
+
]),
|
|
385
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
386
|
+
attributes: canonicalAttributes(3),
|
|
387
|
+
aabb: new Float32Array([0, 0, 0, 1, 1, 0]),
|
|
388
|
+
submeshes: [
|
|
389
|
+
{
|
|
390
|
+
topology: 'triangle-list' as const,
|
|
391
|
+
indexOffset: 0,
|
|
392
|
+
indexCount: 3,
|
|
393
|
+
vertexCount: 3,
|
|
394
|
+
materialSlot: 0,
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
398
|
+
},
|
|
399
|
+
producer: mesh,
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
guid: AssetGuid.format(MATERIAL_GUID),
|
|
403
|
+
sourceKey: 'material/generated',
|
|
404
|
+
asset: { kind: 'material' as const, values: {} },
|
|
405
|
+
producer: material,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
guid: AssetGuid.format(guid('019ffa97-3000-7000-8000-000000000003')),
|
|
409
|
+
sourceKey: 'scene/generated',
|
|
410
|
+
asset: { kind: 'scene' as const, entities: [] },
|
|
411
|
+
producer: scene,
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
const first = await Promise.all(
|
|
415
|
+
inputs.map(({ producer, ...input }) => producer.produce(input)),
|
|
416
|
+
);
|
|
417
|
+
const second = await Promise.all(
|
|
418
|
+
inputs.map(({ producer, ...input }) => producer.produce(input)),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect(first.map((result) => result.ok)).toEqual([true, true, true]);
|
|
422
|
+
expect(second.map((result) => result.ok)).toEqual([true, true, true]);
|
|
423
|
+
expect(first).toEqual(second);
|
|
424
|
+
for (const result of first) {
|
|
425
|
+
if (!result.ok) continue;
|
|
426
|
+
expect(result.value.payload).not.toHaveProperty('scriptableKind');
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
2
|
+
import type { AssetGuid as AssetGuidType, MeshAsset } from '@forgeax/engine-types';
|
|
3
|
+
import { AssetError, err, ok } from '@forgeax/engine-types';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
createScriptablePackStagedAssetSnapshotSource,
|
|
7
|
+
type ScriptablePackStagedOwner,
|
|
8
|
+
} from '../index.js';
|
|
9
|
+
|
|
10
|
+
function guid(value: string): AssetGuidType {
|
|
11
|
+
const parsed = AssetGuid.parse(value);
|
|
12
|
+
if (!parsed.ok) throw parsed.error;
|
|
13
|
+
return parsed.value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mesh(seed: number): MeshAsset {
|
|
17
|
+
return {
|
|
18
|
+
kind: 'mesh',
|
|
19
|
+
vertices: new Float32Array([seed, 0, 0]),
|
|
20
|
+
attributes: {},
|
|
21
|
+
submeshes: [
|
|
22
|
+
{
|
|
23
|
+
topology: 'triangle-list',
|
|
24
|
+
indexOffset: 0,
|
|
25
|
+
indexCount: 0,
|
|
26
|
+
vertexCount: 1,
|
|
27
|
+
materialSlot: 0,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ROOT = guid('019ffa97-2000-7000-8000-000000000001');
|
|
35
|
+
const DEPENDENCY = guid('019ffa97-2000-7000-8000-000000000002');
|
|
36
|
+
const HEALTHY = guid('019ffa97-2000-7000-8000-000000000003');
|
|
37
|
+
|
|
38
|
+
describe('ScriptablePack staged asset snapshot source', () => {
|
|
39
|
+
it('lazily builds owners in one generation, memoizes, and returns private clones', async () => {
|
|
40
|
+
const builds: string[] = [];
|
|
41
|
+
const owners: ScriptablePackStagedOwner[] = [
|
|
42
|
+
{
|
|
43
|
+
id: 'dependency.pack.ts',
|
|
44
|
+
guids: [DEPENDENCY],
|
|
45
|
+
async build() {
|
|
46
|
+
builds.push('dependency');
|
|
47
|
+
return ok([{ guid: DEPENDENCY, asset: mesh(2), digest: 'sha256:dependency' }]);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'root.pack.ts',
|
|
52
|
+
guids: [ROOT],
|
|
53
|
+
async build(source) {
|
|
54
|
+
builds.push('root:start');
|
|
55
|
+
const dependency = await source.readByGuid(DEPENDENCY);
|
|
56
|
+
if (!dependency.ok) return dependency;
|
|
57
|
+
builds.push('root:end');
|
|
58
|
+
const value = dependency.value.asset as MeshAsset;
|
|
59
|
+
return ok([{ guid: ROOT, asset: mesh(value.vertices[0] ?? 0) }]);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
const source = createScriptablePackStagedAssetSnapshotSource({ generation: 12, owners });
|
|
64
|
+
const first = await source.readByGuid(ROOT);
|
|
65
|
+
expect(first).toMatchObject({ ok: true, value: { generation: 12 } });
|
|
66
|
+
if (!first.ok) return;
|
|
67
|
+
expect(builds).toEqual(['root:start', 'dependency', 'root:end']);
|
|
68
|
+
expect((first.value.asset as MeshAsset).vertices[0]).toBe(2);
|
|
69
|
+
|
|
70
|
+
(first.value.asset as MeshAsset).vertices[0] = 99;
|
|
71
|
+
const second = await source.readByGuid(ROOT);
|
|
72
|
+
expect(second.ok && (second.value.asset as MeshAsset).vertices[0]).toBe(2);
|
|
73
|
+
expect(builds).toEqual(['root:start', 'dependency', 'root:end']);
|
|
74
|
+
expect(second.ok && second.value.digest).toMatch(/^sha256:/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('fails a content dependency cycle structurally without consulting fallback', async () => {
|
|
78
|
+
let fallbackReads = 0;
|
|
79
|
+
const owners: ScriptablePackStagedOwner[] = [
|
|
80
|
+
{
|
|
81
|
+
id: 'a.pack.ts',
|
|
82
|
+
guids: [ROOT],
|
|
83
|
+
async build(source) {
|
|
84
|
+
const result = await source.readByGuid(DEPENDENCY);
|
|
85
|
+
return result.ok ? ok([{ guid: ROOT, asset: result.value.asset }]) : result;
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'b.pack.ts',
|
|
90
|
+
guids: [DEPENDENCY],
|
|
91
|
+
async build(source) {
|
|
92
|
+
const result = await source.readByGuid(ROOT);
|
|
93
|
+
return result.ok ? ok([{ guid: DEPENDENCY, asset: result.value.asset }]) : result;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
const source = createScriptablePackStagedAssetSnapshotSource({
|
|
98
|
+
generation: 1,
|
|
99
|
+
owners,
|
|
100
|
+
fallback: {
|
|
101
|
+
async readByGuid() {
|
|
102
|
+
fallbackReads += 1;
|
|
103
|
+
return ok({ asset: mesh(8), generation: 0, digest: 'lkg' });
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const result = await source.readByGuid(ROOT);
|
|
108
|
+
expect(result).toMatchObject({
|
|
109
|
+
ok: false,
|
|
110
|
+
error: {
|
|
111
|
+
code: 'pack-source-build-cycle',
|
|
112
|
+
actual: 'a.pack.ts -> b.pack.ts -> a.pack.ts',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
expect(fallbackReads).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('retries a refused local owner on the same source and coalesces repaired reads', async () => {
|
|
119
|
+
let rootBuilds = 0;
|
|
120
|
+
let dependencyBuilds = 0;
|
|
121
|
+
let healthyBuilds = 0;
|
|
122
|
+
let fallbackReads = 0;
|
|
123
|
+
let repaired = false;
|
|
124
|
+
let releaseRepair!: () => void;
|
|
125
|
+
const repairBuildGate = new Promise<void>((resolve) => {
|
|
126
|
+
releaseRepair = resolve;
|
|
127
|
+
});
|
|
128
|
+
const failure = new AssetError({
|
|
129
|
+
code: 'asset-not-imported',
|
|
130
|
+
expected: 'the repaired local ScriptablePack capability',
|
|
131
|
+
hint: 'repair the local producer capability and retry the same source',
|
|
132
|
+
});
|
|
133
|
+
const owners: ScriptablePackStagedOwner[] = [
|
|
134
|
+
{
|
|
135
|
+
id: 'dependency.pack.ts',
|
|
136
|
+
guids: [DEPENDENCY],
|
|
137
|
+
async build() {
|
|
138
|
+
dependencyBuilds += 1;
|
|
139
|
+
return ok([{ guid: DEPENDENCY, asset: mesh(2), digest: 'sha256:dependency' }]);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'root.pack.ts',
|
|
144
|
+
guids: [ROOT],
|
|
145
|
+
async build(source) {
|
|
146
|
+
rootBuilds += 1;
|
|
147
|
+
const dependency = await source.readByGuid(DEPENDENCY);
|
|
148
|
+
if (!dependency.ok) return dependency;
|
|
149
|
+
if (!repaired) return err(failure);
|
|
150
|
+
await repairBuildGate;
|
|
151
|
+
const value = dependency.value.asset as MeshAsset;
|
|
152
|
+
return ok([
|
|
153
|
+
{ guid: ROOT, asset: mesh((value.vertices[0] ?? 0) + 1), digest: 'sha256:root' },
|
|
154
|
+
]);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'healthy.pack.ts',
|
|
159
|
+
guids: [HEALTHY],
|
|
160
|
+
async build() {
|
|
161
|
+
healthyBuilds += 1;
|
|
162
|
+
return ok([{ guid: HEALTHY, asset: mesh(7), digest: 'sha256:healthy' }]);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
const source = createScriptablePackStagedAssetSnapshotSource({
|
|
167
|
+
generation: 27,
|
|
168
|
+
owners,
|
|
169
|
+
fallback: {
|
|
170
|
+
async readByGuid() {
|
|
171
|
+
fallbackReads += 1;
|
|
172
|
+
return ok({ asset: mesh(99), generation: 0, digest: 'sha256:lkg' });
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const refused = await source.readByGuid(ROOT);
|
|
178
|
+
expect(refused).toMatchObject({
|
|
179
|
+
ok: false,
|
|
180
|
+
error: {
|
|
181
|
+
code: 'asset-not-imported',
|
|
182
|
+
expected: 'the repaired local ScriptablePack capability',
|
|
183
|
+
hint: 'repair the local producer capability and retry the same source',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
expect(rootBuilds).toBe(1);
|
|
187
|
+
expect(dependencyBuilds).toBe(1);
|
|
188
|
+
expect(fallbackReads).toBe(0);
|
|
189
|
+
|
|
190
|
+
const healthy = await source.readByGuid(HEALTHY);
|
|
191
|
+
expect(healthy).toMatchObject({
|
|
192
|
+
ok: true,
|
|
193
|
+
value: { generation: 27, digest: 'sha256:healthy' },
|
|
194
|
+
});
|
|
195
|
+
expect(healthyBuilds).toBe(1);
|
|
196
|
+
expect(fallbackReads).toBe(0);
|
|
197
|
+
|
|
198
|
+
repaired = true;
|
|
199
|
+
const repairedReads = [
|
|
200
|
+
source.readByGuid(ROOT),
|
|
201
|
+
source.readByGuid(ROOT),
|
|
202
|
+
source.readByGuid(ROOT),
|
|
203
|
+
];
|
|
204
|
+
expect(rootBuilds).toBe(2);
|
|
205
|
+
releaseRepair();
|
|
206
|
+
const [first, second, third] = await Promise.all(repairedReads);
|
|
207
|
+
if (
|
|
208
|
+
first === undefined ||
|
|
209
|
+
second === undefined ||
|
|
210
|
+
third === undefined ||
|
|
211
|
+
!first.ok ||
|
|
212
|
+
!second.ok ||
|
|
213
|
+
!third.ok
|
|
214
|
+
)
|
|
215
|
+
throw new Error('repaired concurrent reads must all return snapshots');
|
|
216
|
+
expect(first).toMatchObject({ ok: true, value: { generation: 27, digest: 'sha256:root' } });
|
|
217
|
+
expect(second).toMatchObject({ ok: true, value: { generation: 27, digest: 'sha256:root' } });
|
|
218
|
+
expect(third).toMatchObject({ ok: true, value: { generation: 27, digest: 'sha256:root' } });
|
|
219
|
+
expect((first.value.asset as MeshAsset).vertices[0]).toBe(3);
|
|
220
|
+
expect(rootBuilds).toBe(2);
|
|
221
|
+
expect(dependencyBuilds).toBe(1);
|
|
222
|
+
expect(healthyBuilds).toBe(1);
|
|
223
|
+
expect(fallbackReads).toBe(0);
|
|
224
|
+
|
|
225
|
+
const dependency = await source.readByGuid(DEPENDENCY);
|
|
226
|
+
expect(dependency).toMatchObject({
|
|
227
|
+
ok: true,
|
|
228
|
+
value: { generation: 27, digest: 'sha256:dependency' },
|
|
229
|
+
});
|
|
230
|
+
(first.value.asset as MeshAsset).vertices[0] = 99;
|
|
231
|
+
const memoized = await source.readByGuid(ROOT);
|
|
232
|
+
expect(memoized).toMatchObject({ ok: true, value: { generation: 27, digest: 'sha256:root' } });
|
|
233
|
+
expect(memoized.ok && (memoized.value.asset as MeshAsset).vertices[0]).toBe(3);
|
|
234
|
+
expect(rootBuilds).toBe(2);
|
|
235
|
+
expect(fallbackReads).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('carries the staged generation into every locally built snapshot', async () => {
|
|
239
|
+
const owner: ScriptablePackStagedOwner = {
|
|
240
|
+
id: 'mesh.pack.ts',
|
|
241
|
+
guids: [ROOT],
|
|
242
|
+
async build() {
|
|
243
|
+
return ok([{ guid: ROOT, asset: mesh(3), digest: 'sha256:mesh' }]);
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
const first = await createScriptablePackStagedAssetSnapshotSource({
|
|
247
|
+
generation: 8,
|
|
248
|
+
owners: [owner],
|
|
249
|
+
}).readByGuid(ROOT);
|
|
250
|
+
const second = await createScriptablePackStagedAssetSnapshotSource({
|
|
251
|
+
generation: 9,
|
|
252
|
+
owners: [owner],
|
|
253
|
+
}).readByGuid(ROOT);
|
|
254
|
+
|
|
255
|
+
expect(first).toMatchObject({ ok: true, value: { generation: 8, digest: 'sha256:mesh' } });
|
|
256
|
+
expect(second).toMatchObject({ ok: true, value: { generation: 9, digest: 'sha256:mesh' } });
|
|
257
|
+
});
|
|
258
|
+
});
|