@nice2dev/ui-3d 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cjs/core/i18n.js +16 -0
- package/dist/cjs/core/i18n.js.map +1 -0
- package/dist/cjs/index.js +11 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/model/ModelEditor.js +33 -0
- package/dist/cjs/model/ModelEditor.js.map +1 -0
- package/dist/cjs/model/ModelEditor.module.css.js +6 -0
- package/dist/cjs/model/ModelEditor.module.css.js.map +1 -0
- package/dist/cjs/model/ModelEditorLeftPanel.js +40 -0
- package/dist/cjs/model/ModelEditorLeftPanel.js.map +1 -0
- package/dist/cjs/model/ModelEditorMenuBar.js +14 -0
- package/dist/cjs/model/ModelEditorMenuBar.js.map +1 -0
- package/dist/cjs/model/ModelEditorRightPanel.js +106 -0
- package/dist/cjs/model/ModelEditorRightPanel.js.map +1 -0
- package/dist/cjs/model/ModelEditorSubComponents.js +99 -0
- package/dist/cjs/model/ModelEditorSubComponents.js.map +1 -0
- package/dist/cjs/model/ModelEditorTimeline.js +31 -0
- package/dist/cjs/model/ModelEditorTimeline.js.map +1 -0
- package/dist/cjs/model/ModelEditorToolbar.js +14 -0
- package/dist/cjs/model/ModelEditorToolbar.js.map +1 -0
- package/dist/cjs/model/ModelEditorViewport.js +20 -0
- package/dist/cjs/model/ModelEditorViewport.js.map +1 -0
- package/dist/cjs/model/modelEditorTypes.js +122 -0
- package/dist/cjs/model/modelEditorTypes.js.map +1 -0
- package/dist/cjs/model/useModelEditor.js +1581 -0
- package/dist/cjs/model/useModelEditor.js.map +1 -0
- package/dist/cjs/nice2dev-ui-3d.css +1 -0
- package/dist/esm/core/i18n.js +13 -0
- package/dist/esm/core/i18n.js.map +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/model/ModelEditor.js +31 -0
- package/dist/esm/model/ModelEditor.js.map +1 -0
- package/dist/esm/model/ModelEditor.module.css.js +4 -0
- package/dist/esm/model/ModelEditor.module.css.js.map +1 -0
- package/dist/esm/model/ModelEditorLeftPanel.js +38 -0
- package/dist/esm/model/ModelEditorLeftPanel.js.map +1 -0
- package/dist/esm/model/ModelEditorMenuBar.js +12 -0
- package/dist/esm/model/ModelEditorMenuBar.js.map +1 -0
- package/dist/esm/model/ModelEditorRightPanel.js +85 -0
- package/dist/esm/model/ModelEditorRightPanel.js.map +1 -0
- package/dist/esm/model/ModelEditorSubComponents.js +76 -0
- package/dist/esm/model/ModelEditorSubComponents.js.map +1 -0
- package/dist/esm/model/ModelEditorTimeline.js +29 -0
- package/dist/esm/model/ModelEditorTimeline.js.map +1 -0
- package/dist/esm/model/ModelEditorToolbar.js +12 -0
- package/dist/esm/model/ModelEditorToolbar.js.map +1 -0
- package/dist/esm/model/ModelEditorViewport.js +18 -0
- package/dist/esm/model/ModelEditorViewport.js.map +1 -0
- package/dist/esm/model/modelEditorTypes.js +97 -0
- package/dist/esm/model/modelEditorTypes.js.map +1 -0
- package/dist/esm/model/useModelEditor.js +1560 -0
- package/dist/esm/model/useModelEditor.js.map +1 -0
- package/dist/esm/nice2dev-ui-3d.css +1 -0
- package/dist/types/core/i18n.d.ts +27 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/model/ModelEditor.d.ts +17 -0
- package/dist/types/model/ModelEditorLeftPanel.d.ts +10 -0
- package/dist/types/model/ModelEditorMenuBar.d.ts +11 -0
- package/dist/types/model/ModelEditorRightPanel.d.ts +11 -0
- package/dist/types/model/ModelEditorSubComponents.d.ts +24 -0
- package/dist/types/model/ModelEditorTimeline.d.ts +11 -0
- package/dist/types/model/ModelEditorToolbar.d.ts +11 -0
- package/dist/types/model/ModelEditorViewport.d.ts +11 -0
- package/dist/types/model/modelEditorTypes.d.ts +52 -0
- package/dist/types/model/useModelEditor.d.ts +125 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
4
|
+
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
|
|
5
|
+
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
|
6
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
7
|
+
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
|
8
|
+
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js';
|
|
9
|
+
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js';
|
|
10
|
+
import { PLYExporter } from 'three/examples/jsm/exporters/PLYExporter.js';
|
|
11
|
+
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js';
|
|
12
|
+
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
|
13
|
+
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js';
|
|
14
|
+
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
|
15
|
+
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
|
|
16
|
+
import { TDSLoader } from 'three/examples/jsm/loaders/TDSLoader.js';
|
|
17
|
+
import { ThreeMFLoader } from 'three/examples/jsm/loaders/3MFLoader.js';
|
|
18
|
+
import { AMFLoader } from 'three/examples/jsm/loaders/AMFLoader.js';
|
|
19
|
+
import { PCDLoader } from 'three/examples/jsm/loaders/PCDLoader.js';
|
|
20
|
+
import { VTKLoader } from 'three/examples/jsm/loaders/VTKLoader.js';
|
|
21
|
+
import { VRMLLoader } from 'three/examples/jsm/loaders/VRMLLoader.js';
|
|
22
|
+
import { GCodeLoader } from 'three/examples/jsm/loaders/GCodeLoader.js';
|
|
23
|
+
import { SVGLoader } from 'three/examples/jsm/loaders/SVGLoader.js';
|
|
24
|
+
import { SUPPORTED_EXTENSIONS, pose3dToClip } from './modelEditorTypes.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* useModelEditor — custom hook encapsulating all state,
|
|
28
|
+
* refs, callbacks, side-effects, and scene setup for the
|
|
29
|
+
* ModelEditor (Blender-style 3D editor).
|
|
30
|
+
*
|
|
31
|
+
* Extracted from the original ModelEditor.tsx god-component.
|
|
32
|
+
*/
|
|
33
|
+
const log = { error: console.error, warn: console.warn, info: console.info };
|
|
34
|
+
/* helpers */
|
|
35
|
+
let _nid = 0;
|
|
36
|
+
const nid = () => `n${++_nid}`;
|
|
37
|
+
/* ── Pure helper — extracted outside the hook for stable identity ── */
|
|
38
|
+
function findNodeById(nodes, id) {
|
|
39
|
+
for (const n of nodes) {
|
|
40
|
+
if (n.id === id)
|
|
41
|
+
return n;
|
|
42
|
+
const found = findNodeById(n.children, id);
|
|
43
|
+
if (found)
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/* ═══════════════════════════════════════════
|
|
49
|
+
Hook
|
|
50
|
+
═══════════════════════════════════════════ */
|
|
51
|
+
function useModelEditor({ onSaveToLibrary, onAIPoseVideo, }) {
|
|
52
|
+
/* ── refs ── */
|
|
53
|
+
const mountRef = useRef(null);
|
|
54
|
+
const rendererRef = useRef(null);
|
|
55
|
+
const sceneRef = useRef(new THREE.Scene());
|
|
56
|
+
const cameraRef = useRef(new THREE.PerspectiveCamera(50, 1, 0.01, 10000));
|
|
57
|
+
const orbitRef = useRef(null);
|
|
58
|
+
const transformRef = useRef(null);
|
|
59
|
+
const clockRef = useRef(new THREE.Clock());
|
|
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
|
+
/* ── state ── */
|
|
68
|
+
const [sceneTree, setSceneTree] = useState([]);
|
|
69
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
70
|
+
const [transformMode, setTransformMode] = useState("translate");
|
|
71
|
+
const [animations, setAnimations] = useState([]);
|
|
72
|
+
const [activeAnimIdx, setActiveAnimIdx] = useState(-1);
|
|
73
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
74
|
+
const [animTime, setAnimTime] = useState(0);
|
|
75
|
+
const [animDuration, setAnimDuration] = useState(0);
|
|
76
|
+
const [animSpeed, setAnimSpeed] = useState(1);
|
|
77
|
+
const [loopAnim, setLoopAnim] = useState(true);
|
|
78
|
+
const [bottomCollapsed, setBottomCollapsed] = useState(false);
|
|
79
|
+
const [showGrid, setShowGrid] = useState(true);
|
|
80
|
+
const [showAxes, setShowAxes] = useState(true);
|
|
81
|
+
const [wireframe, setWireframe] = useState(false);
|
|
82
|
+
const [bgColor, setBgColor] = useState("#111122");
|
|
83
|
+
const [statusText, setStatusText] = useState("Ready");
|
|
84
|
+
const [dragOver, setDragOver] = useState(false);
|
|
85
|
+
const [polyCount, setPolyCount] = useState(0);
|
|
86
|
+
const [meshCount, setMeshCount] = useState(0);
|
|
87
|
+
const [boneCount, setBoneCount] = useState(0);
|
|
88
|
+
// Material editor
|
|
89
|
+
const [selMaterialIdx, setSelMaterialIdx] = useState(0);
|
|
90
|
+
const [matRefresh, setMatRefresh] = useState(0);
|
|
91
|
+
// AI panel
|
|
92
|
+
const [aiStatus, setAiStatus] = useState("");
|
|
93
|
+
const [aiBusy, setAiBusy] = useState(false);
|
|
94
|
+
// Blender 2.0 — viewport & editor
|
|
95
|
+
const [shadingMode, setShadingMode] = useState("solid");
|
|
96
|
+
const [editorMode, setEditorMode] = useState("object");
|
|
97
|
+
const [contextMenu, setContextMenu] = useState(null);
|
|
98
|
+
const [outlinerSearch, setOutlinerSearch] = useState("");
|
|
99
|
+
const [snapEnabled, setSnapEnabled] = useState(false);
|
|
100
|
+
const [snapGrid, setSnapGrid] = useState(1);
|
|
101
|
+
const [showSkeleton, setShowSkeleton] = useState(true);
|
|
102
|
+
const [addMenuOpen, setAddMenuOpen] = useState(null);
|
|
103
|
+
const [gizmoSpace, setGizmoSpace] = useState("world");
|
|
104
|
+
const [propTab, setPropTab] = useState("object");
|
|
105
|
+
const [fogEnabled, setFogEnabled] = useState(false);
|
|
106
|
+
const [fogColor, setFogColor] = useState("#111122");
|
|
107
|
+
const [fogNear, setFogNear] = useState(10);
|
|
108
|
+
const [fogFar, setFogFar] = useState(100);
|
|
109
|
+
const [showLightHelpers, setShowLightHelpers] = useState(true);
|
|
110
|
+
// The root object that was loaded (mesh/character)
|
|
111
|
+
const rootObjectRef = useRef(null);
|
|
112
|
+
// Active animation action
|
|
113
|
+
const activeActionRef = useRef(null);
|
|
114
|
+
// All loaded animation clips (for the current model)
|
|
115
|
+
const allClipsRef = useRef([]);
|
|
116
|
+
/* keep refs current */
|
|
117
|
+
const animTimeRef = useRef(animTime);
|
|
118
|
+
animTimeRef.current = animTime;
|
|
119
|
+
const isPlayingRef = useRef(isPlaying);
|
|
120
|
+
isPlayingRef.current = isPlaying;
|
|
121
|
+
const animSpeedRef = useRef(animSpeed);
|
|
122
|
+
animSpeedRef.current = animSpeed;
|
|
123
|
+
const loopAnimRef = useRef(loopAnim);
|
|
124
|
+
loopAnimRef.current = loopAnim;
|
|
125
|
+
/* ─────────────────────────────────────────
|
|
126
|
+
Scene setup
|
|
127
|
+
───────────────────────────────────────── */
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const container = mountRef.current;
|
|
130
|
+
if (!container)
|
|
131
|
+
return;
|
|
132
|
+
const scene = sceneRef.current;
|
|
133
|
+
const camera = cameraRef.current;
|
|
134
|
+
// Renderer
|
|
135
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
|
136
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
137
|
+
renderer.shadowMap.enabled = true;
|
|
138
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
139
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
140
|
+
renderer.toneMappingExposure = 1.2;
|
|
141
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
142
|
+
rendererRef.current = renderer;
|
|
143
|
+
container.appendChild(renderer.domElement);
|
|
144
|
+
// Camera
|
|
145
|
+
camera.position.set(3, 2, 5);
|
|
146
|
+
camera.lookAt(0, 0, 0);
|
|
147
|
+
// Scene background
|
|
148
|
+
scene.background = new THREE.Color("#111122");
|
|
149
|
+
// Lights
|
|
150
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
|
151
|
+
ambientLight.name = "__ambient";
|
|
152
|
+
scene.add(ambientLight);
|
|
153
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
154
|
+
dirLight.name = "__dirLight";
|
|
155
|
+
dirLight.position.set(5, 8, 5);
|
|
156
|
+
dirLight.castShadow = true;
|
|
157
|
+
dirLight.shadow.mapSize.set(2048, 2048);
|
|
158
|
+
dirLight.shadow.camera.near = 0.1;
|
|
159
|
+
dirLight.shadow.camera.far = 50;
|
|
160
|
+
dirLight.shadow.camera.left = -10;
|
|
161
|
+
dirLight.shadow.camera.right = 10;
|
|
162
|
+
dirLight.shadow.camera.top = 10;
|
|
163
|
+
dirLight.shadow.camera.bottom = -10;
|
|
164
|
+
scene.add(dirLight);
|
|
165
|
+
const hemiLight = new THREE.HemisphereLight(0x8888ff, 0x444422, 0.4);
|
|
166
|
+
hemiLight.name = "__hemiLight";
|
|
167
|
+
scene.add(hemiLight);
|
|
168
|
+
// Ground plane
|
|
169
|
+
const ground = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), new THREE.ShadowMaterial({ opacity: 0.15 }));
|
|
170
|
+
ground.name = "__ground";
|
|
171
|
+
ground.rotation.x = -Math.PI / 2;
|
|
172
|
+
ground.receiveShadow = true;
|
|
173
|
+
scene.add(ground);
|
|
174
|
+
// Grid
|
|
175
|
+
const grid = new THREE.GridHelper(20, 20, 0x333355, 0x22223a);
|
|
176
|
+
grid.name = "__grid";
|
|
177
|
+
scene.add(grid);
|
|
178
|
+
// Axes
|
|
179
|
+
const axes = new THREE.AxesHelper(2);
|
|
180
|
+
axes.name = "__axes";
|
|
181
|
+
scene.add(axes);
|
|
182
|
+
// Orbit controls
|
|
183
|
+
const orbit = new OrbitControls(camera, renderer.domElement);
|
|
184
|
+
orbit.enableDamping = true;
|
|
185
|
+
orbit.dampingFactor = 0.08;
|
|
186
|
+
orbit.minDistance = 0.1;
|
|
187
|
+
orbit.maxDistance = 500;
|
|
188
|
+
orbit.target.set(0, 0.5, 0);
|
|
189
|
+
orbitRef.current = orbit;
|
|
190
|
+
// Transform controls
|
|
191
|
+
const tc = new TransformControls(camera, renderer.domElement);
|
|
192
|
+
tc.name = "__transformControls";
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
194
|
+
tc.addEventListener("dragging-changed", ((e) => {
|
|
195
|
+
orbit.enabled = !e.value;
|
|
196
|
+
}));
|
|
197
|
+
scene.add(tc);
|
|
198
|
+
transformRef.current = tc;
|
|
199
|
+
// Resize handler
|
|
200
|
+
const onResize = () => {
|
|
201
|
+
const w = container.clientWidth;
|
|
202
|
+
const h = container.clientHeight;
|
|
203
|
+
if (w === 0 || h === 0)
|
|
204
|
+
return;
|
|
205
|
+
camera.aspect = w / h;
|
|
206
|
+
camera.updateProjectionMatrix();
|
|
207
|
+
renderer.setSize(w, h);
|
|
208
|
+
};
|
|
209
|
+
const ro = new ResizeObserver(onResize);
|
|
210
|
+
ro.observe(container);
|
|
211
|
+
onResize();
|
|
212
|
+
// Animation loop
|
|
213
|
+
const animate = () => {
|
|
214
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
215
|
+
const dt = clockRef.current.getDelta();
|
|
216
|
+
orbit.update();
|
|
217
|
+
// Update mixer
|
|
218
|
+
if (mixerRef.current && isPlayingRef.current) {
|
|
219
|
+
mixerRef.current.update(dt * animSpeedRef.current);
|
|
220
|
+
// Sync time state (throttled to ~15fps for React)
|
|
221
|
+
const action = activeActionRef.current;
|
|
222
|
+
if (action) {
|
|
223
|
+
const t = action.time;
|
|
224
|
+
if (Math.abs(t - animTimeRef.current) > 0.05) {
|
|
225
|
+
setAnimTime(t);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
renderer.render(scene, camera);
|
|
230
|
+
};
|
|
231
|
+
animate();
|
|
232
|
+
return () => {
|
|
233
|
+
cancelAnimationFrame(rafRef.current);
|
|
234
|
+
ro.disconnect();
|
|
235
|
+
const scn = sceneRef.current;
|
|
236
|
+
scn.traverse((obj) => {
|
|
237
|
+
if (obj.geometry)
|
|
238
|
+
obj.geometry.dispose();
|
|
239
|
+
if (obj.material) {
|
|
240
|
+
const mats = Array.isArray(obj.material)
|
|
241
|
+
? obj.material
|
|
242
|
+
: [obj.material];
|
|
243
|
+
mats.forEach((mat) => mat.dispose());
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
if (mixerRef.current) {
|
|
247
|
+
mixerRef.current.stopAllAction();
|
|
248
|
+
}
|
|
249
|
+
renderer.dispose();
|
|
250
|
+
orbit.dispose();
|
|
251
|
+
tc.dispose();
|
|
252
|
+
if (container.contains(renderer.domElement)) {
|
|
253
|
+
container.removeChild(renderer.domElement);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
257
|
+
}, []);
|
|
258
|
+
/* ─────────────────────────────────────────
|
|
259
|
+
Rebuild scene tree from Three.js scene
|
|
260
|
+
───────────────────────────────────────── */
|
|
261
|
+
const rebuildTree = useCallback(() => {
|
|
262
|
+
const scene = sceneRef.current;
|
|
263
|
+
let polys = 0;
|
|
264
|
+
let meshes = 0;
|
|
265
|
+
let bones = 0;
|
|
266
|
+
const walk = (obj, depth) => {
|
|
267
|
+
var _a, _b;
|
|
268
|
+
if (obj.name.startsWith("__"))
|
|
269
|
+
return null;
|
|
270
|
+
if (obj.type === "TransformControlsPlane" || obj.type === "TransformControlsGizmo")
|
|
271
|
+
return null;
|
|
272
|
+
if ("isTransformControls" in obj)
|
|
273
|
+
return null;
|
|
274
|
+
let nodeType = "group";
|
|
275
|
+
if (obj instanceof THREE.Mesh) {
|
|
276
|
+
nodeType = "mesh";
|
|
277
|
+
meshes++;
|
|
278
|
+
const geo = obj.geometry;
|
|
279
|
+
if (geo) {
|
|
280
|
+
const idx = geo.index;
|
|
281
|
+
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;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (obj instanceof THREE.Bone) {
|
|
285
|
+
nodeType = "bone";
|
|
286
|
+
bones++;
|
|
287
|
+
}
|
|
288
|
+
else if (obj instanceof THREE.Light) {
|
|
289
|
+
nodeType = "light";
|
|
290
|
+
}
|
|
291
|
+
else if (obj instanceof THREE.Camera) {
|
|
292
|
+
nodeType = "camera";
|
|
293
|
+
}
|
|
294
|
+
else if (obj instanceof THREE.GridHelper ||
|
|
295
|
+
obj instanceof THREE.AxesHelper) {
|
|
296
|
+
nodeType = "helper";
|
|
297
|
+
}
|
|
298
|
+
const children = [];
|
|
299
|
+
for (const c of obj.children) {
|
|
300
|
+
const cn = walk(c, depth + 1);
|
|
301
|
+
if (cn)
|
|
302
|
+
children.push(cn);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
id: obj.userData.__nodeId || ((obj.userData.__nodeId = nid()), obj.userData.__nodeId),
|
|
306
|
+
name: obj.name || obj.type,
|
|
307
|
+
type: nodeType,
|
|
308
|
+
object: obj,
|
|
309
|
+
children,
|
|
310
|
+
expanded: depth < 2,
|
|
311
|
+
visible: obj.visible,
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
const roots = [];
|
|
315
|
+
for (const c of scene.children) {
|
|
316
|
+
const n = walk(c, 0);
|
|
317
|
+
if (n)
|
|
318
|
+
roots.push(n);
|
|
319
|
+
}
|
|
320
|
+
setSceneTree(roots);
|
|
321
|
+
setPolyCount(Math.round(polys));
|
|
322
|
+
setMeshCount(meshes);
|
|
323
|
+
setBoneCount(bones);
|
|
324
|
+
}, []);
|
|
325
|
+
/* ─────────────────────────────────────────
|
|
326
|
+
File loading
|
|
327
|
+
───────────────────────────────────────── */
|
|
328
|
+
const playClipRef = useRef(() => { });
|
|
329
|
+
const focusOnObjectRef = useRef(() => { });
|
|
330
|
+
const addObjectToScene = useCallback((obj, clips, filename) => {
|
|
331
|
+
const scene = sceneRef.current;
|
|
332
|
+
obj.traverse((child) => {
|
|
333
|
+
if (child instanceof THREE.Mesh) {
|
|
334
|
+
child.castShadow = true;
|
|
335
|
+
child.receiveShadow = true;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
const box = new THREE.Box3().setFromObject(obj);
|
|
339
|
+
const size = box.getSize(new THREE.Vector3());
|
|
340
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
341
|
+
if (maxDim > 0.01) {
|
|
342
|
+
const targetSize = 3;
|
|
343
|
+
const scale = targetSize / maxDim;
|
|
344
|
+
if (scale < 0.5 || scale > 2) {
|
|
345
|
+
obj.scale.multiplyScalar(scale);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const box2 = new THREE.Box3().setFromObject(obj);
|
|
349
|
+
const center = box2.getCenter(new THREE.Vector3());
|
|
350
|
+
obj.position.sub(center);
|
|
351
|
+
obj.position.y += box2.getSize(new THREE.Vector3()).y / 2;
|
|
352
|
+
obj.name = obj.name || filename;
|
|
353
|
+
scene.add(obj);
|
|
354
|
+
rootObjectRef.current = obj;
|
|
355
|
+
if (clips.length > 0) {
|
|
356
|
+
const mixer = new THREE.AnimationMixer(obj);
|
|
357
|
+
mixerRef.current = mixer;
|
|
358
|
+
const newAnims = clips.map((c) => ({
|
|
359
|
+
name: c.name || "Animation",
|
|
360
|
+
clip: c,
|
|
361
|
+
duration: c.duration,
|
|
362
|
+
source: filename,
|
|
363
|
+
}));
|
|
364
|
+
allClipsRef.current = [...allClipsRef.current, ...newAnims];
|
|
365
|
+
setAnimations([...allClipsRef.current]);
|
|
366
|
+
if (allClipsRef.current.length > 0) {
|
|
367
|
+
playClipRef.current(0);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else if (!mixerRef.current && rootObjectRef.current) {
|
|
371
|
+
mixerRef.current = new THREE.AnimationMixer(rootObjectRef.current);
|
|
372
|
+
}
|
|
373
|
+
focusOnObjectRef.current(obj);
|
|
374
|
+
rebuildTree();
|
|
375
|
+
setStatusText(`Loaded: ${filename} (${clips.length} animation(s))`);
|
|
376
|
+
}, [rebuildTree]);
|
|
377
|
+
const loadFile = useCallback(async (file) => {
|
|
378
|
+
var _a, _b, _c, _d, _e, _f;
|
|
379
|
+
const name = file.name.toLowerCase();
|
|
380
|
+
const ext = "." + name.split(".").pop();
|
|
381
|
+
const url = URL.createObjectURL(file);
|
|
382
|
+
setStatusText(`Loading ${file.name}...`);
|
|
383
|
+
try {
|
|
384
|
+
if (ext === ".fbx") {
|
|
385
|
+
const loader = new FBXLoader();
|
|
386
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
387
|
+
addObjectToScene(obj, (_a = obj.animations) !== null && _a !== void 0 ? _a : [], file.name);
|
|
388
|
+
}
|
|
389
|
+
else if (ext === ".glb" || ext === ".gltf") {
|
|
390
|
+
const loader = new GLTFLoader();
|
|
391
|
+
const gltf = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
392
|
+
addObjectToScene(gltf.scene, (_b = gltf.animations) !== null && _b !== void 0 ? _b : [], file.name);
|
|
393
|
+
}
|
|
394
|
+
else if (ext === ".obj") {
|
|
395
|
+
const loader = new OBJLoader();
|
|
396
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
397
|
+
addObjectToScene(obj, [], file.name);
|
|
398
|
+
}
|
|
399
|
+
else if (ext === ".dae") {
|
|
400
|
+
const loader = new ColladaLoader();
|
|
401
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
402
|
+
const collada = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
403
|
+
addObjectToScene(collada.scene, (_d = (_c = collada.scene) === null || _c === void 0 ? void 0 : _c.animations) !== null && _d !== void 0 ? _d : [], file.name);
|
|
404
|
+
}
|
|
405
|
+
else if (ext === ".stl") {
|
|
406
|
+
const loader = new STLLoader();
|
|
407
|
+
const geo = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
408
|
+
const mat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.3, roughness: 0.6 });
|
|
409
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
410
|
+
mesh.name = file.name;
|
|
411
|
+
addObjectToScene(mesh, [], file.name);
|
|
412
|
+
}
|
|
413
|
+
else if (ext === ".ply") {
|
|
414
|
+
const loader = new PLYLoader();
|
|
415
|
+
const geo = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
416
|
+
geo.computeVertexNormals();
|
|
417
|
+
const mat = new THREE.MeshStandardMaterial({ color: 0x888888, vertexColors: geo.hasAttribute("color") });
|
|
418
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
419
|
+
mesh.name = file.name;
|
|
420
|
+
addObjectToScene(mesh, [], file.name);
|
|
421
|
+
}
|
|
422
|
+
else if (ext === ".3ds") {
|
|
423
|
+
const loader = new TDSLoader();
|
|
424
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
425
|
+
addObjectToScene(obj, [], file.name);
|
|
426
|
+
}
|
|
427
|
+
else if (ext === ".3mf") {
|
|
428
|
+
const loader = new ThreeMFLoader();
|
|
429
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
430
|
+
addObjectToScene(obj, [], file.name);
|
|
431
|
+
}
|
|
432
|
+
else if (ext === ".amf") {
|
|
433
|
+
const loader = new AMFLoader();
|
|
434
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
435
|
+
addObjectToScene(obj, [], file.name);
|
|
436
|
+
}
|
|
437
|
+
else if (ext === ".pcd") {
|
|
438
|
+
const loader = new PCDLoader();
|
|
439
|
+
const points = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
440
|
+
points.name = file.name;
|
|
441
|
+
addObjectToScene(points, [], file.name);
|
|
442
|
+
}
|
|
443
|
+
else if (ext === ".vtk" || ext === ".vtp") {
|
|
444
|
+
const loader = new VTKLoader();
|
|
445
|
+
const geo = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
446
|
+
geo.computeVertexNormals();
|
|
447
|
+
const mat = new THREE.MeshStandardMaterial({ color: 0x888888, side: THREE.DoubleSide });
|
|
448
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
449
|
+
mesh.name = file.name;
|
|
450
|
+
addObjectToScene(mesh, [], file.name);
|
|
451
|
+
}
|
|
452
|
+
else if (ext === ".wrl" || ext === ".vrml") {
|
|
453
|
+
const loader = new VRMLLoader();
|
|
454
|
+
const scn = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
455
|
+
const group = new THREE.Group();
|
|
456
|
+
group.name = file.name;
|
|
457
|
+
while (scn.children.length)
|
|
458
|
+
group.add(scn.children[0]);
|
|
459
|
+
addObjectToScene(group, [], file.name);
|
|
460
|
+
}
|
|
461
|
+
else if (ext === ".gcode") {
|
|
462
|
+
const loader = new GCodeLoader();
|
|
463
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
464
|
+
addObjectToScene(obj, [], file.name);
|
|
465
|
+
}
|
|
466
|
+
else if (ext === ".svg") {
|
|
467
|
+
const loader = new SVGLoader();
|
|
468
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
469
|
+
const data = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
470
|
+
const group = new THREE.Group();
|
|
471
|
+
group.name = file.name;
|
|
472
|
+
for (const path of data.paths) {
|
|
473
|
+
const shapes2 = SVGLoader.createShapes(path);
|
|
474
|
+
for (const shape of shapes2) {
|
|
475
|
+
const geo = new THREE.ExtrudeGeometry(shape, { depth: 2, bevelEnabled: false });
|
|
476
|
+
const mat = new THREE.MeshStandardMaterial({ color: ((_f = (_e = path.color) === null || _e === void 0 ? void 0 : _e.getHex) === null || _f === void 0 ? void 0 : _f.call(_e)) || 0x888888, side: THREE.DoubleSide });
|
|
477
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
478
|
+
group.add(mesh);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
group.scale.set(0.01, -0.01, 0.01);
|
|
482
|
+
addObjectToScene(group, [], file.name);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
setStatusText(`Unsupported format: ${ext}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
log.error("Load error:", err);
|
|
490
|
+
setStatusText(`Error loading ${file.name}: ${String(err)}`);
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
URL.revokeObjectURL(url);
|
|
494
|
+
}
|
|
495
|
+
}, [addObjectToScene]);
|
|
496
|
+
const mergeAnimationFBX = useCallback(async (file) => {
|
|
497
|
+
var _a;
|
|
498
|
+
const url = URL.createObjectURL(file);
|
|
499
|
+
setStatusText(`Merging animations from ${file.name}...`);
|
|
500
|
+
try {
|
|
501
|
+
const loader = new FBXLoader();
|
|
502
|
+
const obj = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
|
|
503
|
+
const clips = (_a = obj.animations) !== null && _a !== void 0 ? _a : [];
|
|
504
|
+
if (clips.length === 0) {
|
|
505
|
+
setStatusText(`No animations found in ${file.name}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const root = rootObjectRef.current;
|
|
509
|
+
if (!root) {
|
|
510
|
+
setStatusText("No model loaded — load a base model first");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!mixerRef.current) {
|
|
514
|
+
mixerRef.current = new THREE.AnimationMixer(root);
|
|
515
|
+
}
|
|
516
|
+
const newAnims = clips.map((c) => ({
|
|
517
|
+
name: c.name || file.name.replace(/\.[^.]+$/, ""),
|
|
518
|
+
clip: c,
|
|
519
|
+
duration: c.duration,
|
|
520
|
+
source: file.name,
|
|
521
|
+
}));
|
|
522
|
+
allClipsRef.current = [...allClipsRef.current, ...newAnims];
|
|
523
|
+
setAnimations([...allClipsRef.current]);
|
|
524
|
+
setStatusText(`Merged ${clips.length} animation(s) from ${file.name}`);
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
log.error("Merge error:", err);
|
|
528
|
+
setStatusText(`Error merging ${file.name}: ${String(err)}`);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
URL.revokeObjectURL(url);
|
|
532
|
+
}
|
|
533
|
+
}, []);
|
|
534
|
+
/* ─────────────────────────────────────────
|
|
535
|
+
Animation controls
|
|
536
|
+
───────────────────────────────────────── */
|
|
537
|
+
const playClip = useCallback((index) => {
|
|
538
|
+
const mixer = mixerRef.current;
|
|
539
|
+
if (!mixer || index < 0 || index >= allClipsRef.current.length)
|
|
540
|
+
return;
|
|
541
|
+
if (activeActionRef.current) {
|
|
542
|
+
activeActionRef.current.stop();
|
|
543
|
+
}
|
|
544
|
+
const anim = allClipsRef.current[index];
|
|
545
|
+
const action = mixer.clipAction(anim.clip);
|
|
546
|
+
action.reset();
|
|
547
|
+
action.setLoop(loopAnimRef.current ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
|
|
548
|
+
action.clampWhenFinished = !loopAnimRef.current;
|
|
549
|
+
action.play();
|
|
550
|
+
activeActionRef.current = action;
|
|
551
|
+
setActiveAnimIdx(index);
|
|
552
|
+
setAnimDuration(anim.duration);
|
|
553
|
+
setAnimTime(0);
|
|
554
|
+
setIsPlaying(true);
|
|
555
|
+
}, []);
|
|
556
|
+
playClipRef.current = playClip;
|
|
557
|
+
const togglePlay = useCallback(() => {
|
|
558
|
+
if (activeAnimIdx < 0 && allClipsRef.current.length > 0) {
|
|
559
|
+
playClip(0);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const action = activeActionRef.current;
|
|
563
|
+
if (!action)
|
|
564
|
+
return;
|
|
565
|
+
if (isPlayingRef.current) {
|
|
566
|
+
action.paused = true;
|
|
567
|
+
setIsPlaying(false);
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
action.paused = false;
|
|
571
|
+
setIsPlaying(true);
|
|
572
|
+
}
|
|
573
|
+
}, [activeAnimIdx, playClip]);
|
|
574
|
+
const stopAnim = useCallback(() => {
|
|
575
|
+
const action = activeActionRef.current;
|
|
576
|
+
if (action) {
|
|
577
|
+
action.stop();
|
|
578
|
+
action.reset();
|
|
579
|
+
}
|
|
580
|
+
setIsPlaying(false);
|
|
581
|
+
setAnimTime(0);
|
|
582
|
+
}, []);
|
|
583
|
+
const seekAnim = useCallback((t) => {
|
|
584
|
+
var _a;
|
|
585
|
+
const action = activeActionRef.current;
|
|
586
|
+
if (!action)
|
|
587
|
+
return;
|
|
588
|
+
action.time = t;
|
|
589
|
+
action.paused = true;
|
|
590
|
+
setAnimTime(t);
|
|
591
|
+
setIsPlaying(false);
|
|
592
|
+
(_a = mixerRef.current) === null || _a === void 0 ? void 0 : _a.update(0);
|
|
593
|
+
}, []);
|
|
594
|
+
/* ─────────────────────────────────────────
|
|
595
|
+
Camera helpers
|
|
596
|
+
───────────────────────────────────────── */
|
|
597
|
+
const focusOnObject = useCallback((obj) => {
|
|
598
|
+
const box = new THREE.Box3().setFromObject(obj);
|
|
599
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
600
|
+
const size = box.getSize(new THREE.Vector3());
|
|
601
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
602
|
+
const dist = maxDim * 2;
|
|
603
|
+
const orbit = orbitRef.current;
|
|
604
|
+
if (orbit) {
|
|
605
|
+
orbit.target.copy(center);
|
|
606
|
+
cameraRef.current.position.copy(center.clone().add(new THREE.Vector3(dist * 0.6, dist * 0.4, dist * 0.8)));
|
|
607
|
+
orbit.update();
|
|
608
|
+
}
|
|
609
|
+
}, []);
|
|
610
|
+
focusOnObjectRef.current = focusOnObject;
|
|
611
|
+
const setCameraPreset = useCallback((preset) => {
|
|
612
|
+
const orbit = orbitRef.current;
|
|
613
|
+
if (!orbit)
|
|
614
|
+
return;
|
|
615
|
+
const target = orbit.target.clone();
|
|
616
|
+
const d = cameraRef.current.position.distanceTo(target);
|
|
617
|
+
const pos = new THREE.Vector3();
|
|
618
|
+
switch (preset) {
|
|
619
|
+
case "front":
|
|
620
|
+
pos.set(0, 0, d);
|
|
621
|
+
break;
|
|
622
|
+
case "back":
|
|
623
|
+
pos.set(0, 0, -d);
|
|
624
|
+
break;
|
|
625
|
+
case "left":
|
|
626
|
+
pos.set(-d, 0, 0);
|
|
627
|
+
break;
|
|
628
|
+
case "right":
|
|
629
|
+
pos.set(d, 0, 0);
|
|
630
|
+
break;
|
|
631
|
+
case "top":
|
|
632
|
+
pos.set(0, d, 0.001);
|
|
633
|
+
break;
|
|
634
|
+
case "bottom":
|
|
635
|
+
pos.set(0, -d, 0.001);
|
|
636
|
+
break;
|
|
637
|
+
default: return;
|
|
638
|
+
}
|
|
639
|
+
cameraRef.current.position.copy(target.clone().add(pos));
|
|
640
|
+
orbit.update();
|
|
641
|
+
}, []);
|
|
642
|
+
/* ─────────────────────────────────────────
|
|
643
|
+
Selection & transform
|
|
644
|
+
───────────────────────────────────────── */
|
|
645
|
+
const selectObject = useCallback((node) => {
|
|
646
|
+
var _a;
|
|
647
|
+
setSelectedId((_a = node === null || node === void 0 ? void 0 : node.id) !== null && _a !== void 0 ? _a : null);
|
|
648
|
+
const tc = transformRef.current;
|
|
649
|
+
if (!tc)
|
|
650
|
+
return;
|
|
651
|
+
if (node && node.type !== "helper") {
|
|
652
|
+
tc.attach(node.object);
|
|
653
|
+
tc.setMode(transformMode);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
tc.detach();
|
|
657
|
+
}
|
|
658
|
+
}, [transformMode]);
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
var _a;
|
|
661
|
+
(_a = transformRef.current) === null || _a === void 0 ? void 0 : _a.setMode(transformMode);
|
|
662
|
+
}, [transformMode]);
|
|
663
|
+
/* ─────────────────────────────────────────
|
|
664
|
+
Toggle helpers
|
|
665
|
+
───────────────────────────────────────── */
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
const scene = sceneRef.current;
|
|
668
|
+
const grid = scene.getObjectByName("__grid");
|
|
669
|
+
if (grid)
|
|
670
|
+
grid.visible = showGrid;
|
|
671
|
+
}, [showGrid]);
|
|
672
|
+
useEffect(() => {
|
|
673
|
+
const scene = sceneRef.current;
|
|
674
|
+
const axes = scene.getObjectByName("__axes");
|
|
675
|
+
if (axes)
|
|
676
|
+
axes.visible = showAxes;
|
|
677
|
+
}, [showAxes]);
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
sceneRef.current.background = new THREE.Color(bgColor);
|
|
680
|
+
}, [bgColor]);
|
|
681
|
+
useEffect(() => {
|
|
682
|
+
const root = rootObjectRef.current;
|
|
683
|
+
if (!root)
|
|
684
|
+
return;
|
|
685
|
+
root.traverse((child) => {
|
|
686
|
+
if (child instanceof THREE.Mesh) {
|
|
687
|
+
const mats = Array.isArray(child.material)
|
|
688
|
+
? child.material
|
|
689
|
+
: [child.material];
|
|
690
|
+
mats.forEach((m) => {
|
|
691
|
+
if ("wireframe" in m)
|
|
692
|
+
m.wireframe = wireframe;
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}, [wireframe]);
|
|
697
|
+
/* ─────────────────────────────────────────
|
|
698
|
+
Viewport shading mode
|
|
699
|
+
───────────────────────────────────────── */
|
|
700
|
+
useEffect(() => {
|
|
701
|
+
const renderer = rendererRef.current;
|
|
702
|
+
if (!renderer)
|
|
703
|
+
return;
|
|
704
|
+
const scene = sceneRef.current;
|
|
705
|
+
switch (shadingMode) {
|
|
706
|
+
case "wireframe":
|
|
707
|
+
scene.traverse((obj) => {
|
|
708
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
709
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
710
|
+
mats.forEach((m) => { if ("wireframe" in m)
|
|
711
|
+
m.wireframe = true; });
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
renderer.toneMapping = THREE.NoToneMapping;
|
|
715
|
+
renderer.toneMappingExposure = 1;
|
|
716
|
+
break;
|
|
717
|
+
case "solid":
|
|
718
|
+
scene.traverse((obj) => {
|
|
719
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
720
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
721
|
+
mats.forEach((m) => { if ("wireframe" in m)
|
|
722
|
+
m.wireframe = false; });
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
renderer.toneMapping = THREE.NoToneMapping;
|
|
726
|
+
renderer.toneMappingExposure = 1;
|
|
727
|
+
break;
|
|
728
|
+
case "material":
|
|
729
|
+
scene.traverse((obj) => {
|
|
730
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
731
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
732
|
+
mats.forEach((m) => { if ("wireframe" in m)
|
|
733
|
+
m.wireframe = false; });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
737
|
+
renderer.toneMappingExposure = 1.2;
|
|
738
|
+
break;
|
|
739
|
+
case "rendered":
|
|
740
|
+
scene.traverse((obj) => {
|
|
741
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__")) {
|
|
742
|
+
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
743
|
+
mats.forEach((m) => { if ("wireframe" in m)
|
|
744
|
+
m.wireframe = false; });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
748
|
+
renderer.toneMappingExposure = 1.5;
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
}, [shadingMode]);
|
|
752
|
+
/* Gizmo space */
|
|
753
|
+
useEffect(() => {
|
|
754
|
+
var _a;
|
|
755
|
+
(_a = transformRef.current) === null || _a === void 0 ? void 0 : _a.setSpace(gizmoSpace);
|
|
756
|
+
}, [gizmoSpace]);
|
|
757
|
+
/* Snap settings */
|
|
758
|
+
useEffect(() => {
|
|
759
|
+
const tc = transformRef.current;
|
|
760
|
+
if (!tc)
|
|
761
|
+
return;
|
|
762
|
+
if (snapEnabled) {
|
|
763
|
+
tc.setTranslationSnap(snapGrid);
|
|
764
|
+
tc.setRotationSnap(THREE.MathUtils.degToRad(15));
|
|
765
|
+
tc.setScaleSnap(0.25);
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
tc.setTranslationSnap(null);
|
|
769
|
+
tc.setRotationSnap(null);
|
|
770
|
+
tc.setScaleSnap(null);
|
|
771
|
+
}
|
|
772
|
+
}, [snapEnabled, snapGrid]);
|
|
773
|
+
/* Skeleton helper */
|
|
774
|
+
useEffect(() => {
|
|
775
|
+
const scene = sceneRef.current;
|
|
776
|
+
if (skeletonHelperRef.current) {
|
|
777
|
+
scene.remove(skeletonHelperRef.current);
|
|
778
|
+
skeletonHelperRef.current = null;
|
|
779
|
+
}
|
|
780
|
+
if (!showSkeleton || !rootObjectRef.current)
|
|
781
|
+
return;
|
|
782
|
+
let hasRig = false;
|
|
783
|
+
rootObjectRef.current.traverse((obj) => {
|
|
784
|
+
if (obj instanceof THREE.SkinnedMesh)
|
|
785
|
+
hasRig = true;
|
|
786
|
+
});
|
|
787
|
+
if (hasRig) {
|
|
788
|
+
const helper = new THREE.SkeletonHelper(rootObjectRef.current);
|
|
789
|
+
helper.name = "__skeletonHelper";
|
|
790
|
+
scene.add(helper);
|
|
791
|
+
skeletonHelperRef.current = helper;
|
|
792
|
+
}
|
|
793
|
+
}, [showSkeleton, sceneTree]);
|
|
794
|
+
/* Fog */
|
|
795
|
+
useEffect(() => {
|
|
796
|
+
if (fogEnabled) {
|
|
797
|
+
sceneRef.current.fog = new THREE.Fog(fogColor, fogNear, fogFar);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
sceneRef.current.fog = null;
|
|
801
|
+
}
|
|
802
|
+
}, [fogEnabled, fogColor, fogNear, fogFar]);
|
|
803
|
+
/* Light helpers visibility */
|
|
804
|
+
useEffect(() => {
|
|
805
|
+
sceneRef.current.traverse((obj) => {
|
|
806
|
+
if (obj.name.startsWith("__helper_")) {
|
|
807
|
+
obj.visible = showLightHelpers;
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
}, [showLightHelpers, sceneTree]);
|
|
811
|
+
/* ─────────────────────────────────────────
|
|
812
|
+
Drag-and-drop
|
|
813
|
+
───────────────────────────────────────── */
|
|
814
|
+
const handleDragOver = useCallback((e) => {
|
|
815
|
+
e.preventDefault();
|
|
816
|
+
e.stopPropagation();
|
|
817
|
+
setDragOver(true);
|
|
818
|
+
}, []);
|
|
819
|
+
const handleDragLeave = useCallback((e) => {
|
|
820
|
+
e.preventDefault();
|
|
821
|
+
setDragOver(false);
|
|
822
|
+
}, []);
|
|
823
|
+
const handleDrop = useCallback((e) => {
|
|
824
|
+
e.preventDefault();
|
|
825
|
+
e.stopPropagation();
|
|
826
|
+
setDragOver(false);
|
|
827
|
+
const files = Array.from(e.dataTransfer.files);
|
|
828
|
+
files.forEach((f) => {
|
|
829
|
+
const ext = "." + f.name.toLowerCase().split(".").pop();
|
|
830
|
+
if (SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
831
|
+
loadFile(f);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
}, [loadFile]);
|
|
835
|
+
/* ─────────────────────────────────────────
|
|
836
|
+
Helpers
|
|
837
|
+
───────────────────────────────────────── */
|
|
838
|
+
const selectedNode = useMemo(() => (selectedId ? findNodeById(sceneTree, selectedId) : null), [selectedId, sceneTree]);
|
|
839
|
+
const selectedMaterials = useMemo(() => {
|
|
840
|
+
if (!selectedNode || !(selectedNode.object instanceof THREE.Mesh))
|
|
841
|
+
return [];
|
|
842
|
+
const m = selectedNode.object.material;
|
|
843
|
+
return Array.isArray(m) ? m : [m];
|
|
844
|
+
}, [selectedNode, matRefresh]);
|
|
845
|
+
const deleteSelected = useCallback(() => {
|
|
846
|
+
var _a;
|
|
847
|
+
if (!selectedNode)
|
|
848
|
+
return;
|
|
849
|
+
selectedNode.object.removeFromParent();
|
|
850
|
+
(_a = transformRef.current) === null || _a === void 0 ? void 0 : _a.detach();
|
|
851
|
+
setSelectedId(null);
|
|
852
|
+
rebuildTree();
|
|
853
|
+
}, [selectedNode, rebuildTree]);
|
|
854
|
+
const duplicateSelected = useCallback(() => {
|
|
855
|
+
if (!selectedNode)
|
|
856
|
+
return;
|
|
857
|
+
const clone = selectedNode.object.clone(true);
|
|
858
|
+
clone.name = selectedNode.object.name + ".001";
|
|
859
|
+
clone.position.x += 1;
|
|
860
|
+
sceneRef.current.add(clone);
|
|
861
|
+
rebuildTree();
|
|
862
|
+
setStatusText(`Duplicated ${selectedNode.name}`);
|
|
863
|
+
}, [selectedNode, rebuildTree]);
|
|
864
|
+
/* ─────────────────────────────────────────
|
|
865
|
+
Keyboard shortcuts
|
|
866
|
+
───────────────────────────────────────── */
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
const handleKey = (e) => {
|
|
869
|
+
var _a, _b, _c;
|
|
870
|
+
const tag = (_a = e.target) === null || _a === void 0 ? void 0 : _a.tagName;
|
|
871
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT")
|
|
872
|
+
return;
|
|
873
|
+
switch (e.key.toLowerCase()) {
|
|
874
|
+
case "g":
|
|
875
|
+
setTransformMode("translate");
|
|
876
|
+
break;
|
|
877
|
+
case "r":
|
|
878
|
+
setTransformMode("rotate");
|
|
879
|
+
break;
|
|
880
|
+
case "s":
|
|
881
|
+
if (!e.ctrlKey)
|
|
882
|
+
setTransformMode("scale");
|
|
883
|
+
break;
|
|
884
|
+
case " ":
|
|
885
|
+
e.preventDefault();
|
|
886
|
+
togglePlay();
|
|
887
|
+
break;
|
|
888
|
+
case "f":
|
|
889
|
+
if (rootObjectRef.current)
|
|
890
|
+
focusOnObject(rootObjectRef.current);
|
|
891
|
+
break;
|
|
892
|
+
case "delete":
|
|
893
|
+
if (selectedId) {
|
|
894
|
+
const sel = findNodeById(sceneTree, selectedId);
|
|
895
|
+
if (sel) {
|
|
896
|
+
sel.object.removeFromParent();
|
|
897
|
+
(_b = transformRef.current) === null || _b === void 0 ? void 0 : _b.detach();
|
|
898
|
+
setSelectedId(null);
|
|
899
|
+
rebuildTree();
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
break;
|
|
903
|
+
case "x":
|
|
904
|
+
if (e.ctrlKey && selectedId) {
|
|
905
|
+
const sel = findNodeById(sceneTree, selectedId);
|
|
906
|
+
if (sel) {
|
|
907
|
+
sel.object.removeFromParent();
|
|
908
|
+
(_c = transformRef.current) === null || _c === void 0 ? void 0 : _c.detach();
|
|
909
|
+
setSelectedId(null);
|
|
910
|
+
rebuildTree();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
break;
|
|
914
|
+
case "d":
|
|
915
|
+
if (e.shiftKey) {
|
|
916
|
+
e.preventDefault();
|
|
917
|
+
duplicateSelected();
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
case "tab":
|
|
921
|
+
e.preventDefault();
|
|
922
|
+
setEditorMode((m) => m === "object" ? "edit" : m === "edit" ? "pose" : "object");
|
|
923
|
+
break;
|
|
924
|
+
case "n":
|
|
925
|
+
if (!e.ctrlKey) ;
|
|
926
|
+
break;
|
|
927
|
+
case "1":
|
|
928
|
+
if (e.code.startsWith("Numpad"))
|
|
929
|
+
setCameraPreset("front");
|
|
930
|
+
break;
|
|
931
|
+
case "3":
|
|
932
|
+
if (e.code.startsWith("Numpad"))
|
|
933
|
+
setCameraPreset("right");
|
|
934
|
+
break;
|
|
935
|
+
case "7":
|
|
936
|
+
if (e.code.startsWith("Numpad"))
|
|
937
|
+
setCameraPreset("top");
|
|
938
|
+
break;
|
|
939
|
+
case "4":
|
|
940
|
+
if (e.code.startsWith("Numpad"))
|
|
941
|
+
setCameraPreset("left");
|
|
942
|
+
break;
|
|
943
|
+
case "6":
|
|
944
|
+
if (e.code.startsWith("Numpad"))
|
|
945
|
+
setCameraPreset("right");
|
|
946
|
+
break;
|
|
947
|
+
case "9":
|
|
948
|
+
if (e.code.startsWith("Numpad"))
|
|
949
|
+
setCameraPreset("back");
|
|
950
|
+
break;
|
|
951
|
+
case "2":
|
|
952
|
+
if (e.code.startsWith("Numpad"))
|
|
953
|
+
setCameraPreset("bottom");
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
if (e.key === "Escape") {
|
|
957
|
+
setAddMenuOpen(null);
|
|
958
|
+
setContextMenu(null);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
window.addEventListener("keydown", handleKey);
|
|
962
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
963
|
+
}, [
|
|
964
|
+
togglePlay,
|
|
965
|
+
focusOnObject,
|
|
966
|
+
selectedId,
|
|
967
|
+
sceneTree,
|
|
968
|
+
rebuildTree,
|
|
969
|
+
setCameraPreset,
|
|
970
|
+
duplicateSelected,
|
|
971
|
+
]);
|
|
972
|
+
/* ─────────────────────────────────────────
|
|
973
|
+
File input handlers
|
|
974
|
+
───────────────────────────────────────── */
|
|
975
|
+
const handleFileInput = useCallback((e) => {
|
|
976
|
+
const files = e.target.files;
|
|
977
|
+
if (!files)
|
|
978
|
+
return;
|
|
979
|
+
Array.from(files).forEach(loadFile);
|
|
980
|
+
e.target.value = "";
|
|
981
|
+
}, [loadFile]);
|
|
982
|
+
const handleMergeInput = useCallback((e) => {
|
|
983
|
+
const files = e.target.files;
|
|
984
|
+
if (!files)
|
|
985
|
+
return;
|
|
986
|
+
Array.from(files).forEach(mergeAnimationFBX);
|
|
987
|
+
e.target.value = "";
|
|
988
|
+
}, [mergeAnimationFBX]);
|
|
989
|
+
/* ─────────────────────────────────────────
|
|
990
|
+
Export functions
|
|
991
|
+
───────────────────────────────────────── */
|
|
992
|
+
const exportGLTF = useCallback((binary) => {
|
|
993
|
+
const root = rootObjectRef.current;
|
|
994
|
+
if (!root) {
|
|
995
|
+
setStatusText("Nothing to export");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const exporter = new GLTFExporter();
|
|
999
|
+
const options = { binary, animations: allClipsRef.current.map((a) => a.clip) };
|
|
1000
|
+
exporter.parse(root, (result) => {
|
|
1001
|
+
let blob;
|
|
1002
|
+
if (binary) {
|
|
1003
|
+
blob = new Blob([result], { type: "application/octet-stream" });
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
blob = new Blob([JSON.stringify(result, null, 2)], { type: "application/json" });
|
|
1007
|
+
}
|
|
1008
|
+
const a = document.createElement("a");
|
|
1009
|
+
a.href = URL.createObjectURL(blob);
|
|
1010
|
+
a.download = `export.${binary ? "glb" : "gltf"}`;
|
|
1011
|
+
a.click();
|
|
1012
|
+
URL.revokeObjectURL(a.href);
|
|
1013
|
+
setStatusText(`Exported ${binary ? "GLB" : "GLTF"} successfully`);
|
|
1014
|
+
}, (err) => {
|
|
1015
|
+
log.error("Export error:", err);
|
|
1016
|
+
setStatusText(`Export error: ${String(err)}`);
|
|
1017
|
+
}, options);
|
|
1018
|
+
}, []);
|
|
1019
|
+
const exportOBJ = useCallback(() => {
|
|
1020
|
+
const root = rootObjectRef.current;
|
|
1021
|
+
if (!root) {
|
|
1022
|
+
setStatusText("Nothing to export");
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const exporter = new OBJExporter();
|
|
1026
|
+
const result = exporter.parse(root);
|
|
1027
|
+
const blob = new Blob([result], { type: "text/plain" });
|
|
1028
|
+
const a = document.createElement("a");
|
|
1029
|
+
a.href = URL.createObjectURL(blob);
|
|
1030
|
+
a.download = "export.obj";
|
|
1031
|
+
a.click();
|
|
1032
|
+
URL.revokeObjectURL(a.href);
|
|
1033
|
+
setStatusText("Exported OBJ successfully");
|
|
1034
|
+
}, []);
|
|
1035
|
+
const exportSTL = useCallback((binary) => {
|
|
1036
|
+
const root = rootObjectRef.current;
|
|
1037
|
+
if (!root) {
|
|
1038
|
+
setStatusText("Nothing to export");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const exporter = new STLExporter();
|
|
1042
|
+
const result = exporter.parse(root, { binary });
|
|
1043
|
+
let blob;
|
|
1044
|
+
if (binary) {
|
|
1045
|
+
blob = new Blob([result], { type: "application/octet-stream" });
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
blob = new Blob([result], { type: "text/plain" });
|
|
1049
|
+
}
|
|
1050
|
+
const a = document.createElement("a");
|
|
1051
|
+
a.href = URL.createObjectURL(blob);
|
|
1052
|
+
a.download = "export.stl";
|
|
1053
|
+
a.click();
|
|
1054
|
+
URL.revokeObjectURL(a.href);
|
|
1055
|
+
setStatusText(`Exported STL${binary ? " (binary)" : ""} successfully`);
|
|
1056
|
+
}, []);
|
|
1057
|
+
const exportPLY = useCallback((binary) => {
|
|
1058
|
+
const root = rootObjectRef.current;
|
|
1059
|
+
if (!root) {
|
|
1060
|
+
setStatusText("Nothing to export");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const exporter = new PLYExporter();
|
|
1064
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1065
|
+
exporter.parse(root, (result) => {
|
|
1066
|
+
let blob;
|
|
1067
|
+
if (binary) {
|
|
1068
|
+
blob = new Blob([result], { type: "application/octet-stream" });
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
blob = new Blob([result], { type: "text/plain" });
|
|
1072
|
+
}
|
|
1073
|
+
const a = document.createElement("a");
|
|
1074
|
+
a.href = URL.createObjectURL(blob);
|
|
1075
|
+
a.download = "export.ply";
|
|
1076
|
+
a.click();
|
|
1077
|
+
URL.revokeObjectURL(a.href);
|
|
1078
|
+
setStatusText(`Exported PLY${binary ? " (binary)" : ""} successfully`);
|
|
1079
|
+
}, { binary });
|
|
1080
|
+
}, []);
|
|
1081
|
+
const exportUSDZ = useCallback(async () => {
|
|
1082
|
+
const root = rootObjectRef.current;
|
|
1083
|
+
if (!root) {
|
|
1084
|
+
setStatusText("Nothing to export");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
const exporter = new USDZExporter();
|
|
1089
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1090
|
+
const result = await new Promise((resolve) => {
|
|
1091
|
+
exporter.parse(root, resolve);
|
|
1092
|
+
});
|
|
1093
|
+
const blob = new Blob([result.buffer], { type: "application/octet-stream" });
|
|
1094
|
+
const a = document.createElement("a");
|
|
1095
|
+
a.href = URL.createObjectURL(blob);
|
|
1096
|
+
a.download = "export.usdz";
|
|
1097
|
+
a.click();
|
|
1098
|
+
URL.revokeObjectURL(a.href);
|
|
1099
|
+
setStatusText("Exported USDZ successfully");
|
|
1100
|
+
}
|
|
1101
|
+
catch (err) {
|
|
1102
|
+
log.error("USDZ export error:", err);
|
|
1103
|
+
setStatusText(`USDZ export error: ${String(err)}`);
|
|
1104
|
+
}
|
|
1105
|
+
}, []);
|
|
1106
|
+
/* ─────────────────────────────────────────
|
|
1107
|
+
Dispose & clear
|
|
1108
|
+
───────────────────────────────────────── */
|
|
1109
|
+
const disposeObject = useCallback((obj) => {
|
|
1110
|
+
obj.traverse((child) => {
|
|
1111
|
+
if (child.geometry) {
|
|
1112
|
+
child.geometry.dispose();
|
|
1113
|
+
}
|
|
1114
|
+
if (child.material) {
|
|
1115
|
+
const mats = Array.isArray(child.material)
|
|
1116
|
+
? child.material
|
|
1117
|
+
: [child.material];
|
|
1118
|
+
mats.forEach((mat) => {
|
|
1119
|
+
const m = mat;
|
|
1120
|
+
["map", "normalMap", "roughnessMap", "metalnessMap", "emissiveMap", "aoMap", "alphaMap", "envMap", "lightMap", "bumpMap", "displacementMap", "specularMap"].forEach((prop) => {
|
|
1121
|
+
if (m[prop] && typeof m[prop].dispose === "function")
|
|
1122
|
+
m[prop].dispose();
|
|
1123
|
+
});
|
|
1124
|
+
mat.dispose();
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
}, []);
|
|
1129
|
+
const clearScene = useCallback(() => {
|
|
1130
|
+
var _a;
|
|
1131
|
+
if (!window.confirm("Clear the entire scene? This cannot be undone."))
|
|
1132
|
+
return;
|
|
1133
|
+
const scene = sceneRef.current;
|
|
1134
|
+
const toRemove = [];
|
|
1135
|
+
scene.traverse((obj) => {
|
|
1136
|
+
if (!obj.name.startsWith("__") && obj !== scene && obj.parent === scene) {
|
|
1137
|
+
toRemove.push(obj);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
toRemove.forEach((o) => {
|
|
1141
|
+
disposeObject(o);
|
|
1142
|
+
scene.remove(o);
|
|
1143
|
+
});
|
|
1144
|
+
if (mixerRef.current) {
|
|
1145
|
+
mixerRef.current.stopAllAction();
|
|
1146
|
+
if (rootObjectRef.current) {
|
|
1147
|
+
mixerRef.current.uncacheRoot(rootObjectRef.current);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
(_a = transformRef.current) === null || _a === void 0 ? void 0 : _a.detach();
|
|
1151
|
+
rootObjectRef.current = null;
|
|
1152
|
+
mixerRef.current = null;
|
|
1153
|
+
activeActionRef.current = null;
|
|
1154
|
+
allClipsRef.current = [];
|
|
1155
|
+
setAnimations([]);
|
|
1156
|
+
setActiveAnimIdx(-1);
|
|
1157
|
+
setIsPlaying(false);
|
|
1158
|
+
setAnimTime(0);
|
|
1159
|
+
setAnimDuration(0);
|
|
1160
|
+
setSelectedId(null);
|
|
1161
|
+
rebuildTree();
|
|
1162
|
+
setStatusText("Scene cleared");
|
|
1163
|
+
}, [rebuildTree, disposeObject]);
|
|
1164
|
+
/* ─────────────────────────────────────────
|
|
1165
|
+
Save to Library
|
|
1166
|
+
───────────────────────────────────────── */
|
|
1167
|
+
const saveToLibrary = useCallback(() => {
|
|
1168
|
+
if (!onSaveToLibrary)
|
|
1169
|
+
return;
|
|
1170
|
+
const root = rootObjectRef.current;
|
|
1171
|
+
if (!root) {
|
|
1172
|
+
setStatusText("Nothing to save");
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const exporter = new GLTFExporter();
|
|
1176
|
+
exporter.parse(root, (result) => {
|
|
1177
|
+
const blob = new Blob([result], { type: "model/gltf-binary" });
|
|
1178
|
+
const reader = new FileReader();
|
|
1179
|
+
reader.onload = () => {
|
|
1180
|
+
const defaultName = "model.glb";
|
|
1181
|
+
const name = window.prompt("Save to library as:", defaultName);
|
|
1182
|
+
if (!name || !name.trim())
|
|
1183
|
+
return;
|
|
1184
|
+
onSaveToLibrary(reader.result, name.trim(), "model/gltf-binary");
|
|
1185
|
+
};
|
|
1186
|
+
reader.readAsDataURL(blob);
|
|
1187
|
+
}, (err) => {
|
|
1188
|
+
log.error("Save to library error:", err);
|
|
1189
|
+
setStatusText(`Save error: ${String(err)}`);
|
|
1190
|
+
}, { binary: true, animations: allClipsRef.current.map((a) => a.clip) });
|
|
1191
|
+
}, [onSaveToLibrary]);
|
|
1192
|
+
/* ─────────────────────────────────────────
|
|
1193
|
+
Add Mesh / Light / Camera / Empty
|
|
1194
|
+
───────────────────────────────────────── */
|
|
1195
|
+
const addPrimitive = useCallback((type) => {
|
|
1196
|
+
let geo;
|
|
1197
|
+
switch (type) {
|
|
1198
|
+
case "cube":
|
|
1199
|
+
geo = new THREE.BoxGeometry(1, 1, 1);
|
|
1200
|
+
break;
|
|
1201
|
+
case "sphere":
|
|
1202
|
+
geo = new THREE.SphereGeometry(0.5, 32, 32);
|
|
1203
|
+
break;
|
|
1204
|
+
case "cylinder":
|
|
1205
|
+
geo = new THREE.CylinderGeometry(0.5, 0.5, 1, 32);
|
|
1206
|
+
break;
|
|
1207
|
+
case "cone":
|
|
1208
|
+
geo = new THREE.ConeGeometry(0.5, 1, 32);
|
|
1209
|
+
break;
|
|
1210
|
+
case "torus":
|
|
1211
|
+
geo = new THREE.TorusGeometry(0.5, 0.2, 16, 32);
|
|
1212
|
+
break;
|
|
1213
|
+
case "plane":
|
|
1214
|
+
geo = new THREE.PlaneGeometry(2, 2);
|
|
1215
|
+
break;
|
|
1216
|
+
case "circle":
|
|
1217
|
+
geo = new THREE.CircleGeometry(0.5, 32);
|
|
1218
|
+
break;
|
|
1219
|
+
case "ring":
|
|
1220
|
+
geo = new THREE.RingGeometry(0.3, 0.5, 32);
|
|
1221
|
+
break;
|
|
1222
|
+
case "dodecahedron":
|
|
1223
|
+
geo = new THREE.DodecahedronGeometry(0.5);
|
|
1224
|
+
break;
|
|
1225
|
+
case "icosahedron":
|
|
1226
|
+
geo = new THREE.IcosahedronGeometry(0.5);
|
|
1227
|
+
break;
|
|
1228
|
+
case "octahedron":
|
|
1229
|
+
geo = new THREE.OctahedronGeometry(0.5);
|
|
1230
|
+
break;
|
|
1231
|
+
case "tetrahedron":
|
|
1232
|
+
geo = new THREE.TetrahedronGeometry(0.5);
|
|
1233
|
+
break;
|
|
1234
|
+
case "torusKnot":
|
|
1235
|
+
geo = new THREE.TorusKnotGeometry(0.4, 0.15, 64, 16);
|
|
1236
|
+
break;
|
|
1237
|
+
default: return;
|
|
1238
|
+
}
|
|
1239
|
+
const mat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.1, roughness: 0.7 });
|
|
1240
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
1241
|
+
mesh.name = type.charAt(0).toUpperCase() + type.slice(1);
|
|
1242
|
+
mesh.castShadow = true;
|
|
1243
|
+
mesh.receiveShadow = true;
|
|
1244
|
+
if (type === "plane" || type === "circle") {
|
|
1245
|
+
mesh.rotation.x = -Math.PI / 2;
|
|
1246
|
+
mesh.position.y = 0.001;
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
mesh.position.y = 0.5;
|
|
1250
|
+
}
|
|
1251
|
+
sceneRef.current.add(mesh);
|
|
1252
|
+
rebuildTree();
|
|
1253
|
+
setStatusText(`Added ${mesh.name}`);
|
|
1254
|
+
setAddMenuOpen(null);
|
|
1255
|
+
}, [rebuildTree]);
|
|
1256
|
+
const addSceneLight = useCallback((type) => {
|
|
1257
|
+
let light;
|
|
1258
|
+
switch (type) {
|
|
1259
|
+
case "point": {
|
|
1260
|
+
const l = new THREE.PointLight(0xffffff, 1, 20);
|
|
1261
|
+
l.position.set(0, 3, 0);
|
|
1262
|
+
l.castShadow = true;
|
|
1263
|
+
light = l;
|
|
1264
|
+
break;
|
|
1265
|
+
}
|
|
1266
|
+
case "spot": {
|
|
1267
|
+
const l = new THREE.SpotLight(0xffffff, 1, 20, Math.PI / 6, 0.3);
|
|
1268
|
+
l.position.set(0, 4, 2);
|
|
1269
|
+
l.castShadow = true;
|
|
1270
|
+
light = l;
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
case "directional": {
|
|
1274
|
+
const l = new THREE.DirectionalLight(0xffffff, 1);
|
|
1275
|
+
l.position.set(3, 5, 3);
|
|
1276
|
+
l.castShadow = true;
|
|
1277
|
+
light = l;
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
case "hemisphere": {
|
|
1281
|
+
light = new THREE.HemisphereLight(0x8888ff, 0x442200, 0.5);
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
default: return;
|
|
1285
|
+
}
|
|
1286
|
+
light.name = type.charAt(0).toUpperCase() + type.slice(1) + "Light";
|
|
1287
|
+
sceneRef.current.add(light);
|
|
1288
|
+
let helper = null;
|
|
1289
|
+
if (light instanceof THREE.PointLight) {
|
|
1290
|
+
helper = new THREE.PointLightHelper(light, 0.3);
|
|
1291
|
+
}
|
|
1292
|
+
else if (light instanceof THREE.SpotLight) {
|
|
1293
|
+
helper = new THREE.SpotLightHelper(light);
|
|
1294
|
+
}
|
|
1295
|
+
else if (light instanceof THREE.DirectionalLight) {
|
|
1296
|
+
helper = new THREE.DirectionalLightHelper(light, 0.5);
|
|
1297
|
+
}
|
|
1298
|
+
else if (light instanceof THREE.HemisphereLight) {
|
|
1299
|
+
helper = new THREE.HemisphereLightHelper(light, 0.3);
|
|
1300
|
+
}
|
|
1301
|
+
if (helper) {
|
|
1302
|
+
helper.name = `__helper_${light.name}`;
|
|
1303
|
+
sceneRef.current.add(helper);
|
|
1304
|
+
}
|
|
1305
|
+
rebuildTree();
|
|
1306
|
+
setStatusText(`Added ${light.name}`);
|
|
1307
|
+
setAddMenuOpen(null);
|
|
1308
|
+
}, [rebuildTree]);
|
|
1309
|
+
const addCameraObject = useCallback(() => {
|
|
1310
|
+
const cam = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
|
|
1311
|
+
cam.name = "Camera";
|
|
1312
|
+
cam.position.set(5, 3, 5);
|
|
1313
|
+
cam.lookAt(0, 0, 0);
|
|
1314
|
+
sceneRef.current.add(cam);
|
|
1315
|
+
const helper = new THREE.CameraHelper(cam);
|
|
1316
|
+
helper.name = "__helper_Camera";
|
|
1317
|
+
sceneRef.current.add(helper);
|
|
1318
|
+
rebuildTree();
|
|
1319
|
+
setStatusText("Added Camera");
|
|
1320
|
+
setAddMenuOpen(null);
|
|
1321
|
+
}, [rebuildTree]);
|
|
1322
|
+
const addEmpty = useCallback((type) => {
|
|
1323
|
+
const obj = new THREE.Object3D();
|
|
1324
|
+
obj.name = type === "arrows" ? "Empty_Arrows" : type === "cube_empty" ? "Empty_Cube" : "Empty";
|
|
1325
|
+
sceneRef.current.add(obj);
|
|
1326
|
+
if (type === "arrows") {
|
|
1327
|
+
const h = new THREE.AxesHelper(0.5);
|
|
1328
|
+
h.name = "__emptyAxes";
|
|
1329
|
+
obj.add(h);
|
|
1330
|
+
}
|
|
1331
|
+
rebuildTree();
|
|
1332
|
+
setStatusText(`Added ${obj.name}`);
|
|
1333
|
+
setAddMenuOpen(null);
|
|
1334
|
+
}, [rebuildTree]);
|
|
1335
|
+
/* ─────────────────────────────────────────
|
|
1336
|
+
Raycaster click-to-select
|
|
1337
|
+
───────────────────────────────────────── */
|
|
1338
|
+
const handleViewportClick = useCallback((e) => {
|
|
1339
|
+
if (editorMode !== "object")
|
|
1340
|
+
return;
|
|
1341
|
+
if (!mountRef.current || !rendererRef.current || !cameraRef.current)
|
|
1342
|
+
return;
|
|
1343
|
+
if (e.target !== rendererRef.current.domElement)
|
|
1344
|
+
return;
|
|
1345
|
+
const rect = mountRef.current.getBoundingClientRect();
|
|
1346
|
+
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
1347
|
+
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1348
|
+
raycasterRef.current.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
|
|
1349
|
+
const meshes = [];
|
|
1350
|
+
sceneRef.current.traverse((obj) => {
|
|
1351
|
+
if (obj instanceof THREE.Mesh && !obj.name.startsWith("__"))
|
|
1352
|
+
meshes.push(obj);
|
|
1353
|
+
});
|
|
1354
|
+
const intersects = raycasterRef.current.intersectObjects(meshes, false);
|
|
1355
|
+
if (intersects.length > 0) {
|
|
1356
|
+
const hit = intersects[0].object;
|
|
1357
|
+
const nodeId = hit.userData.__nodeId;
|
|
1358
|
+
if (nodeId) {
|
|
1359
|
+
const node = findNodeById(sceneTree, nodeId);
|
|
1360
|
+
if (node) {
|
|
1361
|
+
selectObject(node);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
rebuildTree();
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
selectObject(null);
|
|
1369
|
+
}
|
|
1370
|
+
}, [editorMode, sceneTree, selectObject, rebuildTree]);
|
|
1371
|
+
/* ─────────────────────────────────────────
|
|
1372
|
+
Context menu
|
|
1373
|
+
───────────────────────────────────────── */
|
|
1374
|
+
const handleContextMenu = useCallback((e) => {
|
|
1375
|
+
e.preventDefault();
|
|
1376
|
+
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
1377
|
+
}, []);
|
|
1378
|
+
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
1379
|
+
useEffect(() => {
|
|
1380
|
+
if (!contextMenu)
|
|
1381
|
+
return;
|
|
1382
|
+
const close = () => setContextMenu(null);
|
|
1383
|
+
window.addEventListener("click", close);
|
|
1384
|
+
return () => window.removeEventListener("click", close);
|
|
1385
|
+
}, [contextMenu]);
|
|
1386
|
+
/* ─────────────────────────────────────────
|
|
1387
|
+
AI animation from video
|
|
1388
|
+
───────────────────────────────────────── */
|
|
1389
|
+
const handleAIVideo = useCallback(async (e) => {
|
|
1390
|
+
var _a;
|
|
1391
|
+
const file = (_a = e.target.files) === null || _a === void 0 ? void 0 : _a[0];
|
|
1392
|
+
if (!file)
|
|
1393
|
+
return;
|
|
1394
|
+
e.target.value = "";
|
|
1395
|
+
if (!onAIPoseVideo) {
|
|
1396
|
+
setAiStatus("AI pose callback not configured.");
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
setAiBusy(true);
|
|
1400
|
+
setAiStatus("Uploading video for AI pose estimation...");
|
|
1401
|
+
try {
|
|
1402
|
+
const result = await onAIPoseVideo(file);
|
|
1403
|
+
setAiStatus(`Got ${result.frame_count} frames at ${result.fps} fps. Creating animation clip...`);
|
|
1404
|
+
const clip = pose3dToClip(result);
|
|
1405
|
+
if (clip && rootObjectRef.current) {
|
|
1406
|
+
if (!mixerRef.current) {
|
|
1407
|
+
mixerRef.current = new THREE.AnimationMixer(rootObjectRef.current);
|
|
1408
|
+
}
|
|
1409
|
+
const newAnim = {
|
|
1410
|
+
name: `AI: ${file.name}`,
|
|
1411
|
+
clip,
|
|
1412
|
+
duration: clip.duration,
|
|
1413
|
+
source: file.name,
|
|
1414
|
+
};
|
|
1415
|
+
allClipsRef.current = [...allClipsRef.current, newAnim];
|
|
1416
|
+
setAnimations([...allClipsRef.current]);
|
|
1417
|
+
playClip(allClipsRef.current.length - 1);
|
|
1418
|
+
setAiStatus("Animation created successfully!");
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
setAiStatus("Could not create animation — load a rigged model first.");
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
catch (err) {
|
|
1425
|
+
log.error("AI pose error:", err);
|
|
1426
|
+
setAiStatus(`Error: ${String(err)}`);
|
|
1427
|
+
}
|
|
1428
|
+
finally {
|
|
1429
|
+
setAiBusy(false);
|
|
1430
|
+
}
|
|
1431
|
+
}, [playClip, onAIPoseVideo]);
|
|
1432
|
+
/* ─────────────────────────────────────────
|
|
1433
|
+
Node helpers
|
|
1434
|
+
───────────────────────────────────────── */
|
|
1435
|
+
const toggleNodeVisibility = useCallback((node) => {
|
|
1436
|
+
node.object.visible = !node.object.visible;
|
|
1437
|
+
rebuildTree();
|
|
1438
|
+
}, [rebuildTree]);
|
|
1439
|
+
const toggleNodeExpanded = useCallback((node) => {
|
|
1440
|
+
node.expanded = !node.expanded;
|
|
1441
|
+
setSceneTree((prev) => [...prev]);
|
|
1442
|
+
}, []);
|
|
1443
|
+
/* ═══════════════════════════════════════════
|
|
1444
|
+
Return API
|
|
1445
|
+
═══════════════════════════════════════════ */
|
|
1446
|
+
return {
|
|
1447
|
+
// refs
|
|
1448
|
+
mountRef,
|
|
1449
|
+
fileInputRef,
|
|
1450
|
+
mergeInputRef,
|
|
1451
|
+
videoInputRef,
|
|
1452
|
+
rootObjectRef,
|
|
1453
|
+
// state
|
|
1454
|
+
sceneTree,
|
|
1455
|
+
selectedId,
|
|
1456
|
+
transformMode,
|
|
1457
|
+
setTransformMode,
|
|
1458
|
+
animations,
|
|
1459
|
+
activeAnimIdx,
|
|
1460
|
+
isPlaying,
|
|
1461
|
+
animTime,
|
|
1462
|
+
animDuration,
|
|
1463
|
+
animSpeed,
|
|
1464
|
+
setAnimSpeed,
|
|
1465
|
+
loopAnim,
|
|
1466
|
+
setLoopAnim,
|
|
1467
|
+
bottomCollapsed,
|
|
1468
|
+
setBottomCollapsed,
|
|
1469
|
+
showGrid,
|
|
1470
|
+
setShowGrid,
|
|
1471
|
+
showAxes,
|
|
1472
|
+
setShowAxes,
|
|
1473
|
+
wireframe,
|
|
1474
|
+
setWireframe,
|
|
1475
|
+
bgColor,
|
|
1476
|
+
setBgColor,
|
|
1477
|
+
statusText,
|
|
1478
|
+
dragOver,
|
|
1479
|
+
polyCount,
|
|
1480
|
+
meshCount,
|
|
1481
|
+
boneCount,
|
|
1482
|
+
selMaterialIdx,
|
|
1483
|
+
setSelMaterialIdx,
|
|
1484
|
+
matRefresh,
|
|
1485
|
+
setMatRefresh,
|
|
1486
|
+
aiStatus,
|
|
1487
|
+
aiBusy,
|
|
1488
|
+
shadingMode,
|
|
1489
|
+
setShadingMode,
|
|
1490
|
+
editorMode,
|
|
1491
|
+
setEditorMode,
|
|
1492
|
+
contextMenu,
|
|
1493
|
+
outlinerSearch,
|
|
1494
|
+
setOutlinerSearch,
|
|
1495
|
+
snapEnabled,
|
|
1496
|
+
setSnapEnabled,
|
|
1497
|
+
snapGrid,
|
|
1498
|
+
setSnapGrid,
|
|
1499
|
+
showSkeleton,
|
|
1500
|
+
setShowSkeleton,
|
|
1501
|
+
addMenuOpen,
|
|
1502
|
+
setAddMenuOpen,
|
|
1503
|
+
gizmoSpace,
|
|
1504
|
+
setGizmoSpace,
|
|
1505
|
+
propTab,
|
|
1506
|
+
setPropTab,
|
|
1507
|
+
fogEnabled,
|
|
1508
|
+
setFogEnabled,
|
|
1509
|
+
fogColor,
|
|
1510
|
+
setFogColor,
|
|
1511
|
+
fogNear,
|
|
1512
|
+
setFogNear,
|
|
1513
|
+
fogFar,
|
|
1514
|
+
setFogFar,
|
|
1515
|
+
showLightHelpers,
|
|
1516
|
+
setShowLightHelpers,
|
|
1517
|
+
// derived
|
|
1518
|
+
selectedNode,
|
|
1519
|
+
selectedMaterials,
|
|
1520
|
+
// callbacks
|
|
1521
|
+
rebuildTree,
|
|
1522
|
+
loadFile,
|
|
1523
|
+
mergeAnimationFBX,
|
|
1524
|
+
playClip,
|
|
1525
|
+
togglePlay,
|
|
1526
|
+
stopAnim,
|
|
1527
|
+
seekAnim,
|
|
1528
|
+
focusOnObject,
|
|
1529
|
+
setCameraPreset,
|
|
1530
|
+
selectObject,
|
|
1531
|
+
deleteSelected,
|
|
1532
|
+
duplicateSelected,
|
|
1533
|
+
handleFileInput,
|
|
1534
|
+
handleMergeInput,
|
|
1535
|
+
handleAIVideo,
|
|
1536
|
+
exportGLTF,
|
|
1537
|
+
exportOBJ,
|
|
1538
|
+
exportSTL,
|
|
1539
|
+
exportPLY,
|
|
1540
|
+
exportUSDZ,
|
|
1541
|
+
clearScene,
|
|
1542
|
+
saveToLibrary,
|
|
1543
|
+
addPrimitive,
|
|
1544
|
+
addSceneLight,
|
|
1545
|
+
addCameraObject,
|
|
1546
|
+
addEmpty,
|
|
1547
|
+
handleViewportClick,
|
|
1548
|
+
handleContextMenu,
|
|
1549
|
+
closeContextMenu,
|
|
1550
|
+
handleDragOver,
|
|
1551
|
+
handleDragLeave,
|
|
1552
|
+
handleDrop,
|
|
1553
|
+
toggleNodeVisibility,
|
|
1554
|
+
toggleNodeExpanded,
|
|
1555
|
+
setStatusText,
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
export { useModelEditor };
|
|
1560
|
+
//# sourceMappingURL=useModelEditor.js.map
|