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