@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.47.0",
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] - Автоматически загружать IFC файл при инициализации
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 (this.options.autoLoad && (this.options.ifcUrl || this.options.ifcFile)) {
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 loader = this.getLoaderForName(url);
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`);