@nice2dev/ui-3d 1.3.14 → 1.3.16

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