react-three-game 0.0.55 → 0.0.57
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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/ContactShadow.d.ts +8 -0
- package/dist/shared/ContactShadow.js +32 -0
- package/dist/shared/GameCanvas.js +1 -3
- package/dist/tools/assetviewer/page.js +36 -15
- package/dist/tools/dragdrop/DragDropLoader.js +17 -40
- package/dist/tools/dragdrop/modelLoader.d.ts +5 -0
- package/dist/tools/dragdrop/modelLoader.js +39 -0
- package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
- package/dist/tools/prefabeditor/Dropdown.js +82 -0
- package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
- package/dist/tools/prefabeditor/EditorTree.js +139 -70
- package/dist/tools/prefabeditor/EditorUI.js +5 -10
- package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
- package/dist/tools/prefabeditor/PrefabEditor.js +70 -3
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +136 -35
- package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
- package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
- package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
- package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
- package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
- package/dist/tools/prefabeditor/components/Input.js +100 -47
- package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
- package/dist/tools/prefabeditor/components/MaterialComponent.js +129 -14
- package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
- package/dist/tools/prefabeditor/components/SpotLightComponent.js +6 -11
- package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
- package/dist/tools/prefabeditor/components/TransformComponent.js +31 -19
- package/dist/tools/prefabeditor/components/index.js +5 -1
- package/dist/tools/prefabeditor/styles.d.ts +17 -4
- package/dist/tools/prefabeditor/styles.js +69 -32
- package/dist/tools/prefabeditor/utils.d.ts +8 -3
- package/dist/tools/prefabeditor/utils.js +92 -6
- package/package.json +1 -1
- package/react-three-game-skill/react-three-game/rules/LIGHTING.md +6 -0
- package/src/index.ts +7 -0
- package/src/shared/ContactShadow.tsx +74 -0
- package/src/shared/GameCanvas.tsx +0 -3
- package/src/tools/assetviewer/page.tsx +78 -46
- package/src/tools/dragdrop/DragDropLoader.tsx +7 -39
- package/src/tools/dragdrop/modelLoader.ts +36 -0
- package/src/tools/prefabeditor/Dropdown.tsx +112 -0
- package/src/tools/prefabeditor/EditorContext.tsx +5 -0
- package/src/tools/prefabeditor/EditorTree.tsx +237 -115
- package/src/tools/prefabeditor/EditorUI.tsx +6 -11
- package/src/tools/prefabeditor/PrefabEditor.tsx +77 -5
- package/src/tools/prefabeditor/PrefabRoot.tsx +228 -59
- package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
- package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
- package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
- package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
- package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
- package/src/tools/prefabeditor/components/Input.tsx +247 -53
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +191 -20
- package/src/tools/prefabeditor/components/ModelComponent.tsx +52 -5
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +14 -16
- package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
- package/src/tools/prefabeditor/components/TransformComponent.tsx +78 -20
- package/src/tools/prefabeditor/components/index.ts +5 -1
- package/src/tools/prefabeditor/styles.ts +71 -32
- package/src/tools/prefabeditor/utils.ts +96 -5
|
@@ -8,12 +8,33 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
10
|
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
|
11
|
-
|
|
11
|
+
import { Box3, PerspectiveCamera, Quaternion, Vector3 } from 'three';
|
|
12
|
+
/** Save a prefab as JSON file, showing a Save As dialog when supported */
|
|
12
13
|
export function saveJson(data, filename) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
15
|
+
const json = JSON.stringify(data, null, 2);
|
|
16
|
+
if ('showSaveFilePicker' in window) {
|
|
17
|
+
try {
|
|
18
|
+
const handle = yield window.showSaveFilePicker({
|
|
19
|
+
suggestedName: `${filename || 'prefab'}.json`,
|
|
20
|
+
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }],
|
|
21
|
+
});
|
|
22
|
+
const writable = yield handle.createWritable();
|
|
23
|
+
yield writable.write(json);
|
|
24
|
+
yield writable.close();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if ((e === null || e === void 0 ? void 0 : e.name) === 'AbortError')
|
|
29
|
+
return; // user cancelled
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Fallback for browsers without File System Access API
|
|
33
|
+
const a = document.createElement('a');
|
|
34
|
+
a.href = "data:text/json;charset=utf-8," + encodeURIComponent(json);
|
|
35
|
+
a.download = `${filename || 'prefab'}.json`;
|
|
36
|
+
a.click();
|
|
37
|
+
});
|
|
17
38
|
}
|
|
18
39
|
/** Load a prefab from JSON file */
|
|
19
40
|
export function loadJson() {
|
|
@@ -85,6 +106,33 @@ export function exportGLBData(sceneRoot) {
|
|
|
85
106
|
return result;
|
|
86
107
|
});
|
|
87
108
|
}
|
|
109
|
+
export function focusCameraOnObject(object, camera, target, update) {
|
|
110
|
+
const bounds = new Box3().setFromObject(object);
|
|
111
|
+
const center = new Vector3();
|
|
112
|
+
const size = new Vector3();
|
|
113
|
+
const quaternion = new Quaternion();
|
|
114
|
+
object.getWorldQuaternion(quaternion);
|
|
115
|
+
if (bounds.isEmpty()) {
|
|
116
|
+
object.getWorldPosition(center);
|
|
117
|
+
size.setScalar(1);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
bounds.getCenter(center);
|
|
121
|
+
bounds.getSize(size);
|
|
122
|
+
}
|
|
123
|
+
const radius = Math.max(size.length() * 0.5, 1);
|
|
124
|
+
const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion).normalize();
|
|
125
|
+
const worldUp = new Vector3(0, 1, 0);
|
|
126
|
+
const elevatedDirection = forward.clone().addScaledVector(worldUp, 0.65).normalize();
|
|
127
|
+
const distance = camera instanceof PerspectiveCamera
|
|
128
|
+
? Math.max(radius / Math.tan((camera.fov * Math.PI) / 360) * 1.8, radius * 3.5)
|
|
129
|
+
: radius * 4.5;
|
|
130
|
+
const nextPosition = center.clone().add(elevatedDirection.multiplyScalar(distance));
|
|
131
|
+
camera.position.copy(nextPosition);
|
|
132
|
+
camera.lookAt(center);
|
|
133
|
+
target.copy(center);
|
|
134
|
+
update === null || update === void 0 ? void 0 : update();
|
|
135
|
+
}
|
|
88
136
|
/** Find a node by ID in the tree */
|
|
89
137
|
export function findNode(root, id) {
|
|
90
138
|
var _a;
|
|
@@ -149,7 +197,7 @@ export function deleteNode(root, id) {
|
|
|
149
197
|
/** Deep clone a node with new IDs */
|
|
150
198
|
export function cloneNode(node) {
|
|
151
199
|
var _a, _b;
|
|
152
|
-
return Object.assign(Object.assign({}, node), { id: crypto.randomUUID(), name: `${(_a = node.name) !== null && _a !== void 0 ? _a :
|
|
200
|
+
return Object.assign(Object.assign({}, node), { id: crypto.randomUUID(), name: `${(_a = node.name) !== null && _a !== void 0 ? _a : node.id} Copy`, children: (_b = node.children) === null || _b === void 0 ? void 0 : _b.map(cloneNode) });
|
|
153
201
|
}
|
|
154
202
|
/** Recursively update all IDs in a node tree */
|
|
155
203
|
export function regenerateIds(node) {
|
|
@@ -180,3 +228,41 @@ export function updateNodeById(root, id, updater) {
|
|
|
180
228
|
return root;
|
|
181
229
|
return Object.assign(Object.assign({}, root), { children: newChildren });
|
|
182
230
|
}
|
|
231
|
+
/** Create a GameObject node for a 3D model file */
|
|
232
|
+
export function createModelNode(filename, name) {
|
|
233
|
+
return {
|
|
234
|
+
id: crypto.randomUUID(),
|
|
235
|
+
name: name !== null && name !== void 0 ? name : filename.replace(/^.*[\/]/, '').replace(/\.[^.]+$/, ''),
|
|
236
|
+
components: {
|
|
237
|
+
transform: {
|
|
238
|
+
type: 'Transform',
|
|
239
|
+
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
240
|
+
},
|
|
241
|
+
model: {
|
|
242
|
+
type: 'Model',
|
|
243
|
+
properties: { filename, instanced: false }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Create a GameObject node for an image as a textured plane */
|
|
249
|
+
export function createImageNode(texturePath, name) {
|
|
250
|
+
return {
|
|
251
|
+
id: crypto.randomUUID(),
|
|
252
|
+
name: name !== null && name !== void 0 ? name : texturePath.replace(/^.*[\/]/, '').replace(/\.[^.]+$/, ''),
|
|
253
|
+
components: {
|
|
254
|
+
transform: {
|
|
255
|
+
type: 'Transform',
|
|
256
|
+
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
257
|
+
},
|
|
258
|
+
geometry: {
|
|
259
|
+
type: 'Geometry',
|
|
260
|
+
properties: { geometryType: 'plane', args: [1, 1] }
|
|
261
|
+
},
|
|
262
|
+
material: {
|
|
263
|
+
type: 'Material',
|
|
264
|
+
properties: { color: '#ffffff', texture: texturePath }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -15,13 +15,20 @@ export { registerComponent } from './tools/prefabeditor/components/ComponentRegi
|
|
|
15
15
|
// Prefab Editor - Input Components
|
|
16
16
|
export {
|
|
17
17
|
FieldRenderer,
|
|
18
|
+
FieldGroup,
|
|
18
19
|
Input,
|
|
19
20
|
Label,
|
|
20
21
|
Vector3Input,
|
|
22
|
+
Vector3Field,
|
|
23
|
+
NumberField,
|
|
21
24
|
ColorInput,
|
|
25
|
+
ColorField,
|
|
22
26
|
StringInput,
|
|
27
|
+
StringField,
|
|
23
28
|
BooleanInput,
|
|
29
|
+
BooleanField,
|
|
24
30
|
SelectInput,
|
|
31
|
+
SelectField,
|
|
25
32
|
} from './tools/prefabeditor/components/Input';
|
|
26
33
|
|
|
27
34
|
// Prefab Editor - Styles & Utils
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import * as THREE from "three/webgpu";
|
|
5
|
+
import {
|
|
6
|
+
float,
|
|
7
|
+
uv,
|
|
8
|
+
vec3,
|
|
9
|
+
smoothstep,
|
|
10
|
+
uniform,
|
|
11
|
+
length,
|
|
12
|
+
} from "three/tsl";
|
|
13
|
+
|
|
14
|
+
interface ContactShadowProps {
|
|
15
|
+
opacity?: number;
|
|
16
|
+
blur?: number;
|
|
17
|
+
scale?: number;
|
|
18
|
+
yOffset?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ContactShadow = ({
|
|
22
|
+
opacity = 0.4,
|
|
23
|
+
blur = 2.5,
|
|
24
|
+
scale = 1.2,
|
|
25
|
+
yOffset = 0.05,
|
|
26
|
+
}: ContactShadowProps) => {
|
|
27
|
+
const material = useMemo(() => {
|
|
28
|
+
const mat = new THREE.MeshBasicNodeMaterial();
|
|
29
|
+
mat.transparent = true;
|
|
30
|
+
mat.depthWrite = false;
|
|
31
|
+
mat.depthTest = true;
|
|
32
|
+
mat.side = THREE.DoubleSide;
|
|
33
|
+
mat.polygonOffset = true;
|
|
34
|
+
mat.polygonOffsetFactor = -1;
|
|
35
|
+
mat.polygonOffsetUnits = -1;
|
|
36
|
+
|
|
37
|
+
const uOpacity = uniform(opacity);
|
|
38
|
+
const uBlur = uniform(blur);
|
|
39
|
+
|
|
40
|
+
// UVs centered around origin
|
|
41
|
+
const centeredUV = uv().sub(0.5).mul(2.0);
|
|
42
|
+
|
|
43
|
+
// IMPORTANT: use functional length(), not .length()
|
|
44
|
+
const dist = length(centeredUV);
|
|
45
|
+
|
|
46
|
+
const innerRadius = float(0.0);
|
|
47
|
+
const outerRadius = float(1.0);
|
|
48
|
+
const blurAmount = uBlur.div(10.0);
|
|
49
|
+
|
|
50
|
+
const alpha = smoothstep(
|
|
51
|
+
outerRadius,
|
|
52
|
+
innerRadius.add(blurAmount),
|
|
53
|
+
dist
|
|
54
|
+
).mul(uOpacity);
|
|
55
|
+
|
|
56
|
+
mat.colorNode = vec3(0.0, 0.0, 0.0);
|
|
57
|
+
mat.opacityNode = alpha;
|
|
58
|
+
|
|
59
|
+
return mat;
|
|
60
|
+
}, [opacity, blur]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<mesh
|
|
64
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
65
|
+
position={[0, yOffset, 0]}
|
|
66
|
+
material={material}
|
|
67
|
+
renderOrder={1}
|
|
68
|
+
>
|
|
69
|
+
<planeGeometry args={[scale, scale]} />
|
|
70
|
+
</mesh>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default ContactShadow;
|
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import { Canvas
|
|
1
|
+
import { Canvas } from "@react-three/fiber";
|
|
2
2
|
import { OrbitControls, Stage, View, PerspectiveCamera } from "@react-three/drei";
|
|
3
|
-
import { Suspense, useEffect, useState, useRef } from "react";
|
|
3
|
+
import { Component as ReactComponent, Suspense, useEffect, useState, useRef } from "react";
|
|
4
4
|
import { TextureLoader } from "three";
|
|
5
5
|
import { loadModel } from "../dragdrop/modelLoader";
|
|
6
6
|
|
|
7
|
+
class ErrorBoundary extends ReactComponent<{ onError?: () => void; children: React.ReactNode }, { hasError: boolean }> {
|
|
8
|
+
constructor(props: any) {
|
|
9
|
+
super(props);
|
|
10
|
+
this.state = { hasError: false };
|
|
11
|
+
}
|
|
12
|
+
static getDerivedStateFromError() { return { hasError: true }; }
|
|
13
|
+
componentDidCatch() { this.props.onError?.(); }
|
|
14
|
+
render() { return this.state.hasError ? null : this.props.children; }
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
// view models and textures in manifest, onselect callback
|
|
8
18
|
|
|
9
19
|
const styles: Record<string, any> = {
|
|
10
20
|
errorIcon: { color: '#fca5a5', fontSize: 12 }, // text-red-400 text-xs
|
|
11
21
|
flexFillRelative: { flex: 1, position: 'relative' },
|
|
12
|
-
bottomLabel: { backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' },
|
|
22
|
+
bottomLabel: { backgroundColor: 'rgba(0,0,0,0.6)', color: '#f9fafb', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' },
|
|
23
|
+
textLight: { color: '#f9fafb' },
|
|
13
24
|
iconLarge: { fontSize: 20 }
|
|
14
25
|
};
|
|
15
26
|
|
|
@@ -49,6 +60,7 @@ function FolderTile({ name, onClick }: { name: string; onClick: () => void }) {
|
|
|
49
60
|
maxWidth: 60,
|
|
50
61
|
aspectRatio: '1 / 1',
|
|
51
62
|
backgroundColor: '#1f2937', /* gray-800 */
|
|
63
|
+
color: '#f9fafb',
|
|
52
64
|
cursor: 'pointer',
|
|
53
65
|
display: 'flex',
|
|
54
66
|
flexDirection: 'column',
|
|
@@ -100,7 +112,7 @@ function AssetListViewer({ files, selected, onSelect, renderCard }: AssetListVie
|
|
|
100
112
|
const { folders, filesInCurrentPath } = getItemsInPath(files, currentPath);
|
|
101
113
|
|
|
102
114
|
return (
|
|
103
|
-
<div>
|
|
115
|
+
<div style={styles.textLight}>
|
|
104
116
|
{currentPath && (
|
|
105
117
|
<button
|
|
106
118
|
onClick={() => {
|
|
@@ -140,17 +152,19 @@ interface TextureListViewerProps {
|
|
|
140
152
|
|
|
141
153
|
export function TextureListViewer({ files, selected, onSelect, basePath = "" }: TextureListViewerProps) {
|
|
142
154
|
return (
|
|
143
|
-
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
156
|
+
<div style={{ width: '100%', height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 4 }}>
|
|
157
|
+
<AssetListViewer
|
|
158
|
+
files={files}
|
|
159
|
+
selected={selected}
|
|
160
|
+
onSelect={onSelect}
|
|
161
|
+
renderCard={(file, onSelectHandler) => (
|
|
162
|
+
<TextureCard file={file} basePath={basePath} onSelect={onSelectHandler} />
|
|
163
|
+
)}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
152
166
|
<SharedCanvas />
|
|
153
|
-
|
|
167
|
+
</div>
|
|
154
168
|
);
|
|
155
169
|
}
|
|
156
170
|
|
|
@@ -175,7 +189,7 @@ function TextureCard({ file, onSelect, basePath = "" }: { file: string; onSelect
|
|
|
175
189
|
return (
|
|
176
190
|
<div
|
|
177
191
|
ref={ref}
|
|
178
|
-
style={{ maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#1f2937', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
192
|
+
style={{ maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#1f2937', color: '#f9fafb', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
179
193
|
onClick={() => onSelect(file)}
|
|
180
194
|
onMouseEnter={() => setIsHovered(true)}
|
|
181
195
|
onMouseLeave={() => setIsHovered(false)}
|
|
@@ -184,21 +198,19 @@ function TextureCard({ file, onSelect, basePath = "" }: { file: string; onSelect
|
|
|
184
198
|
{isInView ? (
|
|
185
199
|
<View style={{ width: '100%', height: '100%' }}>
|
|
186
200
|
<PerspectiveCamera makeDefault position={[0, 0, 2.5]} fov={50} />
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
/>
|
|
197
|
-
</Suspense>
|
|
201
|
+
<ambientLight intensity={0.8} />
|
|
202
|
+
<pointLight position={[5, 5, 5]} intensity={0.5} />
|
|
203
|
+
<TextureSphere url={fullPath} onError={() => setError(true)} />
|
|
204
|
+
<OrbitControls
|
|
205
|
+
enableZoom={false}
|
|
206
|
+
enablePan={false}
|
|
207
|
+
autoRotate={isHovered}
|
|
208
|
+
autoRotateSpeed={2}
|
|
209
|
+
/>
|
|
198
210
|
</View>
|
|
199
211
|
) : null}
|
|
200
212
|
</div>
|
|
201
|
-
<div style={
|
|
213
|
+
<div style={styles.bottomLabel}>
|
|
202
214
|
{file.split('/').pop()}
|
|
203
215
|
</div>
|
|
204
216
|
</div>
|
|
@@ -206,10 +218,23 @@ function TextureCard({ file, onSelect, basePath = "" }: { file: string; onSelect
|
|
|
206
218
|
}
|
|
207
219
|
|
|
208
220
|
function TextureSphere({ url, onError }: { url: string; onError?: () => void }) {
|
|
209
|
-
const texture
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
221
|
+
const [texture, setTexture] = useState<any>(null);
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
setTexture(null);
|
|
225
|
+
const loader = new TextureLoader();
|
|
226
|
+
loader.load(
|
|
227
|
+
url,
|
|
228
|
+
(tex) => setTexture(tex),
|
|
229
|
+
undefined,
|
|
230
|
+
(err) => {
|
|
231
|
+
console.warn('Failed to load texture:', url, err);
|
|
232
|
+
onError?.();
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
}, [url]);
|
|
236
|
+
|
|
237
|
+
if (!texture) return null;
|
|
213
238
|
return (
|
|
214
239
|
<mesh position={[0, 0, 0]}>
|
|
215
240
|
<sphereGeometry args={[1, 32, 32]} />
|
|
@@ -227,17 +252,19 @@ interface ModelListViewerProps {
|
|
|
227
252
|
|
|
228
253
|
export function ModelListViewer({ files, selected, onSelect, basePath = "" }: ModelListViewerProps) {
|
|
229
254
|
return (
|
|
230
|
-
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
255
|
+
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
256
|
+
<div style={{ width: '100%', height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 4 }}>
|
|
257
|
+
<AssetListViewer
|
|
258
|
+
files={files}
|
|
259
|
+
selected={selected}
|
|
260
|
+
onSelect={onSelect}
|
|
261
|
+
renderCard={(file, onSelectHandler) => (
|
|
262
|
+
<ModelCard file={file} basePath={basePath} onSelect={onSelectHandler} />
|
|
263
|
+
)}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
239
266
|
<SharedCanvas />
|
|
240
|
-
|
|
267
|
+
</div>
|
|
241
268
|
);
|
|
242
269
|
}
|
|
243
270
|
|
|
@@ -261,7 +288,7 @@ function ModelCard({ file, onSelect, basePath = "" }: { file: string; onSelect:
|
|
|
261
288
|
return (
|
|
262
289
|
<div
|
|
263
290
|
ref={ref}
|
|
264
|
-
style={{ maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#111827', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
291
|
+
style={{ maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#111827', color: '#f9fafb', cursor: 'pointer', display: 'flex', flexDirection: 'column' }}
|
|
265
292
|
onClick={() => onSelect(file)}
|
|
266
293
|
>
|
|
267
294
|
<div style={styles.flexFillRelative}>
|
|
@@ -277,7 +304,7 @@ function ModelCard({ file, onSelect, basePath = "" }: { file: string; onSelect:
|
|
|
277
304
|
</View>
|
|
278
305
|
) : null}
|
|
279
306
|
</div>
|
|
280
|
-
<div style={
|
|
307
|
+
<div style={styles.bottomLabel}>
|
|
281
308
|
{file.split('/').pop()}
|
|
282
309
|
</div>
|
|
283
310
|
</div>
|
|
@@ -335,10 +362,10 @@ function SoundCard({ file, onSelect, basePath = "" }: { file: string; onSelect:
|
|
|
335
362
|
return (
|
|
336
363
|
<div
|
|
337
364
|
onClick={() => onSelect(file)}
|
|
338
|
-
style={{ aspectRatio: '1 / 1', backgroundColor: '#374151', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}
|
|
365
|
+
style={{ aspectRatio: '1 / 1', backgroundColor: '#374151', color: '#f9fafb', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}
|
|
339
366
|
>
|
|
340
367
|
<div style={styles.iconLarge}>🔊</div>
|
|
341
|
-
<div style={{ fontSize: 12, padding: '0 4px', marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center', width: '100%' }}>{fileName}</div>
|
|
368
|
+
<div style={{ color: '#f9fafb', fontSize: 12, padding: '0 4px', marginTop: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center', width: '100%' }}>{fileName}</div>
|
|
342
369
|
</div>
|
|
343
370
|
);
|
|
344
371
|
}
|
|
@@ -375,14 +402,19 @@ export function SharedCanvas() {
|
|
|
375
402
|
<Canvas
|
|
376
403
|
shadows
|
|
377
404
|
dpr={[1, 1.5]}
|
|
405
|
+
gl={{ alpha: true }}
|
|
378
406
|
camera={{ position: [0, 0, 3], fov: 45, near: 0.1, far: 1000 }}
|
|
407
|
+
onCreated={({ gl }) => {
|
|
408
|
+
gl.setClearAlpha(0);
|
|
409
|
+
}}
|
|
379
410
|
style={{
|
|
380
|
-
position: '
|
|
411
|
+
position: 'fixed',
|
|
381
412
|
top: 0,
|
|
382
413
|
left: 0,
|
|
383
414
|
width: '100vw',
|
|
384
415
|
height: '100vh',
|
|
385
416
|
pointerEvents: 'none',
|
|
417
|
+
background: 'transparent',
|
|
386
418
|
}}
|
|
387
419
|
eventSource={typeof document !== 'undefined' ? document.getElementById('root') || undefined : undefined}
|
|
388
420
|
eventPrefix="client"
|
|
@@ -1,54 +1,22 @@
|
|
|
1
1
|
// DragDropLoader.tsx
|
|
2
2
|
import { useEffect, ChangeEvent } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { parseModelFromFile } from "./modelLoader";
|
|
4
4
|
|
|
5
5
|
interface DragDropLoaderProps {
|
|
6
6
|
onModelLoaded: (model: any, filename: string) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
// Shared file handling logic
|
|
10
9
|
function handleFiles(files: File[], onModelLoaded: (model: any, filename: string) => void) {
|
|
11
|
-
files.forEach((file) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
files.forEach(async (file) => {
|
|
11
|
+
const result = await parseModelFromFile(file);
|
|
12
|
+
if (result.success && result.model) {
|
|
13
|
+
onModelLoaded(result.model, file.name);
|
|
14
|
+
} else {
|
|
15
|
+
console.error("Model parse error:", result.error);
|
|
16
16
|
}
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function loadGLTFFile(file: File, onModelLoaded: (model: any, filename: string) => void) {
|
|
21
|
-
const reader = new FileReader();
|
|
22
|
-
reader.onload = (event) => {
|
|
23
|
-
const arrayBuffer = event.target?.result;
|
|
24
|
-
if (arrayBuffer) {
|
|
25
|
-
const loader = new GLTFLoader();
|
|
26
|
-
const dracoLoader = new DRACOLoader();
|
|
27
|
-
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
|
|
28
|
-
loader.setDRACOLoader(dracoLoader);
|
|
29
|
-
loader.parse(arrayBuffer as ArrayBuffer, "", (gltf) => {
|
|
30
|
-
onModelLoaded(gltf.scene, file.name);
|
|
31
|
-
}, (error) => {
|
|
32
|
-
console.error("GLTFLoader parse error", error);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
reader.readAsArrayBuffer(file);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function loadFBXFile(file: File, onModelLoaded: (model: any, filename: string) => void) {
|
|
40
|
-
const reader = new FileReader();
|
|
41
|
-
reader.onload = (event) => {
|
|
42
|
-
const arrayBuffer = event.target?.result;
|
|
43
|
-
if (arrayBuffer) {
|
|
44
|
-
const loader = new FBXLoader();
|
|
45
|
-
const model = loader.parse(arrayBuffer as ArrayBuffer, "");
|
|
46
|
-
onModelLoaded(model, file.name);
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
reader.readAsArrayBuffer(file);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
20
|
export function DragDropLoader({ onModelLoaded }: DragDropLoaderProps) {
|
|
53
21
|
useEffect(() => {
|
|
54
22
|
function handleDrop(e: DragEvent) {
|
|
@@ -17,6 +17,42 @@ gltfLoader.setDRACOLoader(dracoLoader);
|
|
|
17
17
|
|
|
18
18
|
const fbxLoader = new FBXLoader();
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Parse a model from a File object (e.g. from drag-drop or file picker).
|
|
22
|
+
* Returns the parsed Three.js Object3D scene.
|
|
23
|
+
*/
|
|
24
|
+
export function parseModelFromFile(file: File): Promise<ModelLoadResult> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const reader = new FileReader();
|
|
27
|
+
reader.onload = (event) => {
|
|
28
|
+
const arrayBuffer = event.target?.result as ArrayBuffer;
|
|
29
|
+
if (!arrayBuffer) {
|
|
30
|
+
resolve({ success: false, error: new Error('Failed to read file') });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const name = file.name.toLowerCase();
|
|
34
|
+
if (name.endsWith('.glb') || name.endsWith('.gltf')) {
|
|
35
|
+
gltfLoader.parse(arrayBuffer, '', (gltf) => {
|
|
36
|
+
resolve({ success: true, model: gltf.scene });
|
|
37
|
+
}, (error) => {
|
|
38
|
+
resolve({ success: false, error });
|
|
39
|
+
});
|
|
40
|
+
} else if (name.endsWith('.fbx')) {
|
|
41
|
+
try {
|
|
42
|
+
const model = fbxLoader.parse(arrayBuffer, '');
|
|
43
|
+
resolve({ success: true, model });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
resolve({ success: false, error });
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
resolve({ success: false, error: new Error(`Unsupported file format: ${file.name}`) });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
reader.onerror = () => resolve({ success: false, error: reader.error });
|
|
52
|
+
reader.readAsArrayBuffer(file);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
20
56
|
export async function loadModel(
|
|
21
57
|
filename: string,
|
|
22
58
|
onProgress?: ProgressCallback
|