@galfus/gltf-loader 0.0.0-stage → 0.31.0-alpha

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/src/factory.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Two-stage glTF asset factory and modular sub-factory implementations.
3
+ */
4
+
5
+ import type { Realm3D, TextureId } from '@galfus/engine';
6
+ import { createAnimationPlayer3D } from '@galfus/engine';
7
+ import type {
8
+ ModelAssetFactory,
9
+ ModelBounds,
10
+ ModelImportOptions,
11
+ ModelInstance,
12
+ ModelInstantiateOptions,
13
+ SubModelAssetFactory
14
+ } from '@galfus/model-loader-types';
15
+ import { compileAnimationClips, setupSkins } from './animation';
16
+ import { computeGltfBounds } from './bounds';
17
+ import { GltfInstance } from './instance';
18
+ import type { UploadedGltfResources } from './resources';
19
+ import { spawnSceneGraph } from './scene';
20
+ import type { GlTF } from './types';
21
+
22
+ export interface GltfAssetFactoryInit {
23
+ assetName: string;
24
+ realm: Realm3D;
25
+ gltf: GlTF;
26
+ buffers: Uint8Array[];
27
+ resources: UploadedGltfResources;
28
+ importOptions?: ModelImportOptions;
29
+ }
30
+
31
+ /**
32
+ * Concrete SubModelAssetFactory spawning a specific sub-tree or scene
33
+ * while sharing the GPU resources uploaded by the parent GltfAssetFactory.
34
+ */
35
+ export class SubGltfAssetFactory implements SubModelAssetFactory {
36
+ readonly name: string;
37
+ private readonly _parent: GltfAssetFactory;
38
+ private readonly _targetNodeIndices: number[];
39
+ private readonly _activeInstances = new Set<GltfInstance>();
40
+ private readonly _forgetInstance = (instance: GltfInstance): void => {
41
+ this._activeInstances.delete(instance);
42
+ };
43
+
44
+ constructor(name: string, parent: GltfAssetFactory, targetNodeIndices: number[]) {
45
+ this.name = name;
46
+ this._parent = parent;
47
+ this._targetNodeIndices = [...targetNodeIndices];
48
+ }
49
+
50
+ /**
51
+ * Spawns a new independent instance of this specific sub-tree.
52
+ */
53
+ instantiate(options?: ModelInstantiateOptions): ModelInstance {
54
+ const instance = this._parent._instantiateInternal(
55
+ options,
56
+ this._targetNodeIndices,
57
+ this._forgetInstance
58
+ );
59
+ this._activeInstances.add(instance);
60
+ return instance;
61
+ }
62
+
63
+ /**
64
+ * Destroys all active instances spawned through this sub-factory.
65
+ */
66
+ disposeAllInstances(): void {
67
+ for (const inst of this._activeInstances) {
68
+ inst.dispose();
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Primary glTF asset factory.
75
+ *
76
+ * Holds all shared GPU resources (geometries, materials, textures) in VRAM and
77
+ * provides high-performance instantiation of ECS entities and sub-factories.
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * const factory = await loadGltfAsset(realm, data);
82
+ * const character1 = factory.instantiate({ transform: { position: [0, 0, 0] } });
83
+ * const character2 = factory.instantiate({ transform: { position: [5, 0, 0] } });
84
+ *
85
+ * // Full cleanup
86
+ * factory.dispose();
87
+ * ```
88
+ */
89
+ export class GltfAssetFactory implements ModelAssetFactory {
90
+ readonly assetName: string;
91
+ readonly createdGeometryIds: readonly number[];
92
+ readonly createdMaterialIds: readonly number[];
93
+ readonly createdTextureIds: readonly number[];
94
+ readonly animationNames: readonly string[];
95
+ readonly bounds?: ModelBounds;
96
+
97
+ private readonly _realm: Realm3D;
98
+ private readonly _gltf: GlTF;
99
+ private readonly _buffers: Uint8Array[];
100
+ private readonly _resources: UploadedGltfResources;
101
+ private readonly _importOptions?: ModelImportOptions;
102
+ private readonly _activeInstances = new Set<GltfInstance>();
103
+ private _nextInstanceId = 1;
104
+ private _isDisposed = false;
105
+
106
+ constructor(init: GltfAssetFactoryInit) {
107
+ this.assetName = init.assetName;
108
+ this._realm = init.realm;
109
+ this._gltf = init.gltf;
110
+ this._buffers = init.buffers;
111
+ this._resources = init.resources;
112
+ this._importOptions = init.importOptions;
113
+
114
+ this.createdGeometryIds = Object.freeze([...init.resources.createdGeometryIds]);
115
+ this.createdMaterialIds = Object.freeze([...init.resources.createdMaterialIds]);
116
+ this.createdTextureIds = Object.freeze([...init.resources.createdTextureIds]);
117
+ this.animationNames = Object.freeze(
118
+ (init.gltf.animations ?? []).map((a, i) => a.name ?? `Animation_${i}`)
119
+ );
120
+ this.bounds = computeGltfBounds(init.gltf);
121
+ }
122
+
123
+ /** Indicates whether this factory and its GPU resources have been destroyed. */
124
+ get isDisposed(): boolean {
125
+ return this._isDisposed;
126
+ }
127
+
128
+ /**
129
+ * Spawns a new independent instance of the complete model graph in the ECS.
130
+ */
131
+ instantiate(options?: ModelInstantiateOptions): ModelInstance {
132
+ return this._instantiateInternal(options);
133
+ }
134
+
135
+ /**
136
+ * Internal instantiation driver supporting scoped root node filtering for sub-factories.
137
+ * @internal
138
+ */
139
+ _instantiateInternal(
140
+ options?: ModelInstantiateOptions,
141
+ rootNodeIndices?: number[],
142
+ onDispose?: (instance: GltfInstance) => void
143
+ ): GltfInstance {
144
+ if (this._isDisposed) {
145
+ throw new Error(`Cannot instantiate from disposed GltfAssetFactory '${this.assetName}'`);
146
+ }
147
+
148
+ const instanceId = this._nextInstanceId++;
149
+
150
+ // 1. Spawn Scene Graph entities in the ECS
151
+ const sceneResult = spawnSceneGraph(
152
+ this._realm,
153
+ this._gltf,
154
+ this._buffers,
155
+ this._resources,
156
+ options,
157
+ this._importOptions,
158
+ rootNodeIndices
159
+ );
160
+
161
+ // 2. Bind Skeletal Skins if present
162
+ setupSkins(this._realm, this._gltf, this._buffers, sceneResult);
163
+
164
+ // 3. Compile Animation Clips and bind AnimationPlayer3D
165
+ const clips = compileAnimationClips(
166
+ this._gltf,
167
+ this._buffers,
168
+ sceneResult.nodeEntityMap,
169
+ sceneResult.primitiveEntities
170
+ );
171
+ let animationPlayer: ReturnType<typeof createAnimationPlayer3D> | undefined;
172
+
173
+ if (clips.length > 0) {
174
+ animationPlayer = createAnimationPlayer3D();
175
+ for (const clip of clips) {
176
+ animationPlayer.addClip(clip);
177
+ }
178
+ this._realm.world.attachComponent(sceneResult.rootEntityId, animationPlayer);
179
+ }
180
+
181
+ // 4. Construct GltfInstance wrapper
182
+ const instance = new GltfInstance({
183
+ instanceId,
184
+ realm: this._realm,
185
+ rootEntityId: sceneResult.rootEntityId,
186
+ entities: sceneResult.entities,
187
+ instancedModelIds: sceneResult.instancedModelIds,
188
+ animationPlayer,
189
+ onDispose: (inst) => {
190
+ this._activeInstances.delete(inst);
191
+ onDispose?.(inst);
192
+ }
193
+ });
194
+
195
+ this._activeInstances.add(instance);
196
+ return instance;
197
+ }
198
+
199
+ /**
200
+ * Creates a sub-factory focused on a specific node or sub-tree by name or numerical index.
201
+ *
202
+ * @param nodeNameOrIndex The target node name or index.
203
+ * @returns SubModelAssetFactory for modular instantiation.
204
+ */
205
+ createSubFactory(nodeNameOrIndex: string | number): SubModelAssetFactory {
206
+ if (this._isDisposed) {
207
+ throw new Error(
208
+ `Cannot create sub-factory from disposed GltfAssetFactory '${this.assetName}'`
209
+ );
210
+ }
211
+
212
+ let nodeIdx: number | undefined;
213
+ if (typeof nodeNameOrIndex === 'number') {
214
+ nodeIdx = nodeNameOrIndex;
215
+ } else {
216
+ const nodes = this._gltf.nodes ?? [];
217
+ for (let i = 0; i < nodes.length; i++) {
218
+ if (nodes[i]!.name === nodeNameOrIndex) {
219
+ nodeIdx = i;
220
+ break;
221
+ }
222
+ }
223
+ }
224
+
225
+ if (nodeIdx === undefined || !this._gltf.nodes?.[nodeIdx]) {
226
+ throw new Error(`Node '${nodeNameOrIndex}' not found in glTF asset '${this.assetName}'`);
227
+ }
228
+
229
+ const subName = `${this.assetName}::${this._gltf.nodes[nodeIdx]!.name ?? `Node_${nodeIdx}`}`;
230
+ return new SubGltfAssetFactory(subName, this, [nodeIdx]);
231
+ }
232
+
233
+ /**
234
+ * Creates a sub-factory focused on an alternative scene by index.
235
+ *
236
+ * @param sceneIndex The zero-based scene index.
237
+ * @returns SubModelAssetFactory for scene-scoped instantiation.
238
+ */
239
+ createSubFactoryByScene(sceneIndex: number): SubModelAssetFactory {
240
+ if (this._isDisposed) {
241
+ throw new Error(
242
+ `Cannot create sub-factory from disposed GltfAssetFactory '${this.assetName}'`
243
+ );
244
+ }
245
+
246
+ const scene = this._gltf.scenes?.[sceneIndex];
247
+ if (!scene) {
248
+ throw new Error(`Scene index ${sceneIndex} out of bounds in glTF asset '${this.assetName}'`);
249
+ }
250
+
251
+ const subName = `${this.assetName}::${scene.name ?? `Scene_${sceneIndex}`}`;
252
+ return new SubGltfAssetFactory(subName, this, scene.nodes ?? []);
253
+ }
254
+
255
+ /**
256
+ * Destroys all currently active instances spawned by this factory.
257
+ */
258
+ disposeAllInstances(): void {
259
+ for (const inst of this._activeInstances) {
260
+ inst.dispose();
261
+ }
262
+ this._activeInstances.clear();
263
+ }
264
+
265
+ /**
266
+ * Terminal teardown: destroys all instances and frees all geometries, materials,
267
+ * and textures from the Realm and GPU.
268
+ */
269
+ dispose(): void {
270
+ if (this._isDisposed) return;
271
+ this._isDisposed = true;
272
+
273
+ // 1. Destroy all spawned instances
274
+ this.disposeAllInstances();
275
+
276
+ // 2. Free shared Realm Geometries
277
+ for (const geoId of this.createdGeometryIds) {
278
+ try {
279
+ this._realm.destroyGeometry(geoId);
280
+ } catch {
281
+ // Ignored during shutdown
282
+ }
283
+ }
284
+
285
+ // 3. Free shared Realm Materials
286
+ for (const matId of this.createdMaterialIds) {
287
+ try {
288
+ this._realm.destroyMaterial(matId);
289
+ } catch {
290
+ // Ignored during shutdown
291
+ }
292
+ }
293
+
294
+ // 4. Free shared GPU Textures
295
+ for (const texId of this.createdTextureIds) {
296
+ try {
297
+ this._realm.resources.disposeTexture(texId as unknown as TextureId);
298
+ } catch {
299
+ // Ignored during shutdown
300
+ }
301
+ }
302
+ }
303
+ }
package/src/index.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @module @galfus/gltf-loader
3
+ *
4
+ * Canonical glTF 2.0 and GLB asset loader for Galfus Engine.
5
+ * Supports auto-decoders, two-stage GPU asset caching, skeletal skinning,
6
+ * animation clips, GPU instancing, and modular sub-factories.
7
+ */
8
+
9
+ import type { Realm3D } from '@galfus/engine';
10
+ import type { ModelImportOptions } from '@galfus/model-loader-types';
11
+ import { resolveDecoders } from './decoders/resolver';
12
+ import { GltfAssetFactory } from './factory';
13
+ import { parseGltf } from './parser';
14
+ import { uploadGltfResources } from './resources';
15
+ import type { GlTF, GltfMaterialAlphaModeResolver } from './types';
16
+ import { createZipResolver, findPrimaryGltfPath, isZipContainer, unpackZipArchive } from './zip';
17
+
18
+ export * from './animation';
19
+ export * from './bounds';
20
+ export * from './decoders/resolver';
21
+ export * from './factory';
22
+ export * from './instance';
23
+ export * from './parser';
24
+ export * from './resources';
25
+ export * from './scene';
26
+ export * from './types';
27
+ export * from './zip';
28
+ export * from '@galfus/model-loader-types';
29
+
30
+ export interface GltfLoadOptions extends ModelImportOptions {
31
+ /** Overrides a material's alpha mode for applications that need a specific rendering policy. */
32
+ resolveMaterialAlphaMode?: GltfMaterialAlphaModeResolver;
33
+ }
34
+
35
+ /**
36
+ * Resolves source input into a contiguous Uint8Array byte buffer.
37
+ */
38
+ async function resolveSourceBytes(source: Uint8Array | ArrayBuffer | string): Promise<Uint8Array> {
39
+ if (typeof source !== 'string') {
40
+ return source instanceof Uint8Array ? source : new Uint8Array(source);
41
+ }
42
+
43
+ // HTTP(S) URL
44
+ if (source.startsWith('http://') || source.startsWith('https://')) {
45
+ const res = await fetch(source);
46
+ if (!res.ok) {
47
+ throw new Error(`Failed to fetch glTF asset from "${source}": HTTP ${res.status}`);
48
+ }
49
+ const buf = await res.arrayBuffer();
50
+ return new Uint8Array(buf);
51
+ }
52
+
53
+ // Node.js or Bun local filesystem path
54
+ if (typeof process !== 'undefined' && process.versions?.node) {
55
+ const { readFile } = await import('node:fs/promises');
56
+ const nodeBuf = await readFile(source);
57
+ return new Uint8Array(nodeBuf.buffer, nodeBuf.byteOffset, nodeBuf.byteLength);
58
+ }
59
+
60
+ // Fallback fetch for browser environments
61
+ const res = await fetch(source);
62
+ if (!res.ok) {
63
+ throw new Error(`Failed to load glTF asset from "${source}": HTTP ${res.status}`);
64
+ }
65
+ const buf = await res.arrayBuffer();
66
+ return new Uint8Array(buf);
67
+ }
68
+
69
+ /**
70
+ * Loads a glTF 2.0 (.gltf, .glb, or .zip archive) asset into a high-performance two-stage GltfAssetFactory.
71
+ *
72
+ * Transparently accepts:
73
+ * 1. Monolithic binary .glb containers (in memory or file path).
74
+ * 2. Standalone .gltf JSON files with embedded data URIs.
75
+ * 3. Compact .zip archives containing .gltf, .bin buffers, and texture subdirectories.
76
+ *
77
+ * Uploads all shared geometries, PBR materials, and textures to the GPU once,
78
+ * resolving compression extensions (Draco, Meshopt) automatically.
79
+ *
80
+ * @param realm The destination Realm3D where resources and entities are instantiated.
81
+ * @param source Raw glTF binary (.glb), JSON (.gltf), ZIP (.zip) bytes, or file path / URL.
82
+ * @param options Optional import filters, node renamers, and camera/light toggles.
83
+ * @returns Ready-to-use GltfAssetFactory for spawning ECS instances.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * import { loadGltfAsset } from '@galfus/gltf-loader';
88
+ *
89
+ * // 1. Load from ZIP archive file path
90
+ * const factoryA = await loadGltfAsset(realm, './models/character.zip');
91
+ *
92
+ * // 2. Load from raw GLB bytes
93
+ * const factoryB = await loadGltfAsset(realm, glbData);
94
+ *
95
+ * const instance = factoryA.instantiate({ transform: { position: [0, 0, 0] } });
96
+ * ```
97
+ */
98
+ export async function loadGltfAsset(
99
+ realm: Realm3D,
100
+ source: Uint8Array | ArrayBuffer | string,
101
+ options?: GltfLoadOptions
102
+ ): Promise<GltfAssetFactory> {
103
+ const bytes = await resolveSourceBytes(source);
104
+
105
+ let gltf: GlTF;
106
+ let buffers: Uint8Array[];
107
+ let externalResolver: ((uri: string) => Promise<Uint8Array>) | undefined;
108
+
109
+ // A) Check if source is a ZIP archive
110
+ if (isZipContainer(bytes)) {
111
+ const fileMap = unpackZipArchive(bytes);
112
+ const primaryGltfPath = findPrimaryGltfPath(fileMap);
113
+ if (!primaryGltfPath) {
114
+ throw new Error(
115
+ 'Invalid ZIP model archive: no .gltf or .glb file found in the decompressed directory tree'
116
+ );
117
+ }
118
+ const primaryBytes = fileMap.get(primaryGltfPath)!;
119
+ externalResolver = createZipResolver(fileMap, primaryGltfPath);
120
+ const parsed = await parseGltf(primaryBytes, externalResolver);
121
+ gltf = parsed.gltf;
122
+ buffers = parsed.buffers;
123
+ } else {
124
+ // B) Standard GLB binary or standalone glTF JSON
125
+ if (typeof source === 'string') {
126
+ if (/^https?:\/\//i.test(source)) {
127
+ externalResolver = async (uri) => resolveSourceBytes(new URL(uri, source).href);
128
+ } else if (typeof process !== 'undefined' && process.versions?.node) {
129
+ const path = await import('node:path');
130
+ externalResolver = async (uri) =>
131
+ resolveSourceBytes(path.resolve(path.dirname(source), decodeURIComponent(uri)));
132
+ }
133
+ }
134
+ const parsed = await parseGltf(bytes, externalResolver);
135
+ gltf = parsed.gltf;
136
+ buffers = parsed.buffers;
137
+ }
138
+
139
+ // 2. Automatically resolve required compression decoders (Draco, Meshopt)
140
+ const decoders = await resolveDecoders(gltf, options?.decoders);
141
+
142
+ if (decoders.meshopt) {
143
+ for (const view of gltf.bufferViews ?? []) {
144
+ const ext = view.extensions?.EXT_meshopt_compression;
145
+ if (!ext) continue;
146
+ const sourceBuffer = buffers[ext.buffer];
147
+ if (!sourceBuffer) throw new Error('Missing Meshopt source buffer');
148
+ const offset = ext.byteOffset ?? 0;
149
+ const decoded = await decoders.meshopt.decodeGpuBuffer(
150
+ ext.count,
151
+ ext.byteStride,
152
+ sourceBuffer.subarray(offset, offset + ext.byteLength),
153
+ ext.mode,
154
+ ext.filter
155
+ );
156
+ if (decoded.byteLength !== ext.count * ext.byteStride)
157
+ throw new Error('Invalid Meshopt decoded buffer length');
158
+ view.buffer = buffers.length;
159
+ view.byteOffset = 0;
160
+ view.byteLength = decoded.byteLength;
161
+ buffers.push(decoded);
162
+ }
163
+ }
164
+
165
+ // 3. Upload shared GPU resources (geometries in canonical 136-byte format, PBR materials, textures)
166
+ const resources = await uploadGltfResources(
167
+ realm,
168
+ gltf,
169
+ buffers,
170
+ decoders,
171
+ externalResolver,
172
+ options?.resolveMaterialAlphaMode
173
+ );
174
+
175
+ // 4. Construct and return primary asset factory
176
+ const assetName = gltf.asset.generator ?? 'glTF_Asset';
177
+ return new GltfAssetFactory({
178
+ assetName,
179
+ realm,
180
+ gltf,
181
+ buffers,
182
+ resources,
183
+ importOptions: options
184
+ });
185
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Concrete implementation of ModelInstance for spawned glTF scene graphs.
3
+ */
4
+
5
+ import type { AnimationPlayer3DComponent, Realm3D } from '@galfus/engine';
6
+ import type { ModelInstance } from '@galfus/model-loader-types';
7
+
8
+ export interface GltfInstanceInit {
9
+ instanceId: number;
10
+ realm: Realm3D;
11
+ rootEntityId: number;
12
+ entities: number[];
13
+ instancedModelIds?: number[];
14
+ animationPlayer?: AnimationPlayer3DComponent;
15
+ onDispose?: (instance: GltfInstance) => void;
16
+ }
17
+
18
+ /**
19
+ * Concrete ModelInstance representing a live glTF scene graph in the ECS.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const instance = factory.instantiate();
24
+ * const head = instance.getEntityByName('Head');
25
+ * instance.dispose(); // Destroys entities while preserving shared GPU assets
26
+ * ```
27
+ */
28
+ export class GltfInstance implements ModelInstance {
29
+ readonly instanceId: number;
30
+ readonly rootEntityId: number;
31
+ readonly entities: readonly number[];
32
+ readonly animationPlayer?: AnimationPlayer3DComponent;
33
+
34
+ private readonly _realm: Realm3D;
35
+ private readonly _instancedModelIds: number[];
36
+ private readonly _onDispose?: (instance: GltfInstance) => void;
37
+ private _isDisposed = false;
38
+
39
+ constructor(init: GltfInstanceInit) {
40
+ this.instanceId = init.instanceId;
41
+ this._realm = init.realm;
42
+ this.rootEntityId = init.rootEntityId;
43
+ this.entities = Object.freeze([...init.entities]);
44
+ this._instancedModelIds = init.instancedModelIds ? [...init.instancedModelIds] : [];
45
+ this.animationPlayer = init.animationPlayer;
46
+ this._onDispose = init.onDispose;
47
+ }
48
+
49
+ /** Indicates whether this instance has already been destroyed. */
50
+ get isDisposed(): boolean {
51
+ return this._isDisposed;
52
+ }
53
+
54
+ /**
55
+ * Resolves an entity ID by its assigned name within this instance.
56
+ *
57
+ * @param name The entity name to look up.
58
+ * @returns Entity ID or undefined if not found.
59
+ */
60
+ getEntityByName(name: string): number | undefined {
61
+ if (this._isDisposed) return undefined;
62
+ for (const entityId of this.entities) {
63
+ if (this._realm.getEntityName(entityId) === name) {
64
+ return entityId;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Queries all entities within this instance matching a metadata key and optional value.
72
+ *
73
+ * @param key The metadata key.
74
+ * @param value Optional expected value to match.
75
+ * @returns Array of matching entity IDs.
76
+ */
77
+ getEntitiesByMetadata(key: string, value?: unknown): number[] {
78
+ if (this._isDisposed) return [];
79
+ const results: number[] = [];
80
+ for (const entityId of this.entities) {
81
+ const val = this._realm.getMetadataKV(entityId, key);
82
+ if (val !== undefined) {
83
+ if (value === undefined || val === value) {
84
+ results.push(entityId);
85
+ }
86
+ }
87
+ }
88
+ return results;
89
+ }
90
+
91
+ /**
92
+ * Destroys all ECS entities, stop bound animations, and frees hardware instanced draws.
93
+ *
94
+ * Safe and idempotent. Shared textures, materials, and geometries in the factory remain alive.
95
+ */
96
+ dispose(): void {
97
+ if (this._isDisposed) return;
98
+ this._isDisposed = true;
99
+
100
+ // 1. Stop animation player if present
101
+ if (this.animationPlayer) {
102
+ this.animationPlayer.stop();
103
+ }
104
+
105
+ // 2. Destroy hardware instanced models
106
+ for (const instId of this._instancedModelIds) {
107
+ try {
108
+ this._realm.destroyInstancedModel(instId);
109
+ } catch {
110
+ // Ignored during shutdown
111
+ }
112
+ }
113
+
114
+ // 3. Destroy all ECS entities belonging to this instance
115
+ for (const entityId of this.entities) {
116
+ try {
117
+ this._realm.destroyEntity(entityId);
118
+ } catch {
119
+ // Ignored if entity was already destroyed
120
+ }
121
+ }
122
+
123
+ // 4. Notify parent factory of disposal
124
+ this._onDispose?.(this);
125
+ }
126
+ }