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