@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.
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/IfcViewer.js +154 -28
- package/src/ifc/IfcService.js +0 -2
- package/src/index.js +10 -0
- package/src/main.js +122 -33
- package/src/model-loading/ModelLoaderRegistry.js +252 -0
- package/src/model-loading/loaders/DaeModelLoader.js +275 -0
- package/src/model-loading/loaders/FbxModelLoader.js +68 -0
- package/src/model-loading/loaders/GltfModelLoader.js +316 -0
- package/src/model-loading/loaders/IfcModelLoader.js +77 -0
- package/src/model-loading/loaders/ObjModelLoader.js +310 -0
- package/src/model-loading/loaders/StlModelLoader.js +102 -0
- package/src/model-loading/loaders/TdsModelLoader.js +205 -0
- package/src/viewer/Viewer.js +281 -48
|
@@ -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
|
+
|