@sequent-org/ifc-viewer 1.2.4-ci.45.0 → 1.2.4-ci.47.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.
@@ -0,0 +1,316 @@
1
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
2
+ import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
3
+ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
4
+
5
+ /**
6
+ * glTF / GLB loader.
7
+ *
8
+ * Notes (important for diagnostics):
9
+ * - .glb is usually self-contained (good for <input type="file">).
10
+ * - .gltf often references external .bin / textures. That works well for URL loading
11
+ * (served by dev server), but when selecting a single .gltf file from disk, those
12
+ * external resources will likely be missing. We log a warning in that case.
13
+ */
14
+ export class GltfModelLoader {
15
+ /**
16
+ * @param {{ basisTranscoderPath?: string, basisTranscoderCdnPath?: string }} [options]
17
+ */
18
+ constructor(options = {}) {
19
+ this.id = 'gltf';
20
+ this.extensions = ['.gltf', '.glb'];
21
+ this._options = {
22
+ // 1) Try local (consumer can host these files), 2) fallback to CDN.
23
+ basisTranscoderPath: options.basisTranscoderPath || '/three/basis/',
24
+ basisTranscoderCdnPath: options.basisTranscoderCdnPath || 'https://unpkg.com/three@0.149.0/examples/jsm/libs/basis/',
25
+ };
26
+ /** @type {KTX2Loader|null} */
27
+ this._ktx2 = null;
28
+ this._loader = new GLTFLoader();
29
+ try { this._loader.setMeshoptDecoder?.(MeshoptDecoder); } catch (_) {}
30
+ }
31
+
32
+ /**
33
+ * @param {File} file
34
+ * @param {any} ctx
35
+ */
36
+ async loadFile(file, ctx) {
37
+ const logger = ctx?.logger || console;
38
+ const name = file?.name || '';
39
+ const lower = name.toLowerCase();
40
+
41
+ logger?.log?.('[GltfModelLoader] loadFile', { name, size: file?.size });
42
+
43
+ // GLB: parse ArrayBuffer
44
+ if (lower.endsWith('.glb')) {
45
+ const buf = await file.arrayBuffer();
46
+ const preJson = this._tryReadJsonFromGlb(buf, logger);
47
+ await this._autoConfigureDecoders(preJson, ctx, logger);
48
+ const gltf = await this._parse(buf, '');
49
+ this._logExtensions(gltf, logger);
50
+ await this._applyPbrSpecGlossCompat(gltf, logger);
51
+ this._logSummary(gltf, logger, name);
52
+ return {
53
+ object3D: gltf.scene,
54
+ format: this.id,
55
+ name,
56
+ replacedInViewer: false,
57
+ capabilities: { kind: 'generic' },
58
+ };
59
+ }
60
+
61
+ // GLTF: parse JSON string (external resources likely missing for File input)
62
+ if (lower.endsWith('.gltf')) {
63
+ logger?.warn?.('[GltfModelLoader] .gltf selected from disk: external .bin/textures may be missing (consider loading via URL from /public/).');
64
+ const text = await file.text();
65
+ let preJson = null;
66
+ try { preJson = JSON.parse(text); } catch (_) { preJson = null; }
67
+ await this._autoConfigureDecoders(preJson, ctx, logger);
68
+ const gltf = await this._parse(text, '');
69
+ this._logExtensions(gltf, logger);
70
+ await this._applyPbrSpecGlossCompat(gltf, logger);
71
+ this._logSummary(gltf, logger, name);
72
+ return {
73
+ object3D: gltf.scene,
74
+ format: this.id,
75
+ name,
76
+ replacedInViewer: false,
77
+ capabilities: { kind: 'generic' },
78
+ };
79
+ }
80
+
81
+ throw new Error(`GltfModelLoader: unsupported file: ${name}`);
82
+ }
83
+
84
+ /**
85
+ * @param {string} url
86
+ * @param {any} ctx
87
+ */
88
+ async loadUrl(url, ctx) {
89
+ const logger = ctx?.logger || console;
90
+ logger?.log?.('[GltfModelLoader] loadUrl', { url });
91
+ // For URL we usually can't cheaply pre-read JSON without double fetching.
92
+ // Configure decoders "optimistically" (KTX2/Meshopt) so needed extensions work.
93
+ await this._autoConfigureDecoders(null, ctx, logger);
94
+ const gltf = await this._loader.loadAsync(url);
95
+ this._logExtensions(gltf, logger);
96
+ await this._applyPbrSpecGlossCompat(gltf, logger);
97
+ this._logSummary(gltf, logger, String(url || ''));
98
+ return {
99
+ object3D: gltf.scene,
100
+ format: this.id,
101
+ name: String(url || ''),
102
+ replacedInViewer: false,
103
+ capabilities: { kind: 'generic' },
104
+ };
105
+ }
106
+
107
+ _parse(data, path) {
108
+ return new Promise((resolve, reject) => {
109
+ this._loader.parse(
110
+ data,
111
+ path || '',
112
+ (gltf) => resolve(gltf),
113
+ (err) => reject(err),
114
+ );
115
+ });
116
+ }
117
+
118
+ _logSummary(gltf, logger, name) {
119
+ try {
120
+ const scene = gltf?.scene;
121
+ let meshes = 0;
122
+ scene?.traverse?.((n) => { if (n?.isMesh) meshes++; });
123
+ logger?.log?.('[GltfModelLoader] parsed', {
124
+ name,
125
+ scene: scene ? { type: scene.type, children: Array.isArray(scene.children) ? scene.children.length : undefined } : null,
126
+ meshes,
127
+ animations: Array.isArray(gltf?.animations) ? gltf.animations.length : 0,
128
+ });
129
+ } catch (_) {}
130
+ }
131
+
132
+ _logExtensions(gltf, logger) {
133
+ try {
134
+ const json = gltf?.parser?.json;
135
+ if (!json) return;
136
+ const used = Array.isArray(json.extensionsUsed) ? json.extensionsUsed.slice().sort() : [];
137
+ const required = Array.isArray(json.extensionsRequired) ? json.extensionsRequired.slice().sort() : [];
138
+ if (!used.length && !required.length) return;
139
+ logger?.log?.('[GltfModelLoader] glTF extensions', { used, required });
140
+ } catch (_) {}
141
+ }
142
+
143
+ _tryReadJsonFromGlb(arrayBuffer, logger) {
144
+ try {
145
+ const u8 = new Uint8Array(arrayBuffer);
146
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
147
+ if (dv.byteLength < 20) return null;
148
+ // magic 'glTF' = 0x46546C67 (little-endian in file)
149
+ const magic = dv.getUint32(0, true);
150
+ if (magic !== 0x46546c67) return null;
151
+ const version = dv.getUint32(4, true);
152
+ if (version < 2) return null;
153
+ const totalLength = dv.getUint32(8, true);
154
+ if (!Number.isFinite(totalLength) || totalLength <= 0) return null;
155
+
156
+ let offset = 12;
157
+ while (offset + 8 <= dv.byteLength) {
158
+ const chunkLength = dv.getUint32(offset, true);
159
+ const chunkType = dv.getUint32(offset + 4, true);
160
+ offset += 8;
161
+ if (offset + chunkLength > dv.byteLength) break;
162
+ // JSON chunk type 'JSON' = 0x4E4F534A
163
+ if (chunkType === 0x4E4F534A) {
164
+ const bytes = u8.subarray(offset, offset + chunkLength);
165
+ const text = new TextDecoder().decode(bytes);
166
+ return JSON.parse(text);
167
+ }
168
+ offset += chunkLength;
169
+ }
170
+ return null;
171
+ } catch (e) {
172
+ logger?.warn?.('[GltfModelLoader] failed to pre-read GLB JSON chunk', e);
173
+ return null;
174
+ }
175
+ }
176
+
177
+ async _autoConfigureDecoders(preJson, ctx, logger) {
178
+ // Meshopt is already set in constructor (best-effort).
179
+ // KTX2/BasisU: enable if we can, because users may load glb with KHR_texture_basisu.
180
+ const used = Array.isArray(preJson?.extensionsUsed) ? preJson.extensionsUsed : null;
181
+ const required = Array.isArray(preJson?.extensionsRequired) ? preJson.extensionsRequired : null;
182
+ const needsBasisu =
183
+ (used ? used.includes('KHR_texture_basisu') : true) ||
184
+ (required ? required.includes('KHR_texture_basisu') : false);
185
+ if (!needsBasisu) return;
186
+
187
+ const renderer = ctx?.viewer?.renderer || ctx?.renderer || null;
188
+ if (!renderer) {
189
+ // We can still set loader, but detectSupport needs renderer. Keep it lazy.
190
+ logger?.warn?.('[GltfModelLoader] KTX2Loader not fully initialized (no renderer in ctx). BasisU textures may be unavailable.');
191
+ return;
192
+ }
193
+
194
+ if (!this._ktx2) this._ktx2 = new KTX2Loader();
195
+
196
+ // Try local path first; if missing — fallback to CDN.
197
+ const localPath = this._options.basisTranscoderPath;
198
+ const cdnPath = this._options.basisTranscoderCdnPath;
199
+
200
+ const hasLocal = await this._checkTranscoderAvailable(localPath);
201
+ const basePath = hasLocal ? localPath : cdnPath;
202
+ if (!hasLocal) {
203
+ logger?.warn?.('[GltfModelLoader] Basis transcoder not found at local path, using CDN fallback', { localPath, cdnPath });
204
+ }
205
+
206
+ try {
207
+ this._ktx2.setTranscoderPath(basePath);
208
+ await this._ktx2.detectSupport(renderer);
209
+ this._loader.setKTX2Loader?.(this._ktx2);
210
+ logger?.log?.('[GltfModelLoader] KTX2Loader enabled', { transcoderPath: basePath });
211
+ } catch (e) {
212
+ logger?.warn?.('[GltfModelLoader] KTX2Loader init failed; BasisU textures may be unavailable', e);
213
+ }
214
+ }
215
+
216
+ async _checkTranscoderAvailable(basePath) {
217
+ try {
218
+ if (typeof fetch !== 'function') return false;
219
+ const p = String(basePath || '');
220
+ if (!p) return false;
221
+ const url = (p.endsWith('/') ? p : (p + '/')) + 'basis_transcoder.wasm';
222
+ const res = await fetch(url, { method: 'HEAD' });
223
+ return !!res && res.ok;
224
+ } catch (_) {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Compatibility: KHR_materials_pbrSpecularGlossiness
231
+ *
232
+ * Some glb files (including some system-provided samples) use the legacy
233
+ * spec/gloss workflow extension. Newer GLTFLoader versions may not convert it.
234
+ * We map the most visible parts (diffuseFactor/diffuseTexture) onto the already
235
+ * created MeshStandardMaterial, so the model is not "white".
236
+ *
237
+ * @private
238
+ */
239
+ async _applyPbrSpecGlossCompat(gltf, logger) {
240
+ const parser = gltf?.parser;
241
+ const json = parser?.json;
242
+ const materialDefs = json?.materials;
243
+ if (!parser || !Array.isArray(materialDefs) || materialDefs.length === 0) return;
244
+
245
+ // Fast check: extension used?
246
+ let any = false;
247
+ for (const md of materialDefs) {
248
+ if (md?.extensions?.KHR_materials_pbrSpecularGlossiness) { any = true; break; }
249
+ }
250
+ if (!any) return;
251
+
252
+ let patched = 0;
253
+ let patchedWithTexture = 0;
254
+ let texCoordNonZero = 0;
255
+
256
+ try {
257
+ const materials = await parser.getDependencies('material');
258
+ for (let i = 0; i < materialDefs.length; i++) {
259
+ const ext = materialDefs[i]?.extensions?.KHR_materials_pbrSpecularGlossiness;
260
+ if (!ext) continue;
261
+ const mat = materials?.[i];
262
+ if (!mat) continue;
263
+
264
+ // diffuseFactor: [r,g,b,a]
265
+ const df = Array.isArray(ext.diffuseFactor) ? ext.diffuseFactor : null;
266
+ if (df && df.length >= 3 && mat?.color?.setRGB) {
267
+ const r = Number(df[0]); const g = Number(df[1]); const b = Number(df[2]);
268
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
269
+ mat.color.setRGB(r, g, b);
270
+ }
271
+ if (df.length >= 4) {
272
+ const a = Number(df[3]);
273
+ if (Number.isFinite(a)) {
274
+ mat.opacity = Math.min(1, Math.max(0, a));
275
+ mat.transparent = mat.opacity < 0.999;
276
+ }
277
+ }
278
+ }
279
+
280
+ // diffuseTexture: { index, texCoord? }
281
+ const dt = ext.diffuseTexture;
282
+ const texIndex = dt?.index;
283
+ if (Number.isInteger(texIndex)) {
284
+ try {
285
+ const tex = await parser.getDependency('texture', texIndex);
286
+ if (tex) {
287
+ mat.map = tex;
288
+ patchedWithTexture++;
289
+ const tc = dt?.texCoord;
290
+ if (tc != null && Number(tc) !== 0) texCoordNonZero++;
291
+ }
292
+ } catch (_) {}
293
+ }
294
+
295
+ // Approximate conversion hints (optional): make it less "plastic"
296
+ try {
297
+ if ('metalness' in mat) mat.metalness = 0.0;
298
+ if ('roughness' in mat && typeof ext.glossinessFactor === 'number') {
299
+ mat.roughness = Math.min(1, Math.max(0, 1 - ext.glossinessFactor));
300
+ }
301
+ } catch (_) {}
302
+
303
+ try { mat.needsUpdate = true; } catch (_) {}
304
+ patched++;
305
+ }
306
+ } catch (_) {
307
+ return;
308
+ }
309
+
310
+ if (texCoordNonZero > 0) {
311
+ logger?.warn?.('[GltfModelLoader] KHR_materials_pbrSpecularGlossiness uses non-zero texCoord; multi-UV mapping may require extra handling in this three.js revision.', { texCoordNonZero });
312
+ }
313
+ logger?.log?.('[GltfModelLoader] KHR_materials_pbrSpecularGlossiness compat applied', { patched, patchedWithTexture });
314
+ }
315
+ }
316
+
@@ -0,0 +1,77 @@
1
+ import { IfcService } from "../../ifc/IfcService.js";
2
+
3
+ /**
4
+ * IFC loader adapter.
5
+ *
6
+ * Notes:
7
+ * - Reuses existing IfcService (for stability and to keep IFC-specific capabilities intact).
8
+ * - IfcService currently integrates with Viewer internally via viewer.replaceWithModel().
9
+ * Therefore, this loader returns replacedInViewer=true to prevent double replace.
10
+ */
11
+ export class IfcModelLoader {
12
+ /**
13
+ * @param {IfcService|null} ifcService - optional externally managed service
14
+ */
15
+ constructor(ifcService = null) {
16
+ this.id = 'ifc';
17
+ this.extensions = ['.ifc', '.ifs', '.ifczip', '.zip'];
18
+ /** @type {IfcService|null} */
19
+ this._ifc = ifcService;
20
+ }
21
+
22
+ /**
23
+ * @param {any} ctx
24
+ * @returns {IfcService}
25
+ */
26
+ _getService(ctx) {
27
+ if (this._ifc) return this._ifc;
28
+ const viewer = ctx?.viewer;
29
+ if (!viewer) throw new Error('IfcModelLoader: ctx.viewer is required');
30
+ const wasmUrl = ctx?.wasmUrl || null;
31
+ const svc = new IfcService(viewer, wasmUrl);
32
+ svc.init();
33
+ this._ifc = svc;
34
+ return svc;
35
+ }
36
+
37
+ /**
38
+ * @param {File} file
39
+ * @param {any} ctx
40
+ */
41
+ async loadFile(file, ctx) {
42
+ const ifc = this._getService(ctx);
43
+ const model = await ifc.loadFile(file);
44
+ if (!model) throw new Error('IFC loadFile returned null');
45
+ return {
46
+ object3D: model,
47
+ format: this.id,
48
+ name: file?.name || '',
49
+ replacedInViewer: true,
50
+ capabilities: {
51
+ kind: 'ifc',
52
+ ifcService: ifc,
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @param {string} url
59
+ * @param {any} ctx
60
+ */
61
+ async loadUrl(url, ctx) {
62
+ const ifc = this._getService(ctx);
63
+ const model = await ifc.loadUrl(url);
64
+ if (!model) throw new Error('IFC loadUrl returned null');
65
+ return {
66
+ object3D: model,
67
+ format: this.id,
68
+ name: String(url || ''),
69
+ replacedInViewer: true,
70
+ capabilities: {
71
+ kind: 'ifc',
72
+ ifcService: ifc,
73
+ },
74
+ };
75
+ }
76
+ }
77
+
@@ -0,0 +1,310 @@
1
+ import { LoadingManager } from "three";
2
+ import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js";
3
+ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
4
+
5
+ /**
6
+ * OBJ loader with optional MTL support.
7
+ *
8
+ * Multi-file behavior (for <input multiple>):
9
+ * - User may select:
10
+ * - only .obj -> load OBJ without MTL
11
+ * - .obj + .mtl (+ textures) -> apply MTL and resolve textures from selected files
12
+ *
13
+ * Important: browser cannot access "neighbor" files unless the user selected them.
14
+ */
15
+ export class ObjModelLoader {
16
+ constructor() {
17
+ this.id = 'obj';
18
+ this.extensions = ['.obj'];
19
+ // For file picker convenience (accept=): allow selecting MTL + common textures.
20
+ this.associatedExtensions = ['.mtl', '.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif', '.tga'];
21
+ }
22
+
23
+ async loadFile(file, ctx) {
24
+ // Fallback: single OBJ only
25
+ return await this.loadFiles([file], ctx);
26
+ }
27
+
28
+ /**
29
+ * @param {File[]|FileList} files
30
+ * @param {any} ctx
31
+ */
32
+ async loadFiles(files, ctx) {
33
+ const logger = ctx?.logger || console;
34
+ const arr = Array.from(files || []).filter(Boolean);
35
+ if (!arr.length) throw new Error('ObjModelLoader: no files');
36
+
37
+ const objFile = this._pickObj(arr);
38
+ if (!objFile) {
39
+ throw new Error(`ObjModelLoader: .obj not found in selection: ${arr.map((f) => f?.name).filter(Boolean).join(', ')}`);
40
+ }
41
+
42
+ const base = this._basenameNoExt(objFile.name);
43
+ const mtlFile =
44
+ arr.find((f) => this._isExt(f?.name, '.mtl') && this._basenameNoExt(f.name).toLowerCase() === base.toLowerCase())
45
+ || arr.find((f) => this._isExt(f?.name, '.mtl'));
46
+
47
+ const fileMap = this._buildFileMap(arr);
48
+ const urlMap = new Map();
49
+ const revokeAll = () => {
50
+ try { for (const u of urlMap.values()) URL.revokeObjectURL(u); } catch (_) {}
51
+ };
52
+ const getBlobUrl = (key) => {
53
+ const k = String(key || '').toLowerCase();
54
+ if (!k) return null;
55
+ if (urlMap.has(k)) return urlMap.get(k);
56
+ const f = fileMap.get(k);
57
+ if (!f) return null;
58
+ const u = URL.createObjectURL(f);
59
+ urlMap.set(k, u);
60
+ return u;
61
+ };
62
+
63
+ // URL modifier: map any relative texture file name to the selected files
64
+ const manager = new LoadingManager();
65
+ /** @type {Set<string>} */
66
+ const failedUrls = new Set();
67
+ /** @type {Set<string>} */
68
+ const requestedBasenames = new Set();
69
+ // Track loader-level failures (useful when MTL parsing doesn't reveal all refs)
70
+ manager.onError = (url) => {
71
+ try {
72
+ const raw = String(url || '');
73
+ const clean = raw.split('#')[0].split('?')[0];
74
+ const parts = clean.replace(/\\/g, '/').split('/');
75
+ const last = (parts[parts.length - 1] || '').trim();
76
+ if (last) failedUrls.add(last);
77
+ } catch (_) {}
78
+ };
79
+ manager.setURLModifier((url) => {
80
+ try {
81
+ const raw = String(url || '');
82
+ // Strip query/hash and keep last segment
83
+ const clean = raw.split('#')[0].split('?')[0];
84
+ const parts = clean.replace(/\\/g, '/').split('/');
85
+ const last = (parts[parts.length - 1] || '').trim();
86
+ if (!last) return raw;
87
+ requestedBasenames.add(last);
88
+ const key = last.toLowerCase();
89
+ const blob = getBlobUrl(key);
90
+ return blob || raw;
91
+ } catch (_) {
92
+ return url;
93
+ }
94
+ });
95
+
96
+ try {
97
+ logger?.log?.('[ObjModelLoader] loadFiles', {
98
+ obj: objFile.name,
99
+ mtl: mtlFile ? mtlFile.name : null,
100
+ files: arr.map((f) => f?.name).filter(Boolean),
101
+ });
102
+
103
+ // If MTL exists, parse and apply materials
104
+ let materials = null;
105
+ let missingTextures = [];
106
+ if (mtlFile) {
107
+ const mtlText = await mtlFile.text();
108
+ // Diagnostics: detect missing referenced textures in MTL
109
+ try {
110
+ const refs = this._extractTextureRefsFromMtl(mtlText);
111
+ missingTextures = this._findMissingRefs(refs, fileMap);
112
+ if (missingTextures.length) {
113
+ logger?.warn?.('[ObjModelLoader] MTL references missing texture files (select them too):', missingTextures);
114
+ }
115
+ } catch (_) {}
116
+ const mtlLoader = new MTLLoader(manager);
117
+ materials = mtlLoader.parse(mtlText, '');
118
+ // IMPORTANT: preload loads textures asynchronously via manager.
119
+ // We must NOT revoke blob: URLs until these loads finish.
120
+ try { materials.preload(); } catch (_) {}
121
+ }
122
+
123
+ const objText = await objFile.text();
124
+ const objLoader = new OBJLoader(manager);
125
+ if (materials) objLoader.setMaterials(materials);
126
+ const obj = objLoader.parse(objText);
127
+
128
+ // Wait a bit for async texture loads to complete before revoking blob URLs.
129
+ // If there are no textures, onLoad can fire immediately; otherwise it will fire when all are done.
130
+ await this._waitManagerIdle(manager, 2500);
131
+
132
+ // Diagnostics: mesh/material counts
133
+ try {
134
+ let meshes = 0;
135
+ let mats = 0;
136
+ obj.traverse?.((n) => {
137
+ if (!n?.isMesh) return;
138
+ meshes++;
139
+ const m = n.material;
140
+ mats += Array.isArray(m) ? m.length : (m ? 1 : 0);
141
+ });
142
+ logger?.log?.('[ObjModelLoader] parsed', { obj: objFile.name, meshes, materials: mats, hasMtl: !!mtlFile });
143
+ } catch (_) {}
144
+
145
+ // Merge "missing by parse" + "failed at load time"
146
+ const missingAll = Array.from(new Set([...(missingTextures || []), ...Array.from(failedUrls.values())]));
147
+ if (missingAll.length) {
148
+ logger?.warn?.('[ObjModelLoader] missing assets (select these files too):', missingAll);
149
+ } else if (mtlFile) {
150
+ // If MTL exists but no maps got attached, warn proactively
151
+ try {
152
+ let withMap = 0;
153
+ obj.traverse?.((n) => {
154
+ if (!n?.isMesh) return;
155
+ const m = n.material;
156
+ const arrM = Array.isArray(m) ? m : [m];
157
+ for (const mi of arrM) if (mi?.map) withMap++;
158
+ });
159
+ if (withMap === 0) {
160
+ logger?.warn?.('[ObjModelLoader] MTL loaded, but no texture maps attached. Ensure you selected all referenced image files.', {
161
+ requested: Array.from(requestedBasenames.values()).slice(0, 30),
162
+ });
163
+ }
164
+ } catch (_) {}
165
+ }
166
+
167
+ return {
168
+ object3D: obj,
169
+ format: this.id,
170
+ name: objFile.name,
171
+ replacedInViewer: false,
172
+ capabilities: {
173
+ kind: 'generic',
174
+ hasMtl: !!mtlFile,
175
+ missingAssets: missingAll,
176
+ },
177
+ };
178
+ } finally {
179
+ // We keep URLs until manager finishes async texture loads (or timeout above).
180
+ revokeAll();
181
+ }
182
+ }
183
+
184
+ _pickObj(arr) {
185
+ // Prefer .obj; if multiple, take first
186
+ return arr.find((f) => this._isExt(f?.name, '.obj')) || null;
187
+ }
188
+
189
+ _isExt(name, ext) {
190
+ const n = String(name || '').toLowerCase();
191
+ return n.endsWith(String(ext).toLowerCase());
192
+ }
193
+
194
+ _basenameNoExt(name) {
195
+ const n = String(name || '');
196
+ const base = n.split(/[/\\]/).pop() || n;
197
+ const i = base.lastIndexOf('.');
198
+ return (i >= 0) ? base.slice(0, i) : base;
199
+ }
200
+
201
+ _buildFileMap(arr) {
202
+ // Map by basename only (common in MTL references)
203
+ const map = new Map();
204
+ for (const f of arr) {
205
+ const full = String(f?.name || '');
206
+ if (!full) continue;
207
+ const base = (full.split(/[/\\]/).pop() || full).toLowerCase();
208
+ if (!map.has(base)) map.set(base, f);
209
+ }
210
+ return map;
211
+ }
212
+
213
+ _extractTextureRefsFromMtl(mtlText) {
214
+ // Parse only texture map statements. We keep basenames; actual resolve happens via URLModifier.
215
+ const text = String(mtlText || '');
216
+ const lines = text.split(/\r?\n/);
217
+ const out = new Set();
218
+
219
+ for (let line of lines) {
220
+ line = String(line || '').trim();
221
+ if (!line || line.startsWith('#')) continue;
222
+ // strip inline comment
223
+ const hash = line.indexOf('#');
224
+ if (hash >= 0) line = line.slice(0, hash).trim();
225
+ if (!line) continue;
226
+
227
+ // Tokenize while keeping simple quoted paths
228
+ // Most MTL files are simple: last token is path, options before it.
229
+ const tokens = line.match(/"[^"]+"|\S+/g) || [];
230
+ if (tokens.length < 2) continue;
231
+ const keyword = String(tokens[0] || '').toLowerCase();
232
+ // Typical texture statements (robust to tabs/multiple spaces)
233
+ const isTexKeyword = (
234
+ keyword === 'map_kd' ||
235
+ keyword === 'map_ka' ||
236
+ keyword === 'map_ks' ||
237
+ keyword === 'map_ke' ||
238
+ keyword === 'map_ns' ||
239
+ keyword === 'map_d' ||
240
+ keyword === 'map_bump' ||
241
+ keyword === 'bump' ||
242
+ keyword === 'disp' ||
243
+ keyword === 'decal' ||
244
+ keyword === 'refl'
245
+ );
246
+ if (!isTexKeyword) continue;
247
+
248
+ // Remove the statement keyword
249
+ tokens.shift();
250
+
251
+ // Remove known options and their args (best-effort)
252
+ const rest = [];
253
+ for (let i = 0; i < tokens.length; i++) {
254
+ const t = tokens[i];
255
+ if (!t) continue;
256
+ if (t.startsWith('-')) {
257
+ // options with 1-3 numeric args are common
258
+ const opt = t.toLowerCase();
259
+ const skipArgs = (opt === '-o' || opt === '-s' || opt === '-t') ? 3
260
+ : (opt === '-mm') ? 2
261
+ : (opt === '-bm') ? 1
262
+ : (opt === '-imfchan') ? 1
263
+ : (opt === '-type') ? 1
264
+ : 0;
265
+ i += skipArgs;
266
+ continue;
267
+ }
268
+ rest.push(t);
269
+ }
270
+ if (!rest.length) continue;
271
+
272
+ const rawPath = rest[rest.length - 1].replace(/^"|"$/g, '');
273
+ const base = (rawPath.split(/[/\\]/).pop() || rawPath).trim();
274
+ if (!base) continue;
275
+ out.add(base);
276
+ }
277
+
278
+ return Array.from(out.values());
279
+ }
280
+
281
+ _findMissingRefs(refBasenames, fileMap) {
282
+ const missing = [];
283
+ for (const b of (refBasenames || [])) {
284
+ const key = String(b || '').toLowerCase();
285
+ if (!key) continue;
286
+ if (!fileMap.has(key)) missing.push(b);
287
+ }
288
+ return missing;
289
+ }
290
+
291
+ _waitManagerIdle(manager, timeoutMs = 2000) {
292
+ return new Promise((resolve) => {
293
+ let done = false;
294
+ const finish = () => {
295
+ if (done) return;
296
+ done = true;
297
+ resolve();
298
+ };
299
+ try {
300
+ const prevOnLoad = manager.onLoad;
301
+ manager.onLoad = () => {
302
+ try { prevOnLoad?.(); } catch (_) {}
303
+ finish();
304
+ };
305
+ } catch (_) {}
306
+ setTimeout(finish, Math.max(0, Number(timeoutMs) || 0));
307
+ });
308
+ }
309
+ }
310
+