@nice2dev/ui-3d 1.3.14 → 1.3.16

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cjs/collaborative/collaborativeScene.js +160 -206
  3. package/dist/cjs/core/editorAsk.js +51 -41
  4. package/dist/cjs/core/i18n.js +13 -14
  5. package/dist/cjs/core/threeCompat.js +29 -42
  6. package/dist/cjs/dance/DanceBridge.js +148 -177
  7. package/dist/cjs/dance/DanceScoreEngine.js +209 -221
  8. package/dist/cjs/dance/PoseDetector.js +302 -275
  9. package/dist/cjs/index.js +110 -113
  10. package/dist/cjs/material/MaterialEditor.module.css.js +242 -5
  11. package/dist/cjs/material/NiceMaterialEditor.js +1213 -757
  12. package/dist/cjs/material/materialEditorTypes.js +35 -49
  13. package/dist/cjs/material/materialEditorUtils.js +684 -747
  14. package/dist/cjs/material/materialNodeDefinitions.js +1411 -1459
  15. package/dist/cjs/model/ModelEditor.js +90 -40
  16. package/dist/cjs/model/ModelEditor.module.css.js +257 -5
  17. package/dist/cjs/model/ModelEditorLeftPanel.js +126 -47
  18. package/dist/cjs/model/ModelEditorMenuBar.js +359 -32
  19. package/dist/cjs/model/ModelEditorRightPanel.js +647 -131
  20. package/dist/cjs/model/ModelEditorSubComponents.js +277 -112
  21. package/dist/cjs/model/ModelEditorTimeline.js +232 -34
  22. package/dist/cjs/model/ModelEditorToolbar.js +170 -14
  23. package/dist/cjs/model/ModelEditorViewport.js +231 -39
  24. package/dist/cjs/model/ModelViewer.js +388 -63
  25. package/dist/cjs/model/ModelViewer.module.css.js +77 -5
  26. package/dist/cjs/model/NiceArmatureEditor.js +473 -274
  27. package/dist/cjs/model/NiceMorphTargetEditor.js +333 -227
  28. package/dist/cjs/model/NiceOctree.js +372 -320
  29. package/dist/cjs/model/NicePhysicsSimulation.js +528 -298
  30. package/dist/cjs/model/NiceProceduralGeometry.js +673 -300
  31. package/dist/cjs/model/NiceTerrainEditor.js +595 -385
  32. package/dist/cjs/model/NiceWeightPainter.js +384 -261
  33. package/dist/cjs/model/NiceXRPreview.js +385 -308
  34. package/dist/cjs/model/cadModeUtils.js +103 -106
  35. package/dist/cjs/model/editorShortcuts.js +171 -194
  36. package/dist/cjs/model/modelEditorTypes.js +100 -118
  37. package/dist/cjs/model/modelEditorUtils.js +1061 -1146
  38. package/dist/cjs/model/simsModeUtils.js +1023 -1053
  39. package/dist/cjs/model/useModelEditor.js +1977 -1933
  40. package/dist/cjs/model/useModelViewer.js +633 -665
  41. package/dist/cjs/nice2dev-ui-3d.css +1 -1
  42. package/dist/cjs/particle/NiceParticleEditor.js +1186 -571
  43. package/dist/cjs/particle/ParticleEditor.module.css.js +122 -5
  44. package/dist/cjs/particle/particleEditorTypes.js +62 -70
  45. package/dist/cjs/particle/particleEditorUtils.js +998 -1013
  46. package/dist/cjs/rendering/NiceCascadedShadows.js +381 -259
  47. package/dist/cjs/rendering/NiceRenderExport.js +424 -337
  48. package/dist/cjs/rendering/NiceSSAO.js +454 -337
  49. package/dist/cjs/rendering/NiceSSR.js +387 -261
  50. package/dist/cjs/rendering/NiceWebGPURenderer.js +359 -230
  51. package/dist/cjs/uv/NiceUVEditor.js +787 -550
  52. package/dist/cjs/uv/UVEditor.module.css.js +53 -5
  53. package/dist/cjs/uv/uvEditorTypes.js +59 -76
  54. package/dist/cjs/uv/uvEditorUtils.js +501 -575
  55. package/dist/esm/collaborative/collaborativeScene.js +163 -205
  56. package/dist/esm/core/editorAsk.js +53 -40
  57. package/dist/esm/core/i18n.js +15 -13
  58. package/dist/esm/core/threeCompat.js +18 -26
  59. package/dist/esm/dance/DanceBridge.js +151 -176
  60. package/dist/esm/dance/DanceScoreEngine.js +211 -220
  61. package/dist/esm/dance/PoseDetector.js +304 -274
  62. package/dist/esm/index.js +258 -36
  63. package/dist/esm/material/MaterialEditor.module.css.js +242 -3
  64. package/dist/esm/material/NiceMaterialEditor.js +1215 -756
  65. package/dist/esm/material/materialEditorTypes.js +45 -48
  66. package/dist/esm/material/materialEditorUtils.js +713 -746
  67. package/dist/esm/material/materialNodeDefinitions.js +1415 -1458
  68. package/dist/esm/model/ModelEditor.js +91 -39
  69. package/dist/esm/model/ModelEditor.module.css.js +257 -3
  70. package/dist/esm/model/ModelEditorLeftPanel.js +126 -45
  71. package/dist/esm/model/ModelEditorMenuBar.js +359 -30
  72. package/dist/esm/model/ModelEditorRightPanel.js +634 -114
  73. package/dist/esm/model/ModelEditorSubComponents.js +265 -94
  74. package/dist/esm/model/ModelEditorTimeline.js +232 -32
  75. package/dist/esm/model/ModelEditorToolbar.js +170 -12
  76. package/dist/esm/model/ModelEditorViewport.js +231 -37
  77. package/dist/esm/model/ModelViewer.js +389 -62
  78. package/dist/esm/model/ModelViewer.module.css.js +77 -3
  79. package/dist/esm/model/NiceArmatureEditor.js +462 -258
  80. package/dist/esm/model/NiceMorphTargetEditor.js +322 -211
  81. package/dist/esm/model/NiceOctree.js +361 -304
  82. package/dist/esm/model/NicePhysicsSimulation.js +517 -282
  83. package/dist/esm/model/NiceProceduralGeometry.js +667 -284
  84. package/dist/esm/model/NiceTerrainEditor.js +584 -369
  85. package/dist/esm/model/NiceWeightPainter.js +373 -245
  86. package/dist/esm/model/NiceXRPreview.js +374 -292
  87. package/dist/esm/model/cadModeUtils.js +95 -88
  88. package/dist/esm/model/editorShortcuts.js +172 -193
  89. package/dist/esm/model/modelEditorTypes.js +90 -100
  90. package/dist/esm/model/modelEditorUtils.js +1056 -1128
  91. package/dist/esm/model/simsModeUtils.js +1021 -1035
  92. package/dist/esm/model/useModelEditor.js +1946 -1920
  93. package/dist/esm/model/useModelViewer.js +619 -647
  94. package/dist/esm/nice2dev-ui-3d.css +1 -1
  95. package/dist/esm/particle/NiceParticleEditor.js +1188 -570
  96. package/dist/esm/particle/ParticleEditor.module.css.js +122 -3
  97. package/dist/esm/particle/particleEditorTypes.js +69 -69
  98. package/dist/esm/particle/particleEditorUtils.js +1027 -1012
  99. package/dist/esm/rendering/NiceCascadedShadows.js +370 -243
  100. package/dist/esm/rendering/NiceRenderExport.js +413 -321
  101. package/dist/esm/rendering/NiceSSAO.js +450 -328
  102. package/dist/esm/rendering/NiceSSR.js +383 -252
  103. package/dist/esm/rendering/NiceWebGPURenderer.js +326 -214
  104. package/dist/esm/uv/NiceUVEditor.js +788 -549
  105. package/dist/esm/uv/UVEditor.module.css.js +53 -3
  106. package/dist/esm/uv/uvEditorTypes.js +68 -75
  107. package/dist/esm/uv/uvEditorUtils.js +515 -557
  108. package/package.json +7 -12
@@ -1,657 +1,629 @@
1
- import { resolveCssColor, getInputEngine } from '@nice2dev/ui';
2
- import { useRef, useState, useEffect, useCallback } from 'react';
3
- import * as THREE from 'three';
4
- import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
5
- import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js';
6
- import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
7
- import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
8
- import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
9
- import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
10
- import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
11
- import { useNiceTranslation } from '../core/i18n.js';
12
- import { createFrameClock, SOFT_SHADOW_MAP } from '../core/threeCompat.js';
13
-
14
- /**
15
- * useModelViewer — lightweight hook for the view-only 3D model viewer.
16
- *
17
- * Unlike useModelEditor this hook provides NO editing — just loading,
18
- * orbit/pan/zoom navigation, optional WASD first-person camera,
19
- * gamepad support, VR mode, annotations and measurement tools.
20
- *
21
- * Designed for ERP real-estate / rental-space visualisation and
22
- * product presentation.
23
- */
24
- /* ═══════════════════════════════════════════
25
- Quality presets
26
- ═══════════════════════════════════════════ */
1
+ import { resolveCssColor, getInputEngine } from "@nice2dev/ui";
2
+ import { useRef, useState, useEffect, useCallback } from "react";
3
+ import * as THREE from "three";
4
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
5
+ import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
6
+ import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
7
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
8
+ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
9
+ import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
10
+ import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
11
+ import { useNiceTranslation } from "../core/i18n.js";
12
+ import { createFrameClock, SOFT_SHADOW_MAP } from "../core/threeCompat.js";
27
13
  const QUALITY_MAP = {
28
- low: { pixelRatio: 0.75, shadowMapSize: 512, antialias: false },
29
- medium: { pixelRatio: 1, shadowMapSize: 1024, antialias: true },
30
- high: { pixelRatio: 1, shadowMapSize: 2048, antialias: true },
31
- ultra: { pixelRatio: 2, shadowMapSize: 4096, antialias: true },
14
+ low: { pixelRatio: 0.75, shadowMapSize: 512, antialias: false },
15
+ medium: { pixelRatio: 1, shadowMapSize: 1024, antialias: true },
16
+ high: { pixelRatio: 1, shadowMapSize: 2048, antialias: true },
17
+ ultra: { pixelRatio: 2, shadowMapSize: 4096, antialias: true }
32
18
  };
33
19
  const TONE_MAP = {
34
- linear: THREE.LinearToneMapping,
35
- reinhard: THREE.ReinhardToneMapping,
36
- cineon: THREE.CineonToneMapping,
37
- aces: THREE.ACESFilmicToneMapping,
38
- agx: THREE.AgXToneMapping,
20
+ linear: THREE.LinearToneMapping,
21
+ reinhard: THREE.ReinhardToneMapping,
22
+ cineon: THREE.CineonToneMapping,
23
+ aces: THREE.ACESFilmicToneMapping,
24
+ agx: THREE.AgXToneMapping
39
25
  };
40
- /* ═══════════════════════════════════════════
41
- Hook
42
- ═══════════════════════════════════════════ */
43
26
  function useModelViewer(props = {}) {
44
- var _a;
45
- const { t } = useNiceTranslation();
46
- const { src, format, navigationMode: initNavMode = 'orbit', quality = 'high', autoRotateSpeed: initAutoRotate = 0, backgroundColor = 'var(--nice-bg, #1a1a2e)', showGrid: initShowGrid = true, shadows = true, gamepad = false, cameraPresets: _cameraPresets = [], annotations: _annotations = [], hotspots: _hotspots = [], floorPlans: _floorPlans = [], onHotspotClick: _onHotspotClick, onAnnotationClick: _onAnnotationClick, onLoadProgress, onLoad, onError, clippingPlanes = [], measureMode: initMeasureMode = false, measurements: _measurements = [], onMeasure: _onMeasure, minDistance = 0.1, maxDistance = 1000, fov = 50, vr: _vr = false, walkSpeed = 5, ambientIntensity = 0.5, directionalIntensity = 0.8, antiAlias = true, toneMapping = 'aces', exposure = 1, } = props;
47
- /* ── refs ── */
48
- const mountRef = useRef(null);
49
- const rendererRef = useRef(null);
50
- const sceneRef = useRef(new THREE.Scene());
51
- const cameraRef = useRef(new THREE.PerspectiveCamera(fov, 1, 0.01, 10000));
52
- const orbitRef = useRef(null);
53
- // Created once: useRef's argument runs on every render, and three r183 warns per Clock.
54
- const clockRef = useRef(null);
55
- (_a = clockRef.current) !== null && _a !== void 0 ? _a : (clockRef.current = createFrameClock());
56
- const mixerRef = useRef(null);
57
- const rafRef = useRef(0);
58
- const rootRef = useRef(null);
59
- const gridRef = useRef(null);
60
- const clipsRef = useRef([]);
61
- const keysRef = useRef(new Set());
62
- useRef(null);
63
- const disposeRef = useRef(false);
64
- /* ── state ── */
65
- const [loading, setLoading] = useState(false);
66
- const [loadProgress, setLoadProgress] = useState(0);
67
- const [error, setError] = useState(null);
68
- const [navMode, setNavMode] = useState(initNavMode);
69
- const [projection, setProjection] = useState('perspective');
70
- const [wireframe, setWireframe] = useState(false);
71
- const [gridVisible, setGridVisible] = useState(initShowGrid);
72
- const [autoRotate, setAutoRotate] = useState(initAutoRotate);
73
- const [polyCount, setPolyCount] = useState(0);
74
- const [meshCount, setMeshCount] = useState(0);
75
- const [animNames, setAnimNames] = useState([]);
76
- const [activeAnimIdx, setActiveAnimIdx] = useState(-1);
77
- const [isPlaying, setIsPlaying] = useState(false);
78
- const [animTime, setAnimTime] = useState(0);
79
- const [animDuration, setAnimDuration] = useState(0);
80
- const [animSpeed, setAnimSpeed] = useState(1);
81
- const [isFullscreen, setIsFullscreen] = useState(false);
82
- const [activeFloorPlanId, setActiveFloorPlanId] = useState(null);
83
- const [annotationsVisible, setAnnotationsVisible] = useState(true);
84
- const [clippingEnabled, setClippingEnabled] = useState(clippingPlanes.length > 0);
85
- const [clippingPosition, setClippingPosition] = useState(0);
86
- const [clippingAxis, setClippingAxis] = useState('y');
87
- const [measureModeActive, setMeasureModeActive] = useState(initMeasureMode);
88
- const [dimensions, setDimensions] = useState(null);
89
- const [statusText, setStatusText] = useState('Ready');
90
- /* ── scene setup ── */
91
- useEffect(() => {
92
- var _a;
93
- const mount = mountRef.current;
94
- if (!mount) {
95
- return;
96
- }
97
- disposeRef.current = false;
98
- const q = QUALITY_MAP[quality];
99
- const renderer = new THREE.WebGLRenderer({
100
- antialias: q.antialias && antiAlias,
101
- alpha: backgroundColor === 'transparent',
102
- preserveDrawingBuffer: true,
103
- });
104
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, q.pixelRatio));
105
- renderer.setSize(mount.clientWidth, mount.clientHeight);
106
- renderer.toneMapping = (_a = TONE_MAP[toneMapping]) !== null && _a !== void 0 ? _a : THREE.ACESFilmicToneMapping;
107
- renderer.toneMappingExposure = exposure;
108
- renderer.outputColorSpace = THREE.SRGBColorSpace;
109
- if (shadows) {
110
- renderer.shadowMap.enabled = true;
111
- renderer.shadowMap.type = SOFT_SHADOW_MAP;
112
- }
113
- mount.appendChild(renderer.domElement);
114
- rendererRef.current = renderer;
115
- const scene = sceneRef.current;
116
- if (backgroundColor !== 'transparent') {
117
- // The default is a var() token, which THREE.Color cannot parse (it warns and paints
118
- // white); resolve it against the viewer first.
119
- scene.background = new THREE.Color(resolveCssColor(backgroundColor, mount));
120
- }
121
- // Lights
122
- const amb = new THREE.AmbientLight(0xffffff, ambientIntensity);
123
- scene.add(amb);
124
- const dir = new THREE.DirectionalLight(0xffffff, directionalIntensity);
125
- dir.position.set(5, 10, 7);
126
- if (shadows) {
127
- dir.castShadow = true;
128
- dir.shadow.mapSize.setScalar(q.shadowMapSize);
129
- }
130
- scene.add(dir);
131
- const hemi = new THREE.HemisphereLight(0xaaccff, 0x445522, 0.3);
132
- scene.add(hemi);
133
- // Grid
134
- const grid = new THREE.GridHelper(50, 50, 0x444444, 0x333333);
135
- grid.visible = initShowGrid;
136
- scene.add(grid);
137
- gridRef.current = grid;
138
- // Camera
139
- const camera = cameraRef.current;
140
- camera.position.set(5, 4, 8);
141
- camera.lookAt(0, 0, 0);
142
- // OrbitControls
143
- const orbit = new OrbitControls(camera, renderer.domElement);
144
- orbit.enableDamping = true;
145
- orbit.dampingFactor = 0.08;
146
- orbit.minDistance = minDistance;
147
- orbit.maxDistance = maxDistance;
148
- orbit.autoRotate = initAutoRotate > 0;
149
- orbit.autoRotateSpeed = initAutoRotate;
150
- orbitRef.current = orbit;
151
- // Resize observer
152
- const ro = new ResizeObserver(() => {
153
- if (!mount || disposeRef.current) {
154
- return;
155
- }
156
- const w = mount.clientWidth;
157
- const h = mount.clientHeight;
158
- camera.aspect = w / h;
159
- camera.updateProjectionMatrix();
160
- renderer.setSize(w, h);
161
- });
162
- ro.observe(mount);
163
- // Animation loop
164
- const animate = () => {
165
- var _a, _b, _c, _d, _e, _f;
166
- if (disposeRef.current) {
167
- return;
168
- }
169
- rafRef.current = requestAnimationFrame(animate);
170
- const dt = (_b = (_a = clockRef.current) === null || _a === void 0 ? void 0 : _a.getDelta()) !== null && _b !== void 0 ? _b : 0;
171
- // Gamepad FPS-style nav
172
- if (gamepad && navMode === 'fps') {
173
- // The pads the input engine read this frame (1.3.0: the engine is the only pad reader).
174
- const gp = (_c = getInputEngine().gamepadSnapshot[0]) !== null && _c !== void 0 ? _c : null;
175
- if (gp) {
176
- const lx = (_d = gp.axes[0]) !== null && _d !== void 0 ? _d : 0;
177
- const ly = (_e = gp.axes[1]) !== null && _e !== void 0 ? _e : 0;
178
- const rx = (_f = gp.axes[2]) !== null && _f !== void 0 ? _f : 0;
179
- const fwd = new THREE.Vector3();
180
- camera.getWorldDirection(fwd);
181
- fwd.y = 0;
182
- fwd.normalize();
183
- const right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
184
- camera.position.addScaledVector(fwd, -ly * walkSpeed * dt);
185
- camera.position.addScaledVector(right, lx * walkSpeed * dt);
186
- orbit.target.addScaledVector(fwd, -ly * walkSpeed * dt);
187
- orbit.target.addScaledVector(right, lx * walkSpeed * dt);
188
- camera.rotateY(-rx * 2 * dt);
189
- }
190
- }
191
- // Keyboard WASD
192
- if (navMode === 'fps' || navMode === 'walkthrough') {
193
- const fwd = new THREE.Vector3();
194
- camera.getWorldDirection(fwd);
195
- fwd.y = 0;
196
- fwd.normalize();
197
- const right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
198
- const speed = walkSpeed * dt;
199
- if (keysRef.current.has('w') || keysRef.current.has('arrowup')) {
200
- camera.position.addScaledVector(fwd, speed);
201
- orbit.target.addScaledVector(fwd, speed);
202
- }
203
- if (keysRef.current.has('s') || keysRef.current.has('arrowdown')) {
204
- camera.position.addScaledVector(fwd, -speed);
205
- orbit.target.addScaledVector(fwd, -speed);
206
- }
207
- if (keysRef.current.has('a') || keysRef.current.has('arrowleft')) {
208
- camera.position.addScaledVector(right, -speed);
209
- orbit.target.addScaledVector(right, -speed);
210
- }
211
- if (keysRef.current.has('d') || keysRef.current.has('arrowright')) {
212
- camera.position.addScaledVector(right, speed);
213
- orbit.target.addScaledVector(right, speed);
214
- }
215
- if (keysRef.current.has('q')) {
216
- camera.position.y += speed;
217
- orbit.target.y += speed;
218
- }
219
- if (keysRef.current.has('e')) {
220
- camera.position.y -= speed;
221
- orbit.target.y -= speed;
222
- }
223
- }
224
- if (mixerRef.current) {
225
- mixerRef.current.update(dt);
226
- }
227
- orbit.update();
228
- renderer.render(scene, camera);
229
- };
230
- animate();
231
- // Keyboard listeners
232
- const onKeyDown = (e) => keysRef.current.add(e.key.toLowerCase());
233
- const onKeyUp = (e) => keysRef.current.delete(e.key.toLowerCase());
234
- mount.addEventListener('keydown', onKeyDown);
235
- mount.addEventListener('keyup', onKeyUp);
236
- // Fullscreen
237
- const onFSChange = () => setIsFullscreen(!!document.fullscreenElement);
238
- document.addEventListener('fullscreenchange', onFSChange);
239
- return () => {
240
- disposeRef.current = true;
241
- cancelAnimationFrame(rafRef.current);
242
- ro.disconnect();
243
- orbit.dispose();
244
- renderer.dispose();
245
- mount.removeEventListener('keydown', onKeyDown);
246
- mount.removeEventListener('keyup', onKeyUp);
247
- document.removeEventListener('fullscreenchange', onFSChange);
248
- if (mount.contains(renderer.domElement)) {
249
- mount.removeChild(renderer.domElement);
250
- }
251
- };
252
- // eslint-disable-next-line react-hooks/exhaustive-deps
253
- }, []);
254
- /* ── update grid visibility ── */
255
- useEffect(() => {
256
- if (gridRef.current) {
257
- gridRef.current.visible = gridVisible;
258
- }
259
- }, [gridVisible]);
260
- /* ── update auto-rotate ── */
261
- useEffect(() => {
262
- if (orbitRef.current) {
263
- orbitRef.current.autoRotate = autoRotate > 0;
264
- orbitRef.current.autoRotateSpeed = autoRotate;
265
- }
266
- }, [autoRotate]);
267
- /* ── wireframe toggle ── */
268
- useEffect(() => {
269
- if (!rootRef.current) {
270
- return;
271
- }
272
- rootRef.current.traverse((c) => {
273
- if (c.isMesh) {
274
- const mat = c.material;
275
- const mats = Array.isArray(mat) ? mat : [mat];
276
- mats.forEach((m) => {
277
- if ('wireframe' in m) {
278
- m.wireframe = wireframe;
279
- }
280
- });
281
- }
27
+ const { t } = useNiceTranslation();
28
+ const {
29
+ src,
30
+ format,
31
+ navigationMode: initNavMode = "orbit",
32
+ quality = "high",
33
+ autoRotateSpeed: initAutoRotate = 0,
34
+ backgroundColor = "var(--nice-bg, #1a1a2e)",
35
+ showGrid: initShowGrid = true,
36
+ shadows = true,
37
+ gamepad = false,
38
+ cameraPresets: _cameraPresets = [],
39
+ annotations: _annotations = [],
40
+ hotspots: _hotspots = [],
41
+ floorPlans: _floorPlans = [],
42
+ onHotspotClick: _onHotspotClick,
43
+ onAnnotationClick: _onAnnotationClick,
44
+ onLoadProgress,
45
+ onLoad,
46
+ onError,
47
+ clippingPlanes = [],
48
+ measureMode: initMeasureMode = false,
49
+ measurements: _measurements = [],
50
+ onMeasure: _onMeasure,
51
+ minDistance = 0.1,
52
+ maxDistance = 1e3,
53
+ fov = 50,
54
+ vr: _vr = false,
55
+ walkSpeed = 5,
56
+ ambientIntensity = 0.5,
57
+ directionalIntensity = 0.8,
58
+ antiAlias = true,
59
+ toneMapping = "aces",
60
+ exposure = 1
61
+ } = props;
62
+ const mountRef = useRef(null);
63
+ const rendererRef = useRef(null);
64
+ const sceneRef = useRef(new THREE.Scene());
65
+ const cameraRef = useRef(new THREE.PerspectiveCamera(fov, 1, 0.01, 1e4));
66
+ const orbitRef = useRef(null);
67
+ const clockRef = useRef(null);
68
+ clockRef.current ?? (clockRef.current = createFrameClock());
69
+ const mixerRef = useRef(null);
70
+ const rafRef = useRef(0);
71
+ const rootRef = useRef(null);
72
+ const gridRef = useRef(null);
73
+ const clipsRef = useRef([]);
74
+ const keysRef = useRef(/* @__PURE__ */ new Set());
75
+ useRef(null);
76
+ const disposeRef = useRef(false);
77
+ const [loading, setLoading] = useState(false);
78
+ const [loadProgress, setLoadProgress] = useState(0);
79
+ const [error, setError] = useState(null);
80
+ const [navMode, setNavMode] = useState(initNavMode);
81
+ const [projection, setProjection] = useState("perspective");
82
+ const [wireframe, setWireframe] = useState(false);
83
+ const [gridVisible, setGridVisible] = useState(initShowGrid);
84
+ const [autoRotate, setAutoRotate] = useState(initAutoRotate);
85
+ const [polyCount, setPolyCount] = useState(0);
86
+ const [meshCount, setMeshCount] = useState(0);
87
+ const [animNames, setAnimNames] = useState([]);
88
+ const [activeAnimIdx, setActiveAnimIdx] = useState(-1);
89
+ const [isPlaying, setIsPlaying] = useState(false);
90
+ const [animTime, setAnimTime] = useState(0);
91
+ const [animDuration, setAnimDuration] = useState(0);
92
+ const [animSpeed, setAnimSpeed] = useState(1);
93
+ const [isFullscreen, setIsFullscreen] = useState(false);
94
+ const [activeFloorPlanId, setActiveFloorPlanId] = useState(null);
95
+ const [annotationsVisible, setAnnotationsVisible] = useState(true);
96
+ const [clippingEnabled, setClippingEnabled] = useState(clippingPlanes.length > 0);
97
+ const [clippingPosition, setClippingPosition] = useState(0);
98
+ const [clippingAxis, setClippingAxis] = useState("y");
99
+ const [measureModeActive, setMeasureModeActive] = useState(initMeasureMode);
100
+ const [dimensions, setDimensions] = useState(null);
101
+ const [statusText, setStatusText] = useState("Ready");
102
+ useEffect(() => {
103
+ const mount = mountRef.current;
104
+ if (!mount) {
105
+ return;
106
+ }
107
+ disposeRef.current = false;
108
+ const q = QUALITY_MAP[quality];
109
+ const renderer = new THREE.WebGLRenderer({
110
+ antialias: q.antialias && antiAlias,
111
+ alpha: backgroundColor === "transparent",
112
+ preserveDrawingBuffer: true
113
+ });
114
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, q.pixelRatio));
115
+ renderer.setSize(mount.clientWidth, mount.clientHeight);
116
+ renderer.toneMapping = TONE_MAP[toneMapping] ?? THREE.ACESFilmicToneMapping;
117
+ renderer.toneMappingExposure = exposure;
118
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
119
+ if (shadows) {
120
+ renderer.shadowMap.enabled = true;
121
+ renderer.shadowMap.type = SOFT_SHADOW_MAP;
122
+ }
123
+ mount.appendChild(renderer.domElement);
124
+ rendererRef.current = renderer;
125
+ const scene = sceneRef.current;
126
+ if (backgroundColor !== "transparent") {
127
+ scene.background = new THREE.Color(resolveCssColor(backgroundColor, mount));
128
+ }
129
+ const amb = new THREE.AmbientLight(16777215, ambientIntensity);
130
+ scene.add(amb);
131
+ const dir = new THREE.DirectionalLight(16777215, directionalIntensity);
132
+ dir.position.set(5, 10, 7);
133
+ if (shadows) {
134
+ dir.castShadow = true;
135
+ dir.shadow.mapSize.setScalar(q.shadowMapSize);
136
+ }
137
+ scene.add(dir);
138
+ const hemi = new THREE.HemisphereLight(11193599, 4478242, 0.3);
139
+ scene.add(hemi);
140
+ const grid = new THREE.GridHelper(50, 50, 4473924, 3355443);
141
+ grid.visible = initShowGrid;
142
+ scene.add(grid);
143
+ gridRef.current = grid;
144
+ const camera = cameraRef.current;
145
+ camera.position.set(5, 4, 8);
146
+ camera.lookAt(0, 0, 0);
147
+ const orbit = new OrbitControls(camera, renderer.domElement);
148
+ orbit.enableDamping = true;
149
+ orbit.dampingFactor = 0.08;
150
+ orbit.minDistance = minDistance;
151
+ orbit.maxDistance = maxDistance;
152
+ orbit.autoRotate = initAutoRotate > 0;
153
+ orbit.autoRotateSpeed = initAutoRotate;
154
+ orbitRef.current = orbit;
155
+ const ro = new ResizeObserver(() => {
156
+ if (!mount || disposeRef.current) {
157
+ return;
158
+ }
159
+ const w = mount.clientWidth;
160
+ const h = mount.clientHeight;
161
+ camera.aspect = w / h;
162
+ camera.updateProjectionMatrix();
163
+ renderer.setSize(w, h);
164
+ });
165
+ ro.observe(mount);
166
+ const animate = () => {
167
+ if (disposeRef.current) {
168
+ return;
169
+ }
170
+ rafRef.current = requestAnimationFrame(animate);
171
+ const dt = clockRef.current?.getDelta() ?? 0;
172
+ if (gamepad && navMode === "fps") {
173
+ const gp = getInputEngine().gamepadSnapshot[0] ?? null;
174
+ if (gp) {
175
+ const lx = gp.axes[0] ?? 0;
176
+ const ly = gp.axes[1] ?? 0;
177
+ const rx = gp.axes[2] ?? 0;
178
+ const fwd = new THREE.Vector3();
179
+ camera.getWorldDirection(fwd);
180
+ fwd.y = 0;
181
+ fwd.normalize();
182
+ const right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
183
+ camera.position.addScaledVector(fwd, -ly * walkSpeed * dt);
184
+ camera.position.addScaledVector(right, lx * walkSpeed * dt);
185
+ orbit.target.addScaledVector(fwd, -ly * walkSpeed * dt);
186
+ orbit.target.addScaledVector(right, lx * walkSpeed * dt);
187
+ camera.rotateY(-rx * 2 * dt);
188
+ }
189
+ }
190
+ if (navMode === "fps" || navMode === "walkthrough") {
191
+ const fwd = new THREE.Vector3();
192
+ camera.getWorldDirection(fwd);
193
+ fwd.y = 0;
194
+ fwd.normalize();
195
+ const right = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
196
+ const speed = walkSpeed * dt;
197
+ if (keysRef.current.has("w") || keysRef.current.has("arrowup")) {
198
+ camera.position.addScaledVector(fwd, speed);
199
+ orbit.target.addScaledVector(fwd, speed);
200
+ }
201
+ if (keysRef.current.has("s") || keysRef.current.has("arrowdown")) {
202
+ camera.position.addScaledVector(fwd, -speed);
203
+ orbit.target.addScaledVector(fwd, -speed);
204
+ }
205
+ if (keysRef.current.has("a") || keysRef.current.has("arrowleft")) {
206
+ camera.position.addScaledVector(right, -speed);
207
+ orbit.target.addScaledVector(right, -speed);
208
+ }
209
+ if (keysRef.current.has("d") || keysRef.current.has("arrowright")) {
210
+ camera.position.addScaledVector(right, speed);
211
+ orbit.target.addScaledVector(right, speed);
212
+ }
213
+ if (keysRef.current.has("q")) {
214
+ camera.position.y += speed;
215
+ orbit.target.y += speed;
216
+ }
217
+ if (keysRef.current.has("e")) {
218
+ camera.position.y -= speed;
219
+ orbit.target.y -= speed;
220
+ }
221
+ }
222
+ if (mixerRef.current) {
223
+ mixerRef.current.update(dt);
224
+ }
225
+ orbit.update();
226
+ renderer.render(scene, camera);
227
+ };
228
+ animate();
229
+ const onKeyDown = (e) => keysRef.current.add(e.key.toLowerCase());
230
+ const onKeyUp = (e) => keysRef.current.delete(e.key.toLowerCase());
231
+ mount.addEventListener("keydown", onKeyDown);
232
+ mount.addEventListener("keyup", onKeyUp);
233
+ const onFSChange = () => setIsFullscreen(!!document.fullscreenElement);
234
+ document.addEventListener("fullscreenchange", onFSChange);
235
+ return () => {
236
+ disposeRef.current = true;
237
+ cancelAnimationFrame(rafRef.current);
238
+ ro.disconnect();
239
+ orbit.dispose();
240
+ renderer.dispose();
241
+ mount.removeEventListener("keydown", onKeyDown);
242
+ mount.removeEventListener("keyup", onKeyUp);
243
+ document.removeEventListener("fullscreenchange", onFSChange);
244
+ if (mount.contains(renderer.domElement)) {
245
+ mount.removeChild(renderer.domElement);
246
+ }
247
+ };
248
+ }, []);
249
+ useEffect(() => {
250
+ if (gridRef.current) {
251
+ gridRef.current.visible = gridVisible;
252
+ }
253
+ }, [gridVisible]);
254
+ useEffect(() => {
255
+ if (orbitRef.current) {
256
+ orbitRef.current.autoRotate = autoRotate > 0;
257
+ orbitRef.current.autoRotateSpeed = autoRotate;
258
+ }
259
+ }, [autoRotate]);
260
+ useEffect(() => {
261
+ if (!rootRef.current) {
262
+ return;
263
+ }
264
+ rootRef.current.traverse((c) => {
265
+ if (c.isMesh) {
266
+ const mat = c.material;
267
+ const mats = Array.isArray(mat) ? mat : [mat];
268
+ mats.forEach((m) => {
269
+ if ("wireframe" in m) {
270
+ m.wireframe = wireframe;
271
+ }
282
272
  });
283
- }, [wireframe]);
284
- /* ── clipping ── */
285
- useEffect(() => {
286
- const renderer = rendererRef.current;
287
- if (!renderer) {
288
- return;
289
- }
290
- if (clippingEnabled) {
291
- const normal = new THREE.Vector3(clippingAxis === 'x' ? 1 : 0, clippingAxis === 'y' ? 1 : 0, clippingAxis === 'z' ? 1 : 0);
292
- const plane = new THREE.Plane(normal, -clippingPosition);
293
- renderer.clippingPlanes = [plane];
294
- }
295
- else {
296
- renderer.clippingPlanes = [];
297
- }
298
- }, [clippingEnabled, clippingPosition, clippingAxis]);
299
- /* ── counting helpers ── */
300
- const countScene = useCallback(() => {
301
- let tris = 0;
302
- let meshes = 0;
303
- sceneRef.current.traverse((c) => {
273
+ }
274
+ });
275
+ }, [wireframe]);
276
+ useEffect(() => {
277
+ const renderer = rendererRef.current;
278
+ if (!renderer) {
279
+ return;
280
+ }
281
+ if (clippingEnabled) {
282
+ const normal = new THREE.Vector3(
283
+ clippingAxis === "x" ? 1 : 0,
284
+ clippingAxis === "y" ? 1 : 0,
285
+ clippingAxis === "z" ? 1 : 0
286
+ );
287
+ const plane = new THREE.Plane(normal, -clippingPosition);
288
+ renderer.clippingPlanes = [plane];
289
+ } else {
290
+ renderer.clippingPlanes = [];
291
+ }
292
+ }, [clippingEnabled, clippingPosition, clippingAxis]);
293
+ const countScene = useCallback(() => {
294
+ let tris = 0;
295
+ let meshes = 0;
296
+ sceneRef.current.traverse((c) => {
297
+ if (c.isMesh) {
298
+ meshes++;
299
+ const geo = c.geometry;
300
+ if (geo.index) {
301
+ tris += geo.index.count / 3;
302
+ } else if (geo.attributes.position) {
303
+ tris += geo.attributes.position.count / 3;
304
+ }
305
+ }
306
+ });
307
+ setPolyCount(Math.round(tris));
308
+ setMeshCount(meshes);
309
+ }, []);
310
+ const detectFormat = useCallback((url, hint) => {
311
+ if (hint) {
312
+ return hint;
313
+ }
314
+ const ext = url.split(".").pop()?.split("?")[0]?.toLowerCase() ?? "";
315
+ return ext;
316
+ }, []);
317
+ const loadModel = useCallback(
318
+ async (url, fmt) => {
319
+ setLoading(true);
320
+ setLoadProgress(0);
321
+ setError(null);
322
+ setStatusText(t("useModelViewer.loading", "Loading…"));
323
+ const detectedFmt = detectFormat(url, fmt);
324
+ const onProgress = (e) => {
325
+ if (e.lengthComputable) {
326
+ const p = e.loaded / e.total;
327
+ setLoadProgress(p);
328
+ onLoadProgress?.(p);
329
+ }
330
+ };
331
+ try {
332
+ let obj = null;
333
+ let clips = [];
334
+ if (detectedFmt === "fbx") {
335
+ const loader = new FBXLoader();
336
+ const group = await loader.loadAsync(url, onProgress);
337
+ clips = group.animations ?? [];
338
+ obj = group;
339
+ } else if (detectedFmt === "glb" || detectedFmt === "gltf") {
340
+ const loader = new GLTFLoader();
341
+ const gltf = await loader.loadAsync(url, onProgress);
342
+ clips = gltf.animations ?? [];
343
+ obj = gltf.scene;
344
+ } else if (detectedFmt === "obj") {
345
+ const loader = new OBJLoader();
346
+ obj = await loader.loadAsync(url, onProgress);
347
+ } else if (detectedFmt === "dae") {
348
+ const loader = new ColladaLoader();
349
+ const result = await loader.loadAsync(url, onProgress);
350
+ if (result) {
351
+ clips = result.scene.animations ?? [];
352
+ obj = result.scene;
353
+ }
354
+ } else if (detectedFmt === "stl") {
355
+ const loader = new STLLoader();
356
+ const geo = await loader.loadAsync(url, onProgress);
357
+ const mat = new THREE.MeshStandardMaterial({
358
+ color: 11184810,
359
+ metalness: 0.3,
360
+ roughness: 0.6
361
+ });
362
+ const mesh = new THREE.Mesh(geo, mat);
363
+ mesh.castShadow = true;
364
+ mesh.receiveShadow = true;
365
+ obj = mesh;
366
+ } else if (detectedFmt === "ply") {
367
+ const loader = new PLYLoader();
368
+ const geo = await loader.loadAsync(url, onProgress);
369
+ geo.computeVertexNormals();
370
+ const mat = new THREE.MeshStandardMaterial({
371
+ color: 11184810,
372
+ vertexColors: !!geo.attributes.color
373
+ });
374
+ obj = new THREE.Mesh(geo, mat);
375
+ } else {
376
+ throw new Error(`Unsupported format: ${detectedFmt}`);
377
+ }
378
+ if (obj) {
379
+ if (rootRef.current) {
380
+ sceneRef.current.remove(rootRef.current);
381
+ }
382
+ obj.traverse((c) => {
304
383
  if (c.isMesh) {
305
- meshes++;
306
- const geo = c.geometry;
307
- if (geo.index) {
308
- tris += geo.index.count / 3;
309
- }
310
- else if (geo.attributes.position) {
311
- tris += geo.attributes.position.count / 3;
312
- }
313
- }
314
- });
315
- setPolyCount(Math.round(tris));
316
- setMeshCount(meshes);
317
- }, []);
318
- /* ── detect format from URL ── */
319
- const detectFormat = useCallback((url, hint) => {
320
- var _a, _b, _c;
321
- if (hint) {
322
- return hint;
323
- }
324
- const ext = (_c = (_b = (_a = url.split('.').pop()) === null || _a === void 0 ? void 0 : _a.split('?')[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase()) !== null && _c !== void 0 ? _c : '';
325
- return ext;
326
- }, []);
327
- /* ── load model ── */
328
- const loadModel = useCallback(async (url, fmt) => {
329
- var _a, _b, _c;
330
- setLoading(true);
331
- setLoadProgress(0);
332
- setError(null);
333
- setStatusText(t('useModelViewer.loading', 'Loading…'));
334
- const detectedFmt = detectFormat(url, fmt);
335
- const onProgress = (e) => {
336
- if (e.lengthComputable) {
337
- const p = e.loaded / e.total;
338
- setLoadProgress(p);
339
- onLoadProgress === null || onLoadProgress === void 0 ? void 0 : onLoadProgress(p);
340
- }
341
- };
342
- try {
343
- let obj = null;
344
- let clips = [];
345
- if (detectedFmt === 'fbx') {
346
- const loader = new FBXLoader();
347
- const group = await loader.loadAsync(url, onProgress);
348
- clips = (_a = group.animations) !== null && _a !== void 0 ? _a : [];
349
- obj = group;
350
- }
351
- else if (detectedFmt === 'glb' || detectedFmt === 'gltf') {
352
- const loader = new GLTFLoader();
353
- const gltf = await loader.loadAsync(url, onProgress);
354
- clips = (_b = gltf.animations) !== null && _b !== void 0 ? _b : [];
355
- obj = gltf.scene;
356
- }
357
- else if (detectedFmt === 'obj') {
358
- const loader = new OBJLoader();
359
- obj = await loader.loadAsync(url, onProgress);
360
- }
361
- else if (detectedFmt === 'dae') {
362
- const loader = new ColladaLoader();
363
- const result = await loader.loadAsync(url, onProgress);
364
- if (result) {
365
- clips = (_c = result.scene.animations) !== null && _c !== void 0 ? _c : [];
366
- obj = result.scene;
367
- }
384
+ c.castShadow = true;
385
+ c.receiveShadow = true;
368
386
  }
369
- else if (detectedFmt === 'stl') {
370
- const loader = new STLLoader();
371
- const geo = await loader.loadAsync(url, onProgress);
372
- const mat = new THREE.MeshStandardMaterial({
373
- color: 0xaaaaaa,
374
- metalness: 0.3,
375
- roughness: 0.6,
376
- });
377
- const mesh = new THREE.Mesh(geo, mat);
378
- mesh.castShadow = true;
379
- mesh.receiveShadow = true;
380
- obj = mesh;
387
+ });
388
+ const box = new THREE.Box3().setFromObject(obj);
389
+ const size = box.getSize(new THREE.Vector3());
390
+ const maxDim = Math.max(size.x, size.y, size.z);
391
+ if (maxDim > 0) {
392
+ const target = 5;
393
+ if (maxDim > target * 2 || maxDim < target * 0.1) {
394
+ const scale = target / maxDim;
395
+ obj.scale.multiplyScalar(scale);
381
396
  }
382
- else if (detectedFmt === 'ply') {
383
- const loader = new PLYLoader();
384
- const geo = await loader.loadAsync(url, onProgress);
385
- geo.computeVertexNormals();
386
- const mat = new THREE.MeshStandardMaterial({
387
- color: 0xaaaaaa,
388
- vertexColors: !!geo.attributes.color,
389
- });
390
- obj = new THREE.Mesh(geo, mat);
391
- }
392
- else {
393
- throw new Error(`Unsupported format: ${detectedFmt}`);
394
- }
395
- if (obj) {
396
- // Remove previous model
397
- if (rootRef.current) {
398
- sceneRef.current.remove(rootRef.current);
399
- }
400
- // Enable shadows
401
- obj.traverse((c) => {
402
- if (c.isMesh) {
403
- c.castShadow = true;
404
- c.receiveShadow = true;
405
- }
406
- });
407
- // Auto-scale to normalized size
408
- const box = new THREE.Box3().setFromObject(obj);
409
- const size = box.getSize(new THREE.Vector3());
410
- const maxDim = Math.max(size.x, size.y, size.z);
411
- if (maxDim > 0) {
412
- const target = 5;
413
- if (maxDim > target * 2 || maxDim < target * 0.1) {
414
- const scale = target / maxDim;
415
- obj.scale.multiplyScalar(scale);
416
- }
417
- }
418
- // Center model
419
- const box2 = new THREE.Box3().setFromObject(obj);
420
- const center = box2.getCenter(new THREE.Vector3());
421
- obj.position.sub(center);
422
- obj.position.y += box2.getSize(new THREE.Vector3()).y / 2;
423
- sceneRef.current.add(obj);
424
- rootRef.current = obj;
425
- // Update dimensions
426
- const finalBox = new THREE.Box3().setFromObject(obj);
427
- const s = finalBox.getSize(new THREE.Vector3());
428
- setDimensions({
429
- width: +s.x.toFixed(2),
430
- height: +s.y.toFixed(2),
431
- depth: +s.z.toFixed(2),
432
- });
433
- // Animations
434
- clipsRef.current = clips;
435
- setAnimNames(clips.map((c, i) => c.name || `Clip ${i + 1}`));
436
- if (clips.length > 0) {
437
- const mixer = new THREE.AnimationMixer(obj);
438
- mixerRef.current = mixer;
439
- }
440
- countScene();
441
- setStatusText(t('useModelViewer.loadedTrisMeshes', 'Loaded – {polyCount} tris, {meshCount} meshes')
442
- .replace('{polyCount}', String(polyCount))
443
- .replace('{meshCount}', String(meshCount)));
444
- onLoad === null || onLoad === void 0 ? void 0 : onLoad();
445
- }
446
- }
447
- catch (err) {
448
- const msg = err instanceof Error ? err.message : String(err);
449
- setError(msg);
450
- setStatusText(t('useModelViewer.error', 'Error: {msg}').replace('{msg}', String(msg)));
451
- onError === null || onError === void 0 ? void 0 : onError(err instanceof Error ? err : new Error(msg));
452
- }
453
- finally {
454
- setLoading(false);
455
- setLoadProgress(1);
456
- }
457
- }, [countScene, detectFormat, onLoad, onError, onLoadProgress, polyCount, meshCount, t]);
458
- /* ── auto-load from src ── */
459
- useEffect(() => {
460
- if (src) {
461
- loadModel(src, format);
462
- }
463
- }, [src, format, loadModel]);
464
- /* ── loadFile from disk ── */
465
- const loadFile = useCallback(async (file) => {
466
- var _a;
467
- const url = URL.createObjectURL(file);
468
- const ext = (_a = file.name.split('.').pop()) === null || _a === void 0 ? void 0 : _a.toLowerCase();
469
- try {
470
- await loadModel(url, ext);
471
- }
472
- finally {
473
- URL.revokeObjectURL(url);
474
- }
475
- }, [loadModel]);
476
- /* ── loadUrl ── */
477
- const loadUrl = useCallback(async (url, fmt) => {
478
- await loadModel(url, fmt);
479
- }, [loadModel]);
480
- /* ── animation controls ── */
481
- const playAnimation = useCallback((index) => {
482
- if (!mixerRef.current || index < 0 || index >= clipsRef.current.length) {
483
- return;
484
- }
485
- mixerRef.current.stopAllAction();
486
- const clip = clipsRef.current[index];
487
- const action = mixerRef.current.clipAction(clip);
488
- action.reset().play();
489
- setActiveAnimIdx(index);
490
- setIsPlaying(true);
491
- setAnimDuration(clip.duration);
492
- }, []);
493
- const togglePlay = useCallback(() => {
494
- if (!mixerRef.current) {
495
- return;
496
- }
497
- if (isPlaying) {
498
- mixerRef.current.timeScale = 0;
499
- setIsPlaying(false);
500
- }
501
- else {
502
- mixerRef.current.timeScale = animSpeed;
503
- setIsPlaying(true);
504
- if (activeAnimIdx < 0 && clipsRef.current.length > 0) {
505
- playAnimation(0);
506
- }
507
- }
508
- }, [isPlaying, animSpeed, activeAnimIdx, playAnimation]);
509
- const seekAnim = useCallback((t) => {
510
- if (mixerRef.current) {
511
- mixerRef.current.setTime(t);
512
- }
513
- setAnimTime(t);
514
- }, []);
515
- /* ── camera presets ── */
516
- const goToPreset = useCallback((preset) => {
517
- const cam = cameraRef.current;
518
- const orbit = orbitRef.current;
519
- if (!orbit) {
520
- return;
521
- }
522
- cam.position.set(...preset.position);
523
- orbit.target.set(...preset.target);
524
- if (preset.fov) {
525
- cam.fov = preset.fov;
526
- }
527
- cam.updateProjectionMatrix();
528
- orbit.update();
529
- }, []);
530
- const resetCamera = useCallback(() => {
531
- const cam = cameraRef.current;
532
- cam.position.set(5, 4, 8);
533
- if (orbitRef.current) {
534
- orbitRef.current.target.set(0, 0, 0);
535
- orbitRef.current.update();
536
- }
537
- }, []);
538
- const zoomToFit = useCallback(() => {
539
- if (!rootRef.current) {
540
- return;
541
- }
542
- const box = new THREE.Box3().setFromObject(rootRef.current);
543
- const center = box.getCenter(new THREE.Vector3());
544
- const size = box.getSize(new THREE.Vector3());
545
- const maxDim = Math.max(size.x, size.y, size.z);
546
- const cam = cameraRef.current;
547
- const dist = maxDim / (2 * Math.tan((cam.fov * Math.PI) / 360));
548
- cam.position.copy(center).add(new THREE.Vector3(0, 0, dist * 1.5));
549
- if (orbitRef.current) {
550
- orbitRef.current.target.copy(center);
551
- orbitRef.current.update();
552
- }
553
- }, []);
554
- /* ── screenshot ── */
555
- const screenshot = useCallback((width, height) => {
556
- const renderer = rendererRef.current;
557
- if (!renderer) {
558
- return '';
559
- }
560
- if (width && height) {
561
- renderer.setSize(width, height);
562
- cameraRef.current.aspect = width / height;
563
- cameraRef.current.updateProjectionMatrix();
564
- }
565
- renderer.render(sceneRef.current, cameraRef.current);
566
- const url = renderer.domElement.toDataURL('image/png');
567
- if (width && height && mountRef.current) {
568
- const w = mountRef.current.clientWidth;
569
- const h = mountRef.current.clientHeight;
570
- renderer.setSize(w, h);
571
- cameraRef.current.aspect = w / h;
572
- cameraRef.current.updateProjectionMatrix();
573
- }
574
- return url;
575
- }, []);
576
- /* ── fullscreen ── */
577
- const toggleFullscreen = useCallback(() => {
578
- if (!mountRef.current) {
579
- return;
580
- }
581
- if (document.fullscreenElement) {
582
- document.exitFullscreen();
583
- }
584
- else {
585
- mountRef.current.requestFullscreen();
586
- }
587
- }, []);
588
- /* ── dispose ── */
589
- const dispose = useCallback(() => {
590
- var _a;
591
- disposeRef.current = true;
592
- cancelAnimationFrame(rafRef.current);
593
- (_a = rendererRef.current) === null || _a === void 0 ? void 0 : _a.dispose();
594
- }, []);
595
- /* ── finalise status text ── */
596
- useEffect(() => {
597
- if (!loading && !error && polyCount > 0) {
598
- setStatusText(t('useModelViewer.trianglesMeshesAnimationS', '{polyCount} triangles · {meshCount} meshes · {animNames} animation(s)')
599
- .replace('{polyCount}', String(polyCount.toLocaleString()))
600
- .replace('{meshCount}', String(meshCount))
601
- .replace('{animNames}', String(animNames.length)));
602
- }
603
- }, [loading, error, polyCount, meshCount, animNames.length, t]);
604
- return {
605
- mountRef,
606
- loading,
607
- loadProgress,
608
- error,
609
- navigationMode: navMode,
610
- setNavigationMode: setNavMode,
611
- projection,
612
- setProjection,
613
- wireframe,
614
- setWireframe,
615
- showGrid: gridVisible,
616
- setShowGrid: setGridVisible,
617
- autoRotateSpeed: autoRotate,
618
- setAutoRotateSpeed: setAutoRotate,
619
- polyCount,
620
- meshCount,
621
- animations: animNames,
622
- activeAnimIdx,
623
- playAnimation,
624
- togglePlay,
625
- isPlaying,
626
- animTime,
627
- animDuration,
628
- seekAnim,
629
- animSpeed,
630
- setAnimSpeed,
631
- goToPreset,
632
- resetCamera,
633
- zoomToFit,
634
- screenshot,
635
- toggleFullscreen,
636
- isFullscreen,
637
- loadFile,
638
- loadUrl,
639
- activeFloorPlanId,
640
- setActiveFloorPlanId,
641
- annotationsVisible,
642
- setAnnotationsVisible,
643
- measureMode: measureModeActive,
644
- setMeasureMode: setMeasureModeActive,
645
- clippingEnabled,
646
- setClippingEnabled,
647
- clippingPosition,
648
- setClippingPosition,
649
- clippingAxis,
650
- setClippingAxis,
651
- dimensions,
652
- statusText,
653
- dispose,
654
- };
397
+ }
398
+ const box2 = new THREE.Box3().setFromObject(obj);
399
+ const center = box2.getCenter(new THREE.Vector3());
400
+ obj.position.sub(center);
401
+ obj.position.y += box2.getSize(new THREE.Vector3()).y / 2;
402
+ sceneRef.current.add(obj);
403
+ rootRef.current = obj;
404
+ const finalBox = new THREE.Box3().setFromObject(obj);
405
+ const s = finalBox.getSize(new THREE.Vector3());
406
+ setDimensions({
407
+ width: +s.x.toFixed(2),
408
+ height: +s.y.toFixed(2),
409
+ depth: +s.z.toFixed(2)
410
+ });
411
+ clipsRef.current = clips;
412
+ setAnimNames(clips.map((c, i) => c.name || `Clip ${i + 1}`));
413
+ if (clips.length > 0) {
414
+ const mixer = new THREE.AnimationMixer(obj);
415
+ mixerRef.current = mixer;
416
+ }
417
+ countScene();
418
+ setStatusText(
419
+ t("useModelViewer.loadedTrisMeshes", "Loaded – {polyCount} tris, {meshCount} meshes").replace("{polyCount}", String(polyCount)).replace("{meshCount}", String(meshCount))
420
+ );
421
+ onLoad?.();
422
+ }
423
+ } catch (err) {
424
+ const msg = err instanceof Error ? err.message : String(err);
425
+ setError(msg);
426
+ setStatusText(t("useModelViewer.error", "Error: {msg}").replace("{msg}", String(msg)));
427
+ onError?.(err instanceof Error ? err : new Error(msg));
428
+ } finally {
429
+ setLoading(false);
430
+ setLoadProgress(1);
431
+ }
432
+ },
433
+ [countScene, detectFormat, onLoad, onError, onLoadProgress, polyCount, meshCount, t]
434
+ );
435
+ useEffect(() => {
436
+ if (src) {
437
+ loadModel(src, format);
438
+ }
439
+ }, [src, format, loadModel]);
440
+ const loadFile = useCallback(
441
+ async (file) => {
442
+ const url = URL.createObjectURL(file);
443
+ const ext = file.name.split(".").pop()?.toLowerCase();
444
+ try {
445
+ await loadModel(url, ext);
446
+ } finally {
447
+ URL.revokeObjectURL(url);
448
+ }
449
+ },
450
+ [loadModel]
451
+ );
452
+ const loadUrl = useCallback(
453
+ async (url, fmt) => {
454
+ await loadModel(url, fmt);
455
+ },
456
+ [loadModel]
457
+ );
458
+ const playAnimation = useCallback((index) => {
459
+ if (!mixerRef.current || index < 0 || index >= clipsRef.current.length) {
460
+ return;
461
+ }
462
+ mixerRef.current.stopAllAction();
463
+ const clip = clipsRef.current[index];
464
+ const action = mixerRef.current.clipAction(clip);
465
+ action.reset().play();
466
+ setActiveAnimIdx(index);
467
+ setIsPlaying(true);
468
+ setAnimDuration(clip.duration);
469
+ }, []);
470
+ const togglePlay = useCallback(() => {
471
+ if (!mixerRef.current) {
472
+ return;
473
+ }
474
+ if (isPlaying) {
475
+ mixerRef.current.timeScale = 0;
476
+ setIsPlaying(false);
477
+ } else {
478
+ mixerRef.current.timeScale = animSpeed;
479
+ setIsPlaying(true);
480
+ if (activeAnimIdx < 0 && clipsRef.current.length > 0) {
481
+ playAnimation(0);
482
+ }
483
+ }
484
+ }, [isPlaying, animSpeed, activeAnimIdx, playAnimation]);
485
+ const seekAnim = useCallback((t2) => {
486
+ if (mixerRef.current) {
487
+ mixerRef.current.setTime(t2);
488
+ }
489
+ setAnimTime(t2);
490
+ }, []);
491
+ const goToPreset = useCallback((preset) => {
492
+ const cam = cameraRef.current;
493
+ const orbit = orbitRef.current;
494
+ if (!orbit) {
495
+ return;
496
+ }
497
+ cam.position.set(...preset.position);
498
+ orbit.target.set(...preset.target);
499
+ if (preset.fov) {
500
+ cam.fov = preset.fov;
501
+ }
502
+ cam.updateProjectionMatrix();
503
+ orbit.update();
504
+ }, []);
505
+ const resetCamera = useCallback(() => {
506
+ const cam = cameraRef.current;
507
+ cam.position.set(5, 4, 8);
508
+ if (orbitRef.current) {
509
+ orbitRef.current.target.set(0, 0, 0);
510
+ orbitRef.current.update();
511
+ }
512
+ }, []);
513
+ const zoomToFit = useCallback(() => {
514
+ if (!rootRef.current) {
515
+ return;
516
+ }
517
+ const box = new THREE.Box3().setFromObject(rootRef.current);
518
+ const center = box.getCenter(new THREE.Vector3());
519
+ const size = box.getSize(new THREE.Vector3());
520
+ const maxDim = Math.max(size.x, size.y, size.z);
521
+ const cam = cameraRef.current;
522
+ const dist = maxDim / (2 * Math.tan(cam.fov * Math.PI / 360));
523
+ cam.position.copy(center).add(new THREE.Vector3(0, 0, dist * 1.5));
524
+ if (orbitRef.current) {
525
+ orbitRef.current.target.copy(center);
526
+ orbitRef.current.update();
527
+ }
528
+ }, []);
529
+ const screenshot = useCallback((width, height) => {
530
+ const renderer = rendererRef.current;
531
+ if (!renderer) {
532
+ return "";
533
+ }
534
+ if (width && height) {
535
+ renderer.setSize(width, height);
536
+ cameraRef.current.aspect = width / height;
537
+ cameraRef.current.updateProjectionMatrix();
538
+ }
539
+ renderer.render(sceneRef.current, cameraRef.current);
540
+ const url = renderer.domElement.toDataURL("image/png");
541
+ if (width && height && mountRef.current) {
542
+ const w = mountRef.current.clientWidth;
543
+ const h = mountRef.current.clientHeight;
544
+ renderer.setSize(w, h);
545
+ cameraRef.current.aspect = w / h;
546
+ cameraRef.current.updateProjectionMatrix();
547
+ }
548
+ return url;
549
+ }, []);
550
+ const toggleFullscreen = useCallback(() => {
551
+ if (!mountRef.current) {
552
+ return;
553
+ }
554
+ if (document.fullscreenElement) {
555
+ document.exitFullscreen();
556
+ } else {
557
+ mountRef.current.requestFullscreen();
558
+ }
559
+ }, []);
560
+ const dispose = useCallback(() => {
561
+ disposeRef.current = true;
562
+ cancelAnimationFrame(rafRef.current);
563
+ rendererRef.current?.dispose();
564
+ }, []);
565
+ useEffect(() => {
566
+ if (!loading && !error && polyCount > 0) {
567
+ setStatusText(
568
+ t(
569
+ "useModelViewer.trianglesMeshesAnimationS",
570
+ "{polyCount} triangles · {meshCount} meshes · {animNames} animation(s)"
571
+ ).replace("{polyCount}", String(polyCount.toLocaleString())).replace("{meshCount}", String(meshCount)).replace("{animNames}", String(animNames.length))
572
+ );
573
+ }
574
+ }, [loading, error, polyCount, meshCount, animNames.length, t]);
575
+ return {
576
+ mountRef,
577
+ loading,
578
+ loadProgress,
579
+ error,
580
+ navigationMode: navMode,
581
+ setNavigationMode: setNavMode,
582
+ projection,
583
+ setProjection,
584
+ wireframe,
585
+ setWireframe,
586
+ showGrid: gridVisible,
587
+ setShowGrid: setGridVisible,
588
+ autoRotateSpeed: autoRotate,
589
+ setAutoRotateSpeed: setAutoRotate,
590
+ polyCount,
591
+ meshCount,
592
+ animations: animNames,
593
+ activeAnimIdx,
594
+ playAnimation,
595
+ togglePlay,
596
+ isPlaying,
597
+ animTime,
598
+ animDuration,
599
+ seekAnim,
600
+ animSpeed,
601
+ setAnimSpeed,
602
+ goToPreset,
603
+ resetCamera,
604
+ zoomToFit,
605
+ screenshot,
606
+ toggleFullscreen,
607
+ isFullscreen,
608
+ loadFile,
609
+ loadUrl,
610
+ activeFloorPlanId,
611
+ setActiveFloorPlanId,
612
+ annotationsVisible,
613
+ setAnnotationsVisible,
614
+ measureMode: measureModeActive,
615
+ setMeasureMode: setMeasureModeActive,
616
+ clippingEnabled,
617
+ setClippingEnabled,
618
+ clippingPosition,
619
+ setClippingPosition,
620
+ clippingAxis,
621
+ setClippingAxis,
622
+ dimensions,
623
+ statusText,
624
+ dispose
625
+ };
655
626
  }
656
-
657
- export { useModelViewer };
627
+ export {
628
+ useModelViewer
629
+ };