@flighthq/scene3d-resources 0.2.1-next.840.857425c

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 (91) hide show
  1. package/dist/awd2Load.d.ts +3 -0
  2. package/dist/awd2Load.d.ts.map +1 -0
  3. package/dist/awd2Load.js +15 -0
  4. package/dist/awd2Load.js.map +1 -0
  5. package/dist/enableScene3DResourceFailureGuards.d.ts +5 -0
  6. package/dist/enableScene3DResourceFailureGuards.d.ts.map +1 -0
  7. package/dist/enableScene3DResourceFailureGuards.js +52 -0
  8. package/dist/enableScene3DResourceFailureGuards.js.map +1 -0
  9. package/dist/getScene3DResourceTextures.d.ts +3 -0
  10. package/dist/getScene3DResourceTextures.d.ts.map +1 -0
  11. package/dist/getScene3DResourceTextures.js +37 -0
  12. package/dist/getScene3DResourceTextures.js.map +1 -0
  13. package/dist/gltfLoad.d.ts +4 -0
  14. package/dist/gltfLoad.d.ts.map +1 -0
  15. package/dist/gltfLoad.js +58 -0
  16. package/dist/gltfLoad.js.map +1 -0
  17. package/dist/imageResourceFetch.d.ts +4 -0
  18. package/dist/imageResourceFetch.d.ts.map +1 -0
  19. package/dist/imageResourceFetch.js +29 -0
  20. package/dist/imageResourceFetch.js.map +1 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +18 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/md2Load.d.ts +3 -0
  26. package/dist/md2Load.d.ts.map +1 -0
  27. package/dist/md2Load.js +15 -0
  28. package/dist/md2Load.js.map +1 -0
  29. package/dist/md5Load.d.ts +3 -0
  30. package/dist/md5Load.d.ts.map +1 -0
  31. package/dist/md5Load.js +15 -0
  32. package/dist/md5Load.js.map +1 -0
  33. package/dist/objLoad.d.ts +3 -0
  34. package/dist/objLoad.d.ts.map +1 -0
  35. package/dist/objLoad.js +16 -0
  36. package/dist/objLoad.js.map +1 -0
  37. package/dist/resolveScene3DResources.d.ts +4 -0
  38. package/dist/resolveScene3DResources.d.ts.map +1 -0
  39. package/dist/resolveScene3DResources.js +175 -0
  40. package/dist/resolveScene3DResources.js.map +1 -0
  41. package/dist/resolveScene3DResourcesAndWait.d.ts +4 -0
  42. package/dist/resolveScene3DResourcesAndWait.d.ts.map +1 -0
  43. package/dist/resolveScene3DResourcesAndWait.js +57 -0
  44. package/dist/resolveScene3DResourcesAndWait.js.map +1 -0
  45. package/dist/revealScene3DResourcesOnResolve.d.ts +3 -0
  46. package/dist/revealScene3DResourcesOnResolve.d.ts.map +1 -0
  47. package/dist/revealScene3DResourcesOnResolve.js +95 -0
  48. package/dist/revealScene3DResourcesOnResolve.js.map +1 -0
  49. package/dist/sceneDocumentSource.d.ts +6 -0
  50. package/dist/sceneDocumentSource.d.ts.map +1 -0
  51. package/dist/sceneDocumentSource.js +55 -0
  52. package/dist/sceneDocumentSource.js.map +1 -0
  53. package/dist/sceneMaterialTextureRegistry.d.ts +6 -0
  54. package/dist/sceneMaterialTextureRegistry.d.ts.map +1 -0
  55. package/dist/sceneMaterialTextureRegistry.js +43 -0
  56. package/dist/sceneMaterialTextureRegistry.js.map +1 -0
  57. package/dist/sceneResourceRecovery.d.ts +5 -0
  58. package/dist/sceneResourceRecovery.d.ts.map +1 -0
  59. package/dist/sceneResourceRecovery.js +43 -0
  60. package/dist/sceneResourceRecovery.js.map +1 -0
  61. package/dist/sceneResourceResolver.d.ts +5 -0
  62. package/dist/sceneResourceResolver.d.ts.map +1 -0
  63. package/dist/sceneResourceResolver.js +41 -0
  64. package/dist/sceneResourceResolver.js.map +1 -0
  65. package/dist/sceneResourceSignals.d.ts +5 -0
  66. package/dist/sceneResourceSignals.d.ts.map +1 -0
  67. package/dist/sceneResourceSignals.js +21 -0
  68. package/dist/sceneResourceSignals.js.map +1 -0
  69. package/dist/threeDsLoad.d.ts +3 -0
  70. package/dist/threeDsLoad.d.ts.map +1 -0
  71. package/dist/threeDsLoad.js +14 -0
  72. package/dist/threeDsLoad.js.map +1 -0
  73. package/package.json +55 -0
  74. package/src/awd2Load.test.ts +63 -0
  75. package/src/enableScene3DResourceFailureGuards.test.ts +116 -0
  76. package/src/getScene3DResourceTextures.test.ts +101 -0
  77. package/src/gltfLoad.test.ts +118 -0
  78. package/src/imageResourceFetch.test.ts +95 -0
  79. package/src/md2Load.test.ts +63 -0
  80. package/src/md5Load.test.ts +63 -0
  81. package/src/objLoad.test.ts +63 -0
  82. package/src/resolveScene3DResources.test.ts +335 -0
  83. package/src/resolveScene3DResourcesAndWait.test.ts +119 -0
  84. package/src/revealScene3DResourcesOnResolve.test.ts +164 -0
  85. package/src/sceneDocumentSource.test.ts +116 -0
  86. package/src/sceneMaterialTextureRegistry.test.ts +95 -0
  87. package/src/sceneResourceRecovery.test.ts +98 -0
  88. package/src/sceneResourceResolver.test.ts +80 -0
  89. package/src/sceneResourceResolverTreeShaking.test.ts +51 -0
  90. package/src/sceneResourceSignals.test.ts +51 -0
  91. package/src/threeDsLoad.test.ts +63 -0
@@ -0,0 +1,335 @@
1
+ import type * as ImageModule from '@flighthq/image';
2
+ import { createUnlitMaterial } from '@flighthq/materials';
3
+ import { createBoxMeshGeometry } from '@flighthq/mesh';
4
+ import { addNodeChild } from '@flighthq/node';
5
+ import { createMesh, createScene3D } from '@flighthq/scene3d';
6
+ import { connectSignal } from '@flighthq/signals';
7
+ import { createTexture } from '@flighthq/texture';
8
+ import type { ImageResource, ImageResourceReference, Scene3DResourceResolver, Texture } from '@flighthq/types';
9
+ import { ImageResourceFailureKind, ResourceResolutionState, ImageResourceReferenceKind } from '@flighthq/types';
10
+ import type { Mock } from 'vitest';
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
12
+
13
+ import type * as ResolveScene3DResourcesModule from './resolveScene3DResources';
14
+ import type * as ResolveScene3DResourcesAndWaitModule from './resolveScene3DResourcesAndWait';
15
+ import type * as Scene3DResourceResolverModule from './sceneResourceResolver';
16
+ import type * as Scene3DResourceSignalsModule from './sceneResourceSignals';
17
+
18
+ const fakeImage = { height: 2, width: 2 } as unknown as ImageResource;
19
+ type LoadImageResourceFromBytes = typeof ImageModule.loadImageResourceFromBytes;
20
+
21
+ let createBuiltInScene3DResourceResolver: typeof Scene3DResourceResolverModule.createBuiltInScene3DResourceResolver;
22
+ let disposeScene3DResourceResolver: typeof Scene3DResourceResolverModule.disposeScene3DResourceResolver;
23
+ let enableScene3DResourceSignals: typeof Scene3DResourceSignalsModule.enableScene3DResourceSignals;
24
+ let loadFromBytes: Mock<LoadImageResourceFromBytes>;
25
+ let resolveOneScene3DResourceTexture: typeof ResolveScene3DResourcesModule.resolveOneScene3DResourceTexture;
26
+ let resolveScene3DResources: typeof ResolveScene3DResourcesModule.resolveScene3DResources;
27
+ let waitForScene3DResourceResolver: typeof ResolveScene3DResourcesAndWaitModule.waitForScene3DResourceResolver;
28
+
29
+ function embeddedRef(mimeType: string | null = 'image/png'): ImageResourceReference {
30
+ return {
31
+ bytes: new Uint8Array([9, 9]),
32
+ failure: null,
33
+ kind: ImageResourceReferenceKind.Embedded,
34
+ mimeType,
35
+ state: ResourceResolutionState.Unresolved,
36
+ };
37
+ }
38
+
39
+ function externalRef(uri = 'leaf.png'): ImageResourceReference {
40
+ return {
41
+ basePath: null,
42
+ failure: null,
43
+ kind: ImageResourceReferenceKind.External,
44
+ mimeType: null,
45
+ state: ResourceResolutionState.Unresolved,
46
+ uri,
47
+ };
48
+ }
49
+
50
+ function pendingTexture(): Texture {
51
+ return createTexture({ resource: embeddedRef() });
52
+ }
53
+
54
+ function meshScene3D(...textures: Texture[]) {
55
+ const scene = createScene3D();
56
+ for (const texture of textures) {
57
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: texture })]));
58
+ }
59
+ return scene;
60
+ }
61
+
62
+ async function settle(resolver: Scene3DResourceResolver): Promise<void> {
63
+ await waitForScene3DResourceResolver(resolver);
64
+ }
65
+
66
+ beforeAll(async () => {
67
+ vi.resetModules();
68
+ loadFromBytes = vi.fn<LoadImageResourceFromBytes>();
69
+ vi.doMock('@flighthq/image', () => ({
70
+ loadImageResourceFromBytes: loadFromBytes,
71
+ loadImageResourceFromUrl: vi.fn(),
72
+ }));
73
+ ({ resolveOneScene3DResourceTexture, resolveScene3DResources } = await import('./resolveScene3DResources'));
74
+ ({ waitForScene3DResourceResolver } = await import('./resolveScene3DResourcesAndWait'));
75
+ ({ createBuiltInScene3DResourceResolver, disposeScene3DResourceResolver } = await import('./sceneResourceResolver'));
76
+ ({ enableScene3DResourceSignals } = await import('./sceneResourceSignals'));
77
+ });
78
+
79
+ afterAll(() => {
80
+ vi.doUnmock('@flighthq/image');
81
+ vi.resetModules();
82
+ });
83
+
84
+ afterEach(() => {
85
+ loadFromBytes.mockReset();
86
+ });
87
+
88
+ describe('resolveOneScene3DResourceTexture', () => {
89
+ it('decodes embedded bytes through @flighthq/image', async () => {
90
+ loadFromBytes.mockResolvedValue(fakeImage);
91
+ const resolver = createBuiltInScene3DResourceResolver();
92
+ const ref = embeddedRef('image/jpeg');
93
+ const signal = new AbortController().signal;
94
+ const result = await resolveOneScene3DResourceTexture(resolver, ref, signal);
95
+ expect(loadFromBytes).toHaveBeenCalledWith((ref as { bytes: Uint8Array }).bytes, 'image/jpeg', signal);
96
+ expect(result).toBe(fakeImage);
97
+ disposeScene3DResourceResolver(resolver);
98
+ });
99
+
100
+ it('passes undefined for a null embedded mimeType', async () => {
101
+ loadFromBytes.mockResolvedValue(fakeImage);
102
+ const resolver = createBuiltInScene3DResourceResolver();
103
+ await resolveOneScene3DResourceTexture(resolver, embeddedRef(null), new AbortController().signal);
104
+ expect(loadFromBytes).toHaveBeenCalledWith(expect.anything(), undefined, expect.anything());
105
+ disposeScene3DResourceResolver(resolver);
106
+ });
107
+
108
+ it('routes external refs through the resolver fetch seam', async () => {
109
+ const fetch = vi.fn(async () => fakeImage);
110
+ const resolver = createBuiltInScene3DResourceResolver({ fetch });
111
+ const ref = externalRef();
112
+ const signal = new AbortController().signal;
113
+ const result = await resolveOneScene3DResourceTexture(resolver, ref, signal);
114
+ expect(fetch).toHaveBeenCalledWith(ref, signal);
115
+ expect(result).toBe(fakeImage);
116
+ disposeScene3DResourceResolver(resolver);
117
+ });
118
+ });
119
+
120
+ describe('resolveScene3DResources', () => {
121
+ it('resolves every pending texture, binding the image and advancing state', async () => {
122
+ loadFromBytes.mockResolvedValue(fakeImage);
123
+ const a = pendingTexture();
124
+ const b = pendingTexture();
125
+ const scene = meshScene3D(a, b);
126
+ const resolver = createBuiltInScene3DResourceResolver();
127
+
128
+ resolveScene3DResources(scene.root, resolver);
129
+ await settle(resolver);
130
+
131
+ expect(a.image).toBe(fakeImage);
132
+ expect(b.image).toBe(fakeImage);
133
+ expect(a.resource?.state).toBe(ResourceResolutionState.Resolved);
134
+ expect(a.resource?.failure).toBeNull();
135
+ expect(b.resource?.state).toBe(ResourceResolutionState.Resolved);
136
+ disposeScene3DResourceResolver(resolver);
137
+ });
138
+
139
+ it('fetches and decodes one shared resource once, then binds every subscribed texture', async () => {
140
+ loadFromBytes.mockResolvedValue(fakeImage);
141
+ const ref = embeddedRef();
142
+ const a = createTexture({ resource: ref });
143
+ const b = createTexture({ resource: ref });
144
+ const scene = meshScene3D(a, b);
145
+ const resolver = createBuiltInScene3DResourceResolver();
146
+
147
+ resolveScene3DResources(scene.root, resolver);
148
+ await settle(resolver);
149
+
150
+ expect(loadFromBytes).toHaveBeenCalledTimes(1);
151
+ expect(a.image).toBe(fakeImage);
152
+ expect(b.image).toBe(fakeImage);
153
+ expect(ref.state).toBe(ResourceResolutionState.Resolved);
154
+ disposeScene3DResourceResolver(resolver);
155
+ });
156
+
157
+ it('keeps a shared load alive until its final subscriber leaves the working set', async () => {
158
+ let loadSignal: AbortSignal | undefined;
159
+ loadFromBytes.mockImplementation(
160
+ (_bytes, _mime, signal) =>
161
+ new Promise<ImageResource>((_resolve, reject) => {
162
+ loadSignal = signal;
163
+ signal?.addEventListener('abort', () => reject(signal.reason));
164
+ }),
165
+ );
166
+ const ref = embeddedRef();
167
+ const a = createTexture({ resource: ref });
168
+ const b = createTexture({ resource: ref });
169
+ const scene = meshScene3D(a, b);
170
+ const resolver = createBuiltInScene3DResourceResolver();
171
+
172
+ resolveScene3DResources(scene.root, resolver);
173
+ await vi.waitFor(() => expect(loadSignal).toBeDefined());
174
+ resolveScene3DResources(scene.root, resolver, { select: (texture) => texture === b });
175
+ expect(loadSignal?.aborted).toBe(false);
176
+ expect(ref.state).toBe(ResourceResolutionState.Loading);
177
+
178
+ resolveScene3DResources(scene.root, resolver, { select: () => false });
179
+ expect(loadSignal?.aborted).toBe(true);
180
+ expect(ref.state).toBe(ResourceResolutionState.Unresolved);
181
+ disposeScene3DResourceResolver(resolver);
182
+ });
183
+
184
+ it('binds a later subscriber from the resolved resource cache without decoding again', async () => {
185
+ loadFromBytes.mockResolvedValue(fakeImage);
186
+ const ref = embeddedRef();
187
+ const a = createTexture({ resource: ref });
188
+ const b = createTexture({ resource: ref });
189
+ const scene = meshScene3D(a, b);
190
+ const resolver = createBuiltInScene3DResourceResolver();
191
+
192
+ resolveScene3DResources(scene.root, resolver, { select: (texture) => texture === a });
193
+ await settle(resolver);
194
+ expect(a.image).toBe(fakeImage);
195
+ expect(b.image).toBeNull();
196
+
197
+ resolveScene3DResources(scene.root, resolver, { select: (texture) => texture === b });
198
+ expect(b.image).toBe(fakeImage);
199
+ expect(loadFromBytes).toHaveBeenCalledTimes(1);
200
+ disposeScene3DResourceResolver(resolver);
201
+ });
202
+
203
+ it('limits the working set to textures the select predicate accepts', async () => {
204
+ loadFromBytes.mockResolvedValue(fakeImage);
205
+ const wanted = pendingTexture();
206
+ const skipped = pendingTexture();
207
+ const scene = meshScene3D(wanted, skipped);
208
+ const resolver = createBuiltInScene3DResourceResolver();
209
+
210
+ resolveScene3DResources(scene.root, resolver, { select: (texture) => texture === wanted });
211
+ await settle(resolver);
212
+
213
+ expect(wanted.resource?.state).toBe(ResourceResolutionState.Resolved);
214
+ expect(skipped.resource?.state).toBe(ResourceResolutionState.Unresolved);
215
+ expect(skipped.image).toBeNull();
216
+ disposeScene3DResourceResolver(resolver);
217
+ });
218
+
219
+ it('fails a texture whose external fetch returns null', async () => {
220
+ const texture = createTexture({ resource: externalRef() });
221
+ const scene = meshScene3D(texture);
222
+ const resolver = createBuiltInScene3DResourceResolver({ fetch: async () => null });
223
+
224
+ resolveScene3DResources(scene.root, resolver);
225
+ await settle(resolver);
226
+
227
+ expect(texture.image).toBeNull();
228
+ expect(texture.resource?.state).toBe(ResourceResolutionState.Failed);
229
+ expect(texture.resource?.failure).toEqual({
230
+ kind: ImageResourceFailureKind.Unavailable,
231
+ message: 'Image resource resolution returned no image',
232
+ name: null,
233
+ });
234
+ disposeScene3DResourceResolver(resolver);
235
+ });
236
+
237
+ it('fails a texture whose decode throws', async () => {
238
+ loadFromBytes.mockRejectedValue(new Error('bad image'));
239
+ const texture = pendingTexture();
240
+ const scene = meshScene3D(texture);
241
+ const resolver = createBuiltInScene3DResourceResolver();
242
+
243
+ resolveScene3DResources(scene.root, resolver);
244
+ await settle(resolver);
245
+
246
+ expect(texture.resource?.state).toBe(ResourceResolutionState.Failed);
247
+ expect(texture.resource?.failure).toEqual({
248
+ kind: ImageResourceFailureKind.Error,
249
+ message: 'bad image',
250
+ name: 'Error',
251
+ });
252
+ disposeScene3DResourceResolver(resolver);
253
+ });
254
+
255
+ it('cancels and reverts a load dropped from the working set, then re-requests on re-entry', async () => {
256
+ const loadSignals: AbortSignal[] = [];
257
+ // A load that hangs until its signal aborts, so we can drop it mid-flight.
258
+ loadFromBytes.mockImplementation(
259
+ (_bytes, _mime, signal) =>
260
+ new Promise<ImageResource>((_resolve, reject) => {
261
+ if (signal !== undefined) loadSignals.push(signal);
262
+ signal?.addEventListener('abort', () => reject(signal.reason));
263
+ }),
264
+ );
265
+ const texture = pendingTexture();
266
+ const scene = meshScene3D(texture);
267
+ const resolver = createBuiltInScene3DResourceResolver();
268
+
269
+ resolveScene3DResources(scene.root, resolver, { select: () => true });
270
+ expect(texture.resource?.state).toBe(ResourceResolutionState.Loading);
271
+ await vi.waitFor(() => expect(loadSignals).toHaveLength(1));
272
+
273
+ // Drop it: not in the working set this pass → abort + revert.
274
+ resolveScene3DResources(scene.root, resolver, { select: () => false });
275
+ expect(texture.resource?.state).toBe(ResourceResolutionState.Unresolved);
276
+ expect(texture.image).toBeNull();
277
+ expect(loadSignals[0].aborted).toBe(true);
278
+
279
+ // Re-entry re-requests from scratch.
280
+ resolveScene3DResources(scene.root, resolver, { select: () => true });
281
+ expect(texture.resource?.state).toBe(ResourceResolutionState.Loading);
282
+ await vi.waitFor(() => expect(loadSignals).toHaveLength(2));
283
+
284
+ disposeScene3DResourceResolver(resolver);
285
+ });
286
+
287
+ it('does not re-request a texture already in flight', async () => {
288
+ loadFromBytes.mockImplementation(() => new Promise<ImageResource>(() => {}));
289
+ const texture = pendingTexture();
290
+ const scene = meshScene3D(texture);
291
+ const resolver = createBuiltInScene3DResourceResolver();
292
+
293
+ resolveScene3DResources(scene.root, resolver);
294
+ await vi.waitFor(() => expect(loadFromBytes).toHaveBeenCalledTimes(1));
295
+ resolveScene3DResources(scene.root, resolver);
296
+ expect(loadFromBytes).toHaveBeenCalledTimes(1);
297
+
298
+ disposeScene3DResourceResolver(resolver);
299
+ });
300
+
301
+ it('emits onResourceResolved when signals are enabled', async () => {
302
+ loadFromBytes.mockResolvedValue(fakeImage);
303
+ const texture = pendingTexture();
304
+ const scene = meshScene3D(texture);
305
+ const resolver = createBuiltInScene3DResourceResolver();
306
+ const signals = enableScene3DResourceSignals(resolver);
307
+ const resolved: Texture[] = [];
308
+ const failed: Texture[] = [];
309
+ connectSignal(signals.onResourceResolved, (event) => resolved.push(event.texture));
310
+ connectSignal(signals.onResourceFailed, (event) => failed.push(event.texture));
311
+
312
+ resolveScene3DResources(scene.root, resolver);
313
+ await settle(resolver);
314
+
315
+ expect(resolved).toEqual([texture]);
316
+ expect(failed).toEqual([]);
317
+ disposeScene3DResourceResolver(resolver);
318
+ });
319
+
320
+ it('emits onResourceFailed when a decode throws', async () => {
321
+ loadFromBytes.mockRejectedValue(new Error('bad image'));
322
+ const texture = pendingTexture();
323
+ const scene = meshScene3D(texture);
324
+ const resolver = createBuiltInScene3DResourceResolver();
325
+ const signals = enableScene3DResourceSignals(resolver);
326
+ const failed: Texture[] = [];
327
+ connectSignal(signals.onResourceFailed, (event) => failed.push(event.texture));
328
+
329
+ resolveScene3DResources(scene.root, resolver);
330
+ await settle(resolver);
331
+
332
+ expect(failed).toEqual([texture]);
333
+ disposeScene3DResourceResolver(resolver);
334
+ });
335
+ });
@@ -0,0 +1,119 @@
1
+ import { createUnlitMaterial } from '@flighthq/materials';
2
+ import { createBoxMeshGeometry } from '@flighthq/mesh';
3
+ import { addNodeChild } from '@flighthq/node';
4
+ import { createMesh, createScene3D } from '@flighthq/scene3d';
5
+ import { connectSignal, createSignal } from '@flighthq/signals';
6
+ import { createTexture } from '@flighthq/texture';
7
+ import type { ImageResource, ImageResourceReference, Scene3DResourceLoadProgress } from '@flighthq/types';
8
+ import { ResourceResolutionState, ImageResourceReferenceKind } from '@flighthq/types';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import { loadScene3DResources, waitForScene3DResourceResolver } from './resolveScene3DResourcesAndWait';
12
+ import { createBuiltInScene3DResourceResolver, disposeScene3DResourceResolver } from './sceneResourceResolver';
13
+
14
+ const fakeImage = { height: 1, width: 1 } as unknown as ImageResource;
15
+
16
+ function externalRef(): ImageResourceReference {
17
+ return {
18
+ basePath: null,
19
+ failure: null,
20
+ kind: ImageResourceReferenceKind.External,
21
+ mimeType: null,
22
+ state: ResourceResolutionState.Unresolved,
23
+ uri: 'leaf.png',
24
+ };
25
+ }
26
+
27
+ describe('loadScene3DResources', () => {
28
+ it('awaits every started load so the scene is fully resolved on return', async () => {
29
+ const fetch = vi.fn(async () => fakeImage);
30
+ const a = createTexture({ resource: externalRef() });
31
+ const b = createTexture({ resource: externalRef() });
32
+ const scene = createScene3D();
33
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: a })]));
34
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: b })]));
35
+ const resolver = createBuiltInScene3DResourceResolver({ fetch });
36
+
37
+ await loadScene3DResources(scene.root, resolver);
38
+
39
+ expect(a.image).toBe(fakeImage);
40
+ expect(b.image).toBe(fakeImage);
41
+ expect(a.resource?.state).toBe(ResourceResolutionState.Resolved);
42
+ disposeScene3DResourceResolver(resolver);
43
+ });
44
+
45
+ it('resolves immediately when there is nothing pending', async () => {
46
+ const scene = createScene3D();
47
+ const resolver = createBuiltInScene3DResourceResolver({ fetch: async () => fakeImage });
48
+ await expect(loadScene3DResources(scene.root, resolver)).resolves.toBeUndefined();
49
+ disposeScene3DResourceResolver(resolver);
50
+ });
51
+
52
+ it('reports operation-scoped progress as unique references settle', async () => {
53
+ const a = createTexture({ resource: externalRef() });
54
+ const b = createTexture({ resource: externalRef() });
55
+ const scene = createScene3D();
56
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: a })]));
57
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: b })]));
58
+ const resolver = createBuiltInScene3DResourceResolver({ fetch: async () => fakeImage });
59
+ const events: Scene3DResourceLoadProgress[] = [];
60
+ const progress = createSignal<(event: Readonly<Scene3DResourceLoadProgress>) => void>();
61
+ connectSignal(progress, (event) => events.push({ ...event }));
62
+
63
+ await loadScene3DResources(scene.root, resolver, { progress });
64
+
65
+ expect(events[0]).toEqual({ loaded: 0, total: 2 });
66
+ expect(events.at(-1)).toEqual({ loaded: 2, total: 2 });
67
+ disposeScene3DResourceResolver(resolver);
68
+ });
69
+
70
+ it('counts failed references as terminal without hiding their failure state', async () => {
71
+ const texture = createTexture({ resource: externalRef() });
72
+ const scene = createScene3D();
73
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: texture })]));
74
+ const resolver = createBuiltInScene3DResourceResolver({ fetch: async () => null });
75
+ const events: Scene3DResourceLoadProgress[] = [];
76
+ const progress = createSignal<(event: Readonly<Scene3DResourceLoadProgress>) => void>();
77
+ connectSignal(progress, (event) => events.push({ ...event }));
78
+
79
+ await loadScene3DResources(scene.root, resolver, { progress });
80
+
81
+ expect(texture.image).toBeNull();
82
+ expect(texture.resource?.state).toBe(ResourceResolutionState.Failed);
83
+ expect(events).toEqual([
84
+ { loaded: 0, total: 1 },
85
+ { loaded: 1, total: 1 },
86
+ ]);
87
+ disposeScene3DResourceResolver(resolver);
88
+ });
89
+
90
+ it('waits and reports only the references selected for this operation', async () => {
91
+ const selected = createTexture({ resource: externalRef() });
92
+ const deferred = createTexture({ resource: externalRef() });
93
+ const scene = createScene3D();
94
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: selected })]));
95
+ addNodeChild(scene.root, createMesh(createBoxMeshGeometry(), [createUnlitMaterial({ baseColorMap: deferred })]));
96
+ const fetch = vi.fn(async () => fakeImage);
97
+ const resolver = createBuiltInScene3DResourceResolver({ fetch });
98
+ const events: Scene3DResourceLoadProgress[] = [];
99
+ const progress = createSignal<(event: Readonly<Scene3DResourceLoadProgress>) => void>();
100
+ connectSignal(progress, (event) => events.push({ ...event }));
101
+
102
+ await loadScene3DResources(scene.root, resolver, { progress, select: (texture) => texture === selected });
103
+
104
+ expect(selected.image).toBe(fakeImage);
105
+ expect(deferred.image).toBeNull();
106
+ expect(deferred.resource?.state).toBe(ResourceResolutionState.Unresolved);
107
+ expect(fetch).toHaveBeenCalledOnce();
108
+ expect(events.at(-1)).toEqual({ loaded: 1, total: 1 });
109
+ disposeScene3DResourceResolver(resolver);
110
+ });
111
+ });
112
+
113
+ describe('waitForScene3DResourceResolver', () => {
114
+ it('resolves immediately when the resolver has no pending requests', async () => {
115
+ const resolver = createBuiltInScene3DResourceResolver();
116
+ await expect(waitForScene3DResourceResolver(resolver)).resolves.toBeUndefined();
117
+ disposeScene3DResourceResolver(resolver);
118
+ });
119
+ });
@@ -0,0 +1,164 @@
1
+ import { createStandardPbrMaterial } from '@flighthq/materials';
2
+ import { createBoxMeshGeometry } from '@flighthq/mesh';
3
+ import { addNodeChild } from '@flighthq/node';
4
+ import { createMesh, createScene3D } from '@flighthq/scene3d';
5
+ import { emitSignal } from '@flighthq/signals';
6
+ import { createTexture } from '@flighthq/texture';
7
+ import { createTweenManager, hasTweensOf, updateTweens } from '@flighthq/tween';
8
+ import type { Scene3D } from '@flighthq/types';
9
+ import type {
10
+ EmbeddedImageResourceReference,
11
+ ImageResource,
12
+ Node3D,
13
+ Scene3DResourceEvent,
14
+ Texture,
15
+ } from '@flighthq/types';
16
+ import { ResourceResolutionState } from '@flighthq/types';
17
+
18
+ import { revealScene3DResourcesOnResolve } from './revealScene3DResourcesOnResolve';
19
+ import { createBuiltInScene3DResourceResolver } from './sceneResourceResolver';
20
+ import { enableScene3DResourceSignals } from './sceneResourceSignals';
21
+
22
+ function pendingRef(): EmbeddedImageResourceReference {
23
+ return {
24
+ bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
25
+ failure: null,
26
+ kind: 'Embedded',
27
+ mimeType: 'image/png',
28
+ state: ResourceResolutionState.Unresolved,
29
+ };
30
+ }
31
+
32
+ function sceneWithPendingTexture(): { mesh: Node3D; scene: Scene3D; texture: Texture } {
33
+ const texture = createTexture({ resource: pendingRef() });
34
+ const material = createStandardPbrMaterial({ baseColorMap: texture });
35
+ const mesh = createMesh(createBoxMeshGeometry(), [material]);
36
+ const scene = createScene3D();
37
+ addNodeChild(scene.root, mesh);
38
+ return { mesh, scene, texture };
39
+ }
40
+
41
+ describe('revealScene3DResourcesOnResolve', () => {
42
+ it('hides every object carrying a pending texture to the start opacity up front', () => {
43
+ const { mesh, scene } = sceneWithPendingTexture();
44
+ const resolver = createBuiltInScene3DResourceResolver();
45
+ revealScene3DResourcesOnResolve(resolver, scene.root, createTweenManager());
46
+ expect(mesh.alpha).toBe(0);
47
+ });
48
+
49
+ it('honors a custom from opacity', () => {
50
+ const { mesh, scene } = sceneWithPendingTexture();
51
+ const resolver = createBuiltInScene3DResourceResolver();
52
+ revealScene3DResourcesOnResolve(resolver, scene.root, createTweenManager(), { from: 0.2 });
53
+ expect(mesh.alpha).toBeCloseTo(0.2);
54
+ });
55
+
56
+ it('fades the owning object to full opacity as its texture resolves', () => {
57
+ const { mesh, scene, texture } = sceneWithPendingTexture();
58
+ const resolver = createBuiltInScene3DResourceResolver();
59
+ const manager = createTweenManager();
60
+ revealScene3DResourcesOnResolve(resolver, scene.root, manager, { fadeSeconds: 0.5 });
61
+ expect(mesh.alpha).toBe(0);
62
+
63
+ const signals = enableScene3DResourceSignals(resolver);
64
+ const event: Scene3DResourceEvent = { ref: texture.resource!, texture };
65
+ emitSignal(signals.onResourceResolved, event);
66
+ expect(hasTweensOf(manager, mesh)).toBe(true);
67
+
68
+ updateTweens(manager, 0.5);
69
+ expect(mesh.alpha).toBeCloseTo(1);
70
+ });
71
+
72
+ it('waits for every required texture across a material before revealing its owner', () => {
73
+ const baseColorMap = createTexture({ resource: pendingRef() });
74
+ const normalMap = createTexture({ resource: pendingRef() });
75
+ const material = createStandardPbrMaterial({ baseColorMap, normalMap });
76
+ const mesh = createMesh(createBoxMeshGeometry(), [material]);
77
+ const scene = createScene3D();
78
+ addNodeChild(scene.root, mesh);
79
+ const resolver = createBuiltInScene3DResourceResolver();
80
+ const manager = createTweenManager();
81
+ revealScene3DResourcesOnResolve(resolver, scene.root, manager, { fadeSeconds: 0.5 });
82
+
83
+ const signals = enableScene3DResourceSignals(resolver);
84
+ emitSignal(signals.onResourceResolved, { ref: baseColorMap.resource!, texture: baseColorMap });
85
+ expect(hasTweensOf(manager, mesh)).toBe(false);
86
+
87
+ emitSignal(signals.onResourceResolved, { ref: normalMap.resource!, texture: normalMap });
88
+ expect(hasTweensOf(manager, mesh)).toBe(true);
89
+ });
90
+
91
+ it('treats failure as settled and reveals the fallback only after the remaining resources settle', () => {
92
+ const baseColorMap = createTexture({ resource: pendingRef() });
93
+ const normalMap = createTexture({ resource: pendingRef() });
94
+ const material = createStandardPbrMaterial({ baseColorMap, normalMap });
95
+ const mesh = createMesh(createBoxMeshGeometry(), [material]);
96
+ const scene = createScene3D();
97
+ addNodeChild(scene.root, mesh);
98
+ const resolver = createBuiltInScene3DResourceResolver();
99
+ const manager = createTweenManager();
100
+ revealScene3DResourcesOnResolve(resolver, scene.root, manager, { fadeSeconds: 0.5 });
101
+
102
+ const signals = enableScene3DResourceSignals(resolver);
103
+ emitSignal(signals.onResourceFailed, { ref: normalMap.resource!, texture: normalMap });
104
+ expect(hasTweensOf(manager, mesh)).toBe(false);
105
+
106
+ emitSignal(signals.onResourceResolved, { ref: baseColorMap.resource!, texture: baseColorMap });
107
+ expect(hasTweensOf(manager, mesh)).toBe(true);
108
+ });
109
+
110
+ it('does not hide an owner whose resource was already bound or had already failed', () => {
111
+ const bound = createTexture({
112
+ image: { height: 1, width: 1 } as ImageResource,
113
+ resource: pendingRef(),
114
+ });
115
+ const failedRef = pendingRef();
116
+ failedRef.state = ResourceResolutionState.Failed;
117
+ const failed = createTexture({ resource: failedRef });
118
+ const material = createStandardPbrMaterial({ baseColorMap: bound, normalMap: failed });
119
+ const mesh = createMesh(createBoxMeshGeometry(), [material]);
120
+ const scene = createScene3D();
121
+ addNodeChild(scene.root, mesh);
122
+
123
+ revealScene3DResourcesOnResolve(createBuiltInScene3DResourceResolver(), scene.root, createTweenManager());
124
+ expect(mesh.alpha).toBe(1);
125
+ });
126
+
127
+ it('reveals every owner of one shared texture when that texture settles', () => {
128
+ const texture = createTexture({ resource: pendingRef() });
129
+ const a = createMesh(createBoxMeshGeometry(), [createStandardPbrMaterial({ baseColorMap: texture })]);
130
+ const b = createMesh(createBoxMeshGeometry(), [createStandardPbrMaterial({ baseColorMap: texture })]);
131
+ const scene = createScene3D();
132
+ addNodeChild(scene.root, a);
133
+ addNodeChild(scene.root, b);
134
+ const resolver = createBuiltInScene3DResourceResolver();
135
+ const manager = createTweenManager();
136
+ revealScene3DResourcesOnResolve(resolver, scene.root, manager);
137
+
138
+ emitSignal(enableScene3DResourceSignals(resolver).onResourceResolved, { ref: texture.resource!, texture });
139
+ expect(hasTweensOf(manager, a)).toBe(true);
140
+ expect(hasTweensOf(manager, b)).toBe(true);
141
+ });
142
+
143
+ it('ignores a resolve event for a texture it is not tracking', () => {
144
+ const { scene } = sceneWithPendingTexture();
145
+ const resolver = createBuiltInScene3DResourceResolver();
146
+ const manager = createTweenManager();
147
+ revealScene3DResourcesOnResolve(resolver, scene.root, manager, { fadeSeconds: 0.5 });
148
+
149
+ const stray = createTexture({ resource: pendingRef() });
150
+ emitSignal(enableScene3DResourceSignals(resolver).onResourceResolved, { ref: stray.resource!, texture: stray });
151
+ expect(hasTweensOf(manager, stray as unknown as object)).toBe(false);
152
+ });
153
+
154
+ it('disconnects the listener when the returned disposer runs', () => {
155
+ const { mesh, scene, texture } = sceneWithPendingTexture();
156
+ const resolver = createBuiltInScene3DResourceResolver();
157
+ const manager = createTweenManager();
158
+ const dispose = revealScene3DResourcesOnResolve(resolver, scene.root, manager, { fadeSeconds: 0.5 });
159
+ dispose();
160
+
161
+ emitSignal(enableScene3DResourceSignals(resolver).onResourceResolved, { ref: texture.resource!, texture });
162
+ expect(hasTweensOf(manager, mesh)).toBe(false);
163
+ });
164
+ });