@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.
- package/CHANGELOG.md +13 -0
- package/dist/cjs/collaborative/collaborativeScene.js +160 -206
- package/dist/cjs/core/editorAsk.js +51 -41
- package/dist/cjs/core/i18n.js +13 -14
- package/dist/cjs/core/threeCompat.js +29 -42
- package/dist/cjs/dance/DanceBridge.js +148 -177
- package/dist/cjs/dance/DanceScoreEngine.js +209 -221
- package/dist/cjs/dance/PoseDetector.js +302 -275
- package/dist/cjs/index.js +110 -113
- package/dist/cjs/material/MaterialEditor.module.css.js +242 -5
- package/dist/cjs/material/NiceMaterialEditor.js +1213 -757
- package/dist/cjs/material/materialEditorTypes.js +35 -49
- package/dist/cjs/material/materialEditorUtils.js +684 -747
- package/dist/cjs/material/materialNodeDefinitions.js +1411 -1459
- package/dist/cjs/model/ModelEditor.js +90 -40
- package/dist/cjs/model/ModelEditor.module.css.js +257 -5
- package/dist/cjs/model/ModelEditorLeftPanel.js +126 -47
- package/dist/cjs/model/ModelEditorMenuBar.js +359 -32
- package/dist/cjs/model/ModelEditorRightPanel.js +647 -131
- package/dist/cjs/model/ModelEditorSubComponents.js +277 -112
- package/dist/cjs/model/ModelEditorTimeline.js +232 -34
- package/dist/cjs/model/ModelEditorToolbar.js +170 -14
- package/dist/cjs/model/ModelEditorViewport.js +231 -39
- package/dist/cjs/model/ModelViewer.js +388 -63
- package/dist/cjs/model/ModelViewer.module.css.js +77 -5
- package/dist/cjs/model/NiceArmatureEditor.js +473 -274
- package/dist/cjs/model/NiceMorphTargetEditor.js +333 -227
- package/dist/cjs/model/NiceOctree.js +372 -320
- package/dist/cjs/model/NicePhysicsSimulation.js +528 -298
- package/dist/cjs/model/NiceProceduralGeometry.js +673 -300
- package/dist/cjs/model/NiceTerrainEditor.js +595 -385
- package/dist/cjs/model/NiceWeightPainter.js +384 -261
- package/dist/cjs/model/NiceXRPreview.js +385 -308
- package/dist/cjs/model/cadModeUtils.js +103 -106
- package/dist/cjs/model/editorShortcuts.js +171 -194
- package/dist/cjs/model/modelEditorTypes.js +100 -118
- package/dist/cjs/model/modelEditorUtils.js +1061 -1146
- package/dist/cjs/model/simsModeUtils.js +1023 -1053
- package/dist/cjs/model/useModelEditor.js +1977 -1933
- package/dist/cjs/model/useModelViewer.js +633 -665
- package/dist/cjs/nice2dev-ui-3d.css +1 -1
- package/dist/cjs/particle/NiceParticleEditor.js +1186 -571
- package/dist/cjs/particle/ParticleEditor.module.css.js +122 -5
- package/dist/cjs/particle/particleEditorTypes.js +62 -70
- package/dist/cjs/particle/particleEditorUtils.js +998 -1013
- package/dist/cjs/rendering/NiceCascadedShadows.js +381 -259
- package/dist/cjs/rendering/NiceRenderExport.js +424 -337
- package/dist/cjs/rendering/NiceSSAO.js +454 -337
- package/dist/cjs/rendering/NiceSSR.js +387 -261
- package/dist/cjs/rendering/NiceWebGPURenderer.js +359 -230
- package/dist/cjs/uv/NiceUVEditor.js +787 -550
- package/dist/cjs/uv/UVEditor.module.css.js +53 -5
- package/dist/cjs/uv/uvEditorTypes.js +59 -76
- package/dist/cjs/uv/uvEditorUtils.js +501 -575
- package/dist/esm/collaborative/collaborativeScene.js +163 -205
- package/dist/esm/core/editorAsk.js +53 -40
- package/dist/esm/core/i18n.js +15 -13
- package/dist/esm/core/threeCompat.js +18 -26
- package/dist/esm/dance/DanceBridge.js +151 -176
- package/dist/esm/dance/DanceScoreEngine.js +211 -220
- package/dist/esm/dance/PoseDetector.js +304 -274
- package/dist/esm/index.js +258 -36
- package/dist/esm/material/MaterialEditor.module.css.js +242 -3
- package/dist/esm/material/NiceMaterialEditor.js +1215 -756
- package/dist/esm/material/materialEditorTypes.js +45 -48
- package/dist/esm/material/materialEditorUtils.js +713 -746
- package/dist/esm/material/materialNodeDefinitions.js +1415 -1458
- package/dist/esm/model/ModelEditor.js +91 -39
- package/dist/esm/model/ModelEditor.module.css.js +257 -3
- package/dist/esm/model/ModelEditorLeftPanel.js +126 -45
- package/dist/esm/model/ModelEditorMenuBar.js +359 -30
- package/dist/esm/model/ModelEditorRightPanel.js +634 -114
- package/dist/esm/model/ModelEditorSubComponents.js +265 -94
- package/dist/esm/model/ModelEditorTimeline.js +232 -32
- package/dist/esm/model/ModelEditorToolbar.js +170 -12
- package/dist/esm/model/ModelEditorViewport.js +231 -37
- package/dist/esm/model/ModelViewer.js +389 -62
- package/dist/esm/model/ModelViewer.module.css.js +77 -3
- package/dist/esm/model/NiceArmatureEditor.js +462 -258
- package/dist/esm/model/NiceMorphTargetEditor.js +322 -211
- package/dist/esm/model/NiceOctree.js +361 -304
- package/dist/esm/model/NicePhysicsSimulation.js +517 -282
- package/dist/esm/model/NiceProceduralGeometry.js +667 -284
- package/dist/esm/model/NiceTerrainEditor.js +584 -369
- package/dist/esm/model/NiceWeightPainter.js +373 -245
- package/dist/esm/model/NiceXRPreview.js +374 -292
- package/dist/esm/model/cadModeUtils.js +95 -88
- package/dist/esm/model/editorShortcuts.js +172 -193
- package/dist/esm/model/modelEditorTypes.js +90 -100
- package/dist/esm/model/modelEditorUtils.js +1056 -1128
- package/dist/esm/model/simsModeUtils.js +1021 -1035
- package/dist/esm/model/useModelEditor.js +1946 -1920
- package/dist/esm/model/useModelViewer.js +619 -647
- package/dist/esm/nice2dev-ui-3d.css +1 -1
- package/dist/esm/particle/NiceParticleEditor.js +1188 -570
- package/dist/esm/particle/ParticleEditor.module.css.js +122 -3
- package/dist/esm/particle/particleEditorTypes.js +69 -69
- package/dist/esm/particle/particleEditorUtils.js +1027 -1012
- package/dist/esm/rendering/NiceCascadedShadows.js +370 -243
- package/dist/esm/rendering/NiceRenderExport.js +413 -321
- package/dist/esm/rendering/NiceSSAO.js +450 -328
- package/dist/esm/rendering/NiceSSR.js +383 -252
- package/dist/esm/rendering/NiceWebGPURenderer.js +326 -214
- package/dist/esm/uv/NiceUVEditor.js +788 -549
- package/dist/esm/uv/UVEditor.module.css.js +53 -3
- package/dist/esm/uv/uvEditorTypes.js +68 -75
- package/dist/esm/uv/uvEditorUtils.js +515 -557
- package/package.json +7 -12
|
@@ -1,1950 +1,1976 @@
|
|
|
1
|
-
import { resolveCssColor } from
|
|
2
|
-
import { useRef, useState, useEffect, useCallback, useMemo } from
|
|
3
|
-
import * as THREE from
|
|
4
|
-
import { OrbitControls } from
|
|
5
|
-
import { TransformControls } from
|
|
6
|
-
import { GLTFExporter } from
|
|
7
|
-
import { OBJExporter } from
|
|
8
|
-
import { PLYExporter } from
|
|
9
|
-
import { STLExporter } from
|
|
10
|
-
import { USDZExporter } from
|
|
11
|
-
import { ThreeMFLoader } from
|
|
12
|
-
import { AMFLoader } from
|
|
13
|
-
import { ColladaLoader } from
|
|
14
|
-
import { FBXLoader } from
|
|
15
|
-
import { GCodeLoader } from
|
|
16
|
-
import { GLTFLoader } from
|
|
17
|
-
import { OBJLoader } from
|
|
18
|
-
import { PCDLoader } from
|
|
19
|
-
import { PLYLoader } from
|
|
20
|
-
import { STLLoader } from
|
|
21
|
-
import { SVGLoader } from
|
|
22
|
-
import { TDSLoader } from
|
|
23
|
-
import { VTKLoader } from
|
|
24
|
-
import { useEditorAsk } from
|
|
25
|
-
import { useNiceTranslation } from
|
|
26
|
-
import { createFrameClock, SOFT_SHADOW_MAP } from
|
|
27
|
-
import { SUPPORTED_EXTENSIONS, pose3dToClip } from
|
|
28
|
-
import { UndoRedoStack, createMaterialFromPreset, generateLOD, convertToInstanced, applyHDRIPreset, snapMulti, HDRI_PRESETS, PBR_PRESETS } from
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* useModelEditor — custom hook encapsulating all state,
|
|
32
|
-
* refs, callbacks, side-effects, and scene setup for the
|
|
33
|
-
* ModelEditor (Blender-style 3D editor).
|
|
34
|
-
*
|
|
35
|
-
* Extracted from the original ModelEditor.tsx god-component.
|
|
36
|
-
*/
|
|
1
|
+
import { resolveCssColor } from "@nice2dev/ui";
|
|
2
|
+
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
5
|
+
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
|
6
|
+
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
|
7
|
+
import { OBJExporter } from "three/examples/jsm/exporters/OBJExporter.js";
|
|
8
|
+
import { PLYExporter } from "three/examples/jsm/exporters/PLYExporter.js";
|
|
9
|
+
import { STLExporter } from "three/examples/jsm/exporters/STLExporter.js";
|
|
10
|
+
import { USDZExporter } from "three/examples/jsm/exporters/USDZExporter.js";
|
|
11
|
+
import { ThreeMFLoader } from "three/examples/jsm/loaders/3MFLoader.js";
|
|
12
|
+
import { AMFLoader } from "three/examples/jsm/loaders/AMFLoader.js";
|
|
13
|
+
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
|
|
14
|
+
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
|
|
15
|
+
import { GCodeLoader } from "three/examples/jsm/loaders/GCodeLoader.js";
|
|
16
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
17
|
+
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
|
18
|
+
import { PCDLoader } from "three/examples/jsm/loaders/PCDLoader.js";
|
|
19
|
+
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
|
20
|
+
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
|
21
|
+
import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader.js";
|
|
22
|
+
import { TDSLoader } from "three/examples/jsm/loaders/TDSLoader.js";
|
|
23
|
+
import { VTKLoader } from "three/examples/jsm/loaders/VTKLoader.js";
|
|
24
|
+
import { useEditorAsk } from "../core/editorAsk.js";
|
|
25
|
+
import { useNiceTranslation } from "../core/i18n.js";
|
|
26
|
+
import { createFrameClock, SOFT_SHADOW_MAP } from "../core/threeCompat.js";
|
|
27
|
+
import { SUPPORTED_EXTENSIONS, pose3dToClip } from "./modelEditorTypes.js";
|
|
28
|
+
import { UndoRedoStack, createMaterialFromPreset, generateLOD, convertToInstanced, applyHDRIPreset, snapMulti, HDRI_PRESETS, PBR_PRESETS } from "./modelEditorUtils.js";
|
|
37
29
|
const log = { error: console.error, warn: console.warn };
|
|
38
|
-
/* helpers */
|
|
39
30
|
let _nid = 0;
|
|
40
31
|
const nid = () => `n${++_nid}`;
|
|
41
|
-
/* ── Pure helper — extracted outside the hook for stable identity ── */
|
|
42
32
|
function findNodeById(nodes, id) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
33
|
+
for (const n of nodes) {
|
|
34
|
+
if (n.id === id) {
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
const found = findNodeById(n.children, id);
|
|
38
|
+
if (found) {
|
|
39
|
+
return found;
|
|
51
40
|
}
|
|
52
|
-
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
53
43
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
scene.add(tcHelper);
|
|
215
|
-
transformRef.current = tc;
|
|
216
|
-
// Resize handler
|
|
217
|
-
const onResize = () => {
|
|
218
|
-
const w = container.clientWidth;
|
|
219
|
-
const h = container.clientHeight;
|
|
220
|
-
if (w === 0 || h === 0) {
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
camera.aspect = w / h;
|
|
224
|
-
camera.updateProjectionMatrix();
|
|
225
|
-
renderer.setSize(w, h);
|
|
226
|
-
};
|
|
227
|
-
const ro = new ResizeObserver(onResize);
|
|
228
|
-
ro.observe(container);
|
|
229
|
-
onResize();
|
|
230
|
-
// Animation loop
|
|
231
|
-
const animate = () => {
|
|
232
|
-
var _a, _b;
|
|
233
|
-
rafRef.current = requestAnimationFrame(animate);
|
|
234
|
-
const dt = (_b = (_a = clockRef.current) === null || _a === void 0 ? void 0 : _a.getDelta()) !== null && _b !== void 0 ? _b : 0;
|
|
235
|
-
orbit.update();
|
|
236
|
-
// Update mixer
|
|
237
|
-
if (mixerRef.current && isPlayingRef.current) {
|
|
238
|
-
mixerRef.current.update(dt * animSpeedRef.current);
|
|
239
|
-
// Sync time state (throttled to ~15fps for React)
|
|
240
|
-
const action = activeActionRef.current;
|
|
241
|
-
if (action) {
|
|
242
|
-
const t = action.time;
|
|
243
|
-
if (Math.abs(t - animTimeRef.current) > 0.05) {
|
|
244
|
-
setAnimTime(t);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
renderer.render(scene, camera);
|
|
249
|
-
};
|
|
250
|
-
animate();
|
|
251
|
-
return () => {
|
|
252
|
-
cancelAnimationFrame(rafRef.current);
|
|
253
|
-
ro.disconnect();
|
|
254
|
-
const scn = scene;
|
|
255
|
-
scn.traverse((obj) => {
|
|
256
|
-
if (obj.geometry) {
|
|
257
|
-
obj.geometry.dispose();
|
|
258
|
-
}
|
|
259
|
-
if (obj.material) {
|
|
260
|
-
const mats = Array.isArray(obj.material)
|
|
261
|
-
? obj.material
|
|
262
|
-
: [obj.material];
|
|
263
|
-
mats.forEach((mat) => mat.dispose());
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
if (mixerRef.current) {
|
|
267
|
-
mixerRef.current.stopAllAction();
|
|
268
|
-
}
|
|
269
|
-
renderer.dispose();
|
|
270
|
-
orbit.dispose();
|
|
271
|
-
tc.dispose();
|
|
272
|
-
if (container.contains(renderer.domElement)) {
|
|
273
|
-
container.removeChild(renderer.domElement);
|
|
274
|
-
}
|
|
275
|
-
};
|
|
276
|
-
}, []);
|
|
277
|
-
/* ─────────────────────────────────────────
|
|
278
|
-
Rebuild scene tree from Three.js scene
|
|
279
|
-
───────────────────────────────────────── */
|
|
280
|
-
const rebuildTree = useCallback(() => {
|
|
281
|
-
const scene = sceneRef.current;
|
|
282
|
-
let polys = 0;
|
|
283
|
-
let meshes = 0;
|
|
284
|
-
let bones = 0;
|
|
285
|
-
const walk = (obj, depth) => {
|
|
286
|
-
var _a, _b;
|
|
287
|
-
if (obj.name.startsWith('__')) {
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
if (obj.type === 'TransformControlsPlane' || obj.type === 'TransformControlsGizmo') {
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
if ('isTransformControls' in obj) {
|
|
294
|
-
return null;
|
|
295
|
-
}
|
|
296
|
-
let nodeType = 'group';
|
|
297
|
-
if (obj instanceof THREE.Mesh) {
|
|
298
|
-
nodeType = 'mesh';
|
|
299
|
-
meshes++;
|
|
300
|
-
const geo = obj.geometry;
|
|
301
|
-
if (geo) {
|
|
302
|
-
const idx = geo.index;
|
|
303
|
-
polys += idx ? idx.count / 3 : ((_b = (_a = geo.attributes.position) === null || _a === void 0 ? void 0 : _a.count) !== null && _b !== void 0 ? _b : 0) / 3;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
else if (obj instanceof THREE.Bone) {
|
|
307
|
-
nodeType = 'bone';
|
|
308
|
-
bones++;
|
|
309
|
-
}
|
|
310
|
-
else if (obj instanceof THREE.Light) {
|
|
311
|
-
nodeType = 'light';
|
|
312
|
-
}
|
|
313
|
-
else if (obj instanceof THREE.Camera) {
|
|
314
|
-
nodeType = 'camera';
|
|
315
|
-
}
|
|
316
|
-
else if (obj instanceof THREE.GridHelper || obj instanceof THREE.AxesHelper) {
|
|
317
|
-
nodeType = 'helper';
|
|
318
|
-
}
|
|
319
|
-
const children = [];
|
|
320
|
-
for (const c of obj.children) {
|
|
321
|
-
const cn = walk(c, depth + 1);
|
|
322
|
-
if (cn) {
|
|
323
|
-
children.push(cn);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return {
|
|
327
|
-
id: obj.userData.__nodeId ||
|
|
328
|
-
((obj.userData.__nodeId = nid()), obj.userData.__nodeId),
|
|
329
|
-
name: obj.name || obj.type,
|
|
330
|
-
type: nodeType,
|
|
331
|
-
object: obj,
|
|
332
|
-
children,
|
|
333
|
-
expanded: depth < 2,
|
|
334
|
-
visible: obj.visible,
|
|
335
|
-
};
|
|
336
|
-
};
|
|
337
|
-
const roots = [];
|
|
338
|
-
for (const c of scene.children) {
|
|
339
|
-
const n = walk(c, 0);
|
|
340
|
-
if (n) {
|
|
341
|
-
roots.push(n);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
setSceneTree(roots);
|
|
345
|
-
setPolyCount(Math.round(polys));
|
|
346
|
-
setMeshCount(meshes);
|
|
347
|
-
setBoneCount(bones);
|
|
348
|
-
}, []);
|
|
349
|
-
/* ─────────────────────────────────────────
|
|
350
|
-
File loading
|
|
351
|
-
───────────────────────────────────────── */
|
|
352
|
-
const playClipRef = useRef(() => { });
|
|
353
|
-
const focusOnObjectRef = useRef(() => { });
|
|
354
|
-
const addObjectToScene = useCallback((obj, clips, filename) => {
|
|
355
|
-
const scene = sceneRef.current;
|
|
356
|
-
obj.traverse((child) => {
|
|
357
|
-
if (child instanceof THREE.Mesh) {
|
|
358
|
-
child.castShadow = true;
|
|
359
|
-
child.receiveShadow = true;
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
const box = new THREE.Box3().setFromObject(obj);
|
|
363
|
-
const size = box.getSize(new THREE.Vector3());
|
|
364
|
-
const maxDim = Math.max(size.x, size.y, size.z);
|
|
365
|
-
if (maxDim > 0.01) {
|
|
366
|
-
const targetSize = 3;
|
|
367
|
-
const scale = targetSize / maxDim;
|
|
368
|
-
if (scale < 0.5 || scale > 2) {
|
|
369
|
-
obj.scale.multiplyScalar(scale);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const box2 = new THREE.Box3().setFromObject(obj);
|
|
373
|
-
const center = box2.getCenter(new THREE.Vector3());
|
|
374
|
-
obj.position.sub(center);
|
|
375
|
-
obj.position.y += box2.getSize(new THREE.Vector3()).y / 2;
|
|
376
|
-
obj.name = obj.name || filename;
|
|
377
|
-
scene.add(obj);
|
|
378
|
-
rootObjectRef.current = obj;
|
|
379
|
-
if (clips.length > 0) {
|
|
380
|
-
const mixer = new THREE.AnimationMixer(obj);
|
|
381
|
-
mixerRef.current = mixer;
|
|
382
|
-
const newAnims = clips.map((c) => ({
|
|
383
|
-
name: c.name || 'Animation',
|
|
384
|
-
clip: c,
|
|
385
|
-
duration: c.duration,
|
|
386
|
-
source: filename,
|
|
387
|
-
}));
|
|
388
|
-
allClipsRef.current = [...allClipsRef.current, ...newAnims];
|
|
389
|
-
setAnimations([...allClipsRef.current]);
|
|
390
|
-
if (allClipsRef.current.length > 0) {
|
|
391
|
-
playClipRef.current(0);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
else if (!mixerRef.current && rootObjectRef.current) {
|
|
395
|
-
mixerRef.current = new THREE.AnimationMixer(rootObjectRef.current);
|
|
396
|
-
}
|
|
397
|
-
focusOnObjectRef.current(obj);
|
|
398
|
-
rebuildTree();
|
|
399
|
-
setStatusText(t('useModelEditor.loadedAnimationS', 'Loaded: {filename} ({clips} animation(s))')
|
|
400
|
-
.replace('{filename}', String(filename))
|
|
401
|
-
.replace('{clips}', String(clips.length)));
|
|
402
|
-
}, [rebuildTree, t]);
|
|
403
|
-
const loadFile = useCallback(async (file) => {
|
|
404
|
-
var _a, _b, _c, _d, _e, _f;
|
|
405
|
-
const name = file.name.toLowerCase();
|
|
406
|
-
const ext = '.' + name.split('.').pop();
|
|
407
|
-
const url = URL.createObjectURL(file);
|
|
408
|
-
setStatusText(t('useModelEditor.loading', 'Loading {name}...').replace('{name}', String(file.name)));
|
|
409
|
-
try {
|
|
410
|
-
if (ext === '.fbx') {
|
|
411
|
-
const loader = new FBXLoader();
|
|
412
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
413
|
-
addObjectToScene(obj, (_a = obj.animations) !== null && _a !== void 0 ? _a : [], file.name);
|
|
414
|
-
}
|
|
415
|
-
else if (ext === '.glb' || ext === '.gltf') {
|
|
416
|
-
const loader = new GLTFLoader();
|
|
417
|
-
const gltf = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
418
|
-
addObjectToScene(gltf.scene, (_b = gltf.animations) !== null && _b !== void 0 ? _b : [], file.name);
|
|
419
|
-
}
|
|
420
|
-
else if (ext === '.obj') {
|
|
421
|
-
const loader = new OBJLoader();
|
|
422
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
423
|
-
addObjectToScene(obj, [], file.name);
|
|
424
|
-
}
|
|
425
|
-
else if (ext === '.dae') {
|
|
426
|
-
const loader = new ColladaLoader();
|
|
427
|
-
const collada = await new Promise((res, rej) =>
|
|
428
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ColladaLoader callback type widens beyond { scene: Group }
|
|
429
|
-
loader.load(url, res, undefined, rej));
|
|
430
|
-
addObjectToScene(collada.scene, (_d = (_c = collada.scene) === null || _c === void 0 ? void 0 : _c.animations) !== null && _d !== void 0 ? _d : [], file.name);
|
|
431
|
-
}
|
|
432
|
-
else if (ext === '.stl') {
|
|
433
|
-
const loader = new STLLoader();
|
|
434
|
-
const geo = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
435
|
-
const mat = new THREE.MeshStandardMaterial({
|
|
436
|
-
color: 0x888888,
|
|
437
|
-
metalness: 0.3,
|
|
438
|
-
roughness: 0.6,
|
|
439
|
-
});
|
|
440
|
-
const mesh = new THREE.Mesh(geo, mat);
|
|
441
|
-
mesh.name = file.name;
|
|
442
|
-
addObjectToScene(mesh, [], file.name);
|
|
443
|
-
}
|
|
444
|
-
else if (ext === '.ply') {
|
|
445
|
-
const loader = new PLYLoader();
|
|
446
|
-
const geo = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
447
|
-
geo.computeVertexNormals();
|
|
448
|
-
const mat = new THREE.MeshStandardMaterial({
|
|
449
|
-
color: 0x888888,
|
|
450
|
-
vertexColors: geo.hasAttribute('color'),
|
|
451
|
-
});
|
|
452
|
-
const mesh = new THREE.Mesh(geo, mat);
|
|
453
|
-
mesh.name = file.name;
|
|
454
|
-
addObjectToScene(mesh, [], file.name);
|
|
455
|
-
}
|
|
456
|
-
else if (ext === '.3ds') {
|
|
457
|
-
const loader = new TDSLoader();
|
|
458
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
459
|
-
addObjectToScene(obj, [], file.name);
|
|
460
|
-
}
|
|
461
|
-
else if (ext === '.3mf') {
|
|
462
|
-
const loader = new ThreeMFLoader();
|
|
463
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
464
|
-
addObjectToScene(obj, [], file.name);
|
|
465
|
-
}
|
|
466
|
-
else if (ext === '.amf') {
|
|
467
|
-
const loader = new AMFLoader();
|
|
468
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
469
|
-
addObjectToScene(obj, [], file.name);
|
|
470
|
-
}
|
|
471
|
-
else if (ext === '.pcd') {
|
|
472
|
-
const loader = new PCDLoader();
|
|
473
|
-
const points = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
474
|
-
points.name = file.name;
|
|
475
|
-
addObjectToScene(points, [], file.name);
|
|
476
|
-
}
|
|
477
|
-
else if (ext === '.vtk' || ext === '.vtp') {
|
|
478
|
-
const loader = new VTKLoader();
|
|
479
|
-
const geo = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
480
|
-
geo.computeVertexNormals();
|
|
481
|
-
const mat = new THREE.MeshStandardMaterial({ color: 0x888888, side: THREE.DoubleSide });
|
|
482
|
-
const mesh = new THREE.Mesh(geo, mat);
|
|
483
|
-
mesh.name = file.name;
|
|
484
|
-
addObjectToScene(mesh, [], file.name);
|
|
485
|
-
}
|
|
486
|
-
else if (ext === '.wrl' || ext === '.vrml') {
|
|
487
|
-
// Imported on demand. VRMLLoader embeds chevrotain, whose UMD wrapper registers
|
|
488
|
-
// through a global AMD `define` whenever one exists — and Monaco's loader (used by
|
|
489
|
-
// NiceCodeEditor) installs exactly that. Its exports then come back undefined and
|
|
490
|
-
// `new RegExpParser()` throws at MODULE EVALUATION: imported statically, that
|
|
491
|
-
// took down every page that had shown a code editor before opening a 3D view.
|
|
492
|
-
const { VRMLLoader } = await import('three/examples/jsm/loaders/VRMLLoader.js');
|
|
493
|
-
const loader = new VRMLLoader();
|
|
494
|
-
const scn = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
495
|
-
const group = new THREE.Group();
|
|
496
|
-
group.name = file.name;
|
|
497
|
-
while (scn.children.length) {
|
|
498
|
-
group.add(scn.children[0]);
|
|
499
|
-
}
|
|
500
|
-
addObjectToScene(group, [], file.name);
|
|
501
|
-
}
|
|
502
|
-
else if (ext === '.gcode') {
|
|
503
|
-
const loader = new GCodeLoader();
|
|
504
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
505
|
-
addObjectToScene(obj, [], file.name);
|
|
506
|
-
}
|
|
507
|
-
else if (ext === '.svg') {
|
|
508
|
-
const loader = new SVGLoader();
|
|
509
|
-
// @types/three exports SVGResult, not SVGResultPaths — derive the element
|
|
510
|
-
// type from the real shape rather than importing a name that does not exist.
|
|
511
|
-
const data = await new Promise((res, rej) =>
|
|
512
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SVGLoader callback type widens beyond SVGResult
|
|
513
|
-
loader.load(url, res, undefined, rej));
|
|
514
|
-
const group = new THREE.Group();
|
|
515
|
-
group.name = file.name;
|
|
516
|
-
for (const path of data.paths) {
|
|
517
|
-
const shapes2 = SVGLoader.createShapes(path);
|
|
518
|
-
for (const shape of shapes2) {
|
|
519
|
-
const geo = new THREE.ExtrudeGeometry(shape, { depth: 2, bevelEnabled: false });
|
|
520
|
-
const mat = new THREE.MeshStandardMaterial({
|
|
521
|
-
color: ((_f = (_e = path.color) === null || _e === void 0 ? void 0 : _e.getHex) === null || _f === void 0 ? void 0 : _f.call(_e)) || 0x888888,
|
|
522
|
-
side: THREE.DoubleSide,
|
|
523
|
-
});
|
|
524
|
-
const mesh = new THREE.Mesh(geo, mat);
|
|
525
|
-
group.add(mesh);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
group.scale.set(0.01, -0.01, 0.01);
|
|
529
|
-
addObjectToScene(group, [], file.name);
|
|
530
|
-
}
|
|
531
|
-
else {
|
|
532
|
-
setStatusText(t('useModelEditor.unsupportedFormat', 'Unsupported format: {ext}').replace('{ext}', String(ext)));
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
catch (err) {
|
|
536
|
-
log.error('Load error:', err);
|
|
537
|
-
setStatusText(t('useModelEditor.errorLoading', 'Error loading {name}: {err}')
|
|
538
|
-
.replace('{name}', String(file.name))
|
|
539
|
-
.replace('{err}', String(String(err))));
|
|
540
|
-
}
|
|
541
|
-
finally {
|
|
542
|
-
URL.revokeObjectURL(url);
|
|
543
|
-
}
|
|
544
|
-
}, [addObjectToScene, t]);
|
|
545
|
-
const mergeAnimationFBX = useCallback(async (file) => {
|
|
546
|
-
var _a;
|
|
547
|
-
const url = URL.createObjectURL(file);
|
|
548
|
-
setStatusText(t('useModelEditor.mergingAnimationsFrom', 'Merging animations from {name}...').replace('{name}', String(file.name)));
|
|
549
|
-
try {
|
|
550
|
-
const loader = new FBXLoader();
|
|
551
|
-
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
552
|
-
const clips = (_a = obj.animations) !== null && _a !== void 0 ? _a : [];
|
|
553
|
-
if (clips.length === 0) {
|
|
554
|
-
setStatusText(t('useModelEditor.noAnimationsFoundIn', 'No animations found in {name}').replace('{name}', String(file.name)));
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
const root = rootObjectRef.current;
|
|
558
|
-
if (!root) {
|
|
559
|
-
setStatusText(t('useModelEditor.noModelLoadedLoadA', 'No model loaded — load a base model first'));
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
if (!mixerRef.current) {
|
|
563
|
-
mixerRef.current = new THREE.AnimationMixer(root);
|
|
564
|
-
}
|
|
565
|
-
const newAnims = clips.map((c) => ({
|
|
566
|
-
name: c.name || file.name.replace(/\.[^.]+$/, ''),
|
|
567
|
-
clip: c,
|
|
568
|
-
duration: c.duration,
|
|
569
|
-
source: file.name,
|
|
570
|
-
}));
|
|
571
|
-
allClipsRef.current = [...allClipsRef.current, ...newAnims];
|
|
572
|
-
setAnimations([...allClipsRef.current]);
|
|
573
|
-
setStatusText(t('useModelEditor.mergedAnimationSFrom', 'Merged {clips} animation(s) from {name}')
|
|
574
|
-
.replace('{clips}', String(clips.length))
|
|
575
|
-
.replace('{name}', String(file.name)));
|
|
576
|
-
}
|
|
577
|
-
catch (err) {
|
|
578
|
-
log.error('Merge error:', err);
|
|
579
|
-
setStatusText(t('useModelEditor.errorMerging', 'Error merging {name}: {err}')
|
|
580
|
-
.replace('{name}', String(file.name))
|
|
581
|
-
.replace('{err}', String(String(err))));
|
|
582
|
-
}
|
|
583
|
-
finally {
|
|
584
|
-
URL.revokeObjectURL(url);
|
|
585
|
-
}
|
|
586
|
-
}, [t]);
|
|
587
|
-
/* ─────────────────────────────────────────
|
|
588
|
-
Animation controls
|
|
589
|
-
───────────────────────────────────────── */
|
|
590
|
-
const playClip = useCallback((index) => {
|
|
591
|
-
const mixer = mixerRef.current;
|
|
592
|
-
if (!mixer || index < 0 || index >= allClipsRef.current.length) {
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (activeActionRef.current) {
|
|
596
|
-
activeActionRef.current.stop();
|
|
597
|
-
}
|
|
598
|
-
const anim = allClipsRef.current[index];
|
|
599
|
-
const action = mixer.clipAction(anim.clip);
|
|
600
|
-
action.reset();
|
|
601
|
-
action.setLoop(loopAnimRef.current ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
|
|
602
|
-
action.clampWhenFinished = !loopAnimRef.current;
|
|
603
|
-
action.play();
|
|
604
|
-
activeActionRef.current = action;
|
|
605
|
-
setActiveAnimIdx(index);
|
|
606
|
-
setAnimDuration(anim.duration);
|
|
607
|
-
setAnimTime(0);
|
|
608
|
-
setIsPlaying(true);
|
|
609
|
-
}, []);
|
|
610
|
-
playClipRef.current = playClip;
|
|
611
|
-
const togglePlay = useCallback(() => {
|
|
612
|
-
if (activeAnimIdx < 0 && allClipsRef.current.length > 0) {
|
|
613
|
-
playClip(0);
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
const action = activeActionRef.current;
|
|
617
|
-
if (!action) {
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
if (isPlayingRef.current) {
|
|
621
|
-
action.paused = true;
|
|
622
|
-
setIsPlaying(false);
|
|
623
|
-
}
|
|
624
|
-
else {
|
|
625
|
-
action.paused = false;
|
|
626
|
-
setIsPlaying(true);
|
|
627
|
-
}
|
|
628
|
-
}, [activeAnimIdx, playClip]);
|
|
629
|
-
const stopAnim = useCallback(() => {
|
|
44
|
+
function useModelEditor({
|
|
45
|
+
onSaveToLibrary,
|
|
46
|
+
onAIPoseVideo
|
|
47
|
+
}) {
|
|
48
|
+
const { t } = useNiceTranslation();
|
|
49
|
+
const { pendingAsk, ask } = useEditorAsk();
|
|
50
|
+
const mountRef = useRef(null);
|
|
51
|
+
const rendererRef = useRef(null);
|
|
52
|
+
const sceneRef = useRef(new THREE.Scene());
|
|
53
|
+
const cameraRef = useRef(
|
|
54
|
+
new THREE.PerspectiveCamera(50, 1, 0.01, 1e4)
|
|
55
|
+
);
|
|
56
|
+
const orbitRef = useRef(null);
|
|
57
|
+
const transformRef = useRef(null);
|
|
58
|
+
const clockRef = useRef(null);
|
|
59
|
+
clockRef.current ?? (clockRef.current = createFrameClock());
|
|
60
|
+
const mixerRef = useRef(null);
|
|
61
|
+
const rafRef = useRef(0);
|
|
62
|
+
const fileInputRef = useRef(null);
|
|
63
|
+
const mergeInputRef = useRef(null);
|
|
64
|
+
const videoInputRef = useRef(null);
|
|
65
|
+
const raycasterRef = useRef(new THREE.Raycaster());
|
|
66
|
+
const skeletonHelperRef = useRef(null);
|
|
67
|
+
const [sceneTree, setSceneTree] = useState([]);
|
|
68
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
69
|
+
const [transformMode, setTransformMode] = useState("translate");
|
|
70
|
+
const [animations, setAnimations] = useState([]);
|
|
71
|
+
const [activeAnimIdx, setActiveAnimIdx] = useState(-1);
|
|
72
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
73
|
+
const [animTime, setAnimTime] = useState(0);
|
|
74
|
+
const [animDuration, setAnimDuration] = useState(0);
|
|
75
|
+
const [animSpeed, setAnimSpeed] = useState(1);
|
|
76
|
+
const [loopAnim, setLoopAnim] = useState(true);
|
|
77
|
+
const [bottomCollapsed, setBottomCollapsed] = useState(false);
|
|
78
|
+
const [showGrid, setShowGrid] = useState(true);
|
|
79
|
+
const [showAxes, setShowAxes] = useState(true);
|
|
80
|
+
const [wireframe, setWireframe] = useState(false);
|
|
81
|
+
const [bgColor, setBgColor] = useState("var(--nice-bg, #111122)");
|
|
82
|
+
const [statusText, setStatusText] = useState("Ready");
|
|
83
|
+
const [dragOver, setDragOver] = useState(false);
|
|
84
|
+
const [polyCount, setPolyCount] = useState(0);
|
|
85
|
+
const [meshCount, setMeshCount] = useState(0);
|
|
86
|
+
const [boneCount, setBoneCount] = useState(0);
|
|
87
|
+
const [selMaterialIdx, setSelMaterialIdx] = useState(0);
|
|
88
|
+
const [matRefresh, setMatRefresh] = useState(0);
|
|
89
|
+
const [aiStatus, setAiStatus] = useState("");
|
|
90
|
+
const [aiBusy, setAiBusy] = useState(false);
|
|
91
|
+
const [shadingMode, setShadingMode] = useState("solid");
|
|
92
|
+
const [editorMode, setEditorMode] = useState("object");
|
|
93
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
94
|
+
const [outlinerSearch, setOutlinerSearch] = useState("");
|
|
95
|
+
const [snapEnabled, setSnapEnabled] = useState(false);
|
|
96
|
+
const [snapGrid, setSnapGrid] = useState(1);
|
|
97
|
+
const [showSkeleton, setShowSkeleton] = useState(true);
|
|
98
|
+
const [addMenuOpen, setAddMenuOpen] = useState(null);
|
|
99
|
+
const [gizmoSpace, setGizmoSpace] = useState("world");
|
|
100
|
+
const [propTab, setPropTab] = useState("object");
|
|
101
|
+
const [fogEnabled, setFogEnabled] = useState(false);
|
|
102
|
+
const [fogColor, setFogColor] = useState("var(--nice-bg, #111122)");
|
|
103
|
+
const [fogNear, setFogNear] = useState(10);
|
|
104
|
+
const [fogFar, setFogFar] = useState(100);
|
|
105
|
+
const [showLightHelpers, setShowLightHelpers] = useState(true);
|
|
106
|
+
const rootObjectRef = useRef(null);
|
|
107
|
+
const activeActionRef = useRef(null);
|
|
108
|
+
const allClipsRef = useRef([]);
|
|
109
|
+
const animTimeRef = useRef(animTime);
|
|
110
|
+
animTimeRef.current = animTime;
|
|
111
|
+
const isPlayingRef = useRef(isPlaying);
|
|
112
|
+
isPlayingRef.current = isPlaying;
|
|
113
|
+
const animSpeedRef = useRef(animSpeed);
|
|
114
|
+
animSpeedRef.current = animSpeed;
|
|
115
|
+
const loopAnimRef = useRef(loopAnim);
|
|
116
|
+
loopAnimRef.current = loopAnim;
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const container = mountRef.current;
|
|
119
|
+
if (!container) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const scene = sceneRef.current;
|
|
123
|
+
const camera = cameraRef.current;
|
|
124
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
|
125
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
126
|
+
renderer.shadowMap.enabled = true;
|
|
127
|
+
renderer.shadowMap.type = SOFT_SHADOW_MAP;
|
|
128
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
129
|
+
renderer.toneMappingExposure = 1.2;
|
|
130
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
131
|
+
rendererRef.current = renderer;
|
|
132
|
+
container.appendChild(renderer.domElement);
|
|
133
|
+
camera.position.set(3, 2, 5);
|
|
134
|
+
camera.lookAt(0, 0, 0);
|
|
135
|
+
scene.background = new THREE.Color(
|
|
136
|
+
resolveCssColor("var(--nice-bg-primary, #111122)", container)
|
|
137
|
+
);
|
|
138
|
+
const ambientLight = new THREE.AmbientLight(16777215, 0.5);
|
|
139
|
+
ambientLight.name = "__ambient";
|
|
140
|
+
scene.add(ambientLight);
|
|
141
|
+
const dirLight = new THREE.DirectionalLight(16777215, 1.2);
|
|
142
|
+
dirLight.name = "__dirLight";
|
|
143
|
+
dirLight.position.set(5, 8, 5);
|
|
144
|
+
dirLight.castShadow = true;
|
|
145
|
+
dirLight.shadow.mapSize.set(2048, 2048);
|
|
146
|
+
dirLight.shadow.camera.near = 0.1;
|
|
147
|
+
dirLight.shadow.camera.far = 50;
|
|
148
|
+
dirLight.shadow.camera.left = -10;
|
|
149
|
+
dirLight.shadow.camera.right = 10;
|
|
150
|
+
dirLight.shadow.camera.top = 10;
|
|
151
|
+
dirLight.shadow.camera.bottom = -10;
|
|
152
|
+
scene.add(dirLight);
|
|
153
|
+
const hemiLight = new THREE.HemisphereLight(8947967, 4473890, 0.4);
|
|
154
|
+
hemiLight.name = "__hemiLight";
|
|
155
|
+
scene.add(hemiLight);
|
|
156
|
+
const ground = new THREE.Mesh(
|
|
157
|
+
new THREE.PlaneGeometry(100, 100),
|
|
158
|
+
new THREE.ShadowMaterial({ opacity: 0.15 })
|
|
159
|
+
);
|
|
160
|
+
ground.name = "__ground";
|
|
161
|
+
ground.rotation.x = -Math.PI / 2;
|
|
162
|
+
ground.receiveShadow = true;
|
|
163
|
+
scene.add(ground);
|
|
164
|
+
const grid = new THREE.GridHelper(20, 20, 3355477, 2236986);
|
|
165
|
+
grid.name = "__grid";
|
|
166
|
+
scene.add(grid);
|
|
167
|
+
const axes = new THREE.AxesHelper(2);
|
|
168
|
+
axes.name = "__axes";
|
|
169
|
+
scene.add(axes);
|
|
170
|
+
const orbit = new OrbitControls(camera, renderer.domElement);
|
|
171
|
+
orbit.enableDamping = true;
|
|
172
|
+
orbit.dampingFactor = 0.08;
|
|
173
|
+
orbit.minDistance = 0.1;
|
|
174
|
+
orbit.maxDistance = 500;
|
|
175
|
+
orbit.target.set(0, 0.5, 0);
|
|
176
|
+
orbitRef.current = orbit;
|
|
177
|
+
const tc = new TransformControls(camera, renderer.domElement);
|
|
178
|
+
const tcHelper = tc.getHelper();
|
|
179
|
+
tcHelper.name = "__transformControls";
|
|
180
|
+
tc.addEventListener("dragging-changed", (e) => {
|
|
181
|
+
orbit.enabled = !e.value;
|
|
182
|
+
});
|
|
183
|
+
scene.add(tcHelper);
|
|
184
|
+
transformRef.current = tc;
|
|
185
|
+
const onResize = () => {
|
|
186
|
+
const w = container.clientWidth;
|
|
187
|
+
const h = container.clientHeight;
|
|
188
|
+
if (w === 0 || h === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
camera.aspect = w / h;
|
|
192
|
+
camera.updateProjectionMatrix();
|
|
193
|
+
renderer.setSize(w, h);
|
|
194
|
+
};
|
|
195
|
+
const ro = new ResizeObserver(onResize);
|
|
196
|
+
ro.observe(container);
|
|
197
|
+
onResize();
|
|
198
|
+
const animate = () => {
|
|
199
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
200
|
+
const dt = clockRef.current?.getDelta() ?? 0;
|
|
201
|
+
orbit.update();
|
|
202
|
+
if (mixerRef.current && isPlayingRef.current) {
|
|
203
|
+
mixerRef.current.update(dt * animSpeedRef.current);
|
|
630
204
|
const action = activeActionRef.current;
|
|
631
205
|
if (action) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
setAnimTime(0);
|
|
637
|
-
}, []);
|
|
638
|
-
const seekAnim = useCallback((t) => {
|
|
639
|
-
var _a;
|
|
640
|
-
const action = activeActionRef.current;
|
|
641
|
-
if (!action) {
|
|
642
|
-
return;
|
|
206
|
+
const t2 = action.time;
|
|
207
|
+
if (Math.abs(t2 - animTimeRef.current) > 0.05) {
|
|
208
|
+
setAnimTime(t2);
|
|
209
|
+
}
|
|
643
210
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
211
|
+
}
|
|
212
|
+
renderer.render(scene, camera);
|
|
213
|
+
};
|
|
214
|
+
animate();
|
|
215
|
+
return () => {
|
|
216
|
+
cancelAnimationFrame(rafRef.current);
|
|
217
|
+
ro.disconnect();
|
|
218
|
+
const scn = scene;
|
|
219
|
+
scn.traverse((obj) => {
|
|
220
|
+
if (obj.geometry) {
|
|
221
|
+
obj.geometry.dispose();
|
|
222
|
+
}
|
|
223
|
+
if (obj.material) {
|
|
224
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
225
|
+
mats.forEach((mat) => mat.dispose());
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
if (mixerRef.current) {
|
|
229
|
+
mixerRef.current.stopAllAction();
|
|
230
|
+
}
|
|
231
|
+
renderer.dispose();
|
|
232
|
+
orbit.dispose();
|
|
233
|
+
tc.dispose();
|
|
234
|
+
if (container.contains(renderer.domElement)) {
|
|
235
|
+
container.removeChild(renderer.domElement);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}, []);
|
|
239
|
+
const rebuildTree = useCallback(() => {
|
|
240
|
+
const scene = sceneRef.current;
|
|
241
|
+
let polys = 0;
|
|
242
|
+
let meshes = 0;
|
|
243
|
+
let bones = 0;
|
|
244
|
+
const walk = (obj, depth) => {
|
|
245
|
+
if (obj.name.startsWith("__")) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (obj.type === "TransformControlsPlane" || obj.type === "TransformControlsGizmo") {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if ("isTransformControls" in obj) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
let nodeType = "group";
|
|
255
|
+
if (obj instanceof THREE.Mesh) {
|
|
256
|
+
nodeType = "mesh";
|
|
257
|
+
meshes++;
|
|
258
|
+
const geo = obj.geometry;
|
|
259
|
+
if (geo) {
|
|
260
|
+
const idx = geo.index;
|
|
261
|
+
polys += idx ? idx.count / 3 : (geo.attributes.position?.count ?? 0) / 3;
|
|
262
|
+
}
|
|
263
|
+
} else if (obj instanceof THREE.Bone) {
|
|
264
|
+
nodeType = "bone";
|
|
265
|
+
bones++;
|
|
266
|
+
} else if (obj instanceof THREE.Light) {
|
|
267
|
+
nodeType = "light";
|
|
268
|
+
} else if (obj instanceof THREE.Camera) {
|
|
269
|
+
nodeType = "camera";
|
|
270
|
+
} else if (obj instanceof THREE.GridHelper || obj instanceof THREE.AxesHelper) {
|
|
271
|
+
nodeType = "helper";
|
|
272
|
+
}
|
|
273
|
+
const children = [];
|
|
274
|
+
for (const c of obj.children) {
|
|
275
|
+
const cn = walk(c, depth + 1);
|
|
276
|
+
if (cn) {
|
|
277
|
+
children.push(cn);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
id: obj.userData.__nodeId || (obj.userData.__nodeId = nid(), obj.userData.__nodeId),
|
|
282
|
+
name: obj.name || obj.type,
|
|
283
|
+
type: nodeType,
|
|
284
|
+
object: obj,
|
|
285
|
+
children,
|
|
286
|
+
expanded: depth < 2,
|
|
287
|
+
visible: obj.visible
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
const roots = [];
|
|
291
|
+
for (const c of scene.children) {
|
|
292
|
+
const n = walk(c, 0);
|
|
293
|
+
if (n) {
|
|
294
|
+
roots.push(n);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
setSceneTree(roots);
|
|
298
|
+
setPolyCount(Math.round(polys));
|
|
299
|
+
setMeshCount(meshes);
|
|
300
|
+
setBoneCount(bones);
|
|
301
|
+
}, []);
|
|
302
|
+
const playClipRef = useRef(() => {
|
|
303
|
+
});
|
|
304
|
+
const focusOnObjectRef = useRef(() => {
|
|
305
|
+
});
|
|
306
|
+
const addObjectToScene = useCallback(
|
|
307
|
+
(obj, clips, filename) => {
|
|
308
|
+
const scene = sceneRef.current;
|
|
309
|
+
obj.traverse((child) => {
|
|
310
|
+
if (child instanceof THREE.Mesh) {
|
|
311
|
+
child.castShadow = true;
|
|
312
|
+
child.receiveShadow = true;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
const box = new THREE.Box3().setFromObject(obj);
|
|
316
|
+
const size = box.getSize(new THREE.Vector3());
|
|
317
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
318
|
+
if (maxDim > 0.01) {
|
|
319
|
+
const targetSize = 3;
|
|
320
|
+
const scale = targetSize / maxDim;
|
|
321
|
+
if (scale < 0.5 || scale > 2) {
|
|
322
|
+
obj.scale.multiplyScalar(scale);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const box2 = new THREE.Box3().setFromObject(obj);
|
|
326
|
+
const center = box2.getCenter(new THREE.Vector3());
|
|
327
|
+
obj.position.sub(center);
|
|
328
|
+
obj.position.y += box2.getSize(new THREE.Vector3()).y / 2;
|
|
329
|
+
obj.name = obj.name || filename;
|
|
330
|
+
scene.add(obj);
|
|
331
|
+
rootObjectRef.current = obj;
|
|
332
|
+
if (clips.length > 0) {
|
|
333
|
+
const mixer = new THREE.AnimationMixer(obj);
|
|
334
|
+
mixerRef.current = mixer;
|
|
335
|
+
const newAnims = clips.map((c) => ({
|
|
336
|
+
name: c.name || "Animation",
|
|
337
|
+
clip: c,
|
|
338
|
+
duration: c.duration,
|
|
339
|
+
source: filename
|
|
340
|
+
}));
|
|
341
|
+
allClipsRef.current = [...allClipsRef.current, ...newAnims];
|
|
342
|
+
setAnimations([...allClipsRef.current]);
|
|
343
|
+
if (allClipsRef.current.length > 0) {
|
|
344
|
+
playClipRef.current(0);
|
|
345
|
+
}
|
|
346
|
+
} else if (!mixerRef.current && rootObjectRef.current) {
|
|
347
|
+
mixerRef.current = new THREE.AnimationMixer(rootObjectRef.current);
|
|
348
|
+
}
|
|
349
|
+
focusOnObjectRef.current(obj);
|
|
350
|
+
rebuildTree();
|
|
351
|
+
setStatusText(
|
|
352
|
+
t("useModelEditor.loadedAnimationS", "Loaded: {filename} ({clips} animation(s))").replace("{filename}", String(filename)).replace("{clips}", String(clips.length))
|
|
353
|
+
);
|
|
354
|
+
},
|
|
355
|
+
[rebuildTree, t]
|
|
356
|
+
);
|
|
357
|
+
const loadFile = useCallback(
|
|
358
|
+
async (file) => {
|
|
359
|
+
const name = file.name.toLowerCase();
|
|
360
|
+
const ext = "." + name.split(".").pop();
|
|
361
|
+
const url = URL.createObjectURL(file);
|
|
362
|
+
setStatusText(
|
|
363
|
+
t("useModelEditor.loading", "Loading {name}...").replace("{name}", String(file.name))
|
|
364
|
+
);
|
|
365
|
+
try {
|
|
366
|
+
if (ext === ".fbx") {
|
|
367
|
+
const loader = new FBXLoader();
|
|
368
|
+
const obj = await new Promise(
|
|
369
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
370
|
+
);
|
|
371
|
+
addObjectToScene(obj, obj.animations ?? [], file.name);
|
|
372
|
+
} else if (ext === ".glb" || ext === ".gltf") {
|
|
373
|
+
const loader = new GLTFLoader();
|
|
374
|
+
const gltf = await new Promise(
|
|
375
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
376
|
+
);
|
|
377
|
+
addObjectToScene(gltf.scene, gltf.animations ?? [], file.name);
|
|
378
|
+
} else if (ext === ".obj") {
|
|
379
|
+
const loader = new OBJLoader();
|
|
380
|
+
const obj = await new Promise(
|
|
381
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
382
|
+
);
|
|
383
|
+
addObjectToScene(obj, [], file.name);
|
|
384
|
+
} else if (ext === ".dae") {
|
|
385
|
+
const loader = new ColladaLoader();
|
|
386
|
+
const collada = await new Promise(
|
|
387
|
+
(res, rej) => (
|
|
388
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ColladaLoader callback type widens beyond { scene: Group }
|
|
389
|
+
loader.load(url, res, void 0, rej)
|
|
390
|
+
)
|
|
391
|
+
);
|
|
392
|
+
addObjectToScene(collada.scene, collada.scene?.animations ?? [], file.name);
|
|
393
|
+
} else if (ext === ".stl") {
|
|
394
|
+
const loader = new STLLoader();
|
|
395
|
+
const geo = await new Promise(
|
|
396
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
397
|
+
);
|
|
398
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
399
|
+
color: 8947848,
|
|
400
|
+
metalness: 0.3,
|
|
401
|
+
roughness: 0.6
|
|
402
|
+
});
|
|
403
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
404
|
+
mesh.name = file.name;
|
|
405
|
+
addObjectToScene(mesh, [], file.name);
|
|
406
|
+
} else if (ext === ".ply") {
|
|
407
|
+
const loader = new PLYLoader();
|
|
408
|
+
const geo = await new Promise(
|
|
409
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
410
|
+
);
|
|
411
|
+
geo.computeVertexNormals();
|
|
412
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
413
|
+
color: 8947848,
|
|
414
|
+
vertexColors: geo.hasAttribute("color")
|
|
415
|
+
});
|
|
416
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
417
|
+
mesh.name = file.name;
|
|
418
|
+
addObjectToScene(mesh, [], file.name);
|
|
419
|
+
} else if (ext === ".3ds") {
|
|
420
|
+
const loader = new TDSLoader();
|
|
421
|
+
const obj = await new Promise(
|
|
422
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
423
|
+
);
|
|
424
|
+
addObjectToScene(obj, [], file.name);
|
|
425
|
+
} else if (ext === ".3mf") {
|
|
426
|
+
const loader = new ThreeMFLoader();
|
|
427
|
+
const obj = await new Promise(
|
|
428
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
429
|
+
);
|
|
430
|
+
addObjectToScene(obj, [], file.name);
|
|
431
|
+
} else if (ext === ".amf") {
|
|
432
|
+
const loader = new AMFLoader();
|
|
433
|
+
const obj = await new Promise(
|
|
434
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
435
|
+
);
|
|
436
|
+
addObjectToScene(obj, [], file.name);
|
|
437
|
+
} else if (ext === ".pcd") {
|
|
438
|
+
const loader = new PCDLoader();
|
|
439
|
+
const points = await new Promise(
|
|
440
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
441
|
+
);
|
|
442
|
+
points.name = file.name;
|
|
443
|
+
addObjectToScene(points, [], file.name);
|
|
444
|
+
} else if (ext === ".vtk" || ext === ".vtp") {
|
|
445
|
+
const loader = new VTKLoader();
|
|
446
|
+
const geo = await new Promise(
|
|
447
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
448
|
+
);
|
|
449
|
+
geo.computeVertexNormals();
|
|
450
|
+
const mat = new THREE.MeshStandardMaterial({ color: 8947848, side: THREE.DoubleSide });
|
|
451
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
452
|
+
mesh.name = file.name;
|
|
453
|
+
addObjectToScene(mesh, [], file.name);
|
|
454
|
+
} else if (ext === ".wrl" || ext === ".vrml") {
|
|
455
|
+
const { VRMLLoader } = await import("three/examples/jsm/loaders/VRMLLoader.js");
|
|
456
|
+
const loader = new VRMLLoader();
|
|
457
|
+
const scn = await new Promise(
|
|
458
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
459
|
+
);
|
|
460
|
+
const group = new THREE.Group();
|
|
461
|
+
group.name = file.name;
|
|
462
|
+
while (scn.children.length) {
|
|
463
|
+
group.add(scn.children[0]);
|
|
464
|
+
}
|
|
465
|
+
addObjectToScene(group, [], file.name);
|
|
466
|
+
} else if (ext === ".gcode") {
|
|
467
|
+
const loader = new GCodeLoader();
|
|
468
|
+
const obj = await new Promise(
|
|
469
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
470
|
+
);
|
|
471
|
+
addObjectToScene(obj, [], file.name);
|
|
472
|
+
} else if (ext === ".svg") {
|
|
473
|
+
const loader = new SVGLoader();
|
|
474
|
+
const data = await new Promise(
|
|
475
|
+
(res, rej) => (
|
|
476
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SVGLoader callback type widens beyond SVGResult
|
|
477
|
+
loader.load(url, res, void 0, rej)
|
|
478
|
+
)
|
|
479
|
+
);
|
|
480
|
+
const group = new THREE.Group();
|
|
481
|
+
group.name = file.name;
|
|
482
|
+
for (const path of data.paths) {
|
|
483
|
+
const shapes2 = SVGLoader.createShapes(path);
|
|
484
|
+
for (const shape of shapes2) {
|
|
485
|
+
const geo = new THREE.ExtrudeGeometry(shape, { depth: 2, bevelEnabled: false });
|
|
486
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
487
|
+
color: path.color?.getHex?.() || 8947848,
|
|
488
|
+
side: THREE.DoubleSide
|
|
489
|
+
});
|
|
490
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
491
|
+
group.add(mesh);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
group.scale.set(0.01, -0.01, 0.01);
|
|
495
|
+
addObjectToScene(group, [], file.name);
|
|
496
|
+
} else {
|
|
497
|
+
setStatusText(
|
|
498
|
+
t("useModelEditor.unsupportedFormat", "Unsupported format: {ext}").replace(
|
|
499
|
+
"{ext}",
|
|
500
|
+
String(ext)
|
|
501
|
+
)
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
log.error("Load error:", err);
|
|
506
|
+
setStatusText(
|
|
507
|
+
t("useModelEditor.errorLoading", "Error loading {name}: {err}").replace("{name}", String(file.name)).replace("{err}", String(String(err)))
|
|
508
|
+
);
|
|
509
|
+
} finally {
|
|
510
|
+
URL.revokeObjectURL(url);
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
[addObjectToScene, t]
|
|
514
|
+
);
|
|
515
|
+
const mergeAnimationFBX = useCallback(
|
|
516
|
+
async (file) => {
|
|
517
|
+
const url = URL.createObjectURL(file);
|
|
518
|
+
setStatusText(
|
|
519
|
+
t("useModelEditor.mergingAnimationsFrom", "Merging animations from {name}...").replace(
|
|
520
|
+
"{name}",
|
|
521
|
+
String(file.name)
|
|
522
|
+
)
|
|
523
|
+
);
|
|
524
|
+
try {
|
|
525
|
+
const loader = new FBXLoader();
|
|
526
|
+
const obj = await new Promise(
|
|
527
|
+
(res, rej) => loader.load(url, res, void 0, rej)
|
|
528
|
+
);
|
|
529
|
+
const clips = obj.animations ?? [];
|
|
530
|
+
if (clips.length === 0) {
|
|
531
|
+
setStatusText(
|
|
532
|
+
t("useModelEditor.noAnimationsFoundIn", "No animations found in {name}").replace(
|
|
533
|
+
"{name}",
|
|
534
|
+
String(file.name)
|
|
535
|
+
)
|
|
536
|
+
);
|
|
537
|
+
return;
|
|
737
538
|
}
|
|
738
|
-
}, [showAxes]);
|
|
739
|
-
useEffect(() => {
|
|
740
|
-
// bgColor starts as a var() token; THREE.Color cannot parse it and falls back to
|
|
741
|
-
// white, which overwrote the resolved background set up when the scene was built.
|
|
742
|
-
sceneRef.current.background = new THREE.Color(resolveCssColor(bgColor, mountRef.current));
|
|
743
|
-
}, [bgColor]);
|
|
744
|
-
useEffect(() => {
|
|
745
539
|
const root = rootObjectRef.current;
|
|
746
540
|
if (!root) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
},
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
541
|
+
setStatusText(
|
|
542
|
+
t("useModelEditor.noModelLoadedLoadA", "No model loaded — load a base model first")
|
|
543
|
+
);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!mixerRef.current) {
|
|
547
|
+
mixerRef.current = new THREE.AnimationMixer(root);
|
|
548
|
+
}
|
|
549
|
+
const newAnims = clips.map((c) => ({
|
|
550
|
+
name: c.name || file.name.replace(/\.[^.]+$/, ""),
|
|
551
|
+
clip: c,
|
|
552
|
+
duration: c.duration,
|
|
553
|
+
source: file.name
|
|
554
|
+
}));
|
|
555
|
+
allClipsRef.current = [...allClipsRef.current, ...newAnims];
|
|
556
|
+
setAnimations([...allClipsRef.current]);
|
|
557
|
+
setStatusText(
|
|
558
|
+
t("useModelEditor.mergedAnimationSFrom", "Merged {clips} animation(s) from {name}").replace("{clips}", String(clips.length)).replace("{name}", String(file.name))
|
|
559
|
+
);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
log.error("Merge error:", err);
|
|
562
|
+
setStatusText(
|
|
563
|
+
t("useModelEditor.errorMerging", "Error merging {name}: {err}").replace("{name}", String(file.name)).replace("{err}", String(String(err)))
|
|
564
|
+
);
|
|
565
|
+
} finally {
|
|
566
|
+
URL.revokeObjectURL(url);
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
[t]
|
|
570
|
+
);
|
|
571
|
+
const playClip = useCallback((index) => {
|
|
572
|
+
const mixer = mixerRef.current;
|
|
573
|
+
if (!mixer || index < 0 || index >= allClipsRef.current.length) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (activeActionRef.current) {
|
|
577
|
+
activeActionRef.current.stop();
|
|
578
|
+
}
|
|
579
|
+
const anim = allClipsRef.current[index];
|
|
580
|
+
const action = mixer.clipAction(anim.clip);
|
|
581
|
+
action.reset();
|
|
582
|
+
action.setLoop(loopAnimRef.current ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
|
|
583
|
+
action.clampWhenFinished = !loopAnimRef.current;
|
|
584
|
+
action.play();
|
|
585
|
+
activeActionRef.current = action;
|
|
586
|
+
setActiveAnimIdx(index);
|
|
587
|
+
setAnimDuration(anim.duration);
|
|
588
|
+
setAnimTime(0);
|
|
589
|
+
setIsPlaying(true);
|
|
590
|
+
}, []);
|
|
591
|
+
playClipRef.current = playClip;
|
|
592
|
+
const togglePlay = useCallback(() => {
|
|
593
|
+
if (activeAnimIdx < 0 && allClipsRef.current.length > 0) {
|
|
594
|
+
playClip(0);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const action = activeActionRef.current;
|
|
598
|
+
if (!action) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (isPlayingRef.current) {
|
|
602
|
+
action.paused = true;
|
|
603
|
+
setIsPlaying(false);
|
|
604
|
+
} else {
|
|
605
|
+
action.paused = false;
|
|
606
|
+
setIsPlaying(true);
|
|
607
|
+
}
|
|
608
|
+
}, [activeAnimIdx, playClip]);
|
|
609
|
+
const stopAnim = useCallback(() => {
|
|
610
|
+
const action = activeActionRef.current;
|
|
611
|
+
if (action) {
|
|
612
|
+
action.stop();
|
|
613
|
+
action.reset();
|
|
614
|
+
}
|
|
615
|
+
setIsPlaying(false);
|
|
616
|
+
setAnimTime(0);
|
|
617
|
+
}, []);
|
|
618
|
+
const seekAnim = useCallback((t2) => {
|
|
619
|
+
const action = activeActionRef.current;
|
|
620
|
+
if (!action) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
action.time = t2;
|
|
624
|
+
action.paused = true;
|
|
625
|
+
setAnimTime(t2);
|
|
626
|
+
setIsPlaying(false);
|
|
627
|
+
mixerRef.current?.update(0);
|
|
628
|
+
}, []);
|
|
629
|
+
const focusOnObject = useCallback((obj) => {
|
|
630
|
+
const box = new THREE.Box3().setFromObject(obj);
|
|
631
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
632
|
+
const size = box.getSize(new THREE.Vector3());
|
|
633
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
634
|
+
const dist = maxDim * 2;
|
|
635
|
+
const orbit = orbitRef.current;
|
|
636
|
+
if (orbit) {
|
|
637
|
+
orbit.target.copy(center);
|
|
638
|
+
cameraRef.current.position.copy(
|
|
639
|
+
center.clone().add(new THREE.Vector3(dist * 0.6, dist * 0.4, dist * 0.8))
|
|
640
|
+
);
|
|
641
|
+
orbit.update();
|
|
642
|
+
}
|
|
643
|
+
}, []);
|
|
644
|
+
focusOnObjectRef.current = focusOnObject;
|
|
645
|
+
const setCameraPreset = useCallback((preset) => {
|
|
646
|
+
const orbit = orbitRef.current;
|
|
647
|
+
if (!orbit) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const target = orbit.target.clone();
|
|
651
|
+
const d = cameraRef.current.position.distanceTo(target);
|
|
652
|
+
const pos = new THREE.Vector3();
|
|
653
|
+
switch (preset) {
|
|
654
|
+
case "front":
|
|
655
|
+
pos.set(0, 0, d);
|
|
656
|
+
break;
|
|
657
|
+
case "back":
|
|
658
|
+
pos.set(0, 0, -d);
|
|
659
|
+
break;
|
|
660
|
+
case "left":
|
|
661
|
+
pos.set(-d, 0, 0);
|
|
662
|
+
break;
|
|
663
|
+
case "right":
|
|
664
|
+
pos.set(d, 0, 0);
|
|
665
|
+
break;
|
|
666
|
+
case "top":
|
|
667
|
+
pos.set(0, d, 1e-3);
|
|
668
|
+
break;
|
|
669
|
+
case "bottom":
|
|
670
|
+
pos.set(0, -d, 1e-3);
|
|
671
|
+
break;
|
|
672
|
+
default:
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
cameraRef.current.position.copy(target.clone().add(pos));
|
|
676
|
+
orbit.update();
|
|
677
|
+
}, []);
|
|
678
|
+
const selectObject = useCallback(
|
|
679
|
+
(node) => {
|
|
680
|
+
setSelectedId(node?.id ?? null);
|
|
681
|
+
const tc = transformRef.current;
|
|
682
|
+
if (!tc) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (node && node.type !== "helper") {
|
|
686
|
+
tc.attach(node.object);
|
|
687
|
+
tc.setMode(transformMode);
|
|
688
|
+
} else {
|
|
689
|
+
tc.detach();
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
[transformMode]
|
|
693
|
+
);
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
transformRef.current?.setMode(transformMode);
|
|
696
|
+
}, [transformMode]);
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
const scene = sceneRef.current;
|
|
699
|
+
const grid = scene.getObjectByName("__grid");
|
|
700
|
+
if (grid) {
|
|
701
|
+
grid.visible = showGrid;
|
|
702
|
+
}
|
|
703
|
+
}, [showGrid]);
|
|
704
|
+
useEffect(() => {
|
|
705
|
+
const scene = sceneRef.current;
|
|
706
|
+
const axes = scene.getObjectByName("__axes");
|
|
707
|
+
if (axes) {
|
|
708
|
+
axes.visible = showAxes;
|
|
709
|
+
}
|
|
710
|
+
}, [showAxes]);
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
sceneRef.current.background = new THREE.Color(resolveCssColor(bgColor, mountRef.current));
|
|
713
|
+
}, [bgColor]);
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
const root = rootObjectRef.current;
|
|
716
|
+
if (!root) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
root.traverse((child) => {
|
|
720
|
+
if (child instanceof THREE.Mesh) {
|
|
721
|
+
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
722
|
+
mats.forEach((m) => {
|
|
723
|
+
if ("wireframe" in m) {
|
|
724
|
+
m.wireframe = wireframe;
|
|
725
|
+
}
|
|
912
726
|
});
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
(_a = transformRef.current) === null || _a === void 0 ? void 0 : _a.detach();
|
|
932
|
-
setSelectedId(null);
|
|
933
|
-
rebuildTree();
|
|
934
|
-
}, [selectedNode, rebuildTree]);
|
|
935
|
-
const duplicateSelected = useCallback(() => {
|
|
936
|
-
if (!selectedNode) {
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
const clone = selectedNode.object.clone(true);
|
|
940
|
-
clone.name = selectedNode.object.name + '.001';
|
|
941
|
-
clone.position.x += 1;
|
|
942
|
-
sceneRef.current.add(clone);
|
|
943
|
-
rebuildTree();
|
|
944
|
-
setStatusText(t('useModelEditor.duplicated', 'Duplicated {name}').replace('{name}', String(selectedNode.name)));
|
|
945
|
-
}, [selectedNode, rebuildTree, t]);
|
|
946
|
-
/* ─────────────────────────────────────────
|
|
947
|
-
Keyboard shortcuts
|
|
948
|
-
───────────────────────────────────────── */
|
|
949
|
-
useEffect(() => {
|
|
950
|
-
// Escape, then Tab, leaves the viewport, where Tab cycles the mode (WCAG 2.1.2)
|
|
951
|
-
let tabEscape = false;
|
|
952
|
-
const handleKey = (e) => {
|
|
953
|
-
var _a, _b, _c, _d;
|
|
954
|
-
// a key sent to the window itself counts as the body's
|
|
955
|
-
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
956
|
-
const tag = target === null || target === void 0 ? void 0 : target.tagName;
|
|
957
|
-
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (target === null || target === void 0 ? void 0 : target.isContentEditable)) {
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
// The shortcuts are this editor's: keys elsewhere on the page are not, and Tab / Space on
|
|
961
|
-
// one of its controls move focus and press it. They listened on the whole page — Tab moved
|
|
962
|
-
// focus nowhere on a page with the editor and Space pressed no button (TODO_1.3.0 I4).
|
|
963
|
-
const root = (_b = (_a = mountRef.current) === null || _a === void 0 ? void 0 : _a.closest('.nice-app-shell')) !== null && _b !== void 0 ? _b : mountRef.current;
|
|
964
|
-
if (target && target !== document.body && !(root === null || root === void 0 ? void 0 : root.contains(target))) {
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
if ((e.key === 'Tab' || e.key === ' ') &&
|
|
968
|
-
(target === null || target === void 0 ? void 0 : target.closest('button, a[href], [role="button"], [role="combobox"], [role="menuitem"], [role="tab"], [role="option"], [role="checkbox"], [role="switch"], [role="slider"], [role="treeitem"]'))) {
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
if (e.key === 'Tab' && tabEscape) {
|
|
972
|
-
tabEscape = false;
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
if (!['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) {
|
|
976
|
-
tabEscape = e.key === 'Escape';
|
|
977
|
-
}
|
|
978
|
-
switch (e.key.toLowerCase()) {
|
|
979
|
-
case 'g':
|
|
980
|
-
setTransformMode('translate');
|
|
981
|
-
break;
|
|
982
|
-
case 'r':
|
|
983
|
-
setTransformMode('rotate');
|
|
984
|
-
break;
|
|
985
|
-
case 's':
|
|
986
|
-
if (!e.ctrlKey) {
|
|
987
|
-
setTransformMode('scale');
|
|
988
|
-
}
|
|
989
|
-
break;
|
|
990
|
-
case ' ':
|
|
991
|
-
e.preventDefault();
|
|
992
|
-
togglePlay();
|
|
993
|
-
break;
|
|
994
|
-
case 'f':
|
|
995
|
-
if (rootObjectRef.current) {
|
|
996
|
-
focusOnObject(rootObjectRef.current);
|
|
997
|
-
}
|
|
998
|
-
break;
|
|
999
|
-
case 'delete':
|
|
1000
|
-
if (selectedId) {
|
|
1001
|
-
const sel = findNodeById(sceneTree, selectedId);
|
|
1002
|
-
if (sel) {
|
|
1003
|
-
sel.object.removeFromParent();
|
|
1004
|
-
(_c = transformRef.current) === null || _c === void 0 ? void 0 : _c.detach();
|
|
1005
|
-
setSelectedId(null);
|
|
1006
|
-
rebuildTree();
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
break;
|
|
1010
|
-
case 'x':
|
|
1011
|
-
if (e.ctrlKey && selectedId) {
|
|
1012
|
-
const sel = findNodeById(sceneTree, selectedId);
|
|
1013
|
-
if (sel) {
|
|
1014
|
-
sel.object.removeFromParent();
|
|
1015
|
-
(_d = transformRef.current) === null || _d === void 0 ? void 0 : _d.detach();
|
|
1016
|
-
setSelectedId(null);
|
|
1017
|
-
rebuildTree();
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
break;
|
|
1021
|
-
case 'd':
|
|
1022
|
-
if (e.shiftKey) {
|
|
1023
|
-
e.preventDefault();
|
|
1024
|
-
duplicateSelected();
|
|
1025
|
-
}
|
|
1026
|
-
break;
|
|
1027
|
-
case 'tab':
|
|
1028
|
-
e.preventDefault();
|
|
1029
|
-
setEditorMode((m) => (m === 'object' ? 'edit' : m === 'edit' ? 'pose' : 'object'));
|
|
1030
|
-
break;
|
|
1031
|
-
case 'n':
|
|
1032
|
-
if (!e.ctrlKey) ;
|
|
1033
|
-
break;
|
|
1034
|
-
case '1':
|
|
1035
|
-
if (e.code.startsWith('Numpad')) {
|
|
1036
|
-
setCameraPreset('front');
|
|
1037
|
-
}
|
|
1038
|
-
break;
|
|
1039
|
-
case '3':
|
|
1040
|
-
if (e.code.startsWith('Numpad')) {
|
|
1041
|
-
setCameraPreset('right');
|
|
1042
|
-
}
|
|
1043
|
-
break;
|
|
1044
|
-
case '7':
|
|
1045
|
-
if (e.code.startsWith('Numpad')) {
|
|
1046
|
-
setCameraPreset('top');
|
|
1047
|
-
}
|
|
1048
|
-
break;
|
|
1049
|
-
case '4':
|
|
1050
|
-
if (e.code.startsWith('Numpad')) {
|
|
1051
|
-
setCameraPreset('left');
|
|
1052
|
-
}
|
|
1053
|
-
break;
|
|
1054
|
-
case '6':
|
|
1055
|
-
if (e.code.startsWith('Numpad')) {
|
|
1056
|
-
setCameraPreset('right');
|
|
1057
|
-
}
|
|
1058
|
-
break;
|
|
1059
|
-
case '9':
|
|
1060
|
-
if (e.code.startsWith('Numpad')) {
|
|
1061
|
-
setCameraPreset('back');
|
|
1062
|
-
}
|
|
1063
|
-
break;
|
|
1064
|
-
case '2':
|
|
1065
|
-
if (e.code.startsWith('Numpad')) {
|
|
1066
|
-
setCameraPreset('bottom');
|
|
1067
|
-
}
|
|
1068
|
-
break;
|
|
1069
|
-
}
|
|
1070
|
-
if (e.key === 'Escape') {
|
|
1071
|
-
setAddMenuOpen(null);
|
|
1072
|
-
setContextMenu(null);
|
|
1073
|
-
}
|
|
1074
|
-
};
|
|
1075
|
-
window.addEventListener('keydown', handleKey);
|
|
1076
|
-
return () => window.removeEventListener('keydown', handleKey);
|
|
1077
|
-
}, [
|
|
1078
|
-
togglePlay,
|
|
1079
|
-
focusOnObject,
|
|
1080
|
-
selectedId,
|
|
1081
|
-
sceneTree,
|
|
1082
|
-
rebuildTree,
|
|
1083
|
-
setCameraPreset,
|
|
1084
|
-
duplicateSelected,
|
|
1085
|
-
]);
|
|
1086
|
-
/* ─────────────────────────────────────────
|
|
1087
|
-
File input handlers
|
|
1088
|
-
───────────────────────────────────────── */
|
|
1089
|
-
const handleFileInput = useCallback((e) => {
|
|
1090
|
-
const files = e.target.files;
|
|
1091
|
-
if (!files) {
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
Array.from(files).forEach(loadFile);
|
|
1095
|
-
e.target.value = '';
|
|
1096
|
-
}, [loadFile]);
|
|
1097
|
-
const handleMergeInput = useCallback((e) => {
|
|
1098
|
-
const files = e.target.files;
|
|
1099
|
-
if (!files) {
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
Array.from(files).forEach(mergeAnimationFBX);
|
|
1103
|
-
e.target.value = '';
|
|
1104
|
-
}, [mergeAnimationFBX]);
|
|
1105
|
-
/* ─────────────────────────────────────────
|
|
1106
|
-
Export functions
|
|
1107
|
-
───────────────────────────────────────── */
|
|
1108
|
-
const exportGLTF = useCallback((binary) => {
|
|
1109
|
-
const root = rootObjectRef.current;
|
|
1110
|
-
if (!root) {
|
|
1111
|
-
setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
const exporter = new GLTFExporter();
|
|
1115
|
-
const options = {
|
|
1116
|
-
binary,
|
|
1117
|
-
animations: allClipsRef.current.map((a) => a.clip),
|
|
1118
|
-
};
|
|
1119
|
-
exporter.parse(root, (result) => {
|
|
1120
|
-
let blob;
|
|
1121
|
-
if (binary) {
|
|
1122
|
-
blob = new Blob([result], { type: 'application/octet-stream' });
|
|
1123
|
-
}
|
|
1124
|
-
else {
|
|
1125
|
-
blob = new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' });
|
|
1126
|
-
}
|
|
1127
|
-
const a = document.createElement('a');
|
|
1128
|
-
a.href = URL.createObjectURL(blob);
|
|
1129
|
-
a.download = `export.${binary ? 'glb' : 'gltf'}`;
|
|
1130
|
-
a.click();
|
|
1131
|
-
URL.revokeObjectURL(a.href);
|
|
1132
|
-
setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? 'GLB' : 'GLTF'));
|
|
1133
|
-
}, (err) => {
|
|
1134
|
-
log.error('Export error:', err);
|
|
1135
|
-
setStatusText(t('useModelEditor.exportError', 'Export error: {err}').replace('{err}', String(String(err))));
|
|
1136
|
-
}, options);
|
|
1137
|
-
}, [t]);
|
|
1138
|
-
const exportOBJ = useCallback(() => {
|
|
1139
|
-
const root = rootObjectRef.current;
|
|
1140
|
-
if (!root) {
|
|
1141
|
-
setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
|
|
1142
|
-
return;
|
|
1143
|
-
}
|
|
1144
|
-
const exporter = new OBJExporter();
|
|
1145
|
-
const result = exporter.parse(root);
|
|
1146
|
-
const blob = new Blob([result], { type: 'text/plain' });
|
|
1147
|
-
const a = document.createElement('a');
|
|
1148
|
-
a.href = URL.createObjectURL(blob);
|
|
1149
|
-
a.download = 'export.obj';
|
|
1150
|
-
a.click();
|
|
1151
|
-
URL.revokeObjectURL(a.href);
|
|
1152
|
-
setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', 'OBJ'));
|
|
1153
|
-
}, [t]);
|
|
1154
|
-
const exportSTL = useCallback((binary) => {
|
|
1155
|
-
const root = rootObjectRef.current;
|
|
1156
|
-
if (!root) {
|
|
1157
|
-
setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
const exporter = new STLExporter();
|
|
1161
|
-
const result = exporter.parse(root, { binary });
|
|
1162
|
-
let blob;
|
|
1163
|
-
if (binary) {
|
|
1164
|
-
blob = new Blob([result], { type: 'application/octet-stream' });
|
|
1165
|
-
}
|
|
1166
|
-
else {
|
|
1167
|
-
blob = new Blob([result], { type: 'text/plain' });
|
|
1168
|
-
}
|
|
1169
|
-
const a = document.createElement('a');
|
|
1170
|
-
a.href = URL.createObjectURL(blob);
|
|
1171
|
-
a.download = 'export.stl';
|
|
1172
|
-
a.click();
|
|
1173
|
-
URL.revokeObjectURL(a.href);
|
|
1174
|
-
setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? t('useModelEditor.stlBinary', 'STL (binary)') : 'STL'));
|
|
1175
|
-
}, [t]);
|
|
1176
|
-
const exportPLY = useCallback((binary) => {
|
|
1177
|
-
const root = rootObjectRef.current;
|
|
1178
|
-
if (!root) {
|
|
1179
|
-
setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
const exporter = new PLYExporter();
|
|
1183
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1184
|
-
exporter.parse(root, (result) => {
|
|
1185
|
-
let blob;
|
|
1186
|
-
if (binary) {
|
|
1187
|
-
blob = new Blob([result], { type: 'application/octet-stream' });
|
|
1188
|
-
}
|
|
1189
|
-
else {
|
|
1190
|
-
blob = new Blob([result], { type: 'text/plain' });
|
|
1191
|
-
}
|
|
1192
|
-
const a = document.createElement('a');
|
|
1193
|
-
a.href = URL.createObjectURL(blob);
|
|
1194
|
-
a.download = 'export.ply';
|
|
1195
|
-
a.click();
|
|
1196
|
-
URL.revokeObjectURL(a.href);
|
|
1197
|
-
setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', binary ? t('useModelEditor.plyBinary', 'PLY (binary)') : 'PLY'));
|
|
1198
|
-
}, { binary });
|
|
1199
|
-
}, [t]);
|
|
1200
|
-
const exportUSDZ = useCallback(async () => {
|
|
1201
|
-
const root = rootObjectRef.current;
|
|
1202
|
-
if (!root) {
|
|
1203
|
-
setStatusText(t('useModelEditor.nothingToExport', 'Nothing to export'));
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
try {
|
|
1207
|
-
const exporter = new USDZExporter();
|
|
1208
|
-
const result = await new Promise((resolve) => {
|
|
1209
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- USDZExporter.parse callback signature varies between three.js minor versions
|
|
1210
|
-
exporter.parse(root, resolve);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
}, [wireframe]);
|
|
730
|
+
useEffect(() => {
|
|
731
|
+
const renderer = rendererRef.current;
|
|
732
|
+
if (!renderer) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const scene = sceneRef.current;
|
|
736
|
+
switch (shadingMode) {
|
|
737
|
+
case "wireframe":
|
|
738
|
+
scene.traverse((obj) => {
|
|
739
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
740
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
741
|
+
mats.forEach((m) => {
|
|
742
|
+
if ("wireframe" in m) {
|
|
743
|
+
m.wireframe = true;
|
|
744
|
+
}
|
|
1211
745
|
});
|
|
1212
|
-
|
|
1213
|
-
const a = document.createElement('a');
|
|
1214
|
-
a.href = URL.createObjectURL(blob);
|
|
1215
|
-
a.download = 'export.usdz';
|
|
1216
|
-
a.click();
|
|
1217
|
-
URL.revokeObjectURL(a.href);
|
|
1218
|
-
setStatusText(t('useModelEditor.exportedFormat', 'Exported {format} successfully').replace('{format}', 'USDZ'));
|
|
1219
|
-
}
|
|
1220
|
-
catch (err) {
|
|
1221
|
-
log.error('USDZ export error:', err);
|
|
1222
|
-
setStatusText(t('useModelEditor.usdzExportError', 'USDZ export error: {err}').replace('{err}', String(String(err))));
|
|
1223
|
-
}
|
|
1224
|
-
}, [t]);
|
|
1225
|
-
/* ─────────────────────────────────────────
|
|
1226
|
-
Dispose & clear
|
|
1227
|
-
───────────────────────────────────────── */
|
|
1228
|
-
const disposeObject = useCallback((obj) => {
|
|
1229
|
-
obj.traverse((child) => {
|
|
1230
|
-
if (child.geometry) {
|
|
1231
|
-
child.geometry.dispose();
|
|
1232
|
-
}
|
|
1233
|
-
if (child.material) {
|
|
1234
|
-
const mats = Array.isArray(child.material)
|
|
1235
|
-
? child.material
|
|
1236
|
-
: [child.material];
|
|
1237
|
-
mats.forEach((mat) => {
|
|
1238
|
-
const m = mat;
|
|
1239
|
-
[
|
|
1240
|
-
'map',
|
|
1241
|
-
'normalMap',
|
|
1242
|
-
'roughnessMap',
|
|
1243
|
-
'metalnessMap',
|
|
1244
|
-
'emissiveMap',
|
|
1245
|
-
'aoMap',
|
|
1246
|
-
'alphaMap',
|
|
1247
|
-
'envMap',
|
|
1248
|
-
'lightMap',
|
|
1249
|
-
'bumpMap',
|
|
1250
|
-
'displacementMap',
|
|
1251
|
-
'specularMap',
|
|
1252
|
-
].forEach((prop) => {
|
|
1253
|
-
if (m[prop] && typeof m[prop].dispose === 'function') {
|
|
1254
|
-
m[prop].dispose();
|
|
1255
|
-
}
|
|
1256
|
-
});
|
|
1257
|
-
mat.dispose();
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
746
|
+
}
|
|
1260
747
|
});
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
const toRemove = [];
|
|
748
|
+
renderer.toneMapping = THREE.NoToneMapping;
|
|
749
|
+
renderer.toneMappingExposure = 1;
|
|
750
|
+
break;
|
|
751
|
+
case "solid":
|
|
1266
752
|
scene.traverse((obj) => {
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
753
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
754
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
755
|
+
mats.forEach((m) => {
|
|
756
|
+
if ("wireframe" in m) {
|
|
757
|
+
m.wireframe = false;
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
1274
761
|
});
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
setIsPlaying(false);
|
|
1289
|
-
setAnimTime(0);
|
|
1290
|
-
setAnimDuration(0);
|
|
1291
|
-
setSelectedId(null);
|
|
1292
|
-
rebuildTree();
|
|
1293
|
-
setStatusText(t('useModelEditor.sceneCleared', 'Scene cleared'));
|
|
1294
|
-
}, [rebuildTree, disposeObject, t]);
|
|
1295
|
-
// asks first: clearing cannot be undone
|
|
1296
|
-
const clearScene = useCallback(() => {
|
|
1297
|
-
void ask('confirm', 'Clear the entire scene? This cannot be undone.').then((answer) => {
|
|
1298
|
-
if (answer !== null) {
|
|
1299
|
-
clearSceneNow();
|
|
1300
|
-
}
|
|
762
|
+
renderer.toneMapping = THREE.NoToneMapping;
|
|
763
|
+
renderer.toneMappingExposure = 1;
|
|
764
|
+
break;
|
|
765
|
+
case "material":
|
|
766
|
+
scene.traverse((obj) => {
|
|
767
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
768
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
769
|
+
mats.forEach((m) => {
|
|
770
|
+
if ("wireframe" in m) {
|
|
771
|
+
m.wireframe = false;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
1301
775
|
});
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
const exporter = new GLTFExporter();
|
|
1316
|
-
exporter.parse(root, (result) => {
|
|
1317
|
-
const blob = new Blob([result], { type: 'model/gltf-binary' });
|
|
1318
|
-
const reader = new FileReader();
|
|
1319
|
-
reader.onload = () => {
|
|
1320
|
-
const dataUrl = reader.result;
|
|
1321
|
-
void ask('prompt', 'Save to library as:', 'model.glb').then((name) => {
|
|
1322
|
-
if (name && name.trim()) {
|
|
1323
|
-
onSaveToLibrary(dataUrl, name.trim(), 'model/gltf-binary');
|
|
1324
|
-
}
|
|
1325
|
-
});
|
|
1326
|
-
};
|
|
1327
|
-
reader.readAsDataURL(blob);
|
|
1328
|
-
}, (err) => {
|
|
1329
|
-
log.error('Save to library error:', err);
|
|
1330
|
-
setStatusText(t('useModelEditor.saveError', 'Save error: {err}').replace('{err}', String(String(err))));
|
|
1331
|
-
}, { binary: true, animations: allClipsRef.current.map((a) => a.clip) });
|
|
1332
|
-
}, [onSaveToLibrary, ask, t]);
|
|
1333
|
-
/* ─────────────────────────────────────────
|
|
1334
|
-
Add Mesh / Light / Camera / Empty
|
|
1335
|
-
───────────────────────────────────────── */
|
|
1336
|
-
const addPrimitive = useCallback((type) => {
|
|
1337
|
-
let geo;
|
|
1338
|
-
switch (type) {
|
|
1339
|
-
case 'cube':
|
|
1340
|
-
geo = new THREE.BoxGeometry(1, 1, 1);
|
|
1341
|
-
break;
|
|
1342
|
-
case 'sphere':
|
|
1343
|
-
geo = new THREE.SphereGeometry(0.5, 32, 32);
|
|
1344
|
-
break;
|
|
1345
|
-
case 'cylinder':
|
|
1346
|
-
geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 32);
|
|
1347
|
-
break;
|
|
1348
|
-
case 'cone':
|
|
1349
|
-
geo = new THREE.ConeGeometry(0.5, 1, 32);
|
|
1350
|
-
break;
|
|
1351
|
-
case 'torus':
|
|
1352
|
-
geo = new THREE.TorusGeometry(0.5, 0.2, 16, 32);
|
|
1353
|
-
break;
|
|
1354
|
-
case 'plane':
|
|
1355
|
-
geo = new THREE.PlaneGeometry(2, 2);
|
|
1356
|
-
break;
|
|
1357
|
-
case 'circle':
|
|
1358
|
-
geo = new THREE.CircleGeometry(0.5, 32);
|
|
1359
|
-
break;
|
|
1360
|
-
case 'ring':
|
|
1361
|
-
geo = new THREE.RingGeometry(0.3, 0.5, 32);
|
|
1362
|
-
break;
|
|
1363
|
-
case 'dodecahedron':
|
|
1364
|
-
geo = new THREE.DodecahedronGeometry(0.5);
|
|
1365
|
-
break;
|
|
1366
|
-
case 'icosahedron':
|
|
1367
|
-
geo = new THREE.IcosahedronGeometry(0.5);
|
|
1368
|
-
break;
|
|
1369
|
-
case 'octahedron':
|
|
1370
|
-
geo = new THREE.OctahedronGeometry(0.5);
|
|
1371
|
-
break;
|
|
1372
|
-
case 'tetrahedron':
|
|
1373
|
-
geo = new THREE.TetrahedronGeometry(0.5);
|
|
1374
|
-
break;
|
|
1375
|
-
case 'torusKnot':
|
|
1376
|
-
geo = new THREE.TorusKnotGeometry(0.4, 0.15, 64, 16);
|
|
1377
|
-
break;
|
|
1378
|
-
default:
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1381
|
-
const mat = new THREE.MeshStandardMaterial({
|
|
1382
|
-
color: 0x888888,
|
|
1383
|
-
metalness: 0.1,
|
|
1384
|
-
roughness: 0.7,
|
|
776
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
777
|
+
renderer.toneMappingExposure = 1.2;
|
|
778
|
+
break;
|
|
779
|
+
case "rendered":
|
|
780
|
+
scene.traverse((obj) => {
|
|
781
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
782
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
783
|
+
mats.forEach((m) => {
|
|
784
|
+
if ("wireframe" in m) {
|
|
785
|
+
m.wireframe = false;
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
1385
789
|
});
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
790
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
791
|
+
renderer.toneMappingExposure = 1.5;
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
}, [shadingMode]);
|
|
795
|
+
useEffect(() => {
|
|
796
|
+
transformRef.current?.setSpace(gizmoSpace);
|
|
797
|
+
}, [gizmoSpace]);
|
|
798
|
+
useEffect(() => {
|
|
799
|
+
const tc = transformRef.current;
|
|
800
|
+
if (!tc) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (snapEnabled) {
|
|
804
|
+
tc.setTranslationSnap(snapGrid);
|
|
805
|
+
tc.setRotationSnap(THREE.MathUtils.degToRad(15));
|
|
806
|
+
tc.setScaleSnap(0.25);
|
|
807
|
+
} else {
|
|
808
|
+
tc.setTranslationSnap(null);
|
|
809
|
+
tc.setRotationSnap(null);
|
|
810
|
+
tc.setScaleSnap(null);
|
|
811
|
+
}
|
|
812
|
+
}, [snapEnabled, snapGrid]);
|
|
813
|
+
useEffect(() => {
|
|
814
|
+
const scene = sceneRef.current;
|
|
815
|
+
if (skeletonHelperRef.current) {
|
|
816
|
+
scene.remove(skeletonHelperRef.current);
|
|
817
|
+
skeletonHelperRef.current = null;
|
|
818
|
+
}
|
|
819
|
+
if (!showSkeleton || !rootObjectRef.current) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
let hasRig = false;
|
|
823
|
+
rootObjectRef.current.traverse((obj) => {
|
|
824
|
+
if (obj instanceof THREE.SkinnedMesh) {
|
|
825
|
+
hasRig = true;
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
if (hasRig) {
|
|
829
|
+
const helper = new THREE.SkeletonHelper(rootObjectRef.current);
|
|
830
|
+
helper.name = "__skeletonHelper";
|
|
831
|
+
scene.add(helper);
|
|
832
|
+
skeletonHelperRef.current = helper;
|
|
833
|
+
}
|
|
834
|
+
}, [showSkeleton, sceneTree]);
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
if (fogEnabled) {
|
|
837
|
+
sceneRef.current.fog = new THREE.Fog(
|
|
838
|
+
resolveCssColor(fogColor, mountRef.current),
|
|
839
|
+
fogNear,
|
|
840
|
+
fogFar
|
|
841
|
+
);
|
|
842
|
+
} else {
|
|
843
|
+
sceneRef.current.fog = null;
|
|
844
|
+
}
|
|
845
|
+
}, [fogEnabled, fogColor, fogNear, fogFar]);
|
|
846
|
+
useEffect(() => {
|
|
847
|
+
sceneRef.current.traverse((obj) => {
|
|
848
|
+
if (obj.name.startsWith("__helper_")) {
|
|
849
|
+
obj.visible = showLightHelpers;
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}, [showLightHelpers, sceneTree]);
|
|
853
|
+
const handleDragOver = useCallback((e) => {
|
|
854
|
+
e.preventDefault();
|
|
855
|
+
e.stopPropagation();
|
|
856
|
+
setDragOver(true);
|
|
857
|
+
}, []);
|
|
858
|
+
const handleDragLeave = useCallback((e) => {
|
|
859
|
+
e.preventDefault();
|
|
860
|
+
setDragOver(false);
|
|
861
|
+
}, []);
|
|
862
|
+
const handleDrop = useCallback(
|
|
863
|
+
(e) => {
|
|
864
|
+
e.preventDefault();
|
|
865
|
+
e.stopPropagation();
|
|
866
|
+
setDragOver(false);
|
|
867
|
+
const files = Array.from(e.dataTransfer.files);
|
|
868
|
+
files.forEach((f) => {
|
|
869
|
+
const ext = "." + f.name.toLowerCase().split(".").pop();
|
|
870
|
+
if (SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
871
|
+
loadFile(f);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
},
|
|
875
|
+
[loadFile]
|
|
876
|
+
);
|
|
877
|
+
const selectedNode = useMemo(
|
|
878
|
+
() => selectedId ? findNodeById(sceneTree, selectedId) : null,
|
|
879
|
+
[selectedId, sceneTree]
|
|
880
|
+
);
|
|
881
|
+
const selectedMaterials = useMemo(() => {
|
|
882
|
+
if (!selectedNode || !(selectedNode.object instanceof THREE.Mesh)) {
|
|
883
|
+
return [];
|
|
884
|
+
}
|
|
885
|
+
const m = selectedNode.object.material;
|
|
886
|
+
return Array.isArray(m) ? m : [m];
|
|
887
|
+
}, [selectedNode, matRefresh]);
|
|
888
|
+
const deleteSelected = useCallback(() => {
|
|
889
|
+
if (!selectedNode) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
selectedNode.object.removeFromParent();
|
|
893
|
+
transformRef.current?.detach();
|
|
894
|
+
setSelectedId(null);
|
|
895
|
+
rebuildTree();
|
|
896
|
+
}, [selectedNode, rebuildTree]);
|
|
897
|
+
const duplicateSelected = useCallback(() => {
|
|
898
|
+
if (!selectedNode) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const clone = selectedNode.object.clone(true);
|
|
902
|
+
clone.name = selectedNode.object.name + ".001";
|
|
903
|
+
clone.position.x += 1;
|
|
904
|
+
sceneRef.current.add(clone);
|
|
905
|
+
rebuildTree();
|
|
906
|
+
setStatusText(
|
|
907
|
+
t("useModelEditor.duplicated", "Duplicated {name}").replace(
|
|
908
|
+
"{name}",
|
|
909
|
+
String(selectedNode.name)
|
|
910
|
+
)
|
|
911
|
+
);
|
|
912
|
+
}, [selectedNode, rebuildTree, t]);
|
|
913
|
+
useEffect(() => {
|
|
914
|
+
let tabEscape = false;
|
|
915
|
+
const handleKey = (e) => {
|
|
916
|
+
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
917
|
+
const tag = target?.tagName;
|
|
918
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target?.isContentEditable) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const root = mountRef.current?.closest(".nice-app-shell") ?? mountRef.current;
|
|
922
|
+
if (target && target !== document.body && !root?.contains(target)) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if ((e.key === "Tab" || e.key === " ") && target?.closest(
|
|
926
|
+
'button, a[href], [role="button"], [role="combobox"], [role="menuitem"], [role="tab"], [role="option"], [role="checkbox"], [role="switch"], [role="slider"], [role="treeitem"]'
|
|
927
|
+
)) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (e.key === "Tab" && tabEscape) {
|
|
931
|
+
tabEscape = false;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (!["Shift", "Control", "Alt", "Meta"].includes(e.key)) {
|
|
935
|
+
tabEscape = e.key === "Escape";
|
|
936
|
+
}
|
|
937
|
+
switch (e.key.toLowerCase()) {
|
|
938
|
+
case "g":
|
|
939
|
+
setTransformMode("translate");
|
|
940
|
+
break;
|
|
941
|
+
case "r":
|
|
942
|
+
setTransformMode("rotate");
|
|
943
|
+
break;
|
|
944
|
+
case "s":
|
|
945
|
+
if (!e.ctrlKey) {
|
|
946
|
+
setTransformMode("scale");
|
|
947
|
+
}
|
|
948
|
+
break;
|
|
949
|
+
case " ":
|
|
950
|
+
e.preventDefault();
|
|
951
|
+
togglePlay();
|
|
952
|
+
break;
|
|
953
|
+
case "f":
|
|
954
|
+
if (rootObjectRef.current) {
|
|
955
|
+
focusOnObject(rootObjectRef.current);
|
|
956
|
+
}
|
|
957
|
+
break;
|
|
958
|
+
case "delete":
|
|
959
|
+
if (selectedId) {
|
|
960
|
+
const sel = findNodeById(sceneTree, selectedId);
|
|
961
|
+
if (sel) {
|
|
962
|
+
sel.object.removeFromParent();
|
|
963
|
+
transformRef.current?.detach();
|
|
964
|
+
setSelectedId(null);
|
|
965
|
+
rebuildTree();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
break;
|
|
969
|
+
case "x":
|
|
970
|
+
if (e.ctrlKey && selectedId) {
|
|
971
|
+
const sel = findNodeById(sceneTree, selectedId);
|
|
972
|
+
if (sel) {
|
|
973
|
+
sel.object.removeFromParent();
|
|
974
|
+
transformRef.current?.detach();
|
|
975
|
+
setSelectedId(null);
|
|
976
|
+
rebuildTree();
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
break;
|
|
980
|
+
case "d":
|
|
981
|
+
if (e.shiftKey) {
|
|
982
|
+
e.preventDefault();
|
|
983
|
+
duplicateSelected();
|
|
984
|
+
}
|
|
985
|
+
break;
|
|
986
|
+
case "tab":
|
|
987
|
+
e.preventDefault();
|
|
988
|
+
setEditorMode((m) => m === "object" ? "edit" : m === "edit" ? "pose" : "object");
|
|
989
|
+
break;
|
|
990
|
+
case "n":
|
|
991
|
+
if (!e.ctrlKey) ;
|
|
992
|
+
break;
|
|
993
|
+
case "1":
|
|
994
|
+
if (e.code.startsWith("Numpad")) {
|
|
995
|
+
setCameraPreset("front");
|
|
996
|
+
}
|
|
997
|
+
break;
|
|
998
|
+
case "3":
|
|
999
|
+
if (e.code.startsWith("Numpad")) {
|
|
1000
|
+
setCameraPreset("right");
|
|
1001
|
+
}
|
|
1002
|
+
break;
|
|
1003
|
+
case "7":
|
|
1004
|
+
if (e.code.startsWith("Numpad")) {
|
|
1005
|
+
setCameraPreset("top");
|
|
1006
|
+
}
|
|
1007
|
+
break;
|
|
1008
|
+
case "4":
|
|
1009
|
+
if (e.code.startsWith("Numpad")) {
|
|
1010
|
+
setCameraPreset("left");
|
|
1011
|
+
}
|
|
1012
|
+
break;
|
|
1013
|
+
case "6":
|
|
1014
|
+
if (e.code.startsWith("Numpad")) {
|
|
1015
|
+
setCameraPreset("right");
|
|
1016
|
+
}
|
|
1017
|
+
break;
|
|
1018
|
+
case "9":
|
|
1019
|
+
if (e.code.startsWith("Numpad")) {
|
|
1020
|
+
setCameraPreset("back");
|
|
1021
|
+
}
|
|
1022
|
+
break;
|
|
1023
|
+
case "2":
|
|
1024
|
+
if (e.code.startsWith("Numpad")) {
|
|
1025
|
+
setCameraPreset("bottom");
|
|
1026
|
+
}
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
if (e.key === "Escape") {
|
|
1454
1030
|
setAddMenuOpen(null);
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1031
|
+
setContextMenu(null);
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
window.addEventListener("keydown", handleKey);
|
|
1035
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
1036
|
+
}, [
|
|
1037
|
+
togglePlay,
|
|
1038
|
+
focusOnObject,
|
|
1039
|
+
selectedId,
|
|
1040
|
+
sceneTree,
|
|
1041
|
+
rebuildTree,
|
|
1042
|
+
setCameraPreset,
|
|
1043
|
+
duplicateSelected
|
|
1044
|
+
]);
|
|
1045
|
+
const handleFileInput = useCallback(
|
|
1046
|
+
(e) => {
|
|
1047
|
+
const files = e.target.files;
|
|
1048
|
+
if (!files) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
Array.from(files).forEach(loadFile);
|
|
1052
|
+
e.target.value = "";
|
|
1053
|
+
},
|
|
1054
|
+
[loadFile]
|
|
1055
|
+
);
|
|
1056
|
+
const handleMergeInput = useCallback(
|
|
1057
|
+
(e) => {
|
|
1058
|
+
const files = e.target.files;
|
|
1059
|
+
if (!files) {
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
Array.from(files).forEach(mergeAnimationFBX);
|
|
1063
|
+
e.target.value = "";
|
|
1064
|
+
},
|
|
1065
|
+
[mergeAnimationFBX]
|
|
1066
|
+
);
|
|
1067
|
+
const exportGLTF = useCallback(
|
|
1068
|
+
(binary) => {
|
|
1069
|
+
const root = rootObjectRef.current;
|
|
1070
|
+
if (!root) {
|
|
1071
|
+
setStatusText(t("useModelEditor.nothingToExport", "Nothing to export"));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const exporter = new GLTFExporter();
|
|
1075
|
+
const options = {
|
|
1076
|
+
binary,
|
|
1077
|
+
animations: allClipsRef.current.map((a) => a.clip)
|
|
1078
|
+
};
|
|
1079
|
+
exporter.parse(
|
|
1080
|
+
root,
|
|
1081
|
+
(result) => {
|
|
1082
|
+
let blob;
|
|
1083
|
+
if (binary) {
|
|
1084
|
+
blob = new Blob([result], { type: "application/octet-stream" });
|
|
1085
|
+
} else {
|
|
1086
|
+
blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
|
1087
|
+
}
|
|
1088
|
+
const a = document.createElement("a");
|
|
1089
|
+
a.href = URL.createObjectURL(blob);
|
|
1090
|
+
a.download = `export.${binary ? "glb" : "gltf"}`;
|
|
1091
|
+
a.click();
|
|
1092
|
+
URL.revokeObjectURL(a.href);
|
|
1093
|
+
setStatusText(
|
|
1094
|
+
t("useModelEditor.exportedFormat", "Exported {format} successfully").replace(
|
|
1095
|
+
"{format}",
|
|
1096
|
+
binary ? "GLB" : "GLTF"
|
|
1097
|
+
)
|
|
1098
|
+
);
|
|
1099
|
+
},
|
|
1100
|
+
(err) => {
|
|
1101
|
+
log.error("Export error:", err);
|
|
1102
|
+
setStatusText(
|
|
1103
|
+
t("useModelEditor.exportError", "Export error: {err}").replace(
|
|
1104
|
+
"{err}",
|
|
1105
|
+
String(String(err))
|
|
1106
|
+
)
|
|
1107
|
+
);
|
|
1108
|
+
},
|
|
1109
|
+
options
|
|
1110
|
+
);
|
|
1111
|
+
},
|
|
1112
|
+
[t]
|
|
1113
|
+
);
|
|
1114
|
+
const exportOBJ = useCallback(() => {
|
|
1115
|
+
const root = rootObjectRef.current;
|
|
1116
|
+
if (!root) {
|
|
1117
|
+
setStatusText(t("useModelEditor.nothingToExport", "Nothing to export"));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
const exporter = new OBJExporter();
|
|
1121
|
+
const result = exporter.parse(root);
|
|
1122
|
+
const blob = new Blob([result], { type: "text/plain" });
|
|
1123
|
+
const a = document.createElement("a");
|
|
1124
|
+
a.href = URL.createObjectURL(blob);
|
|
1125
|
+
a.download = "export.obj";
|
|
1126
|
+
a.click();
|
|
1127
|
+
URL.revokeObjectURL(a.href);
|
|
1128
|
+
setStatusText(
|
|
1129
|
+
t("useModelEditor.exportedFormat", "Exported {format} successfully").replace(
|
|
1130
|
+
"{format}",
|
|
1131
|
+
"OBJ"
|
|
1132
|
+
)
|
|
1133
|
+
);
|
|
1134
|
+
}, [t]);
|
|
1135
|
+
const exportSTL = useCallback(
|
|
1136
|
+
(binary) => {
|
|
1137
|
+
const root = rootObjectRef.current;
|
|
1138
|
+
if (!root) {
|
|
1139
|
+
setStatusText(t("useModelEditor.nothingToExport", "Nothing to export"));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const exporter = new STLExporter();
|
|
1143
|
+
const result = exporter.parse(root, { binary });
|
|
1144
|
+
let blob;
|
|
1145
|
+
if (binary) {
|
|
1146
|
+
blob = new Blob([result], { type: "application/octet-stream" });
|
|
1147
|
+
} else {
|
|
1148
|
+
blob = new Blob([result], { type: "text/plain" });
|
|
1149
|
+
}
|
|
1150
|
+
const a = document.createElement("a");
|
|
1151
|
+
a.href = URL.createObjectURL(blob);
|
|
1152
|
+
a.download = "export.stl";
|
|
1153
|
+
a.click();
|
|
1154
|
+
URL.revokeObjectURL(a.href);
|
|
1155
|
+
setStatusText(
|
|
1156
|
+
t("useModelEditor.exportedFormat", "Exported {format} successfully").replace(
|
|
1157
|
+
"{format}",
|
|
1158
|
+
binary ? t("useModelEditor.stlBinary", "STL (binary)") : "STL"
|
|
1159
|
+
)
|
|
1160
|
+
);
|
|
1161
|
+
},
|
|
1162
|
+
[t]
|
|
1163
|
+
);
|
|
1164
|
+
const exportPLY = useCallback(
|
|
1165
|
+
(binary) => {
|
|
1166
|
+
const root = rootObjectRef.current;
|
|
1167
|
+
if (!root) {
|
|
1168
|
+
setStatusText(t("useModelEditor.nothingToExport", "Nothing to export"));
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
const exporter = new PLYExporter();
|
|
1172
|
+
exporter.parse(
|
|
1173
|
+
root,
|
|
1174
|
+
(result) => {
|
|
1175
|
+
let blob;
|
|
1176
|
+
if (binary) {
|
|
1177
|
+
blob = new Blob([result], { type: "application/octet-stream" });
|
|
1178
|
+
} else {
|
|
1179
|
+
blob = new Blob([result], { type: "text/plain" });
|
|
1180
|
+
}
|
|
1181
|
+
const a = document.createElement("a");
|
|
1182
|
+
a.href = URL.createObjectURL(blob);
|
|
1183
|
+
a.download = "export.ply";
|
|
1184
|
+
a.click();
|
|
1185
|
+
URL.revokeObjectURL(a.href);
|
|
1186
|
+
setStatusText(
|
|
1187
|
+
t("useModelEditor.exportedFormat", "Exported {format} successfully").replace(
|
|
1188
|
+
"{format}",
|
|
1189
|
+
binary ? t("useModelEditor.plyBinary", "PLY (binary)") : "PLY"
|
|
1190
|
+
)
|
|
1191
|
+
);
|
|
1192
|
+
},
|
|
1193
|
+
{ binary }
|
|
1194
|
+
);
|
|
1195
|
+
},
|
|
1196
|
+
[t]
|
|
1197
|
+
);
|
|
1198
|
+
const exportUSDZ = useCallback(async () => {
|
|
1199
|
+
const root = rootObjectRef.current;
|
|
1200
|
+
if (!root) {
|
|
1201
|
+
setStatusText(t("useModelEditor.nothingToExport", "Nothing to export"));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const exporter = new USDZExporter();
|
|
1206
|
+
const result = await new Promise((resolve) => {
|
|
1207
|
+
exporter.parse(root, resolve);
|
|
1208
|
+
});
|
|
1209
|
+
const blob = new Blob([result.buffer], { type: "application/octet-stream" });
|
|
1210
|
+
const a = document.createElement("a");
|
|
1211
|
+
a.href = URL.createObjectURL(blob);
|
|
1212
|
+
a.download = "export.usdz";
|
|
1213
|
+
a.click();
|
|
1214
|
+
URL.revokeObjectURL(a.href);
|
|
1215
|
+
setStatusText(
|
|
1216
|
+
t("useModelEditor.exportedFormat", "Exported {format} successfully").replace(
|
|
1217
|
+
"{format}",
|
|
1218
|
+
"USDZ"
|
|
1219
|
+
)
|
|
1220
|
+
);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
log.error("USDZ export error:", err);
|
|
1223
|
+
setStatusText(
|
|
1224
|
+
t("useModelEditor.usdzExportError", "USDZ export error: {err}").replace(
|
|
1225
|
+
"{err}",
|
|
1226
|
+
String(String(err))
|
|
1227
|
+
)
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}, [t]);
|
|
1231
|
+
const disposeObject = useCallback((obj) => {
|
|
1232
|
+
obj.traverse((child) => {
|
|
1233
|
+
if (child.geometry) {
|
|
1234
|
+
child.geometry.dispose();
|
|
1235
|
+
}
|
|
1236
|
+
if (child.material) {
|
|
1237
|
+
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
1238
|
+
mats.forEach((mat) => {
|
|
1239
|
+
const m = mat;
|
|
1240
|
+
[
|
|
1241
|
+
"map",
|
|
1242
|
+
"normalMap",
|
|
1243
|
+
"roughnessMap",
|
|
1244
|
+
"metalnessMap",
|
|
1245
|
+
"emissiveMap",
|
|
1246
|
+
"aoMap",
|
|
1247
|
+
"alphaMap",
|
|
1248
|
+
"envMap",
|
|
1249
|
+
"lightMap",
|
|
1250
|
+
"bumpMap",
|
|
1251
|
+
"displacementMap",
|
|
1252
|
+
"specularMap"
|
|
1253
|
+
].forEach((prop) => {
|
|
1254
|
+
if (m[prop] && typeof m[prop].dispose === "function") {
|
|
1255
|
+
m[prop].dispose();
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
mat.dispose();
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
}, []);
|
|
1263
|
+
const clearSceneNow = useCallback(() => {
|
|
1264
|
+
const scene = sceneRef.current;
|
|
1265
|
+
const toRemove = [];
|
|
1266
|
+
scene.traverse((obj) => {
|
|
1267
|
+
if (!obj.name.startsWith("__") && obj !== scene && obj.parent === scene) {
|
|
1268
|
+
toRemove.push(obj);
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
toRemove.forEach((o) => {
|
|
1272
|
+
disposeObject(o);
|
|
1273
|
+
scene.remove(o);
|
|
1274
|
+
});
|
|
1275
|
+
if (mixerRef.current) {
|
|
1276
|
+
mixerRef.current.stopAllAction();
|
|
1277
|
+
if (rootObjectRef.current) {
|
|
1278
|
+
mixerRef.current.uncacheRoot(rootObjectRef.current);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
transformRef.current?.detach();
|
|
1282
|
+
rootObjectRef.current = null;
|
|
1283
|
+
mixerRef.current = null;
|
|
1284
|
+
activeActionRef.current = null;
|
|
1285
|
+
allClipsRef.current = [];
|
|
1286
|
+
setAnimations([]);
|
|
1287
|
+
setActiveAnimIdx(-1);
|
|
1288
|
+
setIsPlaying(false);
|
|
1289
|
+
setAnimTime(0);
|
|
1290
|
+
setAnimDuration(0);
|
|
1291
|
+
setSelectedId(null);
|
|
1292
|
+
rebuildTree();
|
|
1293
|
+
setStatusText(t("useModelEditor.sceneCleared", "Scene cleared"));
|
|
1294
|
+
}, [rebuildTree, disposeObject, t]);
|
|
1295
|
+
const clearScene = useCallback(() => {
|
|
1296
|
+
void ask("confirm", "Clear the entire scene? This cannot be undone.").then((answer) => {
|
|
1297
|
+
if (answer !== null) {
|
|
1298
|
+
clearSceneNow();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
}, [ask, clearSceneNow]);
|
|
1302
|
+
const saveToLibrary = useCallback(() => {
|
|
1303
|
+
if (!onSaveToLibrary) {
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
const root = rootObjectRef.current;
|
|
1307
|
+
if (!root) {
|
|
1308
|
+
setStatusText(t("useModelEditor.nothingToSave", "Nothing to save"));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const exporter = new GLTFExporter();
|
|
1312
|
+
exporter.parse(
|
|
1313
|
+
root,
|
|
1314
|
+
(result) => {
|
|
1315
|
+
const blob = new Blob([result], { type: "model/gltf-binary" });
|
|
1316
|
+
const reader = new FileReader();
|
|
1317
|
+
reader.onload = () => {
|
|
1318
|
+
const dataUrl = reader.result;
|
|
1319
|
+
void ask("prompt", "Save to library as:", "model.glb").then((name) => {
|
|
1320
|
+
if (name && name.trim()) {
|
|
1321
|
+
onSaveToLibrary(dataUrl, name.trim(), "model/gltf-binary");
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
};
|
|
1325
|
+
reader.readAsDataURL(blob);
|
|
1326
|
+
},
|
|
1327
|
+
(err) => {
|
|
1328
|
+
log.error("Save to library error:", err);
|
|
1329
|
+
setStatusText(
|
|
1330
|
+
t("useModelEditor.saveError", "Save error: {err}").replace("{err}", String(String(err)))
|
|
1331
|
+
);
|
|
1332
|
+
},
|
|
1333
|
+
{ binary: true, animations: allClipsRef.current.map((a) => a.clip) }
|
|
1334
|
+
);
|
|
1335
|
+
}, [onSaveToLibrary, ask, t]);
|
|
1336
|
+
const addPrimitive = useCallback(
|
|
1337
|
+
(type) => {
|
|
1338
|
+
let geo;
|
|
1339
|
+
switch (type) {
|
|
1340
|
+
case "cube":
|
|
1341
|
+
geo = new THREE.BoxGeometry(1, 1, 1);
|
|
1342
|
+
break;
|
|
1343
|
+
case "sphere":
|
|
1344
|
+
geo = new THREE.SphereGeometry(0.5, 32, 32);
|
|
1345
|
+
break;
|
|
1346
|
+
case "cylinder":
|
|
1347
|
+
geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 32);
|
|
1348
|
+
break;
|
|
1349
|
+
case "cone":
|
|
1350
|
+
geo = new THREE.ConeGeometry(0.5, 1, 32);
|
|
1351
|
+
break;
|
|
1352
|
+
case "torus":
|
|
1353
|
+
geo = new THREE.TorusGeometry(0.5, 0.2, 16, 32);
|
|
1354
|
+
break;
|
|
1355
|
+
case "plane":
|
|
1356
|
+
geo = new THREE.PlaneGeometry(2, 2);
|
|
1357
|
+
break;
|
|
1358
|
+
case "circle":
|
|
1359
|
+
geo = new THREE.CircleGeometry(0.5, 32);
|
|
1360
|
+
break;
|
|
1361
|
+
case "ring":
|
|
1362
|
+
geo = new THREE.RingGeometry(0.3, 0.5, 32);
|
|
1363
|
+
break;
|
|
1364
|
+
case "dodecahedron":
|
|
1365
|
+
geo = new THREE.DodecahedronGeometry(0.5);
|
|
1366
|
+
break;
|
|
1367
|
+
case "icosahedron":
|
|
1368
|
+
geo = new THREE.IcosahedronGeometry(0.5);
|
|
1369
|
+
break;
|
|
1370
|
+
case "octahedron":
|
|
1371
|
+
geo = new THREE.OctahedronGeometry(0.5);
|
|
1372
|
+
break;
|
|
1373
|
+
case "tetrahedron":
|
|
1374
|
+
geo = new THREE.TetrahedronGeometry(0.5);
|
|
1375
|
+
break;
|
|
1376
|
+
case "torusKnot":
|
|
1377
|
+
geo = new THREE.TorusKnotGeometry(0.4, 0.15, 64, 16);
|
|
1378
|
+
break;
|
|
1379
|
+
default:
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
1383
|
+
color: 8947848,
|
|
1384
|
+
metalness: 0.1,
|
|
1385
|
+
roughness: 0.7
|
|
1386
|
+
});
|
|
1387
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
1388
|
+
mesh.name = type.charAt(0).toUpperCase() + type.slice(1);
|
|
1389
|
+
mesh.castShadow = true;
|
|
1390
|
+
mesh.receiveShadow = true;
|
|
1391
|
+
if (type === "plane" || type === "circle") {
|
|
1392
|
+
mesh.rotation.x = -Math.PI / 2;
|
|
1393
|
+
mesh.position.y = 1e-3;
|
|
1394
|
+
} else {
|
|
1395
|
+
mesh.position.y = 0.5;
|
|
1396
|
+
}
|
|
1397
|
+
sceneRef.current.add(mesh);
|
|
1398
|
+
rebuildTree();
|
|
1399
|
+
setStatusText(t("useModelEditor.added", "Added {name}").replace("{name}", String(mesh.name)));
|
|
1400
|
+
setAddMenuOpen(null);
|
|
1401
|
+
},
|
|
1402
|
+
[rebuildTree, t]
|
|
1403
|
+
);
|
|
1404
|
+
const addSceneLight = useCallback(
|
|
1405
|
+
(type) => {
|
|
1406
|
+
let light;
|
|
1407
|
+
switch (type) {
|
|
1408
|
+
case "point": {
|
|
1409
|
+
const l = new THREE.PointLight(16777215, 1, 20);
|
|
1410
|
+
l.position.set(0, 3, 0);
|
|
1411
|
+
l.castShadow = true;
|
|
1412
|
+
light = l;
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
case "spot": {
|
|
1416
|
+
const l = new THREE.SpotLight(16777215, 1, 20, Math.PI / 6, 0.3);
|
|
1417
|
+
l.position.set(0, 4, 2);
|
|
1418
|
+
l.castShadow = true;
|
|
1419
|
+
light = l;
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
case "directional": {
|
|
1423
|
+
const l = new THREE.DirectionalLight(16777215, 1);
|
|
1424
|
+
l.position.set(3, 5, 3);
|
|
1425
|
+
l.castShadow = true;
|
|
1426
|
+
light = l;
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
case "hemisphere": {
|
|
1430
|
+
light = new THREE.HemisphereLight(8947967, 4465152, 0.5);
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
default:
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
light.name = type.charAt(0).toUpperCase() + type.slice(1) + "Light";
|
|
1437
|
+
sceneRef.current.add(light);
|
|
1438
|
+
let helper = null;
|
|
1439
|
+
if (light instanceof THREE.PointLight) {
|
|
1440
|
+
helper = new THREE.PointLightHelper(light, 0.3);
|
|
1441
|
+
} else if (light instanceof THREE.SpotLight) {
|
|
1442
|
+
helper = new THREE.SpotLightHelper(light);
|
|
1443
|
+
} else if (light instanceof THREE.DirectionalLight) {
|
|
1444
|
+
helper = new THREE.DirectionalLightHelper(light, 0.5);
|
|
1445
|
+
} else if (light instanceof THREE.HemisphereLight) {
|
|
1446
|
+
helper = new THREE.HemisphereLightHelper(light, 0.3);
|
|
1447
|
+
}
|
|
1448
|
+
if (helper) {
|
|
1449
|
+
helper.name = `__helper_${light.name}`;
|
|
1464
1450
|
sceneRef.current.add(helper);
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1451
|
+
}
|
|
1452
|
+
rebuildTree();
|
|
1453
|
+
setStatusText(
|
|
1454
|
+
t("useModelEditor.added", "Added {name}").replace("{name}", String(light.name))
|
|
1455
|
+
);
|
|
1456
|
+
setAddMenuOpen(null);
|
|
1457
|
+
},
|
|
1458
|
+
[rebuildTree, t]
|
|
1459
|
+
);
|
|
1460
|
+
const addCameraObject = useCallback(() => {
|
|
1461
|
+
const cam = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1e3);
|
|
1462
|
+
cam.name = "Camera";
|
|
1463
|
+
cam.position.set(5, 3, 5);
|
|
1464
|
+
cam.lookAt(0, 0, 0);
|
|
1465
|
+
sceneRef.current.add(cam);
|
|
1466
|
+
const helper = new THREE.CameraHelper(cam);
|
|
1467
|
+
helper.name = "__helper_Camera";
|
|
1468
|
+
sceneRef.current.add(helper);
|
|
1469
|
+
rebuildTree();
|
|
1470
|
+
setStatusText(t("useModelEditor.addedCamera", "Added Camera"));
|
|
1471
|
+
setAddMenuOpen(null);
|
|
1472
|
+
}, [rebuildTree, t]);
|
|
1473
|
+
const addEmpty = useCallback(
|
|
1474
|
+
(type) => {
|
|
1475
|
+
const obj = new THREE.Object3D();
|
|
1476
|
+
obj.name = type === "arrows" ? "Empty_Arrows" : type === "cube_empty" ? "Empty_Cube" : "Empty";
|
|
1477
|
+
sceneRef.current.add(obj);
|
|
1478
|
+
if (type === "arrows") {
|
|
1479
|
+
const h = new THREE.AxesHelper(0.5);
|
|
1480
|
+
h.name = "__emptyAxes";
|
|
1481
|
+
obj.add(h);
|
|
1482
|
+
}
|
|
1483
|
+
rebuildTree();
|
|
1484
|
+
setStatusText(t("useModelEditor.added", "Added {name}").replace("{name}", String(obj.name)));
|
|
1485
|
+
setAddMenuOpen(null);
|
|
1486
|
+
},
|
|
1487
|
+
[rebuildTree, t]
|
|
1488
|
+
);
|
|
1489
|
+
const handleViewportClick = useCallback(
|
|
1490
|
+
(e) => {
|
|
1491
|
+
if (editorMode !== "object") {
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (!mountRef.current || !rendererRef.current || !cameraRef.current) {
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (e.target !== rendererRef.current.domElement) {
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const rect = mountRef.current.getBoundingClientRect();
|
|
1501
|
+
const x = (e.clientX - rect.left) / rect.width * 2 - 1;
|
|
1502
|
+
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1503
|
+
raycasterRef.current.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
|
|
1504
|
+
const meshes = [];
|
|
1505
|
+
sceneRef.current.traverse((obj) => {
|
|
1506
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
1507
|
+
meshes.push(obj);
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
const intersects = raycasterRef.current.intersectObjects(meshes, false);
|
|
1511
|
+
if (intersects.length > 0) {
|
|
1512
|
+
const hit = intersects[0].object;
|
|
1513
|
+
const nodeId = hit.userData.__nodeId;
|
|
1514
|
+
if (nodeId) {
|
|
1515
|
+
const node = findNodeById(sceneTree, nodeId);
|
|
1516
|
+
if (node) {
|
|
1517
|
+
selectObject(node);
|
|
1494
1518
|
return;
|
|
1519
|
+
}
|
|
1495
1520
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1521
|
+
rebuildTree();
|
|
1522
|
+
} else {
|
|
1523
|
+
selectObject(null);
|
|
1524
|
+
}
|
|
1525
|
+
},
|
|
1526
|
+
[editorMode, sceneTree, selectObject, rebuildTree]
|
|
1527
|
+
);
|
|
1528
|
+
const handleContextMenu = useCallback((e) => {
|
|
1529
|
+
e.preventDefault();
|
|
1530
|
+
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
1531
|
+
}, []);
|
|
1532
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
1533
|
+
useEffect(() => {
|
|
1534
|
+
if (!contextMenu) {
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const close = () => setContextMenu(null);
|
|
1538
|
+
window.addEventListener("click", close);
|
|
1539
|
+
return () => window.removeEventListener("click", close);
|
|
1540
|
+
}, [contextMenu]);
|
|
1541
|
+
const handleAIVideo = useCallback(
|
|
1542
|
+
async (e) => {
|
|
1543
|
+
const file = e.target.files?.[0];
|
|
1544
|
+
if (!file) {
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
e.target.value = "";
|
|
1548
|
+
if (!onAIPoseVideo) {
|
|
1549
|
+
setAiStatus(
|
|
1550
|
+
t("useModelEditor.aiPoseCallbackNotConfigured", "AI pose callback not configured.")
|
|
1551
|
+
);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
setAiBusy(true);
|
|
1555
|
+
setAiStatus(
|
|
1556
|
+
t("useModelEditor.uploadingVideoForAiPose", "Uploading video for AI pose estimation...")
|
|
1557
|
+
);
|
|
1558
|
+
try {
|
|
1559
|
+
const result = await onAIPoseVideo(file);
|
|
1560
|
+
setAiStatus(
|
|
1561
|
+
t(
|
|
1562
|
+
"useModelEditor.gotFramesAtFpsCreating",
|
|
1563
|
+
"Got {frame_count} frames at {fps} fps. Creating animation clip..."
|
|
1564
|
+
).replace("{frame_count}", String(result.frame_count)).replace("{fps}", String(result.fps))
|
|
1565
|
+
);
|
|
1566
|
+
const clip = pose3dToClip(result);
|
|
1567
|
+
if (clip && rootObjectRef.current) {
|
|
1568
|
+
if (!mixerRef.current) {
|
|
1569
|
+
mixerRef.current = new THREE.AnimationMixer(rootObjectRef.current);
|
|
1570
|
+
}
|
|
1571
|
+
const newAnim = {
|
|
1572
|
+
name: `AI: ${file.name}`,
|
|
1573
|
+
clip,
|
|
1574
|
+
duration: clip.duration,
|
|
1575
|
+
source: file.name
|
|
1576
|
+
};
|
|
1577
|
+
allClipsRef.current = [...allClipsRef.current, newAnim];
|
|
1578
|
+
setAnimations([...allClipsRef.current]);
|
|
1579
|
+
playClip(allClipsRef.current.length - 1);
|
|
1580
|
+
setAiStatus(
|
|
1581
|
+
t("useModelEditor.animationCreatedSuccessfully", "Animation created successfully!")
|
|
1582
|
+
);
|
|
1583
|
+
} else {
|
|
1584
|
+
setAiStatus(
|
|
1585
|
+
t(
|
|
1586
|
+
"useModelEditor.couldNotCreateAnimationLoad",
|
|
1587
|
+
"Could not create animation — load a rigged model first."
|
|
1588
|
+
)
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
log.error("AI pose error:", err);
|
|
1593
|
+
setAiStatus(
|
|
1594
|
+
t("useModelEditor.error", "Error: {err}").replace("{err}", String(String(err)))
|
|
1595
|
+
);
|
|
1596
|
+
} finally {
|
|
1597
|
+
setAiBusy(false);
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
[playClip, onAIPoseVideo, t]
|
|
1601
|
+
);
|
|
1602
|
+
const toggleNodeVisibility = useCallback(
|
|
1603
|
+
(node) => {
|
|
1604
|
+
node.object.visible = !node.object.visible;
|
|
1605
|
+
rebuildTree();
|
|
1606
|
+
},
|
|
1607
|
+
[rebuildTree]
|
|
1608
|
+
);
|
|
1609
|
+
const toggleNodeExpanded = useCallback((node) => {
|
|
1610
|
+
node.expanded = !node.expanded;
|
|
1611
|
+
setSceneTree((prev) => [...prev]);
|
|
1612
|
+
}, []);
|
|
1613
|
+
const undoRedoRef = useRef(new UndoRedoStack());
|
|
1614
|
+
const [_undoRedoVer, setUndoRedoVer] = useState(0);
|
|
1615
|
+
const pushUndo = useCallback((action) => {
|
|
1616
|
+
undoRedoRef.current.push(action);
|
|
1617
|
+
setUndoRedoVer((v) => v + 1);
|
|
1618
|
+
}, []);
|
|
1619
|
+
const undo = useCallback(() => {
|
|
1620
|
+
const label = undoRedoRef.current.undo();
|
|
1621
|
+
if (label) {
|
|
1622
|
+
setStatusText(t("useModelEditor.undo", "Undo: {label}").replace("{label}", String(label)));
|
|
1623
|
+
rebuildTree();
|
|
1624
|
+
}
|
|
1625
|
+
setUndoRedoVer((v) => v + 1);
|
|
1626
|
+
}, [rebuildTree, t]);
|
|
1627
|
+
const redo = useCallback(() => {
|
|
1628
|
+
const label = undoRedoRef.current.redo();
|
|
1629
|
+
if (label) {
|
|
1630
|
+
setStatusText(t("useModelEditor.redo", "Redo: {label}").replace("{label}", String(label)));
|
|
1631
|
+
rebuildTree();
|
|
1632
|
+
}
|
|
1633
|
+
setUndoRedoVer((v) => v + 1);
|
|
1634
|
+
}, [rebuildTree, t]);
|
|
1635
|
+
const canUndo = undoRedoRef.current.canUndo();
|
|
1636
|
+
const canRedo = undoRedoRef.current.canRedo();
|
|
1637
|
+
const undoLabel = undoRedoRef.current.undoLabel;
|
|
1638
|
+
const redoLabel = undoRedoRef.current.redoLabel;
|
|
1639
|
+
useEffect(() => {
|
|
1640
|
+
const handleKeyDown = (e) => {
|
|
1641
|
+
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
1642
|
+
const tag = target?.tagName;
|
|
1643
|
+
const root = mountRef.current?.closest(".nice-app-shell") ?? mountRef.current;
|
|
1644
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable || target && target !== document.body && !root?.contains(target)) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
|
|
1527
1648
|
e.preventDefault();
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1649
|
+
undo();
|
|
1650
|
+
} else if ((e.ctrlKey || e.metaKey) && (e.key === "y" || e.key === "z" && e.shiftKey)) {
|
|
1651
|
+
e.preventDefault();
|
|
1652
|
+
redo();
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
1656
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
1657
|
+
}, [undo, redo]);
|
|
1658
|
+
const loadFiles = useCallback(
|
|
1659
|
+
async (files) => {
|
|
1660
|
+
const fileArray = Array.from(files).filter((f) => {
|
|
1661
|
+
const ext = f.name.substring(f.name.lastIndexOf(".")).toLowerCase();
|
|
1662
|
+
return SUPPORTED_EXTENSIONS.includes(ext);
|
|
1663
|
+
});
|
|
1664
|
+
if (fileArray.length === 0) {
|
|
1665
|
+
setStatusText(t("useModelEditor.noSupportedFilesFound", "No supported files found"));
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
setStatusText(
|
|
1669
|
+
t("useModelEditor.importingFileS", "Importing {fileArray} file(s)...").replace(
|
|
1670
|
+
"{fileArray}",
|
|
1671
|
+
String(fileArray.length)
|
|
1672
|
+
)
|
|
1673
|
+
);
|
|
1674
|
+
let loaded = 0;
|
|
1675
|
+
for (const file of fileArray) {
|
|
1555
1676
|
try {
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
}
|
|
1622
|
-
setUndoRedoVer((v) => v + 1);
|
|
1623
|
-
}, [rebuildTree, t]);
|
|
1624
|
-
const canUndo = undoRedoRef.current.canUndo();
|
|
1625
|
-
const canRedo = undoRedoRef.current.canRedo();
|
|
1626
|
-
const undoLabel = undoRedoRef.current.undoLabel;
|
|
1627
|
-
const redoLabel = undoRedoRef.current.redoLabel;
|
|
1628
|
-
// Keyboard shortcuts for undo/redo — in this editor only, and not in a text field, whose own
|
|
1629
|
-
// undo they took (on the whole page before)
|
|
1630
|
-
useEffect(() => {
|
|
1631
|
-
const handleKeyDown = (e) => {
|
|
1632
|
-
var _a, _b;
|
|
1633
|
-
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
1634
|
-
const tag = target === null || target === void 0 ? void 0 : target.tagName;
|
|
1635
|
-
const root = (_b = (_a = mountRef.current) === null || _a === void 0 ? void 0 : _a.closest('.nice-app-shell')) !== null && _b !== void 0 ? _b : mountRef.current;
|
|
1636
|
-
if (tag === 'INPUT' ||
|
|
1637
|
-
tag === 'TEXTAREA' ||
|
|
1638
|
-
(target === null || target === void 0 ? void 0 : target.isContentEditable) ||
|
|
1639
|
-
(target && target !== document.body && !(root === null || root === void 0 ? void 0 : root.contains(target)))) {
|
|
1640
|
-
return;
|
|
1641
|
-
}
|
|
1642
|
-
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
1643
|
-
e.preventDefault();
|
|
1644
|
-
undo();
|
|
1645
|
-
}
|
|
1646
|
-
else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
|
1647
|
-
e.preventDefault();
|
|
1648
|
-
redo();
|
|
1649
|
-
}
|
|
1650
|
-
};
|
|
1651
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
1652
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
1653
|
-
}, [undo, redo]);
|
|
1654
|
-
/* ─────────────────────────────────────────
|
|
1655
|
-
Batch Import
|
|
1656
|
-
───────────────────────────────────────── */
|
|
1657
|
-
const loadFiles = useCallback(async (files) => {
|
|
1658
|
-
const fileArray = Array.from(files).filter((f) => {
|
|
1659
|
-
const ext = f.name.substring(f.name.lastIndexOf('.')).toLowerCase();
|
|
1660
|
-
return SUPPORTED_EXTENSIONS.includes(ext);
|
|
1661
|
-
});
|
|
1662
|
-
if (fileArray.length === 0) {
|
|
1663
|
-
setStatusText(t('useModelEditor.noSupportedFilesFound', 'No supported files found'));
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
setStatusText(t('useModelEditor.importingFileS', 'Importing {fileArray} file(s)...').replace('{fileArray}', String(fileArray.length)));
|
|
1667
|
-
let loaded = 0;
|
|
1668
|
-
for (const file of fileArray) {
|
|
1669
|
-
try {
|
|
1670
|
-
await loadFile(file);
|
|
1671
|
-
loaded++;
|
|
1672
|
-
setStatusText(t('useModelEditor.imported', 'Imported {loaded}/{fileArray}: {name}')
|
|
1673
|
-
.replace('{loaded}', String(loaded))
|
|
1674
|
-
.replace('{fileArray}', String(fileArray.length))
|
|
1675
|
-
.replace('{name}', String(file.name)));
|
|
1676
|
-
}
|
|
1677
|
-
catch (err) {
|
|
1678
|
-
log.error(`Failed to import ${file.name}:`, err);
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
setStatusText(t('useModelEditor.batchImportCompleteFilesLoaded', 'Batch import complete: {loaded}/{fileArray} files loaded')
|
|
1682
|
-
.replace('{loaded}', String(loaded))
|
|
1683
|
-
.replace('{fileArray}', String(fileArray.length)));
|
|
1684
|
-
}, [loadFile, t]);
|
|
1685
|
-
/* ─────────────────────────────────────────
|
|
1686
|
-
PBR Presets
|
|
1687
|
-
───────────────────────────────────────── */
|
|
1688
|
-
const applyPreset = useCallback((preset) => {
|
|
1689
|
-
const node = selectedNode;
|
|
1690
|
-
if (!node || !(node.object instanceof THREE.Mesh)) {
|
|
1691
|
-
setStatusText(t('useModelEditor.selectAMeshToApply', 'Select a mesh to apply material preset'));
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
const mesh = node.object;
|
|
1695
|
-
const oldMaterial = Array.isArray(mesh.material)
|
|
1696
|
-
? mesh.material[selMaterialIdx]
|
|
1697
|
-
: mesh.material;
|
|
1698
|
-
if (!oldMaterial) {
|
|
1699
|
-
return;
|
|
1700
|
-
}
|
|
1701
|
-
// Save for undo
|
|
1702
|
-
const oldClone = oldMaterial.clone();
|
|
1703
|
-
const materialIdx = selMaterialIdx;
|
|
1704
|
-
const newMaterial = createMaterialFromPreset(preset);
|
|
1705
|
-
// Copy existing textures if present
|
|
1706
|
-
if (oldMaterial instanceof THREE.MeshStandardMaterial) {
|
|
1707
|
-
if (oldMaterial.map) {
|
|
1708
|
-
newMaterial.map = oldMaterial.map;
|
|
1709
|
-
}
|
|
1710
|
-
if (oldMaterial.normalMap) {
|
|
1711
|
-
newMaterial.normalMap = oldMaterial.normalMap;
|
|
1712
|
-
}
|
|
1713
|
-
if (oldMaterial.aoMap) {
|
|
1714
|
-
newMaterial.aoMap = oldMaterial.aoMap;
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
if (Array.isArray(mesh.material)) {
|
|
1677
|
+
await loadFile(file);
|
|
1678
|
+
loaded++;
|
|
1679
|
+
setStatusText(
|
|
1680
|
+
t("useModelEditor.imported", "Imported {loaded}/{fileArray}: {name}").replace("{loaded}", String(loaded)).replace("{fileArray}", String(fileArray.length)).replace("{name}", String(file.name))
|
|
1681
|
+
);
|
|
1682
|
+
} catch (err) {
|
|
1683
|
+
log.error(`Failed to import ${file.name}:`, err);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
setStatusText(
|
|
1687
|
+
t(
|
|
1688
|
+
"useModelEditor.batchImportCompleteFilesLoaded",
|
|
1689
|
+
"Batch import complete: {loaded}/{fileArray} files loaded"
|
|
1690
|
+
).replace("{loaded}", String(loaded)).replace("{fileArray}", String(fileArray.length))
|
|
1691
|
+
);
|
|
1692
|
+
},
|
|
1693
|
+
[loadFile, t]
|
|
1694
|
+
);
|
|
1695
|
+
const applyPreset = useCallback(
|
|
1696
|
+
(preset) => {
|
|
1697
|
+
const node = selectedNode;
|
|
1698
|
+
if (!node || !(node.object instanceof THREE.Mesh)) {
|
|
1699
|
+
setStatusText(
|
|
1700
|
+
t("useModelEditor.selectAMeshToApply", "Select a mesh to apply material preset")
|
|
1701
|
+
);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const mesh = node.object;
|
|
1705
|
+
const oldMaterial = Array.isArray(mesh.material) ? mesh.material[selMaterialIdx] : mesh.material;
|
|
1706
|
+
if (!oldMaterial) {
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const oldClone = oldMaterial.clone();
|
|
1710
|
+
const materialIdx = selMaterialIdx;
|
|
1711
|
+
const newMaterial = createMaterialFromPreset(preset);
|
|
1712
|
+
if (oldMaterial instanceof THREE.MeshStandardMaterial) {
|
|
1713
|
+
if (oldMaterial.map) {
|
|
1714
|
+
newMaterial.map = oldMaterial.map;
|
|
1715
|
+
}
|
|
1716
|
+
if (oldMaterial.normalMap) {
|
|
1717
|
+
newMaterial.normalMap = oldMaterial.normalMap;
|
|
1718
|
+
}
|
|
1719
|
+
if (oldMaterial.aoMap) {
|
|
1720
|
+
newMaterial.aoMap = oldMaterial.aoMap;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
if (Array.isArray(mesh.material)) {
|
|
1724
|
+
mesh.material[materialIdx] = newMaterial;
|
|
1725
|
+
} else {
|
|
1726
|
+
mesh.material = newMaterial;
|
|
1727
|
+
}
|
|
1728
|
+
pushUndo({
|
|
1729
|
+
label: t("useModelEditor.applyPreset", "Apply {name} preset").replace(
|
|
1730
|
+
"{name}",
|
|
1731
|
+
String(preset.name)
|
|
1732
|
+
),
|
|
1733
|
+
undo: () => {
|
|
1734
|
+
if (Array.isArray(mesh.material)) {
|
|
1735
|
+
mesh.material[materialIdx] = oldClone;
|
|
1736
|
+
} else {
|
|
1737
|
+
mesh.material = oldClone;
|
|
1738
|
+
}
|
|
1739
|
+
},
|
|
1740
|
+
redo: () => {
|
|
1741
|
+
if (Array.isArray(mesh.material)) {
|
|
1718
1742
|
mesh.material[materialIdx] = newMaterial;
|
|
1719
|
-
|
|
1720
|
-
else {
|
|
1743
|
+
} else {
|
|
1721
1744
|
mesh.material = newMaterial;
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
const lod = generateLOD(mesh);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
setMatRefresh((v) => v + 1);
|
|
1749
|
+
setStatusText(
|
|
1750
|
+
t("useModelEditor.appliedPreset", "Applied {name} preset").replace(
|
|
1751
|
+
"{name}",
|
|
1752
|
+
String(preset.name)
|
|
1753
|
+
)
|
|
1754
|
+
);
|
|
1755
|
+
},
|
|
1756
|
+
[selectedNode, selMaterialIdx, pushUndo, t]
|
|
1757
|
+
);
|
|
1758
|
+
const generateSelectedLOD = useCallback(() => {
|
|
1759
|
+
const node = selectedNode;
|
|
1760
|
+
if (!node || !(node.object instanceof THREE.Mesh)) {
|
|
1761
|
+
setStatusText(t("useModelEditor.selectAMeshToGenerate", "Select a mesh to generate LOD"));
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const mesh = node.object;
|
|
1765
|
+
const parent = mesh.parent || sceneRef.current;
|
|
1766
|
+
const lod = generateLOD(mesh);
|
|
1767
|
+
parent.remove(mesh);
|
|
1768
|
+
parent.add(lod);
|
|
1769
|
+
pushUndo({
|
|
1770
|
+
label: t("useModelEditor.generateLodFor", "Generate LOD for {name}").replace(
|
|
1771
|
+
"{name}",
|
|
1772
|
+
String(mesh.name)
|
|
1773
|
+
),
|
|
1774
|
+
undo: () => {
|
|
1775
|
+
parent.remove(lod);
|
|
1776
|
+
parent.add(mesh);
|
|
1777
|
+
},
|
|
1778
|
+
redo: () => {
|
|
1757
1779
|
parent.remove(mesh);
|
|
1758
1780
|
parent.add(lod);
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
rebuildTree();
|
|
1784
|
+
setStatusText(
|
|
1785
|
+
t("useModelEditor.generatedLodFor4Levels", "Generated LOD for {name} (4 levels)").replace(
|
|
1786
|
+
"{name}",
|
|
1787
|
+
String(mesh.name)
|
|
1788
|
+
)
|
|
1789
|
+
);
|
|
1790
|
+
}, [selectedNode, pushUndo, rebuildTree, t]);
|
|
1791
|
+
const convertSelectedToInstanced = useCallback(() => {
|
|
1792
|
+
const root = rootObjectRef.current || sceneRef.current;
|
|
1793
|
+
const result = convertToInstanced(root);
|
|
1794
|
+
if (result.created === 0) {
|
|
1795
|
+
setStatusText(
|
|
1796
|
+
t("useModelEditor.noDuplicateMeshesFoundTo", "No duplicate meshes found to instance")
|
|
1797
|
+
);
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
rebuildTree();
|
|
1801
|
+
setStatusText(
|
|
1802
|
+
t(
|
|
1803
|
+
"useModelEditor.createdInstancedMeshesRemovedDuplicates",
|
|
1804
|
+
"Created {created} instanced meshes, removed {removed} duplicates"
|
|
1805
|
+
).replace("{created}", String(result.created)).replace("{removed}", String(result.removed))
|
|
1806
|
+
);
|
|
1807
|
+
}, [rebuildTree, t]);
|
|
1808
|
+
const applyHDRI = useCallback(
|
|
1809
|
+
(preset) => {
|
|
1810
|
+
const renderer = rendererRef.current;
|
|
1811
|
+
if (!renderer) {
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
applyHDRIPreset(sceneRef.current, renderer, preset);
|
|
1815
|
+
setBgColor(`#${new THREE.Color(preset.bgColor).getHexString()}`);
|
|
1816
|
+
if (preset.fog) {
|
|
1817
|
+
setFogEnabled(true);
|
|
1818
|
+
setFogColor(`#${new THREE.Color(preset.fog.color).getHexString()}`);
|
|
1819
|
+
setFogNear(preset.fog.near);
|
|
1820
|
+
setFogFar(preset.fog.far);
|
|
1821
|
+
} else {
|
|
1822
|
+
setFogEnabled(false);
|
|
1823
|
+
}
|
|
1824
|
+
setStatusText(
|
|
1825
|
+
t("useModelEditor.environment", "Environment: {name}").replace(
|
|
1826
|
+
"{name}",
|
|
1827
|
+
String(preset.name)
|
|
1828
|
+
)
|
|
1829
|
+
);
|
|
1830
|
+
},
|
|
1831
|
+
[t]
|
|
1832
|
+
);
|
|
1833
|
+
const snapPoint = useCallback(
|
|
1834
|
+
(point) => {
|
|
1835
|
+
const root = rootObjectRef.current || sceneRef.current;
|
|
1836
|
+
const modes = snapEnabled ? ["vertex", "edge", "grid"] : ["grid"];
|
|
1837
|
+
return snapMulti(point, root, snapGrid, modes, 0.5);
|
|
1838
|
+
},
|
|
1839
|
+
[snapEnabled, snapGrid]
|
|
1840
|
+
);
|
|
1841
|
+
return {
|
|
1842
|
+
// refs
|
|
1843
|
+
mountRef,
|
|
1844
|
+
fileInputRef,
|
|
1845
|
+
mergeInputRef,
|
|
1846
|
+
videoInputRef,
|
|
1847
|
+
rootObjectRef,
|
|
1848
|
+
// state
|
|
1849
|
+
sceneTree,
|
|
1850
|
+
selectedId,
|
|
1851
|
+
transformMode,
|
|
1852
|
+
setTransformMode,
|
|
1853
|
+
animations,
|
|
1854
|
+
activeAnimIdx,
|
|
1855
|
+
isPlaying,
|
|
1856
|
+
animTime,
|
|
1857
|
+
animDuration,
|
|
1858
|
+
animSpeed,
|
|
1859
|
+
setAnimSpeed,
|
|
1860
|
+
loopAnim,
|
|
1861
|
+
setLoopAnim,
|
|
1862
|
+
bottomCollapsed,
|
|
1863
|
+
setBottomCollapsed,
|
|
1864
|
+
showGrid,
|
|
1865
|
+
setShowGrid,
|
|
1866
|
+
showAxes,
|
|
1867
|
+
setShowAxes,
|
|
1868
|
+
wireframe,
|
|
1869
|
+
setWireframe,
|
|
1870
|
+
bgColor,
|
|
1871
|
+
setBgColor,
|
|
1872
|
+
statusText,
|
|
1873
|
+
pendingAsk,
|
|
1874
|
+
dragOver,
|
|
1875
|
+
polyCount,
|
|
1876
|
+
meshCount,
|
|
1877
|
+
boneCount,
|
|
1878
|
+
selMaterialIdx,
|
|
1879
|
+
setSelMaterialIdx,
|
|
1880
|
+
matRefresh,
|
|
1881
|
+
setMatRefresh,
|
|
1882
|
+
aiStatus,
|
|
1883
|
+
aiBusy,
|
|
1884
|
+
shadingMode,
|
|
1885
|
+
setShadingMode,
|
|
1886
|
+
editorMode,
|
|
1887
|
+
setEditorMode,
|
|
1888
|
+
contextMenu,
|
|
1889
|
+
outlinerSearch,
|
|
1890
|
+
setOutlinerSearch,
|
|
1891
|
+
snapEnabled,
|
|
1892
|
+
setSnapEnabled,
|
|
1893
|
+
snapGrid,
|
|
1894
|
+
setSnapGrid,
|
|
1895
|
+
showSkeleton,
|
|
1896
|
+
setShowSkeleton,
|
|
1897
|
+
addMenuOpen,
|
|
1898
|
+
setAddMenuOpen,
|
|
1899
|
+
gizmoSpace,
|
|
1900
|
+
setGizmoSpace,
|
|
1901
|
+
propTab,
|
|
1902
|
+
setPropTab,
|
|
1903
|
+
fogEnabled,
|
|
1904
|
+
setFogEnabled,
|
|
1905
|
+
fogColor,
|
|
1906
|
+
setFogColor,
|
|
1907
|
+
fogNear,
|
|
1908
|
+
setFogNear,
|
|
1909
|
+
fogFar,
|
|
1910
|
+
setFogFar,
|
|
1911
|
+
showLightHelpers,
|
|
1912
|
+
setShowLightHelpers,
|
|
1913
|
+
// derived
|
|
1914
|
+
selectedNode,
|
|
1915
|
+
selectedMaterials,
|
|
1916
|
+
// callbacks
|
|
1917
|
+
rebuildTree,
|
|
1918
|
+
loadFile,
|
|
1919
|
+
mergeAnimationFBX,
|
|
1920
|
+
playClip,
|
|
1921
|
+
togglePlay,
|
|
1922
|
+
stopAnim,
|
|
1923
|
+
seekAnim,
|
|
1924
|
+
focusOnObject,
|
|
1925
|
+
setCameraPreset,
|
|
1926
|
+
selectObject,
|
|
1927
|
+
deleteSelected,
|
|
1928
|
+
duplicateSelected,
|
|
1929
|
+
handleFileInput,
|
|
1930
|
+
handleMergeInput,
|
|
1931
|
+
handleAIVideo,
|
|
1932
|
+
exportGLTF,
|
|
1933
|
+
exportOBJ,
|
|
1934
|
+
exportSTL,
|
|
1935
|
+
exportPLY,
|
|
1936
|
+
exportUSDZ,
|
|
1937
|
+
clearScene,
|
|
1938
|
+
saveToLibrary,
|
|
1939
|
+
addPrimitive,
|
|
1940
|
+
addSceneLight,
|
|
1941
|
+
addCameraObject,
|
|
1942
|
+
addEmpty,
|
|
1943
|
+
handleViewportClick,
|
|
1944
|
+
handleContextMenu,
|
|
1945
|
+
closeContextMenu,
|
|
1946
|
+
handleDragOver,
|
|
1947
|
+
handleDragLeave,
|
|
1948
|
+
handleDrop,
|
|
1949
|
+
toggleNodeVisibility,
|
|
1950
|
+
toggleNodeExpanded,
|
|
1951
|
+
setStatusText,
|
|
1952
|
+
// undo/redo
|
|
1953
|
+
undo,
|
|
1954
|
+
redo,
|
|
1955
|
+
canUndo,
|
|
1956
|
+
canRedo,
|
|
1957
|
+
undoLabel,
|
|
1958
|
+
redoLabel,
|
|
1959
|
+
// batch import
|
|
1960
|
+
loadFiles,
|
|
1961
|
+
// PBR presets
|
|
1962
|
+
pbrPresets: PBR_PRESETS,
|
|
1963
|
+
applyPreset,
|
|
1964
|
+
// LOD & instancing
|
|
1965
|
+
generateSelectedLOD,
|
|
1966
|
+
convertSelectedToInstanced,
|
|
1967
|
+
// HDRI environment
|
|
1968
|
+
hdriPresets: HDRI_PRESETS,
|
|
1969
|
+
applyHDRIPreset: applyHDRI,
|
|
1970
|
+
// Snap
|
|
1971
|
+
snapPoint
|
|
1972
|
+
};
|
|
1948
1973
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1974
|
+
export {
|
|
1975
|
+
useModelEditor
|
|
1976
|
+
};
|