@file-viewer/renderer-3d 2.1.30 → 2.2.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.en.md +20 -15
- package/README.md +19 -14
- package/dist/model.js +397 -43
- package/dist/occtModel.d.ts +13 -0
- package/dist/occtModel.js +185 -0
- package/package.json +3 -3
package/README.en.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @file-viewer/renderer-3d
|
|
2
2
|
|
|
3
|
-
Standalone 3D model renderer
|
|
3
|
+
Standalone 3D model renderer for Flyfish File Viewer. It uses Three.js, OrbitControls, and lazy format-specific loaders for GLB / GLTF, OBJ, STL, PLY, FBX, DAE, 3DS, 3MF, AMF, USD, KMZ, PCD, VRML, XYZ, VTK, and related files. STEP / STP, IGES / IGS, and BREP use a local OCCT worker for real geometry decoding and rendering.
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
@@ -11,24 +11,29 @@ import { modelRenderer } from '@file-viewer/renderer-3d'
|
|
|
11
11
|
const options = {
|
|
12
12
|
rendererMode: 'replace',
|
|
13
13
|
renderers: modelRenderer,
|
|
14
|
+
model: {
|
|
15
|
+
workerUrl: '/viewer-assets/wasm/model/occt-worker.js',
|
|
16
|
+
runtimeUrl: '/viewer-assets/wasm/model/occt-import-js.js',
|
|
17
|
+
wasmUrl: '/viewer-assets/wasm/model/occt-import-js.wasm',
|
|
18
|
+
workerTimeoutMs: 120_000,
|
|
19
|
+
},
|
|
14
20
|
}
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
`modelRenderer` can be combined with CAD and other renderer packages, or consumed through `@file-viewer/preset-all`. Full packages and `@file-viewer/vite-plugin` prepare the default offline assets. A renderer-only integration must self-host the OCCT worker, runtime, WASM, and both license notices.
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
import { modelRenderer } from '@file-viewer/renderer-3d'
|
|
21
|
-
import { cadRenderer } from '@file-viewer/renderer-cad'
|
|
25
|
+
Default paths:
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
- `wasm/model/occt-worker.js`
|
|
28
|
+
- `wasm/model/occt-import-js.js`
|
|
29
|
+
- `wasm/model/occt-import-js.wasm`
|
|
30
|
+
|
|
31
|
+
For subpath deployments or a dedicated asset origin, provide final URLs through `options.model.workerUrl`, `runtimeUrl`, and `wasmUrl`. There is no runtime CDN fallback. A strict CSP must allow that origin in `worker-src`, `script-src`, and `connect-src`; some browsers also require `script-src 'wasm-unsafe-eval'`.
|
|
28
32
|
|
|
29
|
-
##
|
|
33
|
+
## Boundaries
|
|
30
34
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
+
- STEP / STP, IGES / IGS, and BREP are tessellated by `occt-import-js` / OpenCascade in a worker. Assembly hierarchy, instances, normals, and face colors are preserved when the Three.js scene is built.
|
|
36
|
+
- General models support WebGL orbit controls, fit-to-view, grid, axes, wireframe, and auto-rotate. External textures or binary resources referenced by `gltf`, `dae`, and `fbx` continue to resolve against the original file URL directory.
|
|
37
|
+
- Unified global zoom: the renderer registers the standard zoom provider, so the outer toolbar's zoom in, zoom out, reset, and fit actions control the camera. Wheel, trackpad, and pinch zoom also update the shared zoom state.
|
|
38
|
+
- IFC and 3DM currently provide signature detection and explicit integration guidance only. They still need dedicated `web-ifc` / That Open and `rhino3dm` renderers and are not reported as successful previews.
|
|
39
|
+
- `@file-viewer/core` does not bundle Three.js or a geometry kernel. Install this renderer, the matching preset, or a full package when model preview is required.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @file-viewer/renderer-3d
|
|
2
2
|
|
|
3
|
-
Flyfish File Viewer 的独立 3D 模型 renderer
|
|
3
|
+
Flyfish File Viewer 的独立 3D 模型 renderer。它使用 Three.js、OrbitControls 和按格式异步加载的 loader,在浏览器内预览 GLB / GLTF、OBJ、STL、PLY、FBX、DAE、3DS、3MF、AMF、USD、KMZ、PCD、VRML、XYZ、VTK 等模型;STEP / STP、IGES / IGS 和 BREP 则通过本地 OCCT Worker 完成真实几何解析和渲染。
|
|
4
4
|
|
|
5
5
|
## 用法
|
|
6
6
|
|
|
@@ -11,24 +11,29 @@ import { modelRenderer } from '@file-viewer/renderer-3d'
|
|
|
11
11
|
const options = {
|
|
12
12
|
rendererMode: 'replace',
|
|
13
13
|
renderers: modelRenderer,
|
|
14
|
+
model: {
|
|
15
|
+
workerUrl: '/viewer-assets/wasm/model/occt-worker.js',
|
|
16
|
+
runtimeUrl: '/viewer-assets/wasm/model/occt-import-js.js',
|
|
17
|
+
wasmUrl: '/viewer-assets/wasm/model/occt-import-js.wasm',
|
|
18
|
+
workerTimeoutMs: 120_000,
|
|
19
|
+
},
|
|
14
20
|
}
|
|
15
21
|
```
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
也可以把 `modelRenderer` 与 CAD 等 renderer 组合,或直接使用已经聚合它的 `@file-viewer/preset-all`。full 包和 `@file-viewer/vite-plugin` 会准备默认离线资产;只安装 renderer 时,需要自行托管 OCCT Worker、runtime、WASM 和两个许可证文件。
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
import { modelRenderer } from '@file-viewer/renderer-3d'
|
|
21
|
-
import { cadRenderer } from '@file-viewer/renderer-cad'
|
|
25
|
+
默认路径为:
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
- `wasm/model/occt-worker.js`
|
|
28
|
+
- `wasm/model/occt-import-js.js`
|
|
29
|
+
- `wasm/model/occt-import-js.wasm`
|
|
30
|
+
|
|
31
|
+
部署在子路径或独立资产域名时,通过 `options.model.workerUrl`、`runtimeUrl`、`wasmUrl` 传入最终 URL。不要使用运行时 CDN 回退。严格 CSP 需要在 `worker-src`、`script-src` 和 `connect-src` 中允许对应来源;部分浏览器还需要 `script-src 'wasm-unsafe-eval'`。
|
|
28
32
|
|
|
29
33
|
## 能力边界
|
|
30
34
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
+
- STEP / STP、IGES / IGS、BREP:在 Worker 中使用 `occt-import-js` / OpenCascade 三角化,保留装配层级、实例、法线和面颜色,再构建 Three.js 场景。
|
|
36
|
+
- 通用模型:支持 WebGL 轨道控制、适配视图、网格、坐标轴、线框和自动旋转。`gltf` / `dae` / `fbx` 的外部贴图或二进制资源继续以原始 `url` 目录为基准加载。
|
|
37
|
+
- 全局统一缩放:renderer 注册标准缩放 provider,因此外层工具栏的放大、缩小、比例重置和适配视图会直接控制相机;滚轮、触控板和捏合缩放也会同步更新全局比例状态。
|
|
38
|
+
- IFC、3DM:当前只做签名识别和明确的接入提示,仍需分别接入 `web-ifc` / That Open 与 `rhino3dm`,不会显示成成功预览。
|
|
39
|
+
- `@file-viewer/core` 不内置 Three.js 或几何内核。需要模型预览时请显式安装本包、相应 preset,或 full 包。
|
package/dist/model.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
3
|
-
import { createFileViewerViewStateChange, createFileViewerViewStateChangeEmitter, createFileViewerTranslator, registerFileViewerViewStateProvider, unregisterFileViewerViewStateProvider, } from '@file-viewer/core';
|
|
4
|
-
import { formatGeometryKernelNotice, inspectGeometryKernelFile, isGeometryKernelFormat, } from '@file-viewer/geometry-engine';
|
|
3
|
+
import { createFileViewerViewStateChange, createFileViewerViewStateChangeEmitter, createFileViewerZoomChangeEmitter, createFileViewerTranslator, registerFileViewerZoomProvider, registerFileViewerViewStateProvider, resolveFileViewerColorScheme, resolveFileViewerLocale, resolveFileViewerModelAssetUrls, resolveFileViewerRuntimeAssetBaseUrl, unregisterFileViewerZoomProvider, unregisterFileViewerViewStateProvider, } from '@file-viewer/core';
|
|
4
|
+
import { formatGeometryKernelNotice, importOcctGeometryFile, inspectGeometryKernelFile, isGeometryKernelFormat, } from '@file-viewer/geometry-engine';
|
|
5
|
+
import { buildOcctThreeObject } from './occtModel.js';
|
|
5
6
|
const modelStyle = `
|
|
6
7
|
.model-viewer{display:flex;height:100%;min-height:100%;flex-direction:column;background:#f8fafc;color:#162333}
|
|
7
8
|
.model-viewer *{box-sizing:border-box}
|
|
@@ -25,11 +26,37 @@ const modelStyle = `
|
|
|
25
26
|
[data-viewer-theme='dark'] .model-meta{color:#94a3b8}
|
|
26
27
|
[data-viewer-theme='dark'] .model-meta strong{color:#5eead4}
|
|
27
28
|
[data-viewer-theme='dark'] .model-state{background:rgba(15,23,42,.88);color:#cbd5e1}
|
|
29
|
+
.model-viewer[data-model-theme='dark']{background:#101820;color:#e5eef8}
|
|
30
|
+
.model-viewer[data-model-theme='dark'] .model-toolbar{border-color:rgba(148,163,184,.18);background:#111827}
|
|
31
|
+
.model-viewer[data-model-theme='dark'] .model-actions button{background:#1f2937;color:#cbd5e1}
|
|
32
|
+
.model-viewer[data-model-theme='dark'] .model-actions button.active,.model-viewer[data-model-theme='dark'] .model-actions button:hover{background:rgba(45,212,191,.14);color:#5eead4}
|
|
33
|
+
.model-viewer[data-model-theme='dark'] .model-meta{color:#94a3b8}
|
|
34
|
+
.model-viewer[data-model-theme='dark'] .model-meta strong{color:#5eead4}
|
|
35
|
+
.model-viewer[data-model-theme='dark'] .model-state{background:rgba(15,23,42,.88);color:#cbd5e1}
|
|
28
36
|
@media (prefers-color-scheme:dark){[data-viewer-theme='system'] .model-viewer{background:#101820;color:#e5eef8}[data-viewer-theme='system'] .model-toolbar{border-color:rgba(148,163,184,.18);background:#111827}[data-viewer-theme='system'] .model-actions button{background:#1f2937;color:#cbd5e1}[data-viewer-theme='system'] .model-actions button.active,[data-viewer-theme='system'] .model-actions button:hover{background:rgba(45,212,191,.14);color:#5eead4}[data-viewer-theme='system'] .model-meta{color:#94a3b8}[data-viewer-theme='system'] .model-meta strong{color:#5eead4}[data-viewer-theme='system'] .model-state{background:rgba(15,23,42,.88);color:#cbd5e1}}
|
|
29
37
|
@media (max-width:720px){.model-toolbar{min-height:64px;align-items:flex-start;flex-direction:column;gap:8px;padding:8px 10px}.model-meta{width:100%;justify-content:flex-start}}
|
|
30
38
|
`;
|
|
31
39
|
class ModelPreviewNotice extends Error {
|
|
32
40
|
}
|
|
41
|
+
const MODEL_MIN_ZOOM = 0.1;
|
|
42
|
+
const MODEL_MAX_ZOOM = 20;
|
|
43
|
+
const MODEL_ZOOM_STEP = 1.2;
|
|
44
|
+
const MODEL_THEME_PALETTES = {
|
|
45
|
+
light: {
|
|
46
|
+
background: 0xf8fafc,
|
|
47
|
+
gridCenter: 0xb9c6d4,
|
|
48
|
+
gridLine: 0xdde5ed,
|
|
49
|
+
hemisphereSky: 0xffffff,
|
|
50
|
+
hemisphereGround: 0xd7dee8,
|
|
51
|
+
},
|
|
52
|
+
dark: {
|
|
53
|
+
background: 0x0d141c,
|
|
54
|
+
gridCenter: 0x526579,
|
|
55
|
+
gridLine: 0x263544,
|
|
56
|
+
hemisphereSky: 0xdbeafe,
|
|
57
|
+
hemisphereGround: 0x172231,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
33
60
|
const createStyle = () => {
|
|
34
61
|
const style = document.createElement('style');
|
|
35
62
|
style.textContent = modelStyle;
|
|
@@ -59,6 +86,29 @@ const normalizeError = (reason) => {
|
|
|
59
86
|
return String(reason);
|
|
60
87
|
}
|
|
61
88
|
};
|
|
89
|
+
const isWorkerBootstrapFailure = (reason) => {
|
|
90
|
+
const errorName = reason && typeof reason === 'object' && 'name' in reason
|
|
91
|
+
? String(reason.name || '')
|
|
92
|
+
: '';
|
|
93
|
+
if (errorName === 'SecurityError' || errorName === 'NetworkError') {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return /worker failed to load|worker-src|content security policy|importscripts|script error|loading (?:the )?wasm|fetching of the wasm failed/i
|
|
97
|
+
.test(normalizeError(reason));
|
|
98
|
+
};
|
|
99
|
+
const canUseWorkerUrlFromDocument = (workerUrl, documentRef) => {
|
|
100
|
+
const location = documentRef.defaultView?.location;
|
|
101
|
+
if (!location) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const resolved = new URL(workerUrl, documentRef.baseURI);
|
|
106
|
+
return resolved.protocol === 'blob:' || resolved.origin === location.origin;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
62
112
|
const getResourcePath = (sourceUrl) => {
|
|
63
113
|
if (!sourceUrl) {
|
|
64
114
|
return '';
|
|
@@ -72,22 +122,57 @@ const getResourcePath = (sourceUrl) => {
|
|
|
72
122
|
return slashIndex >= 0 ? clean.slice(0, slashIndex + 1) : '';
|
|
73
123
|
}
|
|
74
124
|
};
|
|
75
|
-
const disposeMaterial = (material) => {
|
|
76
|
-
const materials = Array.isArray(material) ? material : [material];
|
|
77
|
-
materials.forEach(item => item.dispose());
|
|
78
|
-
};
|
|
79
125
|
const disposeObject = (object) => {
|
|
126
|
+
const disposedGeometries = new Set();
|
|
127
|
+
const disposedMaterials = new Set();
|
|
128
|
+
const disposedTextures = new Set();
|
|
129
|
+
const disposedSkeletons = new Set();
|
|
130
|
+
const disposeTexture = (value) => {
|
|
131
|
+
if (value instanceof THREE.Texture && !disposedTextures.has(value)) {
|
|
132
|
+
disposedTextures.add(value);
|
|
133
|
+
value.dispose();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const disposeMaterial = (material) => {
|
|
137
|
+
if (disposedMaterials.has(material)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
disposedMaterials.add(material);
|
|
141
|
+
Object.values(material).forEach(value => {
|
|
142
|
+
disposeTexture(value);
|
|
143
|
+
if (Array.isArray(value)) {
|
|
144
|
+
value.forEach(disposeTexture);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (material instanceof THREE.ShaderMaterial) {
|
|
148
|
+
Object.values(material.uniforms).forEach(uniform => {
|
|
149
|
+
disposeTexture(uniform?.value);
|
|
150
|
+
if (Array.isArray(uniform?.value)) {
|
|
151
|
+
uniform.value.forEach(disposeTexture);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
material.dispose();
|
|
156
|
+
};
|
|
80
157
|
object.traverse(child => {
|
|
81
158
|
const mesh = child;
|
|
82
159
|
const points = child;
|
|
83
|
-
if (mesh.geometry) {
|
|
160
|
+
if (mesh.geometry && !disposedGeometries.has(mesh.geometry)) {
|
|
161
|
+
disposedGeometries.add(mesh.geometry);
|
|
84
162
|
mesh.geometry.dispose();
|
|
85
163
|
}
|
|
86
164
|
if (mesh.material) {
|
|
87
|
-
|
|
165
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|
166
|
+
materials.forEach(disposeMaterial);
|
|
88
167
|
}
|
|
89
168
|
if (points.material) {
|
|
90
|
-
|
|
169
|
+
const materials = Array.isArray(points.material) ? points.material : [points.material];
|
|
170
|
+
materials.forEach(disposeMaterial);
|
|
171
|
+
}
|
|
172
|
+
const skinnedMesh = child;
|
|
173
|
+
if (skinnedMesh.isSkinnedMesh && !disposedSkeletons.has(skinnedMesh.skeleton)) {
|
|
174
|
+
disposedSkeletons.add(skinnedMesh.skeleton);
|
|
175
|
+
skinnedMesh.skeleton.dispose();
|
|
91
176
|
}
|
|
92
177
|
});
|
|
93
178
|
};
|
|
@@ -109,12 +194,20 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
109
194
|
let modelRoot = null;
|
|
110
195
|
let gridHelper = null;
|
|
111
196
|
let axesHelper = null;
|
|
197
|
+
let hemisphereLight = null;
|
|
198
|
+
let themeObserver = null;
|
|
199
|
+
let stopSystemThemeListener = null;
|
|
200
|
+
let activeDarkMode = null;
|
|
112
201
|
let resizeObserver = null;
|
|
113
202
|
let animationFrame = 0;
|
|
114
203
|
let activeVersion = 0;
|
|
204
|
+
let modelMeshCount = 0;
|
|
205
|
+
let zoomBaselineDistance = 0;
|
|
206
|
+
let controlsStartScale = 1;
|
|
115
207
|
let mixer = null;
|
|
116
|
-
const
|
|
208
|
+
const timer = new THREE.Timer();
|
|
117
209
|
const viewStateEmitter = createFileViewerViewStateChangeEmitter();
|
|
210
|
+
const zoomEmitter = createFileViewerZoomChangeEmitter();
|
|
118
211
|
const root = createElement('div', 'model-viewer');
|
|
119
212
|
const toolbar = createElement('div', 'model-toolbar');
|
|
120
213
|
const actions = createElement('div', 'model-actions');
|
|
@@ -130,6 +223,9 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
130
223
|
const canvas = document.createElement('canvas');
|
|
131
224
|
const state = createElement('div', 'model-state', t('model.state.loading'));
|
|
132
225
|
const buttons = [fitButton, rotateButton, wireframeButton, gridButton, axesButton];
|
|
226
|
+
root.dataset.modelFormat = normalizedType;
|
|
227
|
+
root.dataset.modelStatus = status;
|
|
228
|
+
root.dataset.modelMeshCount = '0';
|
|
133
229
|
buttons.forEach(button => {
|
|
134
230
|
button.type = 'button';
|
|
135
231
|
});
|
|
@@ -156,6 +252,8 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
156
252
|
gridButton.classList.toggle('active', showGrid);
|
|
157
253
|
axesButton.classList.toggle('active', showAxes);
|
|
158
254
|
summaryText.textContent = objectSummary;
|
|
255
|
+
root.dataset.modelStatus = status;
|
|
256
|
+
root.dataset.modelMeshCount = String(modelMeshCount);
|
|
159
257
|
state.hidden = status === 'ready';
|
|
160
258
|
state.classList.toggle('error', status === 'error');
|
|
161
259
|
if (status === 'loading') {
|
|
@@ -187,18 +285,65 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
187
285
|
}
|
|
188
286
|
return new THREE.Vector3(x, y, z);
|
|
189
287
|
};
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
288
|
+
const getModelScale = () => {
|
|
289
|
+
if (!camera || !controls || zoomBaselineDistance <= 0) {
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
const distance = camera.position.distanceTo(controls.target);
|
|
293
|
+
return distance > 0
|
|
294
|
+
? THREE.MathUtils.clamp(zoomBaselineDistance / distance, MODEL_MIN_ZOOM, MODEL_MAX_ZOOM)
|
|
295
|
+
: 1;
|
|
296
|
+
};
|
|
297
|
+
const getModelZoomState = () => {
|
|
298
|
+
const scale = getModelScale();
|
|
299
|
+
const ready = status === 'ready' && Boolean(modelRoot && camera && controls && zoomBaselineDistance > 0);
|
|
300
|
+
return {
|
|
301
|
+
scale,
|
|
302
|
+
label: `${Math.round(scale * 100)}%`,
|
|
303
|
+
canZoomIn: ready && scale < MODEL_MAX_ZOOM - 0.001,
|
|
304
|
+
canZoomOut: ready && scale > MODEL_MIN_ZOOM + 0.001,
|
|
305
|
+
canReset: ready && Math.abs(scale - 1) > 0.005,
|
|
306
|
+
minScale: MODEL_MIN_ZOOM,
|
|
307
|
+
maxScale: MODEL_MAX_ZOOM,
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
const setModelZoom = (requestedScale, source = 'api', action = 'zoom-change', notify = true) => {
|
|
311
|
+
if (!camera || !controls || zoomBaselineDistance <= 0) {
|
|
312
|
+
return getModelZoomState();
|
|
313
|
+
}
|
|
314
|
+
const scale = THREE.MathUtils.clamp(requestedScale, MODEL_MIN_ZOOM, MODEL_MAX_ZOOM);
|
|
315
|
+
const direction = camera.position.clone().sub(controls.target);
|
|
316
|
+
if (direction.lengthSq() < 1e-8) {
|
|
317
|
+
direction.set(1, 0.62, 1);
|
|
318
|
+
}
|
|
319
|
+
direction.normalize();
|
|
320
|
+
camera.position.copy(controls.target).addScaledVector(direction, zoomBaselineDistance / scale);
|
|
321
|
+
camera.updateProjectionMatrix();
|
|
322
|
+
controls.update();
|
|
323
|
+
zoomEmitter.emit();
|
|
324
|
+
if (notify) {
|
|
325
|
+
emitViewStateChange(action, source);
|
|
326
|
+
}
|
|
327
|
+
return getModelZoomState();
|
|
328
|
+
};
|
|
329
|
+
const getModelViewState = () => {
|
|
330
|
+
const zoom = getModelZoomState();
|
|
331
|
+
return {
|
|
332
|
+
renderer: 'model',
|
|
333
|
+
scale: zoom.scale,
|
|
334
|
+
zoom,
|
|
335
|
+
extra: {
|
|
336
|
+
status,
|
|
337
|
+
cameraPosition: vectorToArray(camera?.position),
|
|
338
|
+
cameraTarget: vectorToArray(controls?.target),
|
|
339
|
+
zoomBaselineDistance,
|
|
340
|
+
autoRotate,
|
|
341
|
+
wireframe,
|
|
342
|
+
showGrid,
|
|
343
|
+
showAxes,
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
};
|
|
202
347
|
const emitViewStateChange = (action, source = 'viewer') => {
|
|
203
348
|
const state = getModelViewState();
|
|
204
349
|
viewStateEmitter.emit(createFileViewerViewStateChange(state, { action, source }));
|
|
@@ -208,6 +353,11 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
208
353
|
const source = applyOptions.source || 'api';
|
|
209
354
|
const action = applyOptions.action || 'restore';
|
|
210
355
|
const notify = applyOptions.notify !== false;
|
|
356
|
+
const baseline = Number(state.extra?.zoomBaselineDistance);
|
|
357
|
+
if (Number.isFinite(baseline) && baseline > 0) {
|
|
358
|
+
zoomBaselineDistance = baseline;
|
|
359
|
+
}
|
|
360
|
+
const hasCameraSnapshot = Array.isArray(state.extra?.cameraPosition);
|
|
211
361
|
if (camera) {
|
|
212
362
|
camera.position.copy(readVector(state.extra?.cameraPosition, camera.position));
|
|
213
363
|
camera.updateProjectionMatrix();
|
|
@@ -216,6 +366,13 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
216
366
|
controls.target.copy(readVector(state.extra?.cameraTarget, controls.target));
|
|
217
367
|
controls.update();
|
|
218
368
|
}
|
|
369
|
+
const requestedScale = Number(state.scale ?? state.zoom?.scale);
|
|
370
|
+
if (!hasCameraSnapshot && Number.isFinite(requestedScale) && requestedScale > 0) {
|
|
371
|
+
setModelZoom(requestedScale, source, action, false);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
zoomEmitter.emit();
|
|
375
|
+
}
|
|
219
376
|
if (typeof state.extra?.autoRotate === 'boolean') {
|
|
220
377
|
autoRotate = state.extra.autoRotate;
|
|
221
378
|
}
|
|
@@ -247,7 +404,90 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
247
404
|
camera.aspect = width / height;
|
|
248
405
|
camera.updateProjectionMatrix();
|
|
249
406
|
};
|
|
407
|
+
const findThemeHost = () => {
|
|
408
|
+
let current = root;
|
|
409
|
+
while (current) {
|
|
410
|
+
if (current.hasAttribute('data-viewer-theme')) {
|
|
411
|
+
return current;
|
|
412
|
+
}
|
|
413
|
+
const parentElement = current.parentElement;
|
|
414
|
+
if (parentElement) {
|
|
415
|
+
current = parentElement;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const nodeRoot = current.getRootNode();
|
|
419
|
+
current = 'host' in nodeRoot ? nodeRoot.host : null;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
};
|
|
423
|
+
const systemThemeQuery = target.ownerDocument.defaultView?.matchMedia?.('(prefers-color-scheme: dark)');
|
|
424
|
+
const readDarkMode = () => {
|
|
425
|
+
const themeValue = findThemeHost()?.dataset.viewerTheme || context?.options?.theme;
|
|
426
|
+
const declaredTheme = themeValue === 'light' || themeValue === 'dark' || themeValue === 'system'
|
|
427
|
+
? themeValue
|
|
428
|
+
: undefined;
|
|
429
|
+
return resolveFileViewerColorScheme(declaredTheme, systemThemeQuery?.matches ?? false) === 'dark';
|
|
430
|
+
};
|
|
431
|
+
const replaceGridHelper = (darkMode) => {
|
|
432
|
+
if (!scene) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const palette = darkMode ? MODEL_THEME_PALETTES.dark : MODEL_THEME_PALETTES.light;
|
|
436
|
+
const previous = gridHelper;
|
|
437
|
+
const next = new THREE.GridHelper(10, 10, palette.gridCenter, palette.gridLine);
|
|
438
|
+
next.visible = showGrid;
|
|
439
|
+
if (previous) {
|
|
440
|
+
next.scale.copy(previous.scale);
|
|
441
|
+
scene.remove(previous);
|
|
442
|
+
disposeObject(previous);
|
|
443
|
+
}
|
|
444
|
+
gridHelper = next;
|
|
445
|
+
scene.add(next);
|
|
446
|
+
};
|
|
447
|
+
const applyModelTheme = (force = false) => {
|
|
448
|
+
const darkMode = readDarkMode();
|
|
449
|
+
root.dataset.modelTheme = darkMode ? 'dark' : 'light';
|
|
450
|
+
if (!force && activeDarkMode === darkMode) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
activeDarkMode = darkMode;
|
|
454
|
+
const palette = darkMode ? MODEL_THEME_PALETTES.dark : MODEL_THEME_PALETTES.light;
|
|
455
|
+
renderer?.setClearColor(palette.background, 1);
|
|
456
|
+
if (scene) {
|
|
457
|
+
scene.background = new THREE.Color(palette.background);
|
|
458
|
+
}
|
|
459
|
+
if (hemisphereLight) {
|
|
460
|
+
hemisphereLight.color.setHex(palette.hemisphereSky);
|
|
461
|
+
hemisphereLight.groundColor.setHex(palette.hemisphereGround);
|
|
462
|
+
}
|
|
463
|
+
canvas.style.colorScheme = darkMode ? 'dark' : 'light';
|
|
464
|
+
replaceGridHelper(darkMode);
|
|
465
|
+
};
|
|
466
|
+
const observeModelTheme = () => {
|
|
467
|
+
const themeHost = findThemeHost();
|
|
468
|
+
const MutationObserverCtor = target.ownerDocument.defaultView?.MutationObserver;
|
|
469
|
+
if (themeHost && MutationObserverCtor) {
|
|
470
|
+
themeObserver = new MutationObserverCtor(() => applyModelTheme());
|
|
471
|
+
themeObserver.observe(themeHost, {
|
|
472
|
+
attributes: true,
|
|
473
|
+
attributeFilter: ['data-viewer-theme'],
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
if (!systemThemeQuery) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const onSystemThemeChange = () => applyModelTheme();
|
|
480
|
+
if (typeof systemThemeQuery.addEventListener === 'function') {
|
|
481
|
+
systemThemeQuery.addEventListener('change', onSystemThemeChange);
|
|
482
|
+
stopSystemThemeListener = () => systemThemeQuery.removeEventListener('change', onSystemThemeChange);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
systemThemeQuery.addListener(onSystemThemeChange);
|
|
486
|
+
stopSystemThemeListener = () => systemThemeQuery.removeListener(onSystemThemeChange);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
250
489
|
const ensureScene = () => {
|
|
490
|
+
const initializeTheme = !scene || !gridHelper;
|
|
251
491
|
if (!renderer) {
|
|
252
492
|
renderer = new THREE.WebGLRenderer({
|
|
253
493
|
antialias: true,
|
|
@@ -257,21 +497,17 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
257
497
|
});
|
|
258
498
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
259
499
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
260
|
-
renderer.setClearColor(0xf8fafc, 1);
|
|
261
500
|
}
|
|
262
501
|
if (!scene) {
|
|
263
502
|
scene = new THREE.Scene();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
scene.add(hemiLight);
|
|
503
|
+
hemisphereLight = new THREE.HemisphereLight(0xffffff, 0xd7dee8, 2.4);
|
|
504
|
+
scene.add(hemisphereLight);
|
|
267
505
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.2);
|
|
268
506
|
keyLight.position.set(8, 10, 8);
|
|
269
507
|
scene.add(keyLight);
|
|
270
508
|
const fillLight = new THREE.DirectionalLight(0xffffff, 0.9);
|
|
271
509
|
fillLight.position.set(-7, 5, -4);
|
|
272
510
|
scene.add(fillLight);
|
|
273
|
-
gridHelper = new THREE.GridHelper(10, 10, 0xcbd5e1, 0xe2e8f0);
|
|
274
|
-
scene.add(gridHelper);
|
|
275
511
|
axesHelper = new THREE.AxesHelper(3);
|
|
276
512
|
scene.add(axesHelper);
|
|
277
513
|
}
|
|
@@ -286,16 +522,22 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
286
522
|
controls.screenSpacePanning = true;
|
|
287
523
|
controls.autoRotateSpeed = 1.2;
|
|
288
524
|
}
|
|
525
|
+
applyModelTheme(initializeTheme);
|
|
289
526
|
updateHelperVisibility();
|
|
290
527
|
resize();
|
|
291
528
|
};
|
|
292
529
|
const clearModel = () => {
|
|
293
530
|
if (modelRoot && scene) {
|
|
531
|
+
mixer?.stopAllAction();
|
|
532
|
+
mixer?.uncacheRoot(modelRoot);
|
|
294
533
|
scene.remove(modelRoot);
|
|
295
534
|
disposeObject(modelRoot);
|
|
296
535
|
}
|
|
297
536
|
modelRoot = null;
|
|
537
|
+
modelMeshCount = 0;
|
|
538
|
+
zoomBaselineDistance = 0;
|
|
298
539
|
mixer = null;
|
|
540
|
+
zoomEmitter.emit();
|
|
299
541
|
};
|
|
300
542
|
const createSurfaceMaterial = () => new THREE.MeshStandardMaterial({
|
|
301
543
|
color: 0x4f8fba,
|
|
@@ -341,6 +583,7 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
341
583
|
};
|
|
342
584
|
const summarizeModel = (object) => {
|
|
343
585
|
const { meshes, points } = countMeshes(object);
|
|
586
|
+
modelMeshCount = meshes;
|
|
344
587
|
const parts = [];
|
|
345
588
|
if (meshes) {
|
|
346
589
|
parts.push(t('model.summary.meshes', { count: meshes }));
|
|
@@ -367,22 +610,50 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
367
610
|
size,
|
|
368
611
|
};
|
|
369
612
|
};
|
|
370
|
-
const fitToView = (source = 'viewer') => {
|
|
613
|
+
const fitToView = (source = 'viewer', request) => {
|
|
371
614
|
if (!modelRoot || !camera || !controls) {
|
|
372
|
-
return;
|
|
615
|
+
return getModelZoomState();
|
|
373
616
|
}
|
|
374
617
|
const box = new THREE.Box3().setFromObject(modelRoot);
|
|
375
|
-
const size = box.getSize(new THREE.Vector3());
|
|
376
618
|
const center = box.getCenter(new THREE.Vector3());
|
|
377
|
-
const
|
|
378
|
-
const
|
|
619
|
+
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
620
|
+
const radius = Math.max(sphere.radius, 0.5);
|
|
621
|
+
const rect = stage.getBoundingClientRect();
|
|
622
|
+
const width = Math.max(1, rect.width || canvas.clientWidth);
|
|
623
|
+
const height = Math.max(1, rect.height || canvas.clientHeight);
|
|
624
|
+
const aspect = Math.max(0.01, width / height);
|
|
625
|
+
const verticalFov = THREE.MathUtils.degToRad(camera.fov);
|
|
626
|
+
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
|
|
627
|
+
const verticalDistance = radius / Math.max(Math.sin(verticalFov / 2), 0.01);
|
|
628
|
+
const horizontalDistance = radius / Math.max(Math.sin(horizontalFov / 2), 0.01);
|
|
629
|
+
const mode = request?.mode || 'contain';
|
|
630
|
+
let distance = mode === 'cover'
|
|
631
|
+
? Math.min(verticalDistance, horizontalDistance)
|
|
632
|
+
: mode === 'width'
|
|
633
|
+
? horizontalDistance
|
|
634
|
+
: mode === 'height'
|
|
635
|
+
? verticalDistance
|
|
636
|
+
: Math.max(verticalDistance, horizontalDistance);
|
|
637
|
+
const padding = Math.max(0, request?.padding ?? 28);
|
|
638
|
+
const paddingRatio = THREE.MathUtils.clamp(1 - (padding * 2) / Math.min(width, height), 0.2, 1);
|
|
639
|
+
distance = Math.max(distance / paddingRatio, 0.01);
|
|
640
|
+
const direction = camera.position.clone().sub(controls.target);
|
|
641
|
+
if (direction.lengthSq() < 1e-8) {
|
|
642
|
+
direction.set(1, 0.62, 1);
|
|
643
|
+
}
|
|
644
|
+
direction.normalize();
|
|
645
|
+
zoomBaselineDistance = distance;
|
|
379
646
|
camera.near = Math.max(distance / 1000, 0.01);
|
|
380
647
|
camera.far = Math.max(distance * 1000, 1000);
|
|
381
|
-
camera.position.copy(center).
|
|
648
|
+
camera.position.copy(center).addScaledVector(direction, distance);
|
|
382
649
|
camera.updateProjectionMatrix();
|
|
383
650
|
controls.target.copy(center);
|
|
651
|
+
controls.minDistance = distance / MODEL_MAX_ZOOM;
|
|
652
|
+
controls.maxDistance = distance / MODEL_MIN_ZOOM;
|
|
384
653
|
controls.update();
|
|
654
|
+
zoomEmitter.emit();
|
|
385
655
|
emitViewStateChange('fit', source);
|
|
656
|
+
return getModelZoomState();
|
|
386
657
|
};
|
|
387
658
|
const applyModelFit = (request) => {
|
|
388
659
|
if (!modelRoot || !camera || !controls) {
|
|
@@ -395,12 +666,13 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
395
666
|
provider: 'view-state',
|
|
396
667
|
};
|
|
397
668
|
}
|
|
398
|
-
fitToView(request.source);
|
|
669
|
+
const zoom = fitToView(request.source, request);
|
|
399
670
|
const state = getModelViewState();
|
|
400
671
|
return {
|
|
401
672
|
applied: true,
|
|
402
673
|
mode: request.mode,
|
|
403
674
|
resize: request.resize,
|
|
675
|
+
scale: zoom.scale,
|
|
404
676
|
source: request.source,
|
|
405
677
|
provider: 'view-state',
|
|
406
678
|
state,
|
|
@@ -490,9 +762,50 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
490
762
|
const { KMZLoader } = await import('three/addons/loaders/KMZLoader.js');
|
|
491
763
|
return new KMZLoader().parse(buffer).scene;
|
|
492
764
|
};
|
|
765
|
+
const parseOcct = async (modelType) => {
|
|
766
|
+
const modelOptions = context?.options?.model;
|
|
767
|
+
const documentBaseUrl = resolveFileViewerRuntimeAssetBaseUrl(target.ownerDocument);
|
|
768
|
+
const assets = resolveFileViewerModelAssetUrls(modelOptions, documentBaseUrl);
|
|
769
|
+
const params = Object.fromEntries([
|
|
770
|
+
['linearUnit', modelOptions?.linearUnit],
|
|
771
|
+
['linearDeflectionType', modelOptions?.linearDeflectionType],
|
|
772
|
+
['linearDeflection', modelOptions?.linearDeflection],
|
|
773
|
+
['angularDeflection', modelOptions?.angularDeflection],
|
|
774
|
+
].filter(([, value]) => value !== undefined));
|
|
775
|
+
const workerPreferred = modelOptions?.useWorker !== false &&
|
|
776
|
+
canUseWorkerUrlFromDocument(assets.workerUrl, target.ownerDocument);
|
|
777
|
+
const importOptions = {
|
|
778
|
+
...assets,
|
|
779
|
+
useWorker: workerPreferred,
|
|
780
|
+
timeoutMs: modelOptions?.workerTimeoutMs,
|
|
781
|
+
params,
|
|
782
|
+
signal: context?.signal,
|
|
783
|
+
};
|
|
784
|
+
root.dataset.modelImport = workerPreferred ? 'worker' : 'main-thread';
|
|
785
|
+
let result;
|
|
786
|
+
try {
|
|
787
|
+
result = await importOcctGeometryFile(buffer, modelType, importOptions);
|
|
788
|
+
}
|
|
789
|
+
catch (workerError) {
|
|
790
|
+
if (!workerPreferred || context?.signal?.aborted || !isWorkerBootstrapFailure(workerError)) {
|
|
791
|
+
throw workerError;
|
|
792
|
+
}
|
|
793
|
+
root.dataset.modelImport = 'main-thread-fallback';
|
|
794
|
+
try {
|
|
795
|
+
result = await importOcctGeometryFile(buffer, modelType, {
|
|
796
|
+
...importOptions,
|
|
797
|
+
useWorker: false,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch (fallbackError) {
|
|
801
|
+
throw new Error(`${normalizeError(workerError)} Main-thread fallback failed: ${normalizeError(fallbackError)}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return buildOcctThreeObject(result, wireframe).object;
|
|
805
|
+
};
|
|
493
806
|
const explainEngineeringModel = (modelType) => {
|
|
494
807
|
const inspection = inspectGeometryKernelFile(buffer, modelType);
|
|
495
|
-
const notice = formatGeometryKernelNotice(inspection.format || modelType);
|
|
808
|
+
const notice = formatGeometryKernelNotice(inspection.format || modelType, resolveFileViewerLocale(context?.options));
|
|
496
809
|
const signature = inspection.signature ? t('model.notice.signature', { signature: inspection.signature }) : '';
|
|
497
810
|
const warnings = inspection.warnings.length ? ` ${inspection.warnings.join(' ')}` : '';
|
|
498
811
|
throw new ModelPreviewNotice(`${signature}${notice}${warnings}`);
|
|
@@ -549,9 +862,10 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
549
862
|
case 'stp':
|
|
550
863
|
case 'iges':
|
|
551
864
|
case 'igs':
|
|
865
|
+
case 'brep':
|
|
866
|
+
return parseOcct(modelType);
|
|
552
867
|
case 'ifc':
|
|
553
868
|
case '3dm':
|
|
554
|
-
case 'brep':
|
|
555
869
|
return explainEngineeringModel(modelType);
|
|
556
870
|
case 'pcd':
|
|
557
871
|
return parsePcd();
|
|
@@ -574,6 +888,7 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
574
888
|
const version = ++activeVersion;
|
|
575
889
|
status = 'loading';
|
|
576
890
|
errorMessage = '';
|
|
891
|
+
modelMeshCount = 0;
|
|
577
892
|
objectSummary = t('model.state.loadingSummary');
|
|
578
893
|
updateUi();
|
|
579
894
|
ensureScene();
|
|
@@ -586,26 +901,34 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
586
901
|
await addModelToScene(object);
|
|
587
902
|
status = 'ready';
|
|
588
903
|
updateUi();
|
|
904
|
+
zoomEmitter.emit();
|
|
905
|
+
emitViewStateChange('init', 'viewer');
|
|
589
906
|
}
|
|
590
907
|
catch (reason) {
|
|
591
908
|
if (version !== activeVersion) {
|
|
592
909
|
return;
|
|
593
910
|
}
|
|
911
|
+
if (context?.signal?.aborted) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
594
914
|
if (!(reason instanceof ModelPreviewNotice)) {
|
|
595
915
|
console.error(reason);
|
|
596
916
|
}
|
|
597
917
|
status = 'error';
|
|
918
|
+
modelMeshCount = 0;
|
|
598
919
|
errorMessage = normalizeError(reason) || t('model.error.parseFailed', { type: normalizedType.toUpperCase() });
|
|
599
920
|
updateUi();
|
|
921
|
+
zoomEmitter.emit();
|
|
600
922
|
}
|
|
601
923
|
};
|
|
602
|
-
const renderFrame = () => {
|
|
924
|
+
const renderFrame = (timestamp) => {
|
|
603
925
|
if (!renderer || !scene || !camera || !controls) {
|
|
604
926
|
return;
|
|
605
927
|
}
|
|
928
|
+
timer.update(timestamp);
|
|
929
|
+
const delta = timer.getDelta();
|
|
606
930
|
controls.autoRotate = autoRotate;
|
|
607
|
-
controls.update();
|
|
608
|
-
const delta = clock.getDelta();
|
|
931
|
+
controls.update(delta);
|
|
609
932
|
mixer?.update(delta);
|
|
610
933
|
renderer.render(scene, camera);
|
|
611
934
|
animationFrame = window.requestAnimationFrame(renderFrame);
|
|
@@ -616,7 +939,16 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
616
939
|
}
|
|
617
940
|
updateUi();
|
|
618
941
|
};
|
|
942
|
+
const onControlsStart = () => {
|
|
943
|
+
controlsStartScale = getModelScale();
|
|
944
|
+
};
|
|
619
945
|
const onControlsEnd = () => {
|
|
946
|
+
const scale = getModelScale();
|
|
947
|
+
if (Math.abs(scale - controlsStartScale) > 0.002) {
|
|
948
|
+
zoomEmitter.emit();
|
|
949
|
+
emitViewStateChange('zoom-change', 'user');
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
620
952
|
emitViewStateChange('camera-change', 'user');
|
|
621
953
|
};
|
|
622
954
|
fitButton.addEventListener('click', () => fitToView('user'));
|
|
@@ -642,8 +974,19 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
642
974
|
});
|
|
643
975
|
updateUi();
|
|
644
976
|
ensureScene();
|
|
977
|
+
observeModelTheme();
|
|
645
978
|
const controlsWithEvents = controls;
|
|
979
|
+
controlsWithEvents?.addEventListener('start', onControlsStart);
|
|
646
980
|
controlsWithEvents?.addEventListener('end', onControlsEnd);
|
|
981
|
+
registerFileViewerZoomProvider(root, {
|
|
982
|
+
zoomIn: () => setModelZoom(getModelScale() * MODEL_ZOOM_STEP, 'user', 'zoom-in'),
|
|
983
|
+
zoomOut: () => setModelZoom(getModelScale() / MODEL_ZOOM_STEP, 'user', 'zoom-out'),
|
|
984
|
+
resetZoom: () => setModelZoom(1, 'user', 'zoom-reset'),
|
|
985
|
+
setZoom: scale => setModelZoom(scale, 'api', 'zoom-change'),
|
|
986
|
+
fit: applyModelFit,
|
|
987
|
+
getState: getModelZoomState,
|
|
988
|
+
subscribe: zoomEmitter.subscribe,
|
|
989
|
+
});
|
|
647
990
|
registerFileViewerViewStateProvider(root, {
|
|
648
991
|
getState: getModelViewState,
|
|
649
992
|
applyState: applyModelViewState,
|
|
@@ -652,9 +995,9 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
652
995
|
});
|
|
653
996
|
resizeObserver = new ResizeObserver(resize);
|
|
654
997
|
resizeObserver.observe(canvas);
|
|
655
|
-
|
|
998
|
+
timer.connect(target.ownerDocument);
|
|
656
999
|
renderFrame();
|
|
657
|
-
|
|
1000
|
+
await loadModel();
|
|
658
1001
|
return {
|
|
659
1002
|
$el: root,
|
|
660
1003
|
unmount() {
|
|
@@ -662,18 +1005,29 @@ export default async function renderModel(buffer, target, type = 'glb', context)
|
|
|
662
1005
|
window.cancelAnimationFrame(animationFrame);
|
|
663
1006
|
resizeObserver?.disconnect();
|
|
664
1007
|
resizeObserver = null;
|
|
1008
|
+
themeObserver?.disconnect();
|
|
1009
|
+
themeObserver = null;
|
|
1010
|
+
stopSystemThemeListener?.();
|
|
1011
|
+
stopSystemThemeListener = null;
|
|
665
1012
|
clearModel();
|
|
1013
|
+
controlsWithEvents?.removeEventListener('start', onControlsStart);
|
|
666
1014
|
controlsWithEvents?.removeEventListener('end', onControlsEnd);
|
|
1015
|
+
unregisterFileViewerZoomProvider(root);
|
|
667
1016
|
unregisterFileViewerViewStateProvider(root);
|
|
668
1017
|
controls?.dispose();
|
|
669
1018
|
controls = null;
|
|
1019
|
+
if (scene) {
|
|
1020
|
+
disposeObject(scene);
|
|
1021
|
+
}
|
|
670
1022
|
renderer?.dispose();
|
|
1023
|
+
renderer?.renderLists.dispose();
|
|
671
1024
|
renderer = null;
|
|
672
|
-
|
|
1025
|
+
timer.dispose();
|
|
673
1026
|
scene = null;
|
|
674
1027
|
camera = null;
|
|
675
1028
|
gridHelper = null;
|
|
676
1029
|
axesHelper = null;
|
|
1030
|
+
hemisphereLight = null;
|
|
677
1031
|
target.replaceChildren();
|
|
678
1032
|
},
|
|
679
1033
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { GeometryKernelImportResult } from '@file-viewer/geometry-engine';
|
|
3
|
+
export interface OcctThreeObjectStats {
|
|
4
|
+
meshCount: number;
|
|
5
|
+
nodeCount: number;
|
|
6
|
+
triangleCount: number;
|
|
7
|
+
vertexCount: number;
|
|
8
|
+
}
|
|
9
|
+
export interface OcctThreeObjectResult {
|
|
10
|
+
object: THREE.Group;
|
|
11
|
+
stats: OcctThreeObjectStats;
|
|
12
|
+
}
|
|
13
|
+
export declare const buildOcctThreeObject: (result: GeometryKernelImportResult, wireframe?: boolean) => OcctThreeObjectResult;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
const DEFAULT_SURFACE_COLOR = new THREE.Color(0x4f8fba);
|
|
3
|
+
const readColor = (value) => {
|
|
4
|
+
if (!value || value.length < 3) {
|
|
5
|
+
return DEFAULT_SURFACE_COLOR.clone();
|
|
6
|
+
}
|
|
7
|
+
const components = value.slice(0, 3).map(Number);
|
|
8
|
+
if (components.some(component => !Number.isFinite(component))) {
|
|
9
|
+
return DEFAULT_SURFACE_COLOR.clone();
|
|
10
|
+
}
|
|
11
|
+
const [red, green, blue] = components.map(component => THREE.MathUtils.clamp(component, 0, 1));
|
|
12
|
+
return new THREE.Color(red, green, blue);
|
|
13
|
+
};
|
|
14
|
+
const createMaterial = (color, wireframe) => new THREE.MeshStandardMaterial({
|
|
15
|
+
color,
|
|
16
|
+
metalness: 0.06,
|
|
17
|
+
roughness: 0.72,
|
|
18
|
+
side: THREE.DoubleSide,
|
|
19
|
+
wireframe,
|
|
20
|
+
});
|
|
21
|
+
const hasOnlyFiniteNumbers = (values) => {
|
|
22
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
23
|
+
if (!Number.isFinite(Number(values[index]))) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
};
|
|
29
|
+
const normalizeFaces = (faces, triangleCount) => {
|
|
30
|
+
return (faces || [])
|
|
31
|
+
.map(face => ({
|
|
32
|
+
...face,
|
|
33
|
+
first: Math.max(0, Math.floor(Number(face.first))),
|
|
34
|
+
last: Math.min(triangleCount - 1, Math.floor(Number(face.last))),
|
|
35
|
+
}))
|
|
36
|
+
.filter(face => face.first <= face.last && face.first < triangleCount)
|
|
37
|
+
.sort((left, right) => left.first - right.first);
|
|
38
|
+
};
|
|
39
|
+
const buildGeometry = (source) => {
|
|
40
|
+
const sourcePositions = source.attributes?.position?.array || [];
|
|
41
|
+
const sourceIndices = source.index?.array || [];
|
|
42
|
+
if (sourcePositions.length < 9 || sourcePositions.length % 3 !== 0) {
|
|
43
|
+
throw new Error(`OCCT mesh ${source.name || '(unnamed)'} has invalid vertex data.`);
|
|
44
|
+
}
|
|
45
|
+
if (!hasOnlyFiniteNumbers(sourcePositions)) {
|
|
46
|
+
throw new Error(`OCCT mesh ${source.name || '(unnamed)'} has invalid vertex data.`);
|
|
47
|
+
}
|
|
48
|
+
if (sourceIndices.length < 3 || sourceIndices.length % 3 !== 0) {
|
|
49
|
+
throw new Error(`OCCT mesh ${source.name || '(unnamed)'} has invalid triangle data.`);
|
|
50
|
+
}
|
|
51
|
+
const vertexCount = sourcePositions.length / 3;
|
|
52
|
+
for (let index = 0; index < sourceIndices.length; index += 1) {
|
|
53
|
+
const vertexIndex = Number(sourceIndices[index]);
|
|
54
|
+
if (!Number.isInteger(vertexIndex) || vertexIndex < 0 || vertexIndex >= vertexCount) {
|
|
55
|
+
throw new Error(`OCCT mesh ${source.name || '(unnamed)'} has an invalid triangle index.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const positions = Float32Array.from(sourcePositions);
|
|
59
|
+
const indices = Uint32Array.from(sourceIndices);
|
|
60
|
+
const geometry = new THREE.BufferGeometry();
|
|
61
|
+
geometry.name = source.name || '';
|
|
62
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
63
|
+
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
|
64
|
+
const normals = source.attributes?.normal?.array;
|
|
65
|
+
const hasValidNormals = normals &&
|
|
66
|
+
normals.length === positions.length &&
|
|
67
|
+
hasOnlyFiniteNumbers(normals);
|
|
68
|
+
if (hasValidNormals) {
|
|
69
|
+
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(Float32Array.from(normals), 3));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
geometry.computeVertexNormals();
|
|
73
|
+
}
|
|
74
|
+
geometry.computeBoundingBox();
|
|
75
|
+
geometry.computeBoundingSphere();
|
|
76
|
+
return geometry;
|
|
77
|
+
};
|
|
78
|
+
const buildMaterials = (geometry, source, wireframe) => {
|
|
79
|
+
const baseColor = readColor(source.color);
|
|
80
|
+
const materials = [createMaterial(baseColor, wireframe)];
|
|
81
|
+
const materialByColor = new Map();
|
|
82
|
+
const triangleCount = (geometry.index?.count || 0) / 3;
|
|
83
|
+
const faces = normalizeFaces(source.brep_faces, triangleCount);
|
|
84
|
+
if (!faces.length) {
|
|
85
|
+
return materials;
|
|
86
|
+
}
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
for (const face of faces) {
|
|
89
|
+
if (face.first > cursor) {
|
|
90
|
+
geometry.addGroup(cursor * 3, (face.first - cursor) * 3, 0);
|
|
91
|
+
}
|
|
92
|
+
const faceColor = face.color ? readColor(face.color) : baseColor;
|
|
93
|
+
const colorKey = face.color ? faceColor.getHexString() : 'base';
|
|
94
|
+
let materialIndex = colorKey === 'base' ? 0 : materialByColor.get(colorKey);
|
|
95
|
+
if (materialIndex === undefined) {
|
|
96
|
+
materialIndex = materials.length;
|
|
97
|
+
materialByColor.set(colorKey, materialIndex);
|
|
98
|
+
materials.push(createMaterial(faceColor, wireframe));
|
|
99
|
+
}
|
|
100
|
+
const start = Math.max(cursor, face.first);
|
|
101
|
+
if (face.last >= start) {
|
|
102
|
+
geometry.addGroup(start * 3, (face.last - start + 1) * 3, materialIndex);
|
|
103
|
+
cursor = face.last + 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (cursor < triangleCount) {
|
|
107
|
+
geometry.addGroup(cursor * 3, (triangleCount - cursor) * 3, 0);
|
|
108
|
+
}
|
|
109
|
+
return materials;
|
|
110
|
+
};
|
|
111
|
+
const createMeshFactory = (source, wireframe) => {
|
|
112
|
+
const geometry = buildGeometry(source);
|
|
113
|
+
const materials = buildMaterials(geometry, source, wireframe);
|
|
114
|
+
return () => {
|
|
115
|
+
const mesh = new THREE.Mesh(geometry, materials.length === 1 ? materials[0] : materials);
|
|
116
|
+
mesh.name = source.name || '';
|
|
117
|
+
mesh.castShadow = false;
|
|
118
|
+
mesh.receiveShadow = false;
|
|
119
|
+
return mesh;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
export const buildOcctThreeObject = (result, wireframe = false) => {
|
|
123
|
+
if (!result.success || !result.meshes.length) {
|
|
124
|
+
throw new Error('The OpenCascade result does not contain renderable geometry.');
|
|
125
|
+
}
|
|
126
|
+
const meshFactories = result.meshes.map(mesh => createMeshFactory(mesh, wireframe));
|
|
127
|
+
const referencedMeshes = new Set();
|
|
128
|
+
let nodeCount = 0;
|
|
129
|
+
const buildNode = (node) => {
|
|
130
|
+
const group = new THREE.Group();
|
|
131
|
+
group.name = node.name || '';
|
|
132
|
+
nodeCount += 1;
|
|
133
|
+
for (const meshIndex of node.meshes || []) {
|
|
134
|
+
const factory = meshFactories[meshIndex];
|
|
135
|
+
if (!factory) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
referencedMeshes.add(meshIndex);
|
|
139
|
+
group.add(factory());
|
|
140
|
+
}
|
|
141
|
+
for (const child of node.children || []) {
|
|
142
|
+
group.add(buildNode(child));
|
|
143
|
+
}
|
|
144
|
+
return group;
|
|
145
|
+
};
|
|
146
|
+
const object = result.root
|
|
147
|
+
? buildNode(result.root)
|
|
148
|
+
: new THREE.Group();
|
|
149
|
+
object.name ||= 'OCCT model';
|
|
150
|
+
const unassigned = new THREE.Group();
|
|
151
|
+
unassigned.name = 'Unassigned geometry';
|
|
152
|
+
meshFactories.forEach((factory, index) => {
|
|
153
|
+
if (!referencedMeshes.has(index)) {
|
|
154
|
+
unassigned.add(factory());
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
if (unassigned.children.length) {
|
|
158
|
+
nodeCount += 1;
|
|
159
|
+
object.add(unassigned);
|
|
160
|
+
}
|
|
161
|
+
// STEP/IGES mechanical models conventionally use Z-up, while the shared
|
|
162
|
+
// Three.js stage uses Y-up. Keep the grid and orbit controls intuitive.
|
|
163
|
+
object.rotation.x = -Math.PI / 2;
|
|
164
|
+
object.updateMatrixWorld(true);
|
|
165
|
+
const stats = {
|
|
166
|
+
meshCount: 0,
|
|
167
|
+
nodeCount,
|
|
168
|
+
triangleCount: 0,
|
|
169
|
+
vertexCount: 0,
|
|
170
|
+
};
|
|
171
|
+
object.traverse(child => {
|
|
172
|
+
const mesh = child;
|
|
173
|
+
if (!mesh.isMesh || !mesh.geometry.getAttribute('position')) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
stats.meshCount += 1;
|
|
177
|
+
stats.vertexCount += mesh.geometry.getAttribute('position').count;
|
|
178
|
+
stats.triangleCount += (mesh.geometry.index?.count || 0) / 3;
|
|
179
|
+
});
|
|
180
|
+
if (!stats.meshCount) {
|
|
181
|
+
throw new Error('The OpenCascade hierarchy does not reference any renderable meshes.');
|
|
182
|
+
}
|
|
183
|
+
object.userData.fileViewerGeometryStats = stats;
|
|
184
|
+
return { object, stats };
|
|
185
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@file-viewer/renderer-3d",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone 3D model renderer plugin for Flyfish File Viewer powered by Three.js loaders and OrbitControls.",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"LICENSE"
|
|
58
58
|
],
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@file-viewer/core": "2.
|
|
61
|
-
"@file-viewer/geometry-engine": "2.
|
|
60
|
+
"@file-viewer/core": "2.2.0",
|
|
61
|
+
"@file-viewer/geometry-engine": "2.2.0",
|
|
62
62
|
"three": "^0.184.0"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|