@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/dist/cjs/core/i18n.js +16 -0
  5. package/dist/cjs/core/i18n.js.map +1 -0
  6. package/dist/cjs/index.js +11 -0
  7. package/dist/cjs/index.js.map +1 -0
  8. package/dist/cjs/model/ModelEditor.js +33 -0
  9. package/dist/cjs/model/ModelEditor.js.map +1 -0
  10. package/dist/cjs/model/ModelEditor.module.css.js +6 -0
  11. package/dist/cjs/model/ModelEditor.module.css.js.map +1 -0
  12. package/dist/cjs/model/ModelEditorLeftPanel.js +40 -0
  13. package/dist/cjs/model/ModelEditorLeftPanel.js.map +1 -0
  14. package/dist/cjs/model/ModelEditorMenuBar.js +14 -0
  15. package/dist/cjs/model/ModelEditorMenuBar.js.map +1 -0
  16. package/dist/cjs/model/ModelEditorRightPanel.js +106 -0
  17. package/dist/cjs/model/ModelEditorRightPanel.js.map +1 -0
  18. package/dist/cjs/model/ModelEditorSubComponents.js +99 -0
  19. package/dist/cjs/model/ModelEditorSubComponents.js.map +1 -0
  20. package/dist/cjs/model/ModelEditorTimeline.js +31 -0
  21. package/dist/cjs/model/ModelEditorTimeline.js.map +1 -0
  22. package/dist/cjs/model/ModelEditorToolbar.js +14 -0
  23. package/dist/cjs/model/ModelEditorToolbar.js.map +1 -0
  24. package/dist/cjs/model/ModelEditorViewport.js +20 -0
  25. package/dist/cjs/model/ModelEditorViewport.js.map +1 -0
  26. package/dist/cjs/model/modelEditorTypes.js +122 -0
  27. package/dist/cjs/model/modelEditorTypes.js.map +1 -0
  28. package/dist/cjs/model/useModelEditor.js +1581 -0
  29. package/dist/cjs/model/useModelEditor.js.map +1 -0
  30. package/dist/cjs/nice2dev-ui-3d.css +1 -0
  31. package/dist/esm/core/i18n.js +13 -0
  32. package/dist/esm/core/i18n.js.map +1 -0
  33. package/dist/esm/index.js +3 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/model/ModelEditor.js +31 -0
  36. package/dist/esm/model/ModelEditor.js.map +1 -0
  37. package/dist/esm/model/ModelEditor.module.css.js +4 -0
  38. package/dist/esm/model/ModelEditor.module.css.js.map +1 -0
  39. package/dist/esm/model/ModelEditorLeftPanel.js +38 -0
  40. package/dist/esm/model/ModelEditorLeftPanel.js.map +1 -0
  41. package/dist/esm/model/ModelEditorMenuBar.js +12 -0
  42. package/dist/esm/model/ModelEditorMenuBar.js.map +1 -0
  43. package/dist/esm/model/ModelEditorRightPanel.js +85 -0
  44. package/dist/esm/model/ModelEditorRightPanel.js.map +1 -0
  45. package/dist/esm/model/ModelEditorSubComponents.js +76 -0
  46. package/dist/esm/model/ModelEditorSubComponents.js.map +1 -0
  47. package/dist/esm/model/ModelEditorTimeline.js +29 -0
  48. package/dist/esm/model/ModelEditorTimeline.js.map +1 -0
  49. package/dist/esm/model/ModelEditorToolbar.js +12 -0
  50. package/dist/esm/model/ModelEditorToolbar.js.map +1 -0
  51. package/dist/esm/model/ModelEditorViewport.js +18 -0
  52. package/dist/esm/model/ModelEditorViewport.js.map +1 -0
  53. package/dist/esm/model/modelEditorTypes.js +97 -0
  54. package/dist/esm/model/modelEditorTypes.js.map +1 -0
  55. package/dist/esm/model/useModelEditor.js +1560 -0
  56. package/dist/esm/model/useModelEditor.js.map +1 -0
  57. package/dist/esm/nice2dev-ui-3d.css +1 -0
  58. package/dist/types/core/i18n.d.ts +27 -0
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/model/ModelEditor.d.ts +17 -0
  61. package/dist/types/model/ModelEditorLeftPanel.d.ts +10 -0
  62. package/dist/types/model/ModelEditorMenuBar.d.ts +11 -0
  63. package/dist/types/model/ModelEditorRightPanel.d.ts +11 -0
  64. package/dist/types/model/ModelEditorSubComponents.d.ts +24 -0
  65. package/dist/types/model/ModelEditorTimeline.d.ts +11 -0
  66. package/dist/types/model/ModelEditorToolbar.d.ts +11 -0
  67. package/dist/types/model/ModelEditorViewport.d.ts +11 -0
  68. package/dist/types/model/modelEditorTypes.d.ts +52 -0
  69. package/dist/types/model/useModelEditor.d.ts +125 -0
  70. 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