@sequent-org/ifc-viewer 1.2.4-ci.48.0 → 1.2.4-ci.50.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 CHANGED
@@ -367,6 +367,7 @@ npm run test:manual
367
367
  - `.gltf` - glTF JSON (часто требует внешние .bin/текстуры; надёжнее грузить по URL)
368
368
  - `.obj` - Wavefront OBJ (можно загружать один OBJ или OBJ+MTL(+текстуры) через мультивыбор файлов)
369
369
  - `.3ds` - 3D Studio (3DS) (рекомендуется загружать .3ds + текстуры через мультивыбор файлов)
370
+ - `.3dm` - Rhino 3D (3DM) (требуются `rhino3dm.js`/`rhino3dm.wasm`, пакет копирует их в `/public/wasm/rhino3dm/` автоматически)
370
371
  - `.stl` - STL (ASCII/Binary). Обычно без материалов/цветов — отображается с дефолтным материалом.
371
372
  - `.dae` - COLLADA (DAE) (можно загружать .dae + текстуры через мультивыбор файлов)
372
373
 
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.48.0",
4
+ "version": "1.2.4-ci.50.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",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "scripts": {
35
- "postinstall": "node scripts/copy-web-ifc-wasm.mjs",
35
+ "postinstall": "node scripts/copy-web-ifc-wasm.mjs && node scripts/copy-rhino3dm.mjs && node scripts/patch-three-3dm-loader.mjs",
36
36
  "dev": "vite",
37
37
  "build": "vite build",
38
38
  "preview": "vite preview",
@@ -42,6 +42,7 @@
42
42
  "vite": "^7.1.2"
43
43
  },
44
44
  "dependencies": {
45
+ "rhino3dm": "8.4.0",
45
46
  "three": "^0.149.0",
46
47
  "web-ifc": "^0.0.74"
47
48
  }
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ function ensureDir(p) {
6
+ fs.mkdirSync(p, { recursive: true });
7
+ }
8
+
9
+ function copyFile(src, dst) {
10
+ ensureDir(path.dirname(dst));
11
+ fs.copyFileSync(src, dst);
12
+ }
13
+
14
+ function main() {
15
+ // npm sets INIT_CWD to the original working directory where `npm install` was invoked.
16
+ // That is the app root we need to copy into.
17
+ const appRoot = process.env.INIT_CWD || process.cwd();
18
+
19
+ const req = createRequire(import.meta.url);
20
+
21
+ /** @type {string|null} */
22
+ let pkgJsonPath = null;
23
+ try {
24
+ pkgJsonPath = req.resolve('rhino3dm/package.json', { paths: [appRoot, process.cwd()] });
25
+ } catch (_) {
26
+ pkgJsonPath = null;
27
+ }
28
+
29
+ const pkgDir = pkgJsonPath ? path.dirname(pkgJsonPath) : path.join(appRoot, 'node_modules', 'rhino3dm');
30
+
31
+ const version = (() => {
32
+ try {
33
+ if (pkgJsonPath && fs.existsSync(pkgJsonPath)) {
34
+ const raw = fs.readFileSync(pkgJsonPath, 'utf8');
35
+ const json = JSON.parse(raw);
36
+ return String(json?.version || '').trim() || 'unknown';
37
+ }
38
+ } catch (_) {}
39
+ return 'unknown';
40
+ })();
41
+
42
+ const srcJs = path.join(pkgDir, 'rhino3dm.js');
43
+ const srcWasm = path.join(pkgDir, 'rhino3dm.wasm');
44
+
45
+ const dstDir = path.join(appRoot, 'public', 'wasm', 'rhino3dm');
46
+ const dstJs = path.join(dstDir, 'rhino3dm.js');
47
+ const dstWasm = path.join(dstDir, 'rhino3dm.wasm');
48
+
49
+ const dstDirV = path.join(dstDir, `v${version}`);
50
+ const dstJsV = path.join(dstDirV, 'rhino3dm.js');
51
+ const dstWasmV = path.join(dstDirV, 'rhino3dm.wasm');
52
+
53
+ if (!fs.existsSync(srcJs) || !fs.existsSync(srcWasm)) {
54
+ console.error('[copy-rhino3dm] Source not found', {
55
+ appRoot,
56
+ pkgDir,
57
+ srcJs,
58
+ srcWasm,
59
+ existsJs: fs.existsSync(srcJs),
60
+ existsWasm: fs.existsSync(srcWasm),
61
+ });
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ copyFile(srcJs, dstJs);
67
+ copyFile(srcWasm, dstWasm);
68
+
69
+ // Versioned location (recommended to avoid cache-mismatch for end-users)
70
+ copyFile(srcJs, dstJsV);
71
+ copyFile(srcWasm, dstWasmV);
72
+
73
+ const sJs = fs.statSync(srcJs);
74
+ const sWasm = fs.statSync(srcWasm);
75
+ const dJs = fs.statSync(dstJs);
76
+ const dWasm = fs.statSync(dstWasm);
77
+
78
+ console.log('[copy-rhino3dm] OK', {
79
+ appRoot,
80
+ versionDir: `v${version}`,
81
+ src: { js: srcJs, wasm: srcWasm },
82
+ dst: { js: dstJs, wasm: dstWasm },
83
+ dstV: { js: dstJsV, wasm: dstWasmV },
84
+ bytes: { js: dJs.size, wasm: dWasm.size },
85
+ sameSize: { js: sJs.size === dJs.size, wasm: sWasm.size === dWasm.size },
86
+ });
87
+ }
88
+
89
+ main();
90
+
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ function main() {
6
+ const appRoot = process.env.INIT_CWD || process.cwd();
7
+ const req = createRequire(import.meta.url);
8
+
9
+ /** @type {string|null} */
10
+ let threePkgJson = null;
11
+ try {
12
+ threePkgJson = req.resolve('three/package.json', { paths: [appRoot, process.cwd()] });
13
+ } catch (_) {
14
+ threePkgJson = null;
15
+ }
16
+
17
+ const threeDir = threePkgJson ? path.dirname(threePkgJson) : path.join(appRoot, 'node_modules', 'three');
18
+ const target = path.join(threeDir, 'examples', 'jsm', 'loaders', '3DMLoader.js');
19
+
20
+ if (!fs.existsSync(target)) {
21
+ console.warn('[patch-three-3dm-loader] Target not found, skip', { target });
22
+ return;
23
+ }
24
+
25
+ const src = fs.readFileSync(target, 'utf8');
26
+ // Already patched?
27
+ if (src.includes('doc.instanceDefinitions().count;') || src.includes('doc.materials().count;')) {
28
+ console.log('[patch-three-3dm-loader] Already patched');
29
+ return;
30
+ }
31
+
32
+ let out = src;
33
+ const replacements = [
34
+ ['doc.instanceDefinitions().count()', 'doc.instanceDefinitions().count'],
35
+ ['doc.materials().count()', 'doc.materials().count'],
36
+ ['doc.layers().count()', 'doc.layers().count'],
37
+ ['doc.views().count()', 'doc.views().count'],
38
+ ['doc.namedViews().count()', 'doc.namedViews().count'],
39
+ ['doc.groups().count()', 'doc.groups().count'],
40
+ ['doc.strings().count()', 'doc.strings().count'],
41
+ ];
42
+
43
+ let changed = 0;
44
+ for (const [from, to] of replacements) {
45
+ const before = out;
46
+ out = out.split(from).join(to);
47
+ if (out !== before) changed++;
48
+ }
49
+
50
+ if (out === src) {
51
+ console.warn('[patch-three-3dm-loader] No changes applied (unexpected)', { target });
52
+ return;
53
+ }
54
+
55
+ fs.writeFileSync(target, out, 'utf8');
56
+ console.log('[patch-three-3dm-loader] Patched', { target, rulesApplied: changed });
57
+ }
58
+
59
+ main();
60
+
package/src/IfcViewer.js CHANGED
@@ -21,6 +21,7 @@ import { ObjModelLoader } from "./model-loading/loaders/ObjModelLoader.js";
21
21
  import { TdsModelLoader } from "./model-loading/loaders/TdsModelLoader.js";
22
22
  import { StlModelLoader } from "./model-loading/loaders/StlModelLoader.js";
23
23
  import { DaeModelLoader } from "./model-loading/loaders/DaeModelLoader.js";
24
+ import { ThreeDmModelLoader } from "./model-loading/loaders/ThreeDmModelLoader.js";
24
25
  import './style.css';
25
26
 
26
27
 
@@ -34,6 +35,7 @@ export class IfcViewer {
34
35
  * @param {string} [options.modelUrl] - URL для загрузки модели (любой поддерживаемый формат)
35
36
  * @param {File} [options.modelFile] - File объект модели (любой поддерживаемый формат)
36
37
  * @param {string} [options.wasmUrl] - URL для загрузки WASM файла web-ifc
38
+ * @param {string} [options.rhino3dmLibraryPath] - Путь (директория) к rhino3dm.js и rhino3dm.wasm (для .3dm)
37
39
  * @param {boolean} [options.useTestPreset=true] - Включать ли пресет "Тест" по умолчанию (рекомендованные тени/визуал)
38
40
  * @param {boolean} [options.showSidebar=false] - Показывать ли боковую панель с деревом
39
41
  * @param {boolean} [options.showControls=false] - Показывать ли панель управления (нижние кнопки)
@@ -65,6 +67,7 @@ export class IfcViewer {
65
67
  modelUrl: options.modelUrl || null,
66
68
  modelFile: options.modelFile || null,
67
69
  wasmUrl: options.wasmUrl || null,
70
+ rhino3dmLibraryPath: options.rhino3dmLibraryPath || '/wasm/rhino3dm/',
68
71
  // По умолчанию включаем пресет "Тест" для корректного вида теней (как в демо-настройках)
69
72
  useTestPreset: options.useTestPreset !== false,
70
73
  showSidebar: options.showSidebar === true, // по умолчанию false
@@ -207,6 +210,7 @@ export class IfcViewer {
207
210
  result = await this.modelLoaders.loadUrl(loadSource, {
208
211
  viewer: this.viewer,
209
212
  wasmUrl: this.options.wasmUrl,
213
+ rhino3dmLibraryPath: this.options.rhino3dmLibraryPath,
210
214
  logger: console,
211
215
  });
212
216
  } else if (Array.isArray(loadSource) || (typeof FileList !== 'undefined' && loadSource instanceof FileList)) {
@@ -215,17 +219,20 @@ export class IfcViewer {
215
219
  ? await this.modelLoaders.loadFiles(files, {
216
220
  viewer: this.viewer,
217
221
  wasmUrl: this.options.wasmUrl,
222
+ rhino3dmLibraryPath: this.options.rhino3dmLibraryPath,
218
223
  logger: console,
219
224
  })
220
225
  : await this.modelLoaders.loadFile(files[0], {
221
226
  viewer: this.viewer,
222
227
  wasmUrl: this.options.wasmUrl,
228
+ rhino3dmLibraryPath: this.options.rhino3dmLibraryPath,
223
229
  logger: console,
224
230
  });
225
231
  } else if (loadSource instanceof File) {
226
232
  result = await this.modelLoaders.loadFile(loadSource, {
227
233
  viewer: this.viewer,
228
234
  wasmUrl: this.options.wasmUrl,
235
+ rhino3dmLibraryPath: this.options.rhino3dmLibraryPath,
229
236
  logger: console,
230
237
  });
231
238
  } else {
@@ -531,7 +538,8 @@ export class IfcViewer {
531
538
  .register(new ObjModelLoader())
532
539
  .register(new TdsModelLoader())
533
540
  .register(new StlModelLoader())
534
- .register(new DaeModelLoader());
541
+ .register(new DaeModelLoader())
542
+ .register(new ThreeDmModelLoader({ libraryPath: this.options.rhino3dmLibraryPath }));
535
543
 
536
544
  // Если в интерфейсе есть file input — настроим accept
537
545
  try {
package/src/index.js CHANGED
@@ -22,3 +22,4 @@ export { ObjModelLoader } from "./model-loading/loaders/ObjModelLoader.js";
22
22
  export { TdsModelLoader } from "./model-loading/loaders/TdsModelLoader.js";
23
23
  export { StlModelLoader } from "./model-loading/loaders/StlModelLoader.js";
24
24
  export { DaeModelLoader } from "./model-loading/loaders/DaeModelLoader.js";
25
+ export { ThreeDmModelLoader } from "./model-loading/loaders/ThreeDmModelLoader.js";
package/src/main.js CHANGED
@@ -10,6 +10,7 @@ import { ObjModelLoader } from "./model-loading/loaders/ObjModelLoader.js";
10
10
  import { TdsModelLoader } from "./model-loading/loaders/TdsModelLoader.js";
11
11
  import { StlModelLoader } from "./model-loading/loaders/StlModelLoader.js";
12
12
  import { DaeModelLoader } from "./model-loading/loaders/DaeModelLoader.js";
13
+ import { ThreeDmModelLoader } from "./model-loading/loaders/ThreeDmModelLoader.js";
13
14
 
14
15
  // Инициализация three.js Viewer в контейнере #app
15
16
  const app = document.getElementById("app");
@@ -354,7 +355,10 @@ if (app) {
354
355
  .register(new ObjModelLoader())
355
356
  .register(new TdsModelLoader())
356
357
  .register(new StlModelLoader())
357
- .register(new DaeModelLoader());
358
+ .register(new DaeModelLoader())
359
+ .register(new ThreeDmModelLoader());
360
+
361
+ const rhino3dmLibraryPath = '/wasm/rhino3dm/';
358
362
 
359
363
  const uploadBtn = document.getElementById("uploadBtn");
360
364
  const ifcInput = document.getElementById("ifcInput");
@@ -369,8 +373,8 @@ if (app) {
369
373
  try {
370
374
  // Multi-file: e.g. OBJ+MTL (+textures)
371
375
  result = (files.length > 1)
372
- ? await modelLoaders.loadFiles(files, { viewer, wasmUrl: wasmOverride, logger: console })
373
- : await modelLoaders.loadFile(files[0], { viewer, wasmUrl: wasmOverride, logger: console });
376
+ ? await modelLoaders.loadFiles(files, { viewer, wasmUrl: wasmOverride, rhino3dmLibraryPath, logger: console })
377
+ : await modelLoaders.loadFile(files[0], { viewer, wasmUrl: wasmOverride, rhino3dmLibraryPath, logger: console });
374
378
  activeCapabilities = result?.capabilities || null;
375
379
  } catch (err) {
376
380
  console.error('Model load error', err);
@@ -611,7 +615,7 @@ if (app) {
611
615
  const params = new URLSearchParams(location.search);
612
616
  const ifcUrlParam = params.get('ifc');
613
617
  const ifcUrl = ifcUrlParam || DEFAULT_IFC_URL;
614
- const result = await modelLoaders.loadUrl(encodeURI(ifcUrl), { viewer, wasmUrl: wasmOverride, logger: console });
618
+ const result = await modelLoaders.loadUrl(encodeURI(ifcUrl), { viewer, wasmUrl: wasmOverride, rhino3dmLibraryPath, logger: console });
615
619
  activeCapabilities = result?.capabilities || null;
616
620
  if (result?.object3D) {
617
621
  if (activeCapabilities?.kind === 'ifc' && activeCapabilities?.ifcService) {
@@ -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`);
@@ -0,0 +1,147 @@
1
+ import { Box3, Vector3 } from 'three';
2
+ import { Rhino3dmLoader } from 'three/examples/jsm/loaders/3DMLoader.js';
3
+
4
+ /**
5
+ * Rhino 3DM loader.
6
+ *
7
+ * Notes:
8
+ * - Requires `rhino3dm.js` and `rhino3dm.wasm` to be served from a library path.
9
+ * In this package we copy them to `/public/wasm/rhino3dm/` via postinstall script.
10
+ */
11
+ export class ThreeDmModelLoader {
12
+ /**
13
+ * @param {{ libraryPath?: string, workerLimit?: number, rotateXNeg90?: boolean, alignToGround?: boolean }} [options]
14
+ */
15
+ constructor(options = {}) {
16
+ this.id = '3dm';
17
+ this.extensions = ['.3dm'];
18
+ this._libraryPath = options.libraryPath || '/wasm/rhino3dm/';
19
+ this._workerLimit = Number.isFinite(options.workerLimit) ? Number(options.workerLimit) : 4;
20
+ // Many 3DM assets are effectively Z-up; viewer is Y-up. Keep consistent with other format loaders.
21
+ this._rotateXNeg90 = options.rotateXNeg90 !== false; // default true
22
+ // Bring model down to ground plane so shadow receiver is correct.
23
+ this._alignToGround = options.alignToGround !== false; // default true
24
+ }
25
+
26
+ /**
27
+ * @param {File} file
28
+ * @param {any} ctx
29
+ */
30
+ async loadFile(file, ctx) {
31
+ const url = URL.createObjectURL(file);
32
+ try {
33
+ const obj = await this._loadInternal(url, ctx);
34
+ return {
35
+ object3D: obj,
36
+ format: this.id,
37
+ name: file?.name || '',
38
+ replacedInViewer: false,
39
+ capabilities: { kind: 'generic' },
40
+ };
41
+ } finally {
42
+ try { URL.revokeObjectURL(url); } catch (_) {}
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {string} url
48
+ * @param {any} ctx
49
+ */
50
+ async loadUrl(url, ctx) {
51
+ const obj = await this._loadInternal(url, ctx);
52
+ return {
53
+ object3D: obj,
54
+ format: this.id,
55
+ name: String(url || ''),
56
+ replacedInViewer: false,
57
+ capabilities: { kind: 'generic' },
58
+ };
59
+ }
60
+
61
+ async _loadInternal(url, ctx) {
62
+ const logger = ctx?.logger || console;
63
+ const libraryPath = (ctx?.rhino3dmLibraryPath || this._libraryPath || '').toString();
64
+ const normalizedPath = libraryPath.endsWith('/') ? libraryPath : `${libraryPath}/`;
65
+
66
+ const loader = new Rhino3dmLoader();
67
+ loader.setLibraryPath(normalizedPath);
68
+ loader.setWorkerLimit(this._workerLimit);
69
+
70
+ logger?.log?.('[ThreeDmModelLoader] load', {
71
+ url: String(url || ''),
72
+ libraryPath: normalizedPath,
73
+ workerLimit: this._workerLimit,
74
+ });
75
+
76
+ let obj = null;
77
+ try {
78
+ obj = await new Promise((resolve, reject) => {
79
+ try {
80
+ loader.load(
81
+ url,
82
+ (result) => resolve(result),
83
+ undefined,
84
+ (err) => reject(err)
85
+ );
86
+ } catch (e) {
87
+ reject(e);
88
+ }
89
+ });
90
+ } catch (e) {
91
+ const msg = String(e?.message || e?.error?.message || e || '');
92
+ if (msg.includes('.count is not a function')) {
93
+ logger?.error?.(
94
+ '[ThreeDmModelLoader] rhino3dm API mismatch detected. This often happens when rhino3dm version is not compatible with three/examples 3DMLoader. ' +
95
+ 'For three@0.149.0 a compatible rhino3dm version is ~8.4.0.'
96
+ );
97
+ }
98
+ throw e;
99
+ }
100
+
101
+ // Axis + grounding BEFORE Viewer.replaceWithModel(): ensures bbox/shadowReceiver computed correctly.
102
+ try {
103
+ if (obj) {
104
+ if (this._rotateXNeg90) {
105
+ obj.rotation.x = -Math.PI / 2;
106
+ obj.updateMatrixWorld?.(true);
107
+ }
108
+ if (this._alignToGround) {
109
+ const box = new Box3().setFromObject(obj);
110
+ const minY = box.min.y;
111
+ if (Number.isFinite(minY)) {
112
+ obj.position.y -= minY;
113
+ obj.position.y += 0.001; // epsilon to avoid z-fighting with shadow receiver
114
+ obj.updateMatrixWorld?.(true);
115
+ }
116
+ }
117
+ }
118
+ } catch (_) {}
119
+
120
+ // Basic diagnostics
121
+ try {
122
+ let meshes = 0;
123
+ let mats = 0;
124
+ obj?.traverse?.((n) => {
125
+ if (!n?.isMesh) return;
126
+ meshes++;
127
+ const m = n.material;
128
+ const arr = Array.isArray(m) ? m : [m];
129
+ for (const mi of arr) if (mi) mats++;
130
+ });
131
+ let bbox = null;
132
+ try {
133
+ const b = new Box3().setFromObject(obj);
134
+ const size = b.getSize(new Vector3());
135
+ const center = b.getCenter(new Vector3());
136
+ bbox = {
137
+ size: { x: +size.x.toFixed(3), y: +size.y.toFixed(3), z: +size.z.toFixed(3) },
138
+ center: { x: +center.x.toFixed(3), y: +center.y.toFixed(3), z: +center.z.toFixed(3) },
139
+ };
140
+ } catch (_) {}
141
+ logger?.log?.('[ThreeDmModelLoader] parsed', { meshes, materials: mats, bbox });
142
+ } catch (_) {}
143
+
144
+ return obj;
145
+ }
146
+ }
147
+
@@ -1373,6 +1373,10 @@ export class Viewer {
1373
1373
  this.activeModel = object3D;
1374
1374
  this.scene.add(object3D);
1375
1375
 
1376
+ // Сброс MMB-pan (viewOffset) при загрузке новой модели:
1377
+ // иначе экранный сдвиг может "унести" модель из кадра даже при корректном кадрировании по bbox.
1378
+ try { this._mmbPan?.controller?.reset?.(); } catch (_) {}
1379
+
1376
1380
  // Пересчитать плоскость под моделью (3x по площади bbox по X/Z)
1377
1381
  this.#updateShadowReceiverFromModel(object3D);
1378
1382
 
@@ -1627,13 +1631,40 @@ export class Viewer {
1627
1631
  const center = box.getCenter(new THREE.Vector3());
1628
1632
  const minY = box.min.y;
1629
1633
 
1630
- // Требование: площадь плоскости = 3x площади объекта (bbox по X/Z).
1634
+ // Базовая плоскость: площадь = 3x площади объекта (bbox по X/Z).
1631
1635
  // => множитель по размерам = sqrt(3).
1632
1636
  const areaMultiplier = 3;
1633
1637
  const dimMul = Math.sqrt(areaMultiplier);
1634
1638
 
1639
+ // Доп. запас по X/Z из-за длины тени: высокая модель при наклонном солнце
1640
+ // может давать тень далеко за bbox по X/Z.
1641
+ // Оценка смещения тени по земле: displacementXZ ≈ height * |dirXZ| / |dirY|
1642
+ // где dir = (target - lightPos) нормализованный.
1643
+ let extraX = 0;
1644
+ let extraZ = 0;
1645
+ try {
1646
+ const sun = this.sunLight;
1647
+ if (sun) {
1648
+ const targetPos = (sun.target?.position?.clone?.() || center.clone());
1649
+ const dir = targetPos.sub(sun.position).normalize();
1650
+ const ay = Math.max(1e-3, Math.abs(dir.y));
1651
+ extraX = Math.abs(dir.x) * (Math.max(0, size.y) / ay);
1652
+ extraZ = Math.abs(dir.z) * (Math.max(0, size.y) / ay);
1653
+ // небольшой коэффициент запаса, чтобы не ловить «пограничные» обрезания
1654
+ const pad = 1.05;
1655
+ extraX *= pad;
1656
+ extraZ *= pad;
1657
+ }
1658
+ } catch (_) {
1659
+ extraX = 0;
1660
+ extraZ = 0;
1661
+ }
1662
+
1635
1663
  this.shadowReceiver.position.set(center.x, minY + 0.001, center.z);
1636
- this.shadowReceiver.scale.set(Math.max(0.001, size.x * dimMul), Math.max(0.001, size.z * dimMul), 1);
1664
+ // receiver.scale: X->world X, Y->world Z (PlaneGeometry is X/Y in local, rotated -90° around X)
1665
+ const receiverX = Math.max(0.001, (size.x * dimMul) + extraX * 2);
1666
+ const receiverZ = Math.max(0.001, (size.z * dimMul) + extraZ * 2);
1667
+ this.shadowReceiver.scale.set(receiverX, receiverZ, 1);
1637
1668
  this.shadowReceiver.updateMatrixWorld();
1638
1669
 
1639
1670
  // Обновим bbox здания для градиента тени (в XZ)
@@ -1647,8 +1678,8 @@ export class Viewer {
1647
1678
  // чтобы при включении теней они не "обрезались" слишком маленькой областью.
1648
1679
  if (this.sunLight) {
1649
1680
  const cam = this.sunLight.shadow.camera;
1650
- const halfX = (size.x * dimMul) / 2;
1651
- const halfZ = (size.z * dimMul) / 2;
1681
+ const halfX = receiverX / 2;
1682
+ const halfZ = receiverZ / 2;
1652
1683
  cam.left = -halfX;
1653
1684
  cam.right = halfX;
1654
1685
  cam.top = halfZ;