@file-viewer/renderer-3d 2.1.29 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @file-viewer/renderer-3d
2
2
 
3
- Standalone 3D model renderer package for Flyfish File Viewer. It uses Three.js, OrbitControls, and format-specific Three.js loaders to preview GLB/GLTF, OBJ, STL, PLY, FBX, DAE, 3DS, 3MF, AMF, USD, KMZ, PCD, VRML, XYZ, VTK, and related model files directly in the browser.
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
- You can combine it with other renderer packages:
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
- ```ts
20
- import { modelRenderer } from '@file-viewer/renderer-3d'
21
- import { cadRenderer } from '@file-viewer/renderer-cad'
25
+ Default paths:
22
26
 
23
- const options = {
24
- rendererMode: 'replace',
25
- renderers: [modelRenderer, cadRenderer],
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
- ## Capabilities
33
+ ## Boundaries
30
34
 
31
- - Supports WebGL preview, orbit controls, fit-to-view, grid, axes, wireframe, and auto-rotate.
32
- - For `gltf`, `dae`, and `fbx` files with external textures or binary resources, the original URL directory is used as the resource base.
33
- - STEP, IGES, IFC, 3DM, and BREP entries use `@file-viewer/geometry-engine` for lightweight signature detection and accurate conversion guidance; full decoding should keep evolving through dedicated OpenCascade / web-ifc / rhino3dm WASM renderer paths.
34
- - `@file-viewer/core` no longer bundles the 3D renderer and no longer depends directly on `three`. Install this package explicitly for 3D model preview, or use `@file-viewer/preset-all`, which aggregates this renderer automatically.
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 包。它使用 Three.js、OrbitControls 和按格式异步加载的 Three.js loaders,为 GLB/GLTF、OBJ、STL、PLY、FBX、DAE、3DS、3MF、AMF、USD、KMZ、PCD、VRML、XYZ、VTK 等模型提供浏览器端只读预览。
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
- 也可以和其他 renderer 一起组合:
23
+ 也可以把 `modelRenderer` 与 CAD 等 renderer 组合,或直接使用已经聚合它的 `@file-viewer/preset-all`。full 包和 `@file-viewer/vite-plugin` 会准备默认离线资产;只安装 renderer 时,需要自行托管 OCCT Worker、runtime、WASM 和两个许可证文件。
18
24
 
19
- ```ts
20
- import { modelRenderer } from '@file-viewer/renderer-3d'
21
- import { cadRenderer } from '@file-viewer/renderer-cad'
25
+ 默认路径为:
22
26
 
23
- const options = {
24
- rendererMode: 'replace',
25
- renderers: [modelRenderer, cadRenderer],
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
- - 支持 WebGL 交互预览、轨道控制、适配视图、网格、坐标轴、线框和自动旋转。
32
- - `gltf` / `dae` / `fbx` 等带外部贴图或二进制资源的格式,会以原始 `url` 的目录作为资源基准继续加载。
33
- - STEP、IGES、IFC、3DM、BREP 等工程 B-Rep / BIM 格式入口会通过 `@file-viewer/geometry-engine` 做轻量签名识别和明确转换说明;完整解析应继续沿 OpenCascade / web-ifc / rhino3dm 独立 WASM renderer 路线演进。
34
- - `@file-viewer/core` 已不再内置 3D renderer,也不再直接依赖 `three`。需要 3D 模型预览时,请显式安装本包,或使用会自动聚合本包的 `@file-viewer/preset-all`。
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
- disposeMaterial(mesh.material);
165
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
166
+ materials.forEach(disposeMaterial);
88
167
  }
89
168
  if (points.material) {
90
- disposeMaterial(points.material);
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 clock = new THREE.Clock();
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 getModelViewState = () => ({
191
- renderer: 'model',
192
- extra: {
193
- status,
194
- cameraPosition: vectorToArray(camera?.position),
195
- cameraTarget: vectorToArray(controls?.target),
196
- autoRotate,
197
- wireframe,
198
- showGrid,
199
- showAxes,
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
- scene.background = new THREE.Color(0xf8fafc);
265
- const hemiLight = new THREE.HemisphereLight(0xffffff, 0xd7dee8, 2.4);
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 radius = Math.max(size.x, size.y, size.z, 1);
378
- const distance = radius / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) * 1.65;
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).add(new THREE.Vector3(distance, distance * 0.62, distance));
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
- clock.start();
998
+ timer.connect(target.ownerDocument);
656
999
  renderFrame();
657
- void loadModel();
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
- clock.stop();
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.1.29",
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.1.29",
61
- "@file-viewer/geometry-engine": "2.1.29",
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": {