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.
Files changed (68) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/shared/ContactShadow.d.ts +8 -0
  4. package/dist/shared/ContactShadow.js +32 -0
  5. package/dist/shared/GameCanvas.js +1 -3
  6. package/dist/tools/assetviewer/page.js +36 -15
  7. package/dist/tools/dragdrop/DragDropLoader.js +17 -40
  8. package/dist/tools/dragdrop/modelLoader.d.ts +5 -0
  9. package/dist/tools/dragdrop/modelLoader.js +39 -0
  10. package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
  11. package/dist/tools/prefabeditor/Dropdown.js +82 -0
  12. package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
  13. package/dist/tools/prefabeditor/EditorTree.js +139 -70
  14. package/dist/tools/prefabeditor/EditorUI.js +5 -10
  15. package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
  16. package/dist/tools/prefabeditor/PrefabEditor.js +70 -3
  17. package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
  18. package/dist/tools/prefabeditor/PrefabRoot.js +136 -35
  19. package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
  20. package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
  21. package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
  22. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
  23. package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
  24. package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
  25. package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
  26. package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
  27. package/dist/tools/prefabeditor/components/Input.js +100 -47
  28. package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
  29. package/dist/tools/prefabeditor/components/MaterialComponent.js +129 -14
  30. package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
  31. package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
  32. package/dist/tools/prefabeditor/components/SpotLightComponent.js +6 -11
  33. package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
  34. package/dist/tools/prefabeditor/components/TransformComponent.js +31 -19
  35. package/dist/tools/prefabeditor/components/index.js +5 -1
  36. package/dist/tools/prefabeditor/styles.d.ts +17 -4
  37. package/dist/tools/prefabeditor/styles.js +69 -32
  38. package/dist/tools/prefabeditor/utils.d.ts +8 -3
  39. package/dist/tools/prefabeditor/utils.js +92 -6
  40. package/package.json +1 -1
  41. package/react-three-game-skill/react-three-game/rules/LIGHTING.md +6 -0
  42. package/src/index.ts +7 -0
  43. package/src/shared/ContactShadow.tsx +74 -0
  44. package/src/shared/GameCanvas.tsx +0 -3
  45. package/src/tools/assetviewer/page.tsx +78 -46
  46. package/src/tools/dragdrop/DragDropLoader.tsx +7 -39
  47. package/src/tools/dragdrop/modelLoader.ts +36 -0
  48. package/src/tools/prefabeditor/Dropdown.tsx +112 -0
  49. package/src/tools/prefabeditor/EditorContext.tsx +5 -0
  50. package/src/tools/prefabeditor/EditorTree.tsx +237 -115
  51. package/src/tools/prefabeditor/EditorUI.tsx +6 -11
  52. package/src/tools/prefabeditor/PrefabEditor.tsx +77 -5
  53. package/src/tools/prefabeditor/PrefabRoot.tsx +228 -59
  54. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
  55. package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
  56. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
  57. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
  58. package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
  59. package/src/tools/prefabeditor/components/Input.tsx +247 -53
  60. package/src/tools/prefabeditor/components/MaterialComponent.tsx +191 -20
  61. package/src/tools/prefabeditor/components/ModelComponent.tsx +52 -5
  62. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
  63. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +14 -16
  64. package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
  65. package/src/tools/prefabeditor/components/TransformComponent.tsx +78 -20
  66. package/src/tools/prefabeditor/components/index.ts +5 -1
  67. package/src/tools/prefabeditor/styles.ts +71 -32
  68. 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
- /** Save a prefab as JSON file */
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
- const a = document.createElement('a');
14
- a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
15
- a.download = `${filename || 'prefab'}.json`;
16
- a.click();
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 : "Node"} Copy`, children: (_b = node.children) === null || _b === void 0 ? void 0 : _b.map(cloneNode) });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -0,0 +1,6 @@
1
+ For large scenes, bake the shadows.
2
+
3
+ // trigger a shadow update when needed
4
+ envMesh.castShadow = true;
5
+ directionalLight.current.shadow.autoUpdate = false;
6
+ directionalLight.current.shadow.needsUpdate = true;
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;
@@ -40,9 +40,6 @@ export default function GameCanvas({ loader = false, children, glConfig, ...prop
40
40
  });
41
41
  return renderer
42
42
  }}
43
- camera={{
44
- position: [0, 1, 5],
45
- }}
46
43
  {...props}
47
44
  >
48
45
  <Suspense>
@@ -1,15 +1,26 @@
1
- import { Canvas, useLoader } from "@react-three/fiber";
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
- <AssetListViewer
145
- files={files}
146
- selected={selected}
147
- onSelect={onSelect}
148
- renderCard={(file, onSelectHandler) => (
149
- <TextureCard file={file} basePath={basePath} onSelect={onSelectHandler} />
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
- <Suspense fallback={null}>
188
- <ambientLight intensity={0.8} />
189
- <pointLight position={[5, 5, 5]} intensity={0.5} />
190
- <TextureSphere url={fullPath} onError={() => setError(true)} />
191
- <OrbitControls
192
- enableZoom={false}
193
- enablePan={false}
194
- autoRotate={isHovered}
195
- autoRotateSpeed={2}
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={{ backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
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 = useLoader(TextureLoader, url, undefined, (error) => {
210
- console.error('Failed to load texture:', url, error);
211
- onError?.();
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
- <AssetListViewer
232
- files={files}
233
- selected={selected}
234
- onSelect={onSelect}
235
- renderCard={(file, onSelectHandler) => (
236
- <ModelCard file={file} basePath={basePath} onSelect={onSelectHandler} />
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={{ backgroundColor: 'rgba(0,0,0,0.6)', fontSize: 10, padding: '0 4px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
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: 'absolute',
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 { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
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
- if (file.name.endsWith(".glb") || file.name.endsWith(".gltf")) {
13
- loadGLTFFile(file, onModelLoaded);
14
- } else if (file.name.endsWith(".fbx")) {
15
- loadFBXFile(file, onModelLoaded);
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