@sequent-org/ifc-viewer 1.2.4-ci.47.0 → 1.2.4-ci.49.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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sequent-org/ifc-viewer",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.2.4-ci.
|
|
4
|
+
"version": "1.2.4-ci.49.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "IFC 3D model viewer component for web applications - fully self-contained with local IFCLoader",
|
|
7
7
|
"main": "src/index.js",
|
package/src/IfcViewer.js
CHANGED
|
@@ -38,7 +38,7 @@ export class IfcViewer {
|
|
|
38
38
|
* @param {boolean} [options.showSidebar=false] - Показывать ли боковую панель с деревом
|
|
39
39
|
* @param {boolean} [options.showControls=false] - Показывать ли панель управления (нижние кнопки)
|
|
40
40
|
* @param {boolean} [options.showToolbar=true] - Показывать ли верхнюю панель инструментов
|
|
41
|
-
* @param {boolean} [options.autoLoad=true] - Автоматически загружать
|
|
41
|
+
* @param {boolean} [options.autoLoad=true] - Автоматически загружать модель при инициализации (modelUrl/modelFile/ifcUrl/ifcFile)
|
|
42
42
|
* @param {string} [options.theme='light'] - Тема интерфейса ('light' | 'dark')
|
|
43
43
|
* @param {Object} [options.viewerOptions] - Дополнительные опции для Viewer
|
|
44
44
|
*/
|
|
@@ -153,8 +153,14 @@ export class IfcViewer {
|
|
|
153
153
|
// Настраиваем обработчики событий
|
|
154
154
|
this._setupEventHandlers();
|
|
155
155
|
|
|
156
|
-
// Автозагрузка
|
|
157
|
-
if (
|
|
156
|
+
// Автозагрузка модели (в режиме пакета) если указан источник
|
|
157
|
+
if (
|
|
158
|
+
this.options.autoLoad &&
|
|
159
|
+
(this.options.modelUrl ||
|
|
160
|
+
this.options.modelFile ||
|
|
161
|
+
this.options.ifcUrl ||
|
|
162
|
+
this.options.ifcFile)
|
|
163
|
+
) {
|
|
158
164
|
await this.loadModel();
|
|
159
165
|
}
|
|
160
166
|
|
|
@@ -191,12 +191,24 @@ export class ModelLoaderRegistry {
|
|
|
191
191
|
* @returns {Promise<any|null>} LoadResult or null on error
|
|
192
192
|
*/
|
|
193
193
|
async loadUrl(url, ctx = {}) {
|
|
194
|
-
const
|
|
194
|
+
const logger = ctx?.logger || console;
|
|
195
|
+
let loader = this.getLoaderForName(url);
|
|
196
|
+
|
|
197
|
+
// If URL doesn't contain an extension, try to infer format via headers/signature.
|
|
198
|
+
// This is required for CDN-style links like /ifc-files/<id> (no ".ifc" suffix).
|
|
199
|
+
if (!loader) {
|
|
200
|
+
try {
|
|
201
|
+
loader = await this._guessLoaderForUrl(url, logger);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
logger?.warn?.('[ModelLoaderRegistry] url sniff failed', { url, error: e });
|
|
204
|
+
loader = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
195
208
|
if (!loader) {
|
|
196
209
|
throw new Error(`Формат не поддерживается: ${url || 'unknown url'}`);
|
|
197
210
|
}
|
|
198
211
|
|
|
199
|
-
const logger = ctx?.logger || console;
|
|
200
212
|
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
201
213
|
|
|
202
214
|
try {
|
|
@@ -213,6 +225,193 @@ export class ModelLoaderRegistry {
|
|
|
213
225
|
}
|
|
214
226
|
}
|
|
215
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Tries to infer loader for URL without extension.
|
|
230
|
+
*
|
|
231
|
+
* Strategy:
|
|
232
|
+
* - Use Content-Disposition filename (if present) to resolve extension
|
|
233
|
+
* - Else sniff the first bytes (streaming) and match known signatures
|
|
234
|
+
*
|
|
235
|
+
* @param {string} url
|
|
236
|
+
* @param {any} logger
|
|
237
|
+
* @returns {Promise<any|null>}
|
|
238
|
+
*/
|
|
239
|
+
async _guessLoaderForUrl(url, logger) {
|
|
240
|
+
const u = String(url || '');
|
|
241
|
+
if (!u) return null;
|
|
242
|
+
|
|
243
|
+
// 1) Try HEAD headers (may expose filename and/or content-type)
|
|
244
|
+
try {
|
|
245
|
+
if (typeof fetch === 'function') {
|
|
246
|
+
const head = await fetch(u, { method: 'HEAD' });
|
|
247
|
+
const cd = head?.headers?.get?.('content-disposition') || head?.headers?.get?.('Content-Disposition');
|
|
248
|
+
if (cd) {
|
|
249
|
+
const fileName = this._tryParseFilenameFromContentDisposition(cd);
|
|
250
|
+
if (fileName) {
|
|
251
|
+
const byName = this.getLoaderForName(fileName);
|
|
252
|
+
if (byName) {
|
|
253
|
+
logger?.log?.('[ModelLoaderRegistry] url sniff: Content-Disposition matched', { url: u, fileName, loader: byName.id });
|
|
254
|
+
return byName;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const ct = head?.headers?.get?.('content-type') || head?.headers?.get?.('Content-Type');
|
|
259
|
+
// Content-Type is often "application/octet-stream", but keep a couple of strong signals.
|
|
260
|
+
if (ct) {
|
|
261
|
+
const lower = String(ct).toLowerCase();
|
|
262
|
+
if (lower.includes('model/gltf-binary') || lower.includes('model/gltf+json')) {
|
|
263
|
+
const byCt = this.getLoaderForName(lower.includes('binary') ? 'model.glb' : 'model.gltf');
|
|
264
|
+
if (byCt) {
|
|
265
|
+
logger?.log?.('[ModelLoaderRegistry] url sniff: Content-Type matched', { url: u, contentType: ct, loader: byCt.id });
|
|
266
|
+
return byCt;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch (_) {
|
|
272
|
+
// ignore HEAD failures, proceed to signature sniff
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 2) Sniff first bytes (prefer Range; fall back to stream+abort)
|
|
276
|
+
const prefix = await this._readUrlPrefix(u, 4096);
|
|
277
|
+
if (!prefix || !prefix.length) return null;
|
|
278
|
+
|
|
279
|
+
const sig = this._detectSignature(prefix);
|
|
280
|
+
if (!sig) return null;
|
|
281
|
+
|
|
282
|
+
const virtualName = sig.virtualName;
|
|
283
|
+
const bySig = this.getLoaderForName(virtualName);
|
|
284
|
+
if (bySig) {
|
|
285
|
+
logger?.log?.('[ModelLoaderRegistry] url sniff: signature matched', { url: u, signature: sig.kind, virtualName, loader: bySig.id });
|
|
286
|
+
return bySig;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_tryParseFilenameFromContentDisposition(cd) {
|
|
293
|
+
try {
|
|
294
|
+
const s = String(cd || '');
|
|
295
|
+
// filename*=UTF-8''... (RFC 5987)
|
|
296
|
+
const mStar = s.match(/filename\*\s*=\s*([^;]+)/i);
|
|
297
|
+
if (mStar) {
|
|
298
|
+
const v = mStar[1].trim();
|
|
299
|
+
const parts = v.split("''");
|
|
300
|
+
const encoded = parts.length >= 2 ? parts.slice(1).join("''") : v;
|
|
301
|
+
const cleaned = encoded.replace(/^["']|["']$/g, '');
|
|
302
|
+
try { return decodeURIComponent(cleaned); } catch (_) { return cleaned; }
|
|
303
|
+
}
|
|
304
|
+
// filename="..."
|
|
305
|
+
const m = s.match(/filename\s*=\s*([^;]+)/i);
|
|
306
|
+
if (m) {
|
|
307
|
+
const v = m[1].trim().replace(/^["']|["']$/g, '');
|
|
308
|
+
return v || null;
|
|
309
|
+
}
|
|
310
|
+
} catch (_) {}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_detectSignature(bytes) {
|
|
315
|
+
try {
|
|
316
|
+
const b0 = bytes[0];
|
|
317
|
+
const b1 = bytes[1];
|
|
318
|
+
const b2 = bytes[2];
|
|
319
|
+
const b3 = bytes[3];
|
|
320
|
+
|
|
321
|
+
// ZIP: "PK"
|
|
322
|
+
if (b0 === 0x50 && b1 === 0x4b) {
|
|
323
|
+
// Could be IFZ/IFCZIP most often for this package
|
|
324
|
+
return { kind: 'zip', virtualName: 'model.ifczip' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// GLB: "glTF"
|
|
328
|
+
if (b0 === 0x67 && b1 === 0x6c && b2 === 0x54 && b3 === 0x46) {
|
|
329
|
+
return { kind: 'glb', virtualName: 'model.glb' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Text signatures: decode a small prefix as ASCII
|
|
333
|
+
const n = Math.min(bytes.length, 256);
|
|
334
|
+
let text = '';
|
|
335
|
+
for (let i = 0; i < n; i++) {
|
|
336
|
+
const c = bytes[i];
|
|
337
|
+
text += (c >= 32 && c <= 126) ? String.fromCharCode(c) : ' ';
|
|
338
|
+
}
|
|
339
|
+
const t = text.trim().toUpperCase();
|
|
340
|
+
|
|
341
|
+
// IFC STEP: "ISO-10303-21"
|
|
342
|
+
if (t.startsWith('ISO-10303-21')) {
|
|
343
|
+
return { kind: 'ifc-step', virtualName: 'model.ifc' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// DAE: XML with <COLLADA ...>
|
|
347
|
+
if (t.startsWith('<?XML') || t.startsWith('<COLLADA') || t.includes('<COLLADA')) {
|
|
348
|
+
return { kind: 'dae-xml', virtualName: 'model.dae' };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// OBJ: common first tokens ("mtllib", "o", "v", "#")
|
|
352
|
+
if (/^(#|MTLLIB\s+|O\s+|V\s+|VN\s+|VT\s+)/i.test(text.trim())) {
|
|
353
|
+
return { kind: 'obj-text', virtualName: 'model.obj' };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// STL ASCII: starts with "solid"
|
|
357
|
+
if (t.startsWith('SOLID')) {
|
|
358
|
+
return { kind: 'stl-ascii', virtualName: 'model.stl' };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return null;
|
|
362
|
+
} catch (_) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async _readUrlPrefix(url, maxBytes = 4096) {
|
|
368
|
+
if (typeof fetch !== 'function') return new Uint8Array();
|
|
369
|
+
const u = String(url || '');
|
|
370
|
+
const n = Math.max(1, Number(maxBytes) || 4096);
|
|
371
|
+
|
|
372
|
+
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
373
|
+
const headers = {};
|
|
374
|
+
// Attempt Range. Some servers may ignore it; we still stop reading after maxBytes.
|
|
375
|
+
try { headers.Range = `bytes=0-${n - 1}`; } catch (_) {}
|
|
376
|
+
|
|
377
|
+
const res = await fetch(u, { method: 'GET', headers, signal: controller?.signal });
|
|
378
|
+
if (!res || !res.ok) {
|
|
379
|
+
throw new Error(`Failed to fetch url prefix: ${res?.status || 'unknown'}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Prefer streaming to avoid downloading whole file if Range is ignored.
|
|
383
|
+
const reader = res.body?.getReader?.();
|
|
384
|
+
if (!reader) {
|
|
385
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
386
|
+
return buf.slice(0, n);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @type {Uint8Array[]} */
|
|
390
|
+
const chunks = [];
|
|
391
|
+
let total = 0;
|
|
392
|
+
while (total < n) {
|
|
393
|
+
// eslint-disable-next-line no-await-in-loop
|
|
394
|
+
const { value, done } = await reader.read();
|
|
395
|
+
if (done) break;
|
|
396
|
+
if (value && value.length) {
|
|
397
|
+
chunks.push(value);
|
|
398
|
+
total += value.length;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
try { controller?.abort?.(); } catch (_) {}
|
|
403
|
+
|
|
404
|
+
const out = new Uint8Array(Math.min(total, n));
|
|
405
|
+
let offset = 0;
|
|
406
|
+
for (const c of chunks) {
|
|
407
|
+
if (offset >= out.length) break;
|
|
408
|
+
const take = Math.min(c.length, out.length - offset);
|
|
409
|
+
out.set(c.subarray(0, take), offset);
|
|
410
|
+
offset += take;
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
216
415
|
_validateResult(result, loaderId) {
|
|
217
416
|
if (!result || typeof result !== 'object') {
|
|
218
417
|
throw new Error(`Loader "${loaderId}" returned invalid result`);
|