babylon.quarks 0.20.0 → 0.21.0

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.
@@ -3535,6 +3535,18 @@ declare class ParticleSystem implements IParticleSystem {
3535
3535
  clone(): ParticleSystem;
3536
3536
  }
3537
3537
 
3538
+ /** Kind of linked asset the loader asks a {@link QuarksResourceResolver} to handle. */
3539
+ type QuarksResourceKind = 'effect' | 'geometry' | 'image';
3540
+ /**
3541
+ * Hooks for resolving relative source links (textures, geometry files) and loading their JSON.
3542
+ * Omit either method to keep the default (`baseUrl` join / `fetch`).
3543
+ */
3544
+ interface QuarksResourceResolver {
3545
+ /** Resolve a relative url to an absolute / blob / data url. Default: {@link QuarksLoader.joinUrl}. */
3546
+ resolve?(url: string, kind: QuarksResourceKind, baseUrl: string): string | Promise<string>;
3547
+ /** Load a JSON asset (geometry files). Default: `fetch(resolvedUrl).json()`. */
3548
+ fetchJson?(resolvedUrl: string): Promise<unknown>;
3549
+ }
3538
3550
  interface QuarksLoaderOptions {
3539
3551
  baseUrl?: string;
3540
3552
  /**
@@ -3546,19 +3558,96 @@ interface QuarksLoaderOptions {
3546
3558
  * change one effect's texture settings without touching the others.
3547
3559
  */
3548
3560
  shareResources?: boolean;
3561
+ /** Custom url / fetch backend (File picker, CDN, IndexedDB). */
3562
+ resolver?: QuarksResourceResolver;
3563
+ }
3564
+ /** One in-memory effect for {@link QuarksLoader.loadFromJSONAll}. */
3565
+ interface QuarksJSONLoadEntry {
3566
+ json: unknown;
3567
+ /** Overrides loader `baseUrl` for relative texture / geometry links in this effect. */
3568
+ baseUrl?: string;
3569
+ }
3570
+ /**
3571
+ * Options for {@link QuarksLoader.loadAll} / {@link QuarksLoader.loadFromJSONAll}.
3572
+ * Batch loads share the per-scene fetch cache and `shareResources`, so N effects that point at
3573
+ * the same `assets/geometries/*.json` or texture only pay for that asset once.
3574
+ */
3575
+ interface QuarksBatchLoadOptions {
3576
+ /**
3577
+ * Max concurrent effect-document fetches (phase 1). Linked geometry/image resolve still runs
3578
+ * across the whole pack in parallel so shared urls coalesce. Default: all at once.
3579
+ */
3580
+ concurrency?: number;
3581
+ /**
3582
+ * Called once per effect when it has fully settled (parsed successfully, or failed under
3583
+ * `continueOnError`). `done` is how many of `total` have settled so far.
3584
+ */
3585
+ onProgress?: (done: number, total: number) => void;
3586
+ /**
3587
+ * When true, a failed effect becomes `null` in the result array and the rest keep loading.
3588
+ * When false (default), the first failure rejects the whole batch.
3589
+ */
3590
+ continueOnError?: boolean;
3549
3591
  }
3550
3592
  declare class QuarksLoader {
3551
3593
  private scene;
3552
3594
  private options;
3553
3595
  constructor(scene: Scene, options?: QuarksLoaderOptions);
3596
+ /** Fetches an effect JSON from `url`, resolves linked sources, and parses it. */
3554
3597
  load(url: string): Promise<TransformNode>;
3598
+ /**
3599
+ * Resolves linked geometry / image urls on `json` (via {@link resolveResources}) then parses.
3600
+ * Use when the effect JSON is already in hand (File, CDN payload, editor import).
3601
+ */
3602
+ loadFromJSON(json: any, baseUrl?: string): Promise<TransformNode>;
3603
+ /**
3604
+ * Loads many effects in parallel for battle / level preload. Phase 1 fetches every effect
3605
+ * JSON (optionally concurrency-limited); phase 2 resolves all linked geometries/images across
3606
+ * the pack together so shared asset urls hit the fetch cache once; phase 3 parses.
3607
+ *
3608
+ * @example
3609
+ * ```ts
3610
+ * const roots = await loader.loadAll([
3611
+ * 'fx/hit.json', 'fx/explosion.json', 'fx/heal.json',
3612
+ * ], { concurrency: 6, continueOnError: true });
3613
+ * ```
3614
+ */
3615
+ loadAll(urls: string[], options?: QuarksBatchLoadOptions & {
3616
+ continueOnError?: false;
3617
+ }): Promise<TransformNode[]>;
3618
+ loadAll(urls: string[], options: QuarksBatchLoadOptions & {
3619
+ continueOnError: true;
3620
+ }): Promise<(TransformNode | null)[]>;
3621
+ /**
3622
+ * Same two-phase batch as {@link loadAll}, but effect JSON is already in memory (asset pack,
3623
+ * IndexedDB, editor). Entries may be bare JSON or `{ json, baseUrl }`.
3624
+ */
3625
+ loadFromJSONAll(entries: Array<QuarksJSONLoadEntry | unknown>, options?: QuarksBatchLoadOptions & {
3626
+ continueOnError?: false;
3627
+ }): Promise<TransformNode[]>;
3628
+ loadFromJSONAll(entries: Array<QuarksJSONLoadEntry | unknown>, options: QuarksBatchLoadOptions & {
3629
+ continueOnError: true;
3630
+ }): Promise<(TransformNode | null)[]>;
3631
+ /**
3632
+ * Inlines external geometry files and rewrites relative image urls through the loader's
3633
+ * {@link QuarksResourceResolver}. Idempotent for already-inlined geometries. Failures warn
3634
+ * and leave the entry as is.
3635
+ */
3636
+ resolveResources(json: any, baseUrl?: string): Promise<void>;
3637
+ /**
3638
+ * Same as instance {@link resolveResources}, but usable without constructing a loader (e.g.
3639
+ * editor import that only prepares JSON). Pass `scene` to share the per-scene fetch cache.
3640
+ */
3641
+ static resolveLinkedResources(json: any, options?: {
3642
+ baseUrl?: string;
3643
+ resolver?: QuarksResourceResolver;
3644
+ scene?: Scene;
3645
+ }): Promise<void>;
3555
3646
  /**
3556
3647
  * Inlines geometries that an export wrote to their own file (`{uuid, type: 'QuarksGeometry',
3557
- * url}` — e.g. the Unity exporter's "geometries as files"): each `url` is passed to
3558
- * `loadGeometry`, and the `positions / indices / uvs / normals` it returns replace the url in
3559
- * place, so `parse()` (which is synchronous) sees an ordinary inline geometry. `load()` does
3560
- * this with `fetch`; call it yourself before `parse()` when the files come from elsewhere.
3561
- * A geometry that fails to load is left as is and warned about.
3648
+ * url}`). Prefer {@link load}, {@link loadFromJSON}, or {@link resolveResources} — those use
3649
+ * the loader's resolver and fetch cache. Kept for callers that already have a custom
3650
+ * `loadGeometry` and no `QuarksLoader` instance.
3562
3651
  */
3563
3652
  static resolveExternalGeometries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void>;
3564
3653
  parse(json: any, baseUrl?: string): TransformNode;
@@ -3566,15 +3655,31 @@ declare class QuarksLoader {
3566
3655
  /** The scene's copy of an identical geometry when resources are shared, else `geometry` itself. */
3567
3656
  private share;
3568
3657
  /**
3569
- * Forgets the textures and geometries shared between loads into `scene` (see
3570
- * {@link QuarksLoaderOptions.shareResources}). Effects already loaded keep theirs; the next
3571
- * load creates new ones. Textures are not disposed — they may still be in use.
3658
+ * Forgets the textures, geometries, and in-flight JSON fetches shared between loads into
3659
+ * `scene` (see {@link QuarksLoaderOptions.shareResources}). Effects already loaded keep
3660
+ * theirs; the next load creates new ones. Textures are not disposed — they may still be in use.
3572
3661
  */
3573
3662
  static clearSharedResources(scene: Scene): void;
3663
+ /** Joins `baseUrl` with a relative `url`; leaves data/blob/absolute/root-absolute urls alone. */
3664
+ static joinUrl(baseUrl: string, url: string): string;
3574
3665
  private parseSphereGeometry;
3575
3666
  private parseBufferGeometry;
3576
3667
  private extractInterleavedAttribute;
3577
3668
  private int32ArrayToFloat32;
3669
+ private resolveResourceUrl;
3670
+ /** Fetches one effect JSON; documents are not fetch-cached because resolve mutates them. */
3671
+ private fetchEffectDocument;
3672
+ /**
3673
+ * Resolve every document's linked resources in parallel (shared urls coalesce via the
3674
+ * scene fetch cache), then parse. Each index settles exactly once for `onProgress`.
3675
+ */
3676
+ private loadDocuments;
3677
+ /** Runs `fn` over `items` with at most `concurrency` in flight (or all if unset/≤0). */
3678
+ private static mapPool;
3679
+ private static resolveWith;
3680
+ /** One in-flight fetch per scene + resolved url when `scene` is set; otherwise uncached. */
3681
+ private static fetchJsonWith;
3682
+ private static inlineGeometryEntries;
3578
3683
  private parseImages;
3579
3684
  /**
3580
3685
  * Flags an image entry that is plainly not an image, which otherwise only
@@ -3586,13 +3691,6 @@ declare class QuarksLoader {
3586
3691
  * file backs.
3587
3692
  */
3588
3693
  private static warnIfNotAnImageUrl;
3589
- /**
3590
- * `img.url` is already absolute for data/blob URIs and full http(s) URLs — common for
3591
- * "portable" exports that embed textures as base64 (see ensurePortableTextureUrl in
3592
- * babylon.quarks-editor). Prepending baseUrl to those turns e.g.
3593
- * "data:image/png;base64,AAAA" into an invalid path like "/some/dir/data:image/png;base64,AAAA".
3594
- */
3595
- private static resolveUrl;
3596
3694
  private parseTextures;
3597
3695
  private mapWrapMode;
3598
3696
  private parseMaterials;
@@ -3828,4 +3926,4 @@ declare class TrailBatch extends VFXBatch {
3828
3926
  }
3829
3927
 
3830
3928
  export { ApplyCollision, ApplyForce, ApplySequences, AxisAngleGenerator, BatchedParticleRenderer, BatchedRenderer, BehaviorFromJSON, BehaviorTypes, Bezier, BoxEmitFrom, BoxEmitter, ChangeEmitDirection, CircleEmitter, ColorBySpeed, ColorGeneratorFromJSON, ColorOverLife, ColorRange, ConeEmitter, ConstantColor, ConstantValue, DEG2RAD, DonutEmitter, EdgeEmitter, EmitSubParticleSystem, EmitterFromJSON, EmitterMode, EmitterShapes, Euler, EulerGenerator, ForceOverLife, FrameOverLife, GeneratorFromJSON, Gradient, GravityForce, GridEmitter, HemisphereEmitter, InheritVelocity, IntervalValue, LimitSpeedOverLife, MathUtils, Matrix3, Matrix4, MeshSurfaceEmitter, MeshSurfaceEmitterPlugin, NodeParticle, Noise, OrbitOverLife, ParticleEmitter, ParticleMeshPhysicsMaterial, ParticleMeshStandardMaterial, ParticleStore, ParticleSystem, PiecewiseBezier, PiecewiseFunction, Plugins, PointEmitter, QuarksLoader, QuarksPrefab, QuarksUtil, Quaternion, RAD2DEG, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RecordState, RectangleEmitter, RenderMode, Rotation3DOverLife, RotationBySpeed, RotationGeneratorFromJSON, RotationOverLife, SequencerFromJSON, ShapeTransform, SizeBySpeed, SizeOverLife, SpeedOverLife, SphereEmitter, SpriteBatch, SpriteParticle, SubParticleEmitMode, TextureSequencer, TrailBatch, TrailParticle, TurbulenceField, VFXBatch, ValueGeneratorFromJSON, Vector2, Vector3, Vector3Function, Vector3GeneratorFromJSON, Vector3View, Vector4, Vector4View, VelocityOverLife, WebGLCoordinateSystem, WebGPUCoordinateSystem, WidthOverLength, cacheEnvAtlas, ceilPowerOfTwo, clamp, createEnvAtlasFromFaceUrls, cubeFaceUrls, damp, degToRad, denormalize, ensureEnvAtlasFromCube, euclideanModulo, floorPowerOfTwo, generateUUID, getCachedEnvAtlas, getPhysicsResolver, getRegisteredShaderChunks, getShaderChunk, getValueFromEmitterMode, inverseLerp, isPowerOfTwo, lerp, loadPlugin, _default$7 as localParticlePhysicsVertShader, _default$6 as localParticleVertShader, mapLinear, normalize, _default$5 as particleFragShader, _default$4 as particlePhysicsFragShader, _default$3 as particleVertShader, pingpong, planBehaviorFusion, radToDeg, randFloat, randFloatSpread, randInt, registerShaderChunks, seededRandom, setPhysicsResolver, setQuaternionFromProperEuler, smootherstep, smoothstep, _default$2 as stretchedBillboardVertShader, _default$1 as trailFragShader, _default as trailVertShader, unloadPlugin };
3831
- export type { AdaptivePerformanceOptions, AdaptivePerformanceState, AnimationData, BabylonMetaData, BabylonParticleMaterialParameters, Behavior, BehaviorPlugin, BillBoardSettings, BoxEmitterParameters, BurstParameters, BurstParametersJSON, CircleEmitterParameters, ColorGenerator, ConeEmitterParameters, Constructable, DonutEmitterParameters, EdgeEmitterParameters, EmissionState, EmitterShape, EmitterShapePlugin, EulerOrder, FieldType, FunctionColorGenerator, FunctionJSON, FunctionValueGenerator, FusedPass, FusionStep, GeneratorMemory, GridEmitterParameters, HemisphereEmitterParameters, IAnimationData, IEmitter, IParticle, IParticleSystem, IPrefab, InheritVelocityMode, JsonMetaData, MeshSettings, ParameterPair, ParameterType, Particle, ParticleSystemEvent, ParticleSystemEventType, ParticleSystemJSONParameters, ParticleSystemParameters, Plugin, QuarksLoaderOptions, QuarksTimelineClip, RectangleEmitterParameters, RendererEmitterSettings, Resource, RotationGenerator, Sequencer, SerializationOptions, ShapeJSON, ShapeTransformJSON, SphereEmitterParameters, StoredBatchSettings, StretchedBillBoardSettings, TrailSettings, VFXBatchSettings, ValueGenerator, Vector3Generator, VelocityOverLifeSpace };
3929
+ export type { AdaptivePerformanceOptions, AdaptivePerformanceState, AnimationData, BabylonMetaData, BabylonParticleMaterialParameters, Behavior, BehaviorPlugin, BillBoardSettings, BoxEmitterParameters, BurstParameters, BurstParametersJSON, CircleEmitterParameters, ColorGenerator, ConeEmitterParameters, Constructable, DonutEmitterParameters, EdgeEmitterParameters, EmissionState, EmitterShape, EmitterShapePlugin, EulerOrder, FieldType, FunctionColorGenerator, FunctionJSON, FunctionValueGenerator, FusedPass, FusionStep, GeneratorMemory, GridEmitterParameters, HemisphereEmitterParameters, IAnimationData, IEmitter, IParticle, IParticleSystem, IPrefab, InheritVelocityMode, JsonMetaData, MeshSettings, ParameterPair, ParameterType, Particle, ParticleSystemEvent, ParticleSystemEventType, ParticleSystemJSONParameters, ParticleSystemParameters, Plugin, QuarksBatchLoadOptions, QuarksJSONLoadEntry, QuarksLoaderOptions, QuarksResourceKind, QuarksResourceResolver, QuarksTimelineClip, RectangleEmitterParameters, RendererEmitterSettings, Resource, RotationGenerator, Sequencer, SerializationOptions, ShapeJSON, ShapeTransformJSON, SphereEmitterParameters, StoredBatchSettings, StretchedBillBoardSettings, TrailSettings, VFXBatchSettings, ValueGenerator, Vector3Generator, VelocityOverLifeSpace };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A High-Performance Particle System for Babylon.js",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",
@@ -12,6 +12,20 @@ import {ParticleSystem} from './ParticleSystem';
12
12
  import {QuarksPrefab} from './QuarksPrefab';
13
13
  import {ensureTriangleIndices} from './geometryUtil';
14
14
 
15
+ /** Kind of linked asset the loader asks a {@link QuarksResourceResolver} to handle. */
16
+ export type QuarksResourceKind = 'effect' | 'geometry' | 'image';
17
+
18
+ /**
19
+ * Hooks for resolving relative source links (textures, geometry files) and loading their JSON.
20
+ * Omit either method to keep the default (`baseUrl` join / `fetch`).
21
+ */
22
+ export interface QuarksResourceResolver {
23
+ /** Resolve a relative url to an absolute / blob / data url. Default: {@link QuarksLoader.joinUrl}. */
24
+ resolve?(url: string, kind: QuarksResourceKind, baseUrl: string): string | Promise<string>;
25
+ /** Load a JSON asset (geometry files). Default: `fetch(resolvedUrl).json()`. */
26
+ fetchJson?(resolvedUrl: string): Promise<unknown>;
27
+ }
28
+
15
29
  export interface QuarksLoaderOptions {
16
30
  baseUrl?: string;
17
31
  /**
@@ -23,6 +37,38 @@ export interface QuarksLoaderOptions {
23
37
  * change one effect's texture settings without touching the others.
24
38
  */
25
39
  shareResources?: boolean;
40
+ /** Custom url / fetch backend (File picker, CDN, IndexedDB). */
41
+ resolver?: QuarksResourceResolver;
42
+ }
43
+
44
+ /** One in-memory effect for {@link QuarksLoader.loadFromJSONAll}. */
45
+ export interface QuarksJSONLoadEntry {
46
+ json: unknown;
47
+ /** Overrides loader `baseUrl` for relative texture / geometry links in this effect. */
48
+ baseUrl?: string;
49
+ }
50
+
51
+ /**
52
+ * Options for {@link QuarksLoader.loadAll} / {@link QuarksLoader.loadFromJSONAll}.
53
+ * Batch loads share the per-scene fetch cache and `shareResources`, so N effects that point at
54
+ * the same `assets/geometries/*.json` or texture only pay for that asset once.
55
+ */
56
+ export interface QuarksBatchLoadOptions {
57
+ /**
58
+ * Max concurrent effect-document fetches (phase 1). Linked geometry/image resolve still runs
59
+ * across the whole pack in parallel so shared urls coalesce. Default: all at once.
60
+ */
61
+ concurrency?: number;
62
+ /**
63
+ * Called once per effect when it has fully settled (parsed successfully, or failed under
64
+ * `continueOnError`). `done` is how many of `total` have settled so far.
65
+ */
66
+ onProgress?: (done: number, total: number) => void;
67
+ /**
68
+ * When true, a failed effect becomes `null` in the result array and the rest keep loading.
69
+ * When false (default), the first failure rejects the whole batch.
70
+ */
71
+ continueOnError?: boolean;
26
72
  }
27
73
 
28
74
  interface ParsedGeometry {
@@ -35,6 +81,8 @@ interface ParsedGeometry {
35
81
  /** Textures and geometries reused across loads, per scene. See QuarksLoaderOptions.shareResources. */
36
82
  const sharedTextures = new WeakMap<Scene, Map<string, Texture>>();
37
83
  const sharedGeometries = new WeakMap<Scene, Map<string, ParsedGeometry>>();
84
+ /** In-flight / completed JSON fetches keyed by `json|resolvedUrl`, per scene. */
85
+ const sharedFetches = new WeakMap<Scene, Map<string, Promise<unknown>>>();
38
86
 
39
87
  function sceneMap<T>(maps: WeakMap<Scene, Map<string, T>>, scene: Scene): Map<string, T> {
40
88
  let map = maps.get(scene);
@@ -86,49 +134,131 @@ export class QuarksLoader {
86
134
  this.options = options;
87
135
  }
88
136
 
137
+ /** Fetches an effect JSON from `url`, resolves linked sources, and parses it. */
89
138
  async load(url: string): Promise<TransformNode> {
90
- const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
91
- const response = await fetch(url);
92
- const json = await response.json();
93
- await QuarksLoader.resolveExternalGeometries(json, async (geometryUrl) => {
94
- const geometryResponse = await fetch(QuarksLoader.resolveUrl(baseUrl, geometryUrl));
95
- if (!geometryResponse.ok) {
96
- throw new Error(`HTTP ${geometryResponse.status}`);
97
- }
98
- return geometryResponse.json();
139
+ const {json, baseUrl} = await this.fetchEffectDocument(url);
140
+ return this.loadFromJSON(json, baseUrl);
141
+ }
142
+
143
+ /**
144
+ * Resolves linked geometry / image urls on `json` (via {@link resolveResources}) then parses.
145
+ * Use when the effect JSON is already in hand (File, CDN payload, editor import).
146
+ */
147
+ async loadFromJSON(json: any, baseUrl?: string): Promise<TransformNode> {
148
+ const resolvedBase = baseUrl ?? this.options.baseUrl ?? '';
149
+ await this.resolveResources(json, resolvedBase);
150
+ return this.parse(json, resolvedBase);
151
+ }
152
+
153
+ /**
154
+ * Loads many effects in parallel for battle / level preload. Phase 1 fetches every effect
155
+ * JSON (optionally concurrency-limited); phase 2 resolves all linked geometries/images across
156
+ * the pack together so shared asset urls hit the fetch cache once; phase 3 parses.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * const roots = await loader.loadAll([
161
+ * 'fx/hit.json', 'fx/explosion.json', 'fx/heal.json',
162
+ * ], { concurrency: 6, continueOnError: true });
163
+ * ```
164
+ */
165
+ loadAll(urls: string[], options?: QuarksBatchLoadOptions & {continueOnError?: false}): Promise<TransformNode[]>;
166
+ loadAll(
167
+ urls: string[],
168
+ options: QuarksBatchLoadOptions & {continueOnError: true}
169
+ ): Promise<(TransformNode | null)[]>;
170
+ async loadAll(urls: string[], options: QuarksBatchLoadOptions = {}): Promise<(TransformNode | null)[]> {
171
+ const docs = await QuarksLoader.mapPool(urls, options.concurrency, async (url, index) => {
172
+ try {
173
+ return await this.fetchEffectDocument(url);
174
+ } catch (err) {
175
+ if (!options.continueOnError) {
176
+ throw err;
177
+ }
178
+ console.warn(`[QuarksLoader] Failed to fetch effect "${url}" (${index + 1}/${urls.length}):`, err);
179
+ return null;
180
+ }
99
181
  });
100
- return this.parse(json, baseUrl);
182
+ return this.loadDocuments(docs, options);
101
183
  }
102
184
 
103
185
  /**
104
- * Inlines geometries that an export wrote to their own file (`{uuid, type: 'QuarksGeometry',
105
- * url}` — e.g. the Unity exporter's "geometries as files"): each `url` is passed to
106
- * `loadGeometry`, and the `positions / indices / uvs / normals` it returns replace the url in
107
- * place, so `parse()` (which is synchronous) sees an ordinary inline geometry. `load()` does
108
- * this with `fetch`; call it yourself before `parse()` when the files come from elsewhere.
109
- * A geometry that fails to load is left as is and warned about.
186
+ * Same two-phase batch as {@link loadAll}, but effect JSON is already in memory (asset pack,
187
+ * IndexedDB, editor). Entries may be bare JSON or `{ json, baseUrl }`.
110
188
  */
111
- static async resolveExternalGeometries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
112
- const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
189
+ loadFromJSONAll(
190
+ entries: Array<QuarksJSONLoadEntry | unknown>,
191
+ options?: QuarksBatchLoadOptions & {continueOnError?: false}
192
+ ): Promise<TransformNode[]>;
193
+ loadFromJSONAll(
194
+ entries: Array<QuarksJSONLoadEntry | unknown>,
195
+ options: QuarksBatchLoadOptions & {continueOnError: true}
196
+ ): Promise<(TransformNode | null)[]>;
197
+ async loadFromJSONAll(
198
+ entries: Array<QuarksJSONLoadEntry | unknown>,
199
+ options: QuarksBatchLoadOptions = {}
200
+ ): Promise<(TransformNode | null)[]> {
201
+ const docs: Array<{json: any; baseUrl: string} | null> = entries.map((entry) => {
202
+ if (entry !== null && typeof entry === 'object' && 'json' in (entry as object)) {
203
+ const typed = entry as QuarksJSONLoadEntry;
204
+ return {json: typed.json as any, baseUrl: typed.baseUrl ?? this.options.baseUrl ?? ''};
205
+ }
206
+ return {json: entry as any, baseUrl: this.options.baseUrl ?? ''};
207
+ });
208
+ return this.loadDocuments(docs, options);
209
+ }
210
+
211
+ /**
212
+ * Inlines external geometry files and rewrites relative image urls through the loader's
213
+ * {@link QuarksResourceResolver}. Idempotent for already-inlined geometries. Failures warn
214
+ * and leave the entry as is.
215
+ */
216
+ async resolveResources(json: any, baseUrl: string = ''): Promise<void> {
217
+ await QuarksLoader.resolveLinkedResources(json, {
218
+ baseUrl,
219
+ resolver: this.options.resolver,
220
+ scene: this.scene,
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Same as instance {@link resolveResources}, but usable without constructing a loader (e.g.
226
+ * editor import that only prepares JSON). Pass `scene` to share the per-scene fetch cache.
227
+ */
228
+ static async resolveLinkedResources(
229
+ json: any,
230
+ options: {baseUrl?: string; resolver?: QuarksResourceResolver; scene?: Scene} = {}
231
+ ): Promise<void> {
232
+ const baseUrl = options.baseUrl ?? '';
233
+ const resolver = options.resolver;
234
+ const scene = options.scene;
235
+
236
+ await QuarksLoader.inlineGeometryEntries(json, async (geometryUrl) => {
237
+ const resolved = await QuarksLoader.resolveWith(resolver, geometryUrl, 'geometry', baseUrl);
238
+ return QuarksLoader.fetchJsonWith(resolver, resolved, scene);
239
+ });
240
+
241
+ const images: any[] = Array.isArray(json?.images) ? json.images : [];
113
242
  await Promise.all(
114
- geometries
115
- .filter((geom) => typeof geom?.url === 'string' && !geom.positions)
116
- .map(async (geom) => {
117
- try {
118
- const data = await loadGeometry(geom.url);
119
- for (const key of ['positions', 'indices', 'uvs', 'normals']) {
120
- if (data?.[key] !== undefined) {
121
- geom[key] = data[key];
122
- }
123
- }
124
- delete geom.url;
125
- } catch (err) {
126
- console.warn(`[QuarksLoader] Could not load geometry "${geom.url}":`, err);
127
- }
128
- })
243
+ images.map(async (img) => {
244
+ if (typeof img?.url !== 'string' || !img.url) {
245
+ return;
246
+ }
247
+ img.url = await QuarksLoader.resolveWith(resolver, img.url, 'image', baseUrl);
248
+ })
129
249
  );
130
250
  }
131
251
 
252
+ /**
253
+ * Inlines geometries that an export wrote to their own file (`{uuid, type: 'QuarksGeometry',
254
+ * url}`). Prefer {@link load}, {@link loadFromJSON}, or {@link resolveResources} — those use
255
+ * the loader's resolver and fetch cache. Kept for callers that already have a custom
256
+ * `loadGeometry` and no `QuarksLoader` instance.
257
+ */
258
+ static async resolveExternalGeometries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
259
+ await QuarksLoader.inlineGeometryEntries(json, loadGeometry);
260
+ }
261
+
132
262
  parse(json: any, baseUrl: string = ''): TransformNode {
133
263
  const meta: LoadedMeta = {
134
264
  textures: {},
@@ -175,8 +305,8 @@ export class QuarksLoader {
175
305
  } else if (geom.type === 'QuarksGeometry') {
176
306
  if (typeof geom.url === 'string' && !geom.positions) {
177
307
  console.warn(
178
- `[QuarksLoader] Geometry "${geom.url}" is an external file; use load() or ` +
179
- 'QuarksLoader.resolveExternalGeometries() before parse().'
308
+ `[QuarksLoader] Geometry "${geom.url}" is an external file; use load(), ` +
309
+ 'loadFromJSON(), or resolveResources() before parse().'
180
310
  );
181
311
  }
182
312
  meta.geometries[geom.uuid] = this.share({
@@ -205,13 +335,22 @@ export class QuarksLoader {
205
335
  }
206
336
 
207
337
  /**
208
- * Forgets the textures and geometries shared between loads into `scene` (see
209
- * {@link QuarksLoaderOptions.shareResources}). Effects already loaded keep theirs; the next
210
- * load creates new ones. Textures are not disposed — they may still be in use.
338
+ * Forgets the textures, geometries, and in-flight JSON fetches shared between loads into
339
+ * `scene` (see {@link QuarksLoaderOptions.shareResources}). Effects already loaded keep
340
+ * theirs; the next load creates new ones. Textures are not disposed — they may still be in use.
211
341
  */
212
342
  static clearSharedResources(scene: Scene): void {
213
343
  sharedTextures.delete(scene);
214
344
  sharedGeometries.delete(scene);
345
+ sharedFetches.delete(scene);
346
+ }
347
+
348
+ /** Joins `baseUrl` with a relative `url`; leaves data/blob/absolute/root-absolute urls alone. */
349
+ static joinUrl(baseUrl: string, url: string): string {
350
+ if (/^(data|blob):/i.test(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('/')) {
351
+ return url;
352
+ }
353
+ return baseUrl + url;
215
354
  }
216
355
 
217
356
  private parseSphereGeometry(geom: any): ParsedGeometry {
@@ -366,13 +505,191 @@ export class QuarksLoader {
366
505
  return new Float32Array(buffer) as any;
367
506
  }
368
507
 
508
+ private async resolveResourceUrl(url: string, kind: QuarksResourceKind, baseUrl: string): Promise<string> {
509
+ return QuarksLoader.resolveWith(this.options.resolver, url, kind, baseUrl);
510
+ }
511
+
512
+ /** Fetches one effect JSON; documents are not fetch-cached because resolve mutates them. */
513
+ private async fetchEffectDocument(url: string): Promise<{json: any; baseUrl: string}> {
514
+ const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
515
+ const resolvedEffect = await this.resolveResourceUrl(url, 'effect', baseUrl);
516
+ const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined);
517
+ return {json, baseUrl};
518
+ }
519
+
520
+ /**
521
+ * Resolve every document's linked resources in parallel (shared urls coalesce via the
522
+ * scene fetch cache), then parse. Each index settles exactly once for `onProgress`.
523
+ */
524
+ private async loadDocuments(
525
+ docs: Array<{json: any; baseUrl: string} | null>,
526
+ options: QuarksBatchLoadOptions
527
+ ): Promise<(TransformNode | null)[]> {
528
+ const total = docs.length;
529
+ if (total === 0) {
530
+ return [];
531
+ }
532
+
533
+ const results: (TransformNode | null)[] = new Array(total);
534
+ const settled = new Array<boolean>(total).fill(false);
535
+ let done = 0;
536
+ const settle = (index: number, value: TransformNode | null) => {
537
+ if (settled[index]) {
538
+ return;
539
+ }
540
+ settled[index] = true;
541
+ results[index] = value;
542
+ done++;
543
+ options.onProgress?.(done, total);
544
+ };
545
+
546
+ await Promise.all(
547
+ docs.map(async (doc, i) => {
548
+ if (!doc) {
549
+ return;
550
+ }
551
+ try {
552
+ await this.resolveResources(doc.json, doc.baseUrl);
553
+ } catch (err) {
554
+ if (!options.continueOnError) {
555
+ throw err;
556
+ }
557
+ console.warn(`[QuarksLoader] Failed to resolve resources for effect ${i + 1}/${total}:`, err);
558
+ docs[i] = null;
559
+ settle(i, null);
560
+ }
561
+ })
562
+ );
563
+
564
+ for (let i = 0; i < total; i++) {
565
+ if (settled[i]) {
566
+ continue;
567
+ }
568
+ const doc = docs[i];
569
+ if (!doc) {
570
+ settle(i, null);
571
+ continue;
572
+ }
573
+ try {
574
+ settle(i, this.parse(doc.json, doc.baseUrl));
575
+ } catch (err) {
576
+ if (!options.continueOnError) {
577
+ throw err;
578
+ }
579
+ console.warn(`[QuarksLoader] Failed to parse effect ${i + 1}/${total}:`, err);
580
+ settle(i, null);
581
+ }
582
+ }
583
+ return results;
584
+ }
585
+
586
+ /** Runs `fn` over `items` with at most `concurrency` in flight (or all if unset/≤0). */
587
+ private static async mapPool<T, R>(
588
+ items: T[],
589
+ concurrency: number | undefined,
590
+ fn: (item: T, index: number) => Promise<R>
591
+ ): Promise<R[]> {
592
+ if (items.length === 0) {
593
+ return [];
594
+ }
595
+ const limit = concurrency !== undefined && concurrency > 0 ? concurrency : items.length;
596
+ const results = new Array<R>(items.length);
597
+ let next = 0;
598
+
599
+ const worker = async () => {
600
+ while (next < items.length) {
601
+ const index = next++;
602
+ results[index] = await fn(items[index], index);
603
+ }
604
+ };
605
+
606
+ const workers = Math.min(limit, items.length);
607
+ await Promise.all(Array.from({length: workers}, () => worker()));
608
+ return results;
609
+ }
610
+
611
+ private static async resolveWith(
612
+ resolver: QuarksResourceResolver | undefined,
613
+ url: string,
614
+ kind: QuarksResourceKind,
615
+ baseUrl: string
616
+ ): Promise<string> {
617
+ if (resolver?.resolve) {
618
+ return resolver.resolve(url, kind, baseUrl);
619
+ }
620
+ return QuarksLoader.joinUrl(baseUrl, url);
621
+ }
622
+
623
+ /** One in-flight fetch per scene + resolved url when `scene` is set; otherwise uncached. */
624
+ private static fetchJsonWith(
625
+ resolver: QuarksResourceResolver | undefined,
626
+ resolvedUrl: string,
627
+ scene: Scene | undefined
628
+ ): Promise<unknown> {
629
+ const load = async () => {
630
+ if (resolver?.fetchJson) {
631
+ return resolver.fetchJson(resolvedUrl);
632
+ }
633
+ const response = await fetch(resolvedUrl);
634
+ if (!response.ok) {
635
+ throw new Error(`HTTP ${response.status}`);
636
+ }
637
+ return response.json();
638
+ };
639
+
640
+ if (!scene) {
641
+ return load();
642
+ }
643
+
644
+ const cache = sceneMap(sharedFetches, scene);
645
+ const key = `json|${resolvedUrl}`;
646
+ let pending = cache.get(key);
647
+ if (pending === undefined) {
648
+ pending = load().catch((err) => {
649
+ if (cache.get(key) === pending) {
650
+ cache.delete(key);
651
+ }
652
+ throw err;
653
+ });
654
+ cache.set(key, pending);
655
+ }
656
+ return pending;
657
+ }
658
+
659
+ private static async inlineGeometryEntries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
660
+ const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
661
+ await Promise.all(
662
+ geometries
663
+ .filter((geom) => typeof geom?.url === 'string' && !geom.positions)
664
+ .map(async (geom) => {
665
+ try {
666
+ const data = await loadGeometry(geom.url);
667
+ // Copy arrays so a shared fetch-cache document is not aliased across
668
+ // effects — mutating one effect's inlined geometry must not corrupt others.
669
+ for (const key of ['positions', 'indices', 'uvs', 'normals']) {
670
+ const value = data?.[key];
671
+ if (value === undefined) {
672
+ continue;
673
+ }
674
+ geom[key] = Array.isArray(value) ? value.slice() : value;
675
+ }
676
+ delete geom.url;
677
+ } catch (err) {
678
+ console.warn(`[QuarksLoader] Could not load geometry "${geom.url}":`, err);
679
+ }
680
+ })
681
+ );
682
+ }
683
+
369
684
  private parseImages(images: any[], baseUrl: string, _meta: LoadedMeta): void {
370
685
  (this as any)._images = {};
371
686
  for (const img of images) {
372
687
  if (img.url) {
373
688
  QuarksLoader.warnIfNotAnImageUrl(img.url);
374
689
  }
375
- (this as any)._images[img.uuid] = img.url ? QuarksLoader.resolveUrl(baseUrl, img.url) : null;
690
+ // Urls are usually already absolute after resolveResources; joinUrl is a no-op for
691
+ // data/blob/http(s) and still supports callers that use parse() with a baseUrl only.
692
+ (this as any)._images[img.uuid] = img.url ? QuarksLoader.joinUrl(baseUrl, img.url) : null;
376
693
  }
377
694
  }
378
695
 
@@ -395,19 +712,6 @@ export class QuarksLoader {
395
712
  );
396
713
  }
397
714
 
398
- /**
399
- * `img.url` is already absolute for data/blob URIs and full http(s) URLs — common for
400
- * "portable" exports that embed textures as base64 (see ensurePortableTextureUrl in
401
- * babylon.quarks-editor). Prepending baseUrl to those turns e.g.
402
- * "data:image/png;base64,AAAA" into an invalid path like "/some/dir/data:image/png;base64,AAAA".
403
- */
404
- private static resolveUrl(baseUrl: string, url: string): string {
405
- if (/^(data|blob):/i.test(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('/')) {
406
- return url;
407
- }
408
- return baseUrl + url;
409
- }
410
-
411
715
  private parseTextures(textures: any[], meta: LoadedMeta): void {
412
716
  const images = (this as any)._images || {};
413
717
  for (const texDef of textures) {