@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 +1 -0
- package/package.json +3 -2
- package/scripts/copy-rhino3dm.mjs +90 -0
- package/scripts/patch-three-3dm-loader.mjs +60 -0
- package/src/IfcViewer.js +9 -1
- package/src/index.js +1 -0
- package/src/main.js +8 -4
- package/src/model-loading/ModelLoaderRegistry.js +201 -2
- package/src/model-loading/loaders/ThreeDmModelLoader.js +147 -0
- package/src/viewer/Viewer.js +35 -4
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.
|
|
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
|
|
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
|
+
|
package/src/viewer/Viewer.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
1651
|
-
const halfZ =
|
|
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;
|