react-three-game 0.0.6 → 0.0.8
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/README.md +2 -0
- package/assets/editor.gif +0 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +5 -3
- package/package.json +2 -2
- package/src/index.ts +15 -0
- package/src/shared/GameCanvas.tsx +48 -0
- package/src/tools/assetviewer/page.tsx +411 -0
- package/src/tools/dragdrop/DragDropLoader.tsx +105 -0
- package/src/tools/dragdrop/modelLoader.ts +65 -0
- package/src/tools/dragdrop/page.tsx +42 -0
- package/src/tools/prefabeditor/EditorTree.tsx +277 -0
- package/src/tools/prefabeditor/EditorUI.tsx +273 -0
- package/src/tools/prefabeditor/EventSystem.tsx +36 -0
- package/src/tools/prefabeditor/InstanceProvider.tsx +326 -0
- package/src/tools/prefabeditor/PrefabEditor.tsx +130 -0
- package/src/tools/prefabeditor/PrefabRoot.tsx +460 -0
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +26 -0
- package/src/tools/prefabeditor/components/GeometryComponent.tsx +43 -0
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +153 -0
- package/src/tools/prefabeditor/components/ModelComponent.tsx +68 -0
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +47 -0
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +53 -0
- package/src/tools/prefabeditor/components/TransformComponent.tsx +49 -0
- package/src/tools/prefabeditor/components/index.ts +16 -0
- package/src/tools/prefabeditor/page.tsx +10 -0
- package/src/tools/prefabeditor/types.ts +28 -0
- package/tsconfig.json +17 -17
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ npm i react-three-game @react-three/fiber three
|
|
|
10
10
|
[](https://www.typescriptlang.org/)
|
|
11
11
|
[](https://react.dev/)
|
|
12
12
|
|
|
13
|
+

|
|
14
|
+
|
|
13
15
|
## Core Principle
|
|
14
16
|
|
|
15
17
|
Scenes are JSON prefabs. Components are registered modules. Hierarchy is declarative.
|
|
Binary file
|
|
@@ -167,12 +167,14 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
|
|
|
167
167
|
}
|
|
168
168
|
// --- 3. Core content decided by component registry ---
|
|
169
169
|
const core = renderCoreNode(gameObject, ctx, parentMatrix);
|
|
170
|
-
// --- 4. Wrap with physics if needed ---
|
|
171
|
-
const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
|
|
172
170
|
// --- 5. Render children (always relative transforms) ---
|
|
173
171
|
const children = ((_d = gameObject.children) !== null && _d !== void 0 ? _d : []).map((child) => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: worldMatrix }, child.id)));
|
|
172
|
+
// --- 4. Wrap with physics if needed ---
|
|
173
|
+
// Combine core and children so they both get wrapped by physics (if present)
|
|
174
|
+
const content = (_jsxs(_Fragment, { children: [core, children] }));
|
|
175
|
+
const physicsWrapped = wrapPhysicsIfNeeded(gameObject, content, ctx);
|
|
174
176
|
// --- 6. Final group wrapper ---
|
|
175
|
-
return (
|
|
177
|
+
return (_jsx("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: physicsWrapped }));
|
|
176
178
|
}
|
|
177
179
|
// Helper: render an instanced GameInstance (terminal node)
|
|
178
180
|
function renderInstancedNode(gameObject, worldMatrix, ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-three-game",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Batteries included React Three Fiber game engine",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"keywords": [],
|
|
15
15
|
"author": "prnth",
|
|
16
|
-
"license": "
|
|
16
|
+
"license": "VPL",
|
|
17
17
|
"type": "module",
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@react-three/fiber": "^9.0.0",
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export { default as GameCanvas } from './shared/GameCanvas';
|
|
3
|
+
export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
|
|
4
|
+
export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
|
|
5
|
+
export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
|
|
6
|
+
export {
|
|
7
|
+
default as AssetViewerPage,
|
|
8
|
+
TextureListViewer,
|
|
9
|
+
ModelListViewer,
|
|
10
|
+
SoundListViewer,
|
|
11
|
+
SharedCanvas,
|
|
12
|
+
} from './tools/assetviewer/page';
|
|
13
|
+
|
|
14
|
+
// Types
|
|
15
|
+
export type { Prefab, GameObject } from './tools/prefabeditor/types';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Canvas, extend } from "@react-three/fiber";
|
|
2
|
+
import { WebGPURenderer, MeshBasicNodeMaterial, MeshStandardNodeMaterial, SpriteNodeMaterial, PCFShadowMap } from "three/webgpu";
|
|
3
|
+
import { Suspense, useState } from "react";
|
|
4
|
+
import { WebGPURendererParameters } from "three/src/renderers/webgpu/WebGPURenderer.Nodes.js";
|
|
5
|
+
import { Loader } from "@react-three/drei";
|
|
6
|
+
|
|
7
|
+
// generic version
|
|
8
|
+
// extend(THREE as any)
|
|
9
|
+
|
|
10
|
+
extend({
|
|
11
|
+
MeshBasicNodeMaterial: MeshBasicNodeMaterial,
|
|
12
|
+
MeshStandardNodeMaterial: MeshStandardNodeMaterial,
|
|
13
|
+
SpriteNodeMaterial: SpriteNodeMaterial,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export default function GameCanvas({ loader = false, children, ...props }: { loader?: boolean, children: React.ReactNode, props?: WebGPURendererParameters }) {
|
|
18
|
+
const [frameloop, setFrameloop] = useState<"never" | "always">("never");
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
|
|
21
|
+
return <>
|
|
22
|
+
<Canvas
|
|
23
|
+
shadows={{ type: PCFShadowMap, }}
|
|
24
|
+
frameloop={frameloop}
|
|
25
|
+
gl={async ({ canvas }) => {
|
|
26
|
+
const renderer = new WebGPURenderer({
|
|
27
|
+
canvas: canvas as HTMLCanvasElement,
|
|
28
|
+
// @ts-expect-error futuristic
|
|
29
|
+
shadowMap: true,
|
|
30
|
+
antialias: true,
|
|
31
|
+
...props,
|
|
32
|
+
});
|
|
33
|
+
await renderer.init().then(() => {
|
|
34
|
+
setFrameloop("always");
|
|
35
|
+
});
|
|
36
|
+
return renderer
|
|
37
|
+
}}
|
|
38
|
+
camera={{
|
|
39
|
+
position: [0, 1, 5],
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<Suspense>
|
|
43
|
+
{children}
|
|
44
|
+
</Suspense>
|
|
45
|
+
</Canvas>
|
|
46
|
+
{loader ? <Loader /> : null}
|
|
47
|
+
</>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Canvas, useLoader } from "@react-three/fiber";
|
|
4
|
+
import { OrbitControls, useGLTF, useFBX, Stage, View, PerspectiveCamera } from "@react-three/drei";
|
|
5
|
+
import { Suspense, useEffect, useState, useRef } from "react";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { TextureLoader } from "three";
|
|
8
|
+
|
|
9
|
+
// view models and textures in manifest, onselect callback
|
|
10
|
+
|
|
11
|
+
function getItemsInPath(files: string[], currentPath: string) {
|
|
12
|
+
// Remove the leading category folder (e.g., /textures/, /models/, /sounds/)
|
|
13
|
+
const filesWithoutCategory = files.map(file => {
|
|
14
|
+
const parts = file.split('/').filter(Boolean);
|
|
15
|
+
return parts.length > 1 ? '/' + parts.slice(1).join('/') : '';
|
|
16
|
+
}).filter(Boolean);
|
|
17
|
+
|
|
18
|
+
const prefix = currentPath ? `/${currentPath}/` : '/';
|
|
19
|
+
const relevantFiles = filesWithoutCategory.filter(file => file.startsWith(prefix));
|
|
20
|
+
|
|
21
|
+
const folders = new Set<string>();
|
|
22
|
+
const filesInCurrentPath: string[] = [];
|
|
23
|
+
|
|
24
|
+
relevantFiles.forEach((file, index) => {
|
|
25
|
+
const relativePath = file.slice(prefix.length);
|
|
26
|
+
const parts = relativePath.split('/').filter(Boolean);
|
|
27
|
+
|
|
28
|
+
if (parts.length > 1) {
|
|
29
|
+
folders.add(parts[0]);
|
|
30
|
+
} else if (parts[0]) {
|
|
31
|
+
// Return the original file path
|
|
32
|
+
filesInCurrentPath.push(files[filesWithoutCategory.indexOf(file)]);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return { folders: Array.from(folders), filesInCurrentPath };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function FolderTile({ name, onClick }: { name: string; onClick: () => void }) {
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
onClick={onClick}
|
|
43
|
+
className="aspect-square bg-gray-800 cursor-pointer hover:bg-gray-700 flex flex-col items-center justify-center"
|
|
44
|
+
>
|
|
45
|
+
<div className="text-3xl">📁</div>
|
|
46
|
+
<div className="text-xs text-center truncate w-full px-1 mt-1">{name}</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useInView() {
|
|
52
|
+
const [isInView, setIsInView] = useState(false);
|
|
53
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const observer = new IntersectionObserver(
|
|
57
|
+
([entry]) => {
|
|
58
|
+
setIsInView(entry.isIntersecting);
|
|
59
|
+
},
|
|
60
|
+
{ rootMargin: '100px' }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (ref.current) {
|
|
64
|
+
observer.observe(ref.current);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
if (ref.current) {
|
|
69
|
+
observer.unobserve(ref.current);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
return { ref, isInView };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function AssetViewerPage({ basePath = "" }: { basePath?: string } = {}) {
|
|
78
|
+
const [textures, setTextures] = useState<string[]>([]);
|
|
79
|
+
const [models, setModels] = useState<string[]>([]);
|
|
80
|
+
const [sounds, setSounds] = useState<string[]>([]);
|
|
81
|
+
const [loading, setLoading] = useState(true);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const base = basePath ? `${basePath}/` : '';
|
|
85
|
+
Promise.all([
|
|
86
|
+
fetch(`/${base}textures/manifest.json`).then(r => r.json()),
|
|
87
|
+
fetch(`/${base}models/manifest.json`).then(r => r.json()),
|
|
88
|
+
fetch(`/${base}sound/manifest.json`).then(r => r.json()).catch(() => [])
|
|
89
|
+
]).then(([textureData, modelData, soundData]) => {
|
|
90
|
+
setTextures(textureData);
|
|
91
|
+
setModels(modelData);
|
|
92
|
+
setSounds(soundData);
|
|
93
|
+
setLoading(false);
|
|
94
|
+
});
|
|
95
|
+
}, [basePath]);
|
|
96
|
+
|
|
97
|
+
if (loading) {
|
|
98
|
+
return <div className="p-4 text-gray-300">Loading manifests...</div>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<div className="p-2 text-gray-300 overflow-y-auto h-screen text-sm">
|
|
104
|
+
<h1 className="text-lg mb-2 font-bold">Asset Viewer</h1>
|
|
105
|
+
|
|
106
|
+
<h2 className="text-sm mt-4 mb-1 font-semibold">Textures ({textures.length})</h2>
|
|
107
|
+
<TextureListViewer files={textures} basePath={basePath} onSelect={(file) => console.log('Selected texture:', file)} />
|
|
108
|
+
|
|
109
|
+
<h2 className="text-sm mt-4 mb-1 font-semibold">Models ({models.length})</h2>
|
|
110
|
+
<ModelListViewer files={models} basePath={basePath} onSelect={(file) => console.log('Selected model:', file)} />
|
|
111
|
+
|
|
112
|
+
{sounds.length > 0 && (
|
|
113
|
+
<>
|
|
114
|
+
<h2 className="text-sm mt-4 mb-1 font-semibold">Sounds ({sounds.length})</h2>
|
|
115
|
+
<SoundListViewer files={sounds} basePath={basePath} onSelect={(file) => console.log('Selected sound:', file)} />
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
<SharedCanvas />
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface AssetListViewerProps {
|
|
125
|
+
files: string[];
|
|
126
|
+
selected?: string;
|
|
127
|
+
onSelect: (file: string) => void;
|
|
128
|
+
renderCard: (file: string, onSelect: (file: string) => void) => React.ReactNode;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function AssetListViewer({ files, selected, onSelect, renderCard }: AssetListViewerProps) {
|
|
132
|
+
const [currentPath, setCurrentPath] = useState('');
|
|
133
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
134
|
+
const { folders, filesInCurrentPath } = getItemsInPath(files, currentPath);
|
|
135
|
+
|
|
136
|
+
const showCompactView = selected && !showPicker;
|
|
137
|
+
|
|
138
|
+
if (showCompactView) {
|
|
139
|
+
return (
|
|
140
|
+
<div className="flex gap-1 items-center">
|
|
141
|
+
{renderCard(selected, onSelect)}
|
|
142
|
+
<button
|
|
143
|
+
onClick={() => setShowPicker(true)}
|
|
144
|
+
className="px-2 py-1 bg-gray-800 hover:bg-gray-700 text-xs"
|
|
145
|
+
>
|
|
146
|
+
Change
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div>
|
|
154
|
+
{currentPath && (
|
|
155
|
+
<button
|
|
156
|
+
onClick={() => {
|
|
157
|
+
const pathParts = currentPath.split('/').filter(Boolean);
|
|
158
|
+
pathParts.pop();
|
|
159
|
+
setCurrentPath(pathParts.join('/'));
|
|
160
|
+
}}
|
|
161
|
+
className="mb-1 px-2 py-1 bg-gray-800 hover:bg-gray-700 text-xs"
|
|
162
|
+
>
|
|
163
|
+
← Back
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
<div className="grid grid-cols-3 gap-1">
|
|
167
|
+
{folders.map((folder) => (
|
|
168
|
+
<FolderTile
|
|
169
|
+
key={folder}
|
|
170
|
+
name={folder}
|
|
171
|
+
onClick={() => setCurrentPath(currentPath ? `${currentPath}/${folder}` : folder)}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
{filesInCurrentPath.map((file) => (
|
|
175
|
+
<div key={file}>
|
|
176
|
+
{renderCard(file, (f) => {
|
|
177
|
+
onSelect(f);
|
|
178
|
+
if (selected) setShowPicker(false);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface TextureListViewerProps {
|
|
188
|
+
files: string[];
|
|
189
|
+
selected?: string;
|
|
190
|
+
onSelect: (file: string) => void;
|
|
191
|
+
basePath?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function TextureListViewer({ files, selected, onSelect, basePath = "" }: TextureListViewerProps) {
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
<AssetListViewer
|
|
198
|
+
files={files}
|
|
199
|
+
selected={selected}
|
|
200
|
+
onSelect={onSelect}
|
|
201
|
+
renderCard={(file, onSelectHandler) => (
|
|
202
|
+
<TextureCard file={file} basePath={basePath} onSelect={onSelectHandler} />
|
|
203
|
+
)}
|
|
204
|
+
/>
|
|
205
|
+
<SharedCanvas />
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function TextureCard({ file, onSelect, basePath = "" }: { file: string; onSelect: (file: string) => void; basePath?: string }) {
|
|
211
|
+
const [error, setError] = useState(false);
|
|
212
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
213
|
+
const { ref, isInView } = useInView();
|
|
214
|
+
const fullPath = basePath ? `/${basePath}${file}` : file;
|
|
215
|
+
|
|
216
|
+
if (error) {
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
ref={ref}
|
|
220
|
+
className="aspect-square bg-gray-700 cursor-pointer hover:bg-gray-600 flex items-center justify-center"
|
|
221
|
+
onClick={() => onSelect(file)}
|
|
222
|
+
>
|
|
223
|
+
<div className="text-red-400 text-xs">✗</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
ref={ref}
|
|
231
|
+
className="aspect-square bg-gray-800 cursor-pointer hover:bg-gray-700 flex flex-col"
|
|
232
|
+
onClick={() => onSelect(file)}
|
|
233
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
234
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
235
|
+
>
|
|
236
|
+
<div className="flex-1 relative">
|
|
237
|
+
{isInView ? (
|
|
238
|
+
<View className="w-full h-full">
|
|
239
|
+
<PerspectiveCamera makeDefault position={[0, 0, 2.5]} fov={50} />
|
|
240
|
+
<Suspense fallback={null}>
|
|
241
|
+
<ambientLight intensity={0.8} />
|
|
242
|
+
<pointLight position={[5, 5, 5]} intensity={0.5} />
|
|
243
|
+
<TextureSphere url={fullPath} onError={() => setError(true)} />
|
|
244
|
+
<OrbitControls
|
|
245
|
+
enableZoom={false}
|
|
246
|
+
enablePan={false}
|
|
247
|
+
autoRotate={isHovered}
|
|
248
|
+
autoRotateSpeed={2}
|
|
249
|
+
/>
|
|
250
|
+
</Suspense>
|
|
251
|
+
</View>
|
|
252
|
+
) : null}
|
|
253
|
+
</div>
|
|
254
|
+
<div className="bg-black/60 text-[10px] px-1 truncate text-center">
|
|
255
|
+
{file.split('/').pop()}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function TextureSphere({ url, onError }: { url: string; onError?: () => void }) {
|
|
262
|
+
const texture = useLoader(TextureLoader, url, undefined, (error) => {
|
|
263
|
+
console.error('Failed to load texture:', url, error);
|
|
264
|
+
onError?.();
|
|
265
|
+
});
|
|
266
|
+
return (
|
|
267
|
+
<mesh position={[0, 0, 0]}>
|
|
268
|
+
<sphereGeometry args={[1, 32, 32]} />
|
|
269
|
+
<meshStandardMaterial map={texture} />
|
|
270
|
+
</mesh>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface ModelListViewerProps {
|
|
275
|
+
files: string[];
|
|
276
|
+
selected?: string;
|
|
277
|
+
onSelect: (file: string) => void;
|
|
278
|
+
basePath?: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function ModelListViewer({ files, selected, onSelect, basePath = "" }: ModelListViewerProps) {
|
|
282
|
+
return (
|
|
283
|
+
<>
|
|
284
|
+
<AssetListViewer
|
|
285
|
+
files={files}
|
|
286
|
+
selected={selected}
|
|
287
|
+
onSelect={onSelect}
|
|
288
|
+
renderCard={(file, onSelectHandler) => (
|
|
289
|
+
<ModelCard file={file} basePath={basePath} onSelect={onSelectHandler} />
|
|
290
|
+
)}
|
|
291
|
+
/>
|
|
292
|
+
<SharedCanvas />
|
|
293
|
+
</>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function ModelCard({ file, onSelect, basePath = "" }: { file: string; onSelect: (file: string) => void; basePath?: string }) {
|
|
298
|
+
const [error, setError] = useState(false);
|
|
299
|
+
const { ref, isInView } = useInView();
|
|
300
|
+
const fullPath = basePath ? `/${basePath}${file}` : file;
|
|
301
|
+
|
|
302
|
+
if (error) {
|
|
303
|
+
return (
|
|
304
|
+
<div
|
|
305
|
+
ref={ref}
|
|
306
|
+
className="aspect-square bg-gray-700 cursor-pointer hover:bg-gray-600 flex items-center justify-center"
|
|
307
|
+
onClick={() => onSelect(file)}
|
|
308
|
+
>
|
|
309
|
+
<div className="text-red-400 text-xs">✗</div>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<div
|
|
316
|
+
ref={ref}
|
|
317
|
+
className="aspect-square bg-gray-900 cursor-pointer hover:bg-gray-800 flex flex-col"
|
|
318
|
+
onClick={() => onSelect(file)}
|
|
319
|
+
>
|
|
320
|
+
<div className="flex-1 relative">
|
|
321
|
+
{isInView ? (
|
|
322
|
+
<View className="w-full h-full">
|
|
323
|
+
<PerspectiveCamera makeDefault position={[0, 1, 3]} fov={50} />
|
|
324
|
+
<Suspense fallback={null}>
|
|
325
|
+
<Stage intensity={0.5} environment="city">
|
|
326
|
+
<ModelPreview url={fullPath} onError={() => setError(true)} />
|
|
327
|
+
</Stage>
|
|
328
|
+
<OrbitControls enableZoom={false} />
|
|
329
|
+
</Suspense>
|
|
330
|
+
</View>
|
|
331
|
+
) : null}
|
|
332
|
+
</div>
|
|
333
|
+
<div className="bg-black/60 text-[10px] px-1 truncate text-center">
|
|
334
|
+
{file.split('/').pop()}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function ModelPreview({ url, onError }: { url: string; onError?: () => void }) {
|
|
341
|
+
const isFbx = url.toLowerCase().endsWith('.fbx');
|
|
342
|
+
if (isFbx) return <FBXModel url={url} onError={onError} />;
|
|
343
|
+
return <GLTFModel url={url} onError={onError} />;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function GLTFModel({ url, onError }: { url: string; onError?: () => void }) {
|
|
347
|
+
const { scene } = useGLTF(url);
|
|
348
|
+
return <primitive object={scene} />;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function FBXModel({ url, onError }: { url: string; onError?: () => void }) {
|
|
352
|
+
const fbx = useFBX(url);
|
|
353
|
+
return <primitive object={fbx} scale={0.01} />;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface SoundListViewerProps {
|
|
357
|
+
files: string[];
|
|
358
|
+
selected?: string;
|
|
359
|
+
onSelect: (file: string) => void;
|
|
360
|
+
basePath?: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function SoundListViewer({ files, selected, onSelect, basePath = "" }: SoundListViewerProps) {
|
|
364
|
+
return (
|
|
365
|
+
<AssetListViewer
|
|
366
|
+
files={files}
|
|
367
|
+
selected={selected}
|
|
368
|
+
onSelect={onSelect}
|
|
369
|
+
renderCard={(file, onSelectHandler) => (
|
|
370
|
+
<SoundCard file={file} basePath={basePath} onSelect={onSelectHandler} />
|
|
371
|
+
)}
|
|
372
|
+
/>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function SoundCard({ file, onSelect, basePath = "" }: { file: string; onSelect: (file: string) => void; basePath?: string }) {
|
|
377
|
+
const fileName = file.split('/').pop() || '';
|
|
378
|
+
const fullPath = basePath ? `/${basePath}${file}` : file;
|
|
379
|
+
return (
|
|
380
|
+
<div
|
|
381
|
+
onClick={() => onSelect(file)}
|
|
382
|
+
className="aspect-square bg-gray-700 cursor-pointer hover:bg-gray-600 flex flex-col items-center justify-center"
|
|
383
|
+
>
|
|
384
|
+
<div className="text-2xl">🔊</div>
|
|
385
|
+
<div className="text-[10px] px-1 mt-1 truncate text-center w-full">{fileName}</div>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Shared Canvas Component - can be used independently in any viewer
|
|
391
|
+
export function SharedCanvas() {
|
|
392
|
+
return (
|
|
393
|
+
<Canvas
|
|
394
|
+
shadows
|
|
395
|
+
dpr={[1, 1.5]}
|
|
396
|
+
camera={{ position: [0, 0, 3], fov: 45, near: 0.1, far: 1000 }}
|
|
397
|
+
style={{
|
|
398
|
+
position: 'fixed',
|
|
399
|
+
top: 0,
|
|
400
|
+
left: 0,
|
|
401
|
+
width: '100vw',
|
|
402
|
+
height: '100vh',
|
|
403
|
+
pointerEvents: 'none',
|
|
404
|
+
}}
|
|
405
|
+
eventSource={typeof document !== 'undefined' ? document.getElementById('root') || undefined : undefined}
|
|
406
|
+
eventPrefix="client"
|
|
407
|
+
>
|
|
408
|
+
<View.Port />
|
|
409
|
+
</Canvas>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// DragDropLoader.tsx
|
|
2
|
+
import { useEffect, ChangeEvent } from "react";
|
|
3
|
+
import { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
|
|
4
|
+
|
|
5
|
+
interface DragDropLoaderProps {
|
|
6
|
+
onModelLoaded: (model: any, filename: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Shared file handling logic
|
|
10
|
+
function handleFiles(files: File[], onModelLoaded: (model: any, filename: string) => void) {
|
|
11
|
+
files.forEach((file) => {
|
|
12
|
+
if (file.name.endsWith(".glb") || file.name.endsWith(".gltf")) {
|
|
13
|
+
loadGLTFFile(file, onModelLoaded);
|
|
14
|
+
} else if (file.name.endsWith(".fbx")) {
|
|
15
|
+
loadFBXFile(file, onModelLoaded);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
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
|
+
export function DragDropLoader({ onModelLoaded }: DragDropLoaderProps) {
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
function handleDrop(e: DragEvent) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
|
|
58
|
+
handleFiles(files, onModelLoaded);
|
|
59
|
+
}
|
|
60
|
+
function handleDragOver(e: DragEvent) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
}
|
|
64
|
+
window.addEventListener("drop", handleDrop);
|
|
65
|
+
window.addEventListener("dragover", handleDragOver);
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener("drop", handleDrop);
|
|
68
|
+
window.removeEventListener("dragover", handleDragOver);
|
|
69
|
+
};
|
|
70
|
+
}, [onModelLoaded]);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// FilePicker component
|
|
75
|
+
interface FilePickerProps {
|
|
76
|
+
onModelLoaded: (model: any, filename: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function FilePicker({ onModelLoaded }: FilePickerProps) {
|
|
80
|
+
function onChange(e: ChangeEvent<HTMLInputElement>) {
|
|
81
|
+
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
82
|
+
handleFiles(files, onModelLoaded);
|
|
83
|
+
}
|
|
84
|
+
// Ref for the hidden input
|
|
85
|
+
const inputId = "file-picker-input";
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<input
|
|
89
|
+
id={inputId}
|
|
90
|
+
type="file"
|
|
91
|
+
accept=".glb,.gltf,.fbx"
|
|
92
|
+
multiple
|
|
93
|
+
onChange={onChange}
|
|
94
|
+
className="hidden"
|
|
95
|
+
/>
|
|
96
|
+
<button
|
|
97
|
+
className="px-3 py-1 bg-blue-500/20 hover:bg-blue-500/30 border border-blue-400/40 hover:border-blue-400/60 text-blue-200 hover:text-blue-100 text-xs font-medium transition-all"
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => document.getElementById(inputId)?.click()}
|
|
100
|
+
>
|
|
101
|
+
Select Files
|
|
102
|
+
</button>
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
}
|