react-three-game 0.0.58 → 0.0.59
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/.github/copilot-instructions.md +1 -1
- package/README.md +43 -32
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/tools/assetviewer/page.js +1 -1
- package/dist/tools/dragdrop/DragDropLoader.d.ts +19 -6
- package/dist/tools/dragdrop/DragDropLoader.js +77 -40
- package/dist/tools/dragdrop/index.d.ts +4 -0
- package/dist/tools/dragdrop/index.js +2 -0
- package/dist/tools/dragdrop/modelLoader.d.ts +5 -6
- package/dist/tools/dragdrop/modelLoader.js +62 -49
- package/dist/tools/dragdrop/page.js +3 -3
- package/dist/tools/prefabeditor/PrefabEditor.js +1 -1
- package/dist/tools/prefabeditor/PrefabRoot.js +5 -3
- package/package.json +2 -2
- package/react-three-game-skill/react-three-game/SKILL.md +59 -4
- package/react-three-game-skill/react-three-game/rules/ADVANCED_PHYSICS.md +7 -5
- package/src/index.ts +1 -1
- package/src/tools/assetviewer/page.tsx +1 -1
- package/src/tools/dragdrop/DragDropLoader.tsx +118 -55
- package/src/tools/dragdrop/index.ts +4 -0
- package/src/tools/dragdrop/modelLoader.ts +95 -50
- package/src/tools/dragdrop/page.tsx +7 -4
- package/src/tools/prefabeditor/PrefabEditor.tsx +1 -1
- package/src/tools/prefabeditor/PrefabRoot.tsx +6 -3
|
@@ -44,7 +44,7 @@ const MyComponent: Component = {
|
|
|
44
44
|
|
|
45
45
|
## Usage Modes
|
|
46
46
|
|
|
47
|
-
**
|
|
47
|
+
**PrefabRoot**: Pure renderer for embedding prefab data in standard R3F applications. Render it inside a regular `@react-three/fiber` `Canvas`. `GameCanvas` provides the WebGPU canvas setup. Add a `Physics` wrapper to enable physics. Use this to integrate prefabs into larger R3F scenes.
|
|
48
48
|
|
|
49
49
|
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children.
|
|
50
50
|
|
package/README.md
CHANGED
|
@@ -16,47 +16,58 @@ npx skills add https://github.com/prnthh/react-three-game-skill
|
|
|
16
16
|
|
|
17
17
|
## Usage Modes
|
|
18
18
|
|
|
19
|
-
**
|
|
19
|
+
**PrefabRoot**: Pure renderer for embedding prefab data in standard R3F applications. Render it inside a regular `@react-three/fiber` `Canvas`. `GameCanvas` provides the WebGPU canvas setup. Add a `Physics` wrapper to enable physics. Use this to integrate prefabs into larger R3F scenes.
|
|
20
20
|
|
|
21
21
|
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children.
|
|
22
22
|
|
|
23
23
|
## Basic Usage
|
|
24
24
|
|
|
25
25
|
```jsx
|
|
26
|
-
import { Physics } from
|
|
27
|
-
import { GameCanvas, PrefabRoot } from
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
physics: { type: "Physics", properties: { type: "dynamic" } }
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
]
|
|
26
|
+
import { Physics } from "@react-three/rapier";
|
|
27
|
+
import { GameCanvas, PrefabRoot } from "react-three-game";
|
|
28
|
+
|
|
29
|
+
const sceneData = {
|
|
30
|
+
root: {
|
|
31
|
+
id: "scene",
|
|
32
|
+
children: [
|
|
33
|
+
{
|
|
34
|
+
id: "ground",
|
|
35
|
+
components: {
|
|
36
|
+
transform: { type: "Transform", properties: { position: [0, 0, 0], rotation: [-1.57, 0, 0] } },
|
|
37
|
+
geometry: { type: "Geometry", properties: { geometryType: "plane", args: [50, 50] } },
|
|
38
|
+
material: { type: "Material", properties: { color: "#3a3" } },
|
|
39
|
+
physics: { type: "Physics", properties: { type: "fixed" } }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "ball",
|
|
44
|
+
components: {
|
|
45
|
+
transform: { type: "Transform", properties: { position: [0, 5, 0] } },
|
|
46
|
+
geometry: { type: "Geometry", properties: { geometryType: "sphere" } },
|
|
47
|
+
material: { type: "Material", properties: { color: "#f66" } },
|
|
48
|
+
physics: { type: "Physics", properties: { type: "dynamic" } }
|
|
49
|
+
}
|
|
54
50
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default function Home() {
|
|
56
|
+
return (
|
|
57
|
+
<main className="flex h-screen w-screen">
|
|
58
|
+
<GameCanvas>
|
|
59
|
+
<Physics>
|
|
60
|
+
<ambientLight intensity={0.8} />
|
|
61
|
+
<PrefabRoot data={sceneData} />
|
|
62
|
+
</Physics>
|
|
63
|
+
</GameCanvas>
|
|
64
|
+
</main>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
58
67
|
```
|
|
59
68
|
|
|
69
|
+
`GameCanvas` provides the library's WebGPU canvas setup.
|
|
70
|
+
|
|
60
71
|
## GameObject Schema
|
|
61
72
|
|
|
62
73
|
```typescript
|
package/dist/index.d.ts
CHANGED
|
@@ -16,5 +16,5 @@ export { gameEvents, useGameEvent, getEntityIdFromRigidBody } from './tools/pref
|
|
|
16
16
|
export type { GameEventType, GameEventMap, GameEventPayload, PhysicsEventType, PhysicsEventPayload } from './tools/prefabeditor/GameEvents';
|
|
17
17
|
export { entityEvents, useEntityEvent } from './tools/prefabeditor/GameEvents';
|
|
18
18
|
export type { EntityEventType, EntityEventPayload } from './tools/prefabeditor/GameEvents';
|
|
19
|
-
export
|
|
19
|
+
export * from './tools/dragdrop';
|
|
20
20
|
export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
|
package/dist/index.js
CHANGED
|
@@ -17,5 +17,5 @@ export { gameEvents, useGameEvent, getEntityIdFromRigidBody } from './tools/pref
|
|
|
17
17
|
// Backward compatibility aliases
|
|
18
18
|
export { entityEvents, useEntityEvent } from './tools/prefabeditor/GameEvents';
|
|
19
19
|
// Asset Tools
|
|
20
|
-
export
|
|
20
|
+
export * from './tools/dragdrop';
|
|
21
21
|
export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
|
|
@@ -3,7 +3,7 @@ import { Canvas } from "@react-three/fiber";
|
|
|
3
3
|
import { OrbitControls, View, PerspectiveCamera } from "@react-three/drei";
|
|
4
4
|
import { Component as ReactComponent, Suspense, useEffect, useState, useRef } from "react";
|
|
5
5
|
import { TextureLoader } from "three";
|
|
6
|
-
import { loadModel } from "../dragdrop
|
|
6
|
+
import { loadModel } from "../dragdrop";
|
|
7
7
|
class ErrorBoundary extends ReactComponent {
|
|
8
8
|
constructor(props) {
|
|
9
9
|
super(props);
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import type { LoadedModel } from "./modelLoader";
|
|
3
|
+
export interface FileLoadOptions {
|
|
4
|
+
onModelLoaded?: (model: LoadedModel, filename: string, file: File) => void | Promise<void>;
|
|
5
|
+
onFileLoaded?: (file: File) => void | Promise<void>;
|
|
6
|
+
onFilesLoaded?: (files: File[]) => void | Promise<void>;
|
|
7
|
+
onModelError?: (error: unknown, filename: string, file: File) => void | Promise<void>;
|
|
8
|
+
parseModels?: boolean;
|
|
3
9
|
}
|
|
4
|
-
|
|
5
|
-
interface
|
|
6
|
-
|
|
10
|
+
type DivProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "onDrop" | "onDragOver">;
|
|
11
|
+
export interface DragDropLoaderProps extends FileLoadOptions, DivProps {
|
|
12
|
+
children?: ReactNode;
|
|
7
13
|
}
|
|
8
|
-
export
|
|
14
|
+
export interface FilePickerProps extends FileLoadOptions, DivProps {
|
|
15
|
+
accept?: string;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
multiple?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function loadFiles(files: File[], { onModelLoaded, onFileLoaded, onFilesLoaded, onModelError, parseModels }: FileLoadOptions): Promise<void>;
|
|
20
|
+
export declare function DragDropLoader({ children, ...divProps }: DragDropLoaderProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export declare function FilePicker({ accept, children, multiple, ...divProps }: FilePickerProps): import("react/jsx-runtime").JSX.Element;
|
|
9
22
|
export {};
|
|
@@ -7,49 +7,86 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
onModelLoaded(result.model, file.name);
|
|
10
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
11
|
+
var t = {};
|
|
12
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
13
|
+
t[p] = s[p];
|
|
14
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
15
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
16
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
17
|
+
t[p[i]] = s[p[i]];
|
|
19
18
|
}
|
|
20
|
-
|
|
19
|
+
return t;
|
|
20
|
+
};
|
|
21
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
22
|
+
import { useRef } from "react";
|
|
23
|
+
import { canParseModelFile, parseModelFromFile } from "./modelLoader";
|
|
24
|
+
function getFiles(fileList) {
|
|
25
|
+
return fileList ? Array.from(fileList) : [];
|
|
26
|
+
}
|
|
27
|
+
export function loadFiles(files_1, _a) {
|
|
28
|
+
return __awaiter(this, arguments, void 0, function* (files, { onModelLoaded, onFileLoaded, onFilesLoaded, onModelError, parseModels = true }) {
|
|
29
|
+
yield Promise.all(files.map((file) => __awaiter(this, void 0, void 0, function* () {
|
|
30
|
+
yield (onFileLoaded === null || onFileLoaded === void 0 ? void 0 : onFileLoaded(file));
|
|
31
|
+
if (!parseModels || !canParseModelFile(file) || (!onModelLoaded && !onModelError)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const result = yield parseModelFromFile(file);
|
|
35
|
+
if (result.success && result.model) {
|
|
36
|
+
yield (onModelLoaded === null || onModelLoaded === void 0 ? void 0 : onModelLoaded(result.model, file.name, file));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (onModelError) {
|
|
40
|
+
yield onModelError(result.error, file.name, file);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
21
43
|
console.error("Model parse error:", result.error);
|
|
22
|
-
}
|
|
23
|
-
|
|
44
|
+
})));
|
|
45
|
+
yield (onFilesLoaded === null || onFilesLoaded === void 0 ? void 0 : onFilesLoaded(files));
|
|
46
|
+
});
|
|
24
47
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function handleDrop(e) {
|
|
28
|
-
var _a;
|
|
29
|
-
e.preventDefault();
|
|
30
|
-
e.stopPropagation();
|
|
31
|
-
const files = ((_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.files) ? Array.from(e.dataTransfer.files) : [];
|
|
32
|
-
handleFiles(files, onModelLoaded);
|
|
33
|
-
}
|
|
34
|
-
function handleDragOver(e) {
|
|
35
|
-
e.preventDefault();
|
|
36
|
-
e.stopPropagation();
|
|
37
|
-
}
|
|
38
|
-
window.addEventListener("drop", handleDrop);
|
|
39
|
-
window.addEventListener("dragover", handleDragOver);
|
|
40
|
-
return () => {
|
|
41
|
-
window.removeEventListener("drop", handleDrop);
|
|
42
|
-
window.removeEventListener("dragover", handleDragOver);
|
|
43
|
-
};
|
|
44
|
-
}, [onModelLoaded]);
|
|
45
|
-
return null;
|
|
48
|
+
function reportFileLoadError(error) {
|
|
49
|
+
console.error("File load error:", error);
|
|
46
50
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
function createLoadHandlers(options) {
|
|
52
|
+
return {
|
|
53
|
+
onFileLoaded: options.onFileLoaded,
|
|
54
|
+
onFilesLoaded: options.onFilesLoaded,
|
|
55
|
+
onModelError: options.onModelError,
|
|
56
|
+
onModelLoaded: options.onModelLoaded,
|
|
57
|
+
parseModels: options.parseModels,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function DragDropLoader(_a) {
|
|
61
|
+
var { children } = _a, divProps = __rest(_a, ["children"]);
|
|
62
|
+
const loadOptions = createLoadHandlers(divProps);
|
|
63
|
+
function handleDrop(event) {
|
|
64
|
+
var _a;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
void loadFiles(getFiles((_a = event.dataTransfer) === null || _a === void 0 ? void 0 : _a.files), loadOptions).catch(reportFileLoadError);
|
|
68
|
+
}
|
|
69
|
+
function handleDragOver(event) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
event.stopPropagation();
|
|
72
|
+
}
|
|
73
|
+
return (_jsx("div", Object.assign({}, divProps, { onDrop: handleDrop, onDragOver: handleDragOver, children: children })));
|
|
74
|
+
}
|
|
75
|
+
export function FilePicker(_a) {
|
|
76
|
+
var { accept = ".glb,.gltf,.fbx", children, multiple = true } = _a, divProps = __rest(_a, ["accept", "children", "multiple"]);
|
|
77
|
+
const inputRef = useRef(null);
|
|
78
|
+
const { onClick } = divProps, wrapperProps = __rest(divProps, ["onClick"]);
|
|
79
|
+
const loadOptions = createLoadHandlers(divProps);
|
|
80
|
+
function onChange(event) {
|
|
81
|
+
void loadFiles(getFiles(event.target.files), loadOptions).catch(reportFileLoadError);
|
|
82
|
+
event.target.value = "";
|
|
83
|
+
}
|
|
84
|
+
function handleClick(event) {
|
|
85
|
+
var _a;
|
|
86
|
+
onClick === null || onClick === void 0 ? void 0 : onClick(event);
|
|
87
|
+
if (!event.defaultPrevented) {
|
|
88
|
+
(_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.click();
|
|
89
|
+
}
|
|
51
90
|
}
|
|
52
|
-
|
|
53
|
-
const inputId = "file-picker-input";
|
|
54
|
-
return (_jsxs(_Fragment, { children: [_jsx("input", { id: inputId, type: "file", accept: ".glb,.gltf,.fbx", multiple: true, onChange: onChange, className: "hidden" }), _jsx("button", { 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", type: "button", onClick: () => { var _a; return (_a = document.getElementById(inputId)) === null || _a === void 0 ? void 0 : _a.click(); }, children: "Select Files" })] }));
|
|
91
|
+
return (_jsxs("div", Object.assign({}, wrapperProps, { onClick: handleClick, children: [_jsx("input", { ref: inputRef, type: "file", accept: accept, multiple: multiple, onChange: onChange, hidden: true }), children !== null && children !== void 0 ? children : "Select Files"] })));
|
|
55
92
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DragDropLoader, FilePicker, loadFiles } from "./DragDropLoader";
|
|
2
|
+
export type { DragDropLoaderProps, FileLoadOptions, FilePickerProps } from "./DragDropLoader";
|
|
3
|
+
export { loadModel, parseModelFromFile } from "./modelLoader";
|
|
4
|
+
export type { LoadedModel, ModelLoadResult, ProgressCallback } from "./modelLoader";
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
import type { Object3D } from "three";
|
|
2
|
+
export type LoadedModel = Object3D;
|
|
1
3
|
export type ModelLoadResult = {
|
|
2
4
|
success: boolean;
|
|
3
|
-
model?:
|
|
4
|
-
error?:
|
|
5
|
+
model?: LoadedModel;
|
|
6
|
+
error?: unknown;
|
|
5
7
|
};
|
|
6
8
|
export type ProgressCallback = (filename: string, loaded: number, total: number) => void;
|
|
7
|
-
|
|
8
|
-
* Parse a model from a File object (e.g. from drag-drop or file picker).
|
|
9
|
-
* Returns the parsed Three.js Object3D scene.
|
|
10
|
-
*/
|
|
9
|
+
export declare function canParseModelFile(file: File | string): boolean;
|
|
11
10
|
export declare function parseModelFromFile(file: File): Promise<ModelLoadResult>;
|
|
12
11
|
export declare function loadModel(filename: string, onProgress?: ProgressCallback): Promise<ModelLoadResult>;
|
|
@@ -7,47 +7,62 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
import {
|
|
11
|
-
// Singleton loader instances
|
|
10
|
+
import { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
|
|
12
11
|
const dracoLoader = new DRACOLoader();
|
|
13
12
|
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
|
|
14
13
|
const gltfLoader = new GLTFLoader();
|
|
15
14
|
gltfLoader.setDRACOLoader(dracoLoader);
|
|
16
15
|
const fbxLoader = new FBXLoader();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
function normalizeModelPath(name) {
|
|
17
|
+
return name.split(/[?#]/, 1)[0].toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
function getModelFileKind(name) {
|
|
20
|
+
const normalizedName = normalizeModelPath(name);
|
|
21
|
+
if (normalizedName.endsWith(".glb") || normalizedName.endsWith(".gltf")) {
|
|
22
|
+
return "gltf";
|
|
23
|
+
}
|
|
24
|
+
if (normalizedName.endsWith(".fbx")) {
|
|
25
|
+
return "fbx";
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function canParseModelFile(file) {
|
|
30
|
+
const filename = typeof file === "string" ? file : file.name;
|
|
31
|
+
return getModelFileKind(filename) !== null;
|
|
32
|
+
}
|
|
33
|
+
function parseModelBuffer(arrayBuffer, sourceName) {
|
|
34
|
+
const modelFileKind = getModelFileKind(sourceName);
|
|
35
|
+
if (modelFileKind === "gltf") {
|
|
36
|
+
return new Promise(resolve => {
|
|
37
|
+
gltfLoader.parse(arrayBuffer, "", gltf => {
|
|
38
|
+
resolve({ success: true, model: gltf.scene });
|
|
39
|
+
}, error => {
|
|
40
|
+
resolve({ success: false, error });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (modelFileKind === "fbx") {
|
|
45
|
+
try {
|
|
46
|
+
const model = fbxLoader.parse(arrayBuffer, "");
|
|
47
|
+
return Promise.resolve({ success: true, model });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return Promise.resolve({ success: false, error });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Promise.resolve({ success: false, error: new Error(`Unsupported file format: ${sourceName}`) });
|
|
54
|
+
}
|
|
21
55
|
export function parseModelFromFile(file) {
|
|
22
|
-
return new Promise(
|
|
56
|
+
return new Promise(resolve => {
|
|
23
57
|
const reader = new FileReader();
|
|
24
|
-
reader.onload =
|
|
58
|
+
reader.onload = event => {
|
|
25
59
|
var _a;
|
|
26
60
|
const arrayBuffer = (_a = event.target) === null || _a === void 0 ? void 0 : _a.result;
|
|
27
61
|
if (!arrayBuffer) {
|
|
28
|
-
resolve({ success: false, error: new Error(
|
|
62
|
+
resolve({ success: false, error: new Error("Failed to read file") });
|
|
29
63
|
return;
|
|
30
64
|
}
|
|
31
|
-
|
|
32
|
-
if (name.endsWith('.glb') || name.endsWith('.gltf')) {
|
|
33
|
-
gltfLoader.parse(arrayBuffer, '', (gltf) => {
|
|
34
|
-
resolve({ success: true, model: gltf.scene });
|
|
35
|
-
}, (error) => {
|
|
36
|
-
resolve({ success: false, error });
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
else if (name.endsWith('.fbx')) {
|
|
40
|
-
try {
|
|
41
|
-
const model = fbxLoader.parse(arrayBuffer, '');
|
|
42
|
-
resolve({ success: true, model });
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
resolve({ success: false, error });
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
resolve({ success: false, error: new Error(`Unsupported file format: ${file.name}`) });
|
|
50
|
-
}
|
|
65
|
+
void parseModelBuffer(arrayBuffer, file.name).then(resolve);
|
|
51
66
|
};
|
|
52
67
|
reader.onerror = () => resolve({ success: false, error: reader.error });
|
|
53
68
|
reader.readAsArrayBuffer(file);
|
|
@@ -56,33 +71,31 @@ export function parseModelFromFile(file) {
|
|
|
56
71
|
export function loadModel(filename, onProgress) {
|
|
57
72
|
return __awaiter(this, void 0, void 0, function* () {
|
|
58
73
|
try {
|
|
59
|
-
// Use filename directly (should already include leading /)
|
|
60
74
|
const fullPath = filename;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
onProgress(filename, progressEvent.loaded, total);
|
|
75
|
+
const modelFileKind = getModelFileKind(filename);
|
|
76
|
+
if (modelFileKind === "gltf") {
|
|
77
|
+
return new Promise(resolve => {
|
|
78
|
+
gltfLoader.load(fullPath, gltf => resolve({ success: true, model: gltf.scene }), progressEvent => {
|
|
79
|
+
if (!onProgress) {
|
|
80
|
+
return;
|
|
68
81
|
}
|
|
69
|
-
|
|
82
|
+
const total = progressEvent.total || progressEvent.loaded;
|
|
83
|
+
onProgress(filename, progressEvent.loaded, total);
|
|
84
|
+
}, error => resolve({ success: false, error }));
|
|
70
85
|
});
|
|
71
86
|
}
|
|
72
|
-
|
|
73
|
-
return new Promise(
|
|
74
|
-
fbxLoader.load(fullPath,
|
|
75
|
-
if (onProgress) {
|
|
76
|
-
|
|
77
|
-
const total = progressEvent.total || progressEvent.loaded;
|
|
78
|
-
onProgress(filename, progressEvent.loaded, total);
|
|
87
|
+
if (modelFileKind === "fbx") {
|
|
88
|
+
return new Promise(resolve => {
|
|
89
|
+
fbxLoader.load(fullPath, model => resolve({ success: true, model }), progressEvent => {
|
|
90
|
+
if (!onProgress) {
|
|
91
|
+
return;
|
|
79
92
|
}
|
|
80
|
-
|
|
93
|
+
const total = progressEvent.total || progressEvent.loaded;
|
|
94
|
+
onProgress(filename, progressEvent.loaded, total);
|
|
95
|
+
}, error => resolve({ success: false, error }));
|
|
81
96
|
});
|
|
82
97
|
}
|
|
83
|
-
|
|
84
|
-
return { success: false, error: new Error(`Unsupported file format: ${filename}`) };
|
|
85
|
-
}
|
|
98
|
+
return { success: false, error: new Error(`Unsupported file format: ${filename}`) };
|
|
86
99
|
}
|
|
87
100
|
catch (error) {
|
|
88
101
|
return { success: false, error };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Physics, RigidBody } from "@react-three/rapier";
|
|
4
4
|
import { OrbitControls } from "@react-three/drei";
|
|
5
5
|
import { useState } from "react";
|
|
6
|
-
import { DragDropLoader } from "./
|
|
6
|
+
import { DragDropLoader } from "./index";
|
|
7
7
|
import GameCanvas from "../../shared/GameCanvas";
|
|
8
8
|
export default function Home() {
|
|
9
9
|
const [models, setModels] = useState([]);
|
|
10
|
-
return (
|
|
10
|
+
return (_jsx(DragDropLoader, { onModelLoaded: model => setModels(prev => [...prev, model]), className: "w-full items-center justify-items-center min-h-screen", style: { height: "100vh" }, children: _jsx("div", { className: "w-full items-center justify-items-center min-h-screen", style: { height: "100vh" }, children: _jsx(GameCanvas, { children: _jsxs(Physics, { children: [_jsx(RigidBody, { children: _jsxs("mesh", { castShadow: true, children: [_jsx("boxGeometry", { args: [1, 1, 1] }), _jsx("meshStandardMaterial", { color: "orange" })] }) }), _jsx(RigidBody, { type: "fixed", children: _jsxs("mesh", { position: [0, -2, 0], scale: [10, 0.1, 10], receiveShadow: true, children: [_jsx("boxGeometry", {}), _jsx("meshStandardMaterial", { color: "gray" })] }) }), models.map((model, idx) => (_jsx("primitive", { object: model, position: [0, 0, 0] }, idx))), _jsx("ambientLight", { intensity: 0.5 }), _jsx("pointLight", { position: [10, 10, 10], castShadow: true, intensity: 1000 }), _jsx(OrbitControls, {})] }) }) }) }));
|
|
11
11
|
}
|
|
@@ -7,7 +7,7 @@ import EditorUI from "./EditorUI";
|
|
|
7
7
|
import { base, toolbar } from "./styles";
|
|
8
8
|
import { EditorContext } from "./EditorContext";
|
|
9
9
|
import { exportGLB, createModelNode, createImageNode } from "./utils";
|
|
10
|
-
import { parseModelFromFile } from "../dragdrop
|
|
10
|
+
import { parseModelFromFile } from "../dragdrop";
|
|
11
11
|
const DEFAULT_PREFAB = {
|
|
12
12
|
id: "prefab-default",
|
|
13
13
|
name: "New Prefab",
|
|
@@ -13,7 +13,7 @@ import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, us
|
|
|
13
13
|
import { BoxHelper, Euler, Matrix4, Quaternion, SRGBColorSpace, TextureLoader, Vector3, } from "three";
|
|
14
14
|
import { getComponent, registerComponent, getNonComposableKeys } from "./components/ComponentRegistry";
|
|
15
15
|
import components from "./components";
|
|
16
|
-
import { loadModel } from "../dragdrop
|
|
16
|
+
import { loadModel } from "../dragdrop";
|
|
17
17
|
import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
|
|
18
18
|
import { focusCameraOnObject, updateNode } from "./utils";
|
|
19
19
|
import { EditorContext } from "./EditorContext";
|
|
@@ -120,8 +120,10 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
120
120
|
? `${basePath}${file}`
|
|
121
121
|
: `${basePath}/${file}`;
|
|
122
122
|
const res = yield loadModel(path);
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
const model = res.model;
|
|
124
|
+
if (res.success && model) {
|
|
125
|
+
setModels(m => (Object.assign(Object.assign({}, m), { [file]: model })));
|
|
126
|
+
}
|
|
125
127
|
}));
|
|
126
128
|
const loader = new TextureLoader();
|
|
127
129
|
texturesToLoad.forEach(file => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-three-game",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.59",
|
|
4
|
+
"description": "high performance 3D game engine for React",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -124,6 +124,8 @@ Scenes are defined as JSON prefabs with a root node containing children:
|
|
|
124
124
|
| SpotLight | `SpotLight` | `color`, `intensity`, `angle`, `penumbra`, `distance?`, `castShadow?` |
|
|
125
125
|
| DirectionalLight | `DirectionalLight` | `color`, `intensity`, `castShadow?`, `targetOffset?: [x,y,z]` |
|
|
126
126
|
| AmbientLight | `AmbientLight` | `color`, `intensity` |
|
|
127
|
+
| Environment | `Environment` | `intensity`, `resolution` |
|
|
128
|
+
| Camera | `Camera` | `fov`, `near`, `far`, `zoom` |
|
|
127
129
|
| Text | `Text` | `text`, `font`, `size`, `depth`, `width`, `align`, `color` |
|
|
128
130
|
|
|
129
131
|
### Text Component
|
|
@@ -167,20 +169,23 @@ Use radians: `1.57` = 90°, `3.14` = 180°, `-1.57` = -90°
|
|
|
167
169
|
|
|
168
170
|
### Usage Modes
|
|
169
171
|
|
|
170
|
-
**
|
|
172
|
+
**PrefabRoot**: Pure renderer for embedding prefab data in standard R3F applications. Render it inside a regular `@react-three/fiber` `Canvas`. `GameCanvas` provides the WebGPU canvas setup. Add a `Physics` wrapper to enable physics. Use this to integrate prefabs into larger R3F scenes.
|
|
171
173
|
|
|
172
174
|
```jsx
|
|
175
|
+
import { Canvas } from '@react-three/fiber';
|
|
173
176
|
import { Physics } from '@react-three/rapier';
|
|
174
|
-
import {
|
|
177
|
+
import { PrefabRoot } from 'react-three-game';
|
|
175
178
|
|
|
176
|
-
<
|
|
179
|
+
<Canvas>
|
|
177
180
|
<Physics>
|
|
178
181
|
<PrefabRoot data={prefabData} />
|
|
179
182
|
<CustomComponent />
|
|
180
183
|
</Physics>
|
|
181
|
-
</
|
|
184
|
+
</Canvas>
|
|
182
185
|
```
|
|
183
186
|
|
|
187
|
+
`GameCanvas` provides the library's WebGPU canvas setup.
|
|
188
|
+
|
|
184
189
|
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children. Editor actions live under `Menu > File`, and exports under `Menu > Export`.
|
|
185
190
|
|
|
186
191
|
```jsx
|
|
@@ -231,6 +236,36 @@ function DynamicLight() {
|
|
|
231
236
|
|
|
232
237
|
**Use cases**: Player controllers, AI behaviors, procedural animation, real-time effects.
|
|
233
238
|
|
|
239
|
+
## World Scene Pattern
|
|
240
|
+
|
|
241
|
+
The current world demo combines prefab-authored level geometry with runtime React behavior:
|
|
242
|
+
|
|
243
|
+
- Static level layout, props, and collision live in prefab JSON.
|
|
244
|
+
- `Environment` can wrap sky geometry or lighting content for a full scene backdrop.
|
|
245
|
+
- `Camera` can live in the prefab so view-only scenes and editor scenes share the same authored viewpoint.
|
|
246
|
+
- Runtime logic can use `useFrame` plus `updateNodeById` to animate prefab entities without abandoning the JSON scene model.
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"id": "environment",
|
|
251
|
+
"components": {
|
|
252
|
+
"environment": {
|
|
253
|
+
"type": "Environment",
|
|
254
|
+
"properties": { "intensity": 1, "resolution": 256 }
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
"children": [
|
|
258
|
+
{
|
|
259
|
+
"id": "sky",
|
|
260
|
+
"components": {
|
|
261
|
+
"geometry": { "type": "Geometry", "properties": { "geometryType": "sphere", "args": [100, 32, 16] } },
|
|
262
|
+
"material": { "type": "Material", "properties": { "texture": "/textures/skybox/skybox1.jpg", "side": "BackSide", "materialType": "basic" } }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
234
269
|
## Quick Reference Examples
|
|
235
270
|
|
|
236
271
|
```json
|
|
@@ -379,6 +414,26 @@ const MyComponent: Component = {
|
|
|
379
414
|
registerComponent(MyComponent);
|
|
380
415
|
```
|
|
381
416
|
|
|
417
|
+
Use the component in prefab JSON by adding a component entry whose `type` matches the registered component name:
|
|
418
|
+
|
|
419
|
+
```json
|
|
420
|
+
{
|
|
421
|
+
"components": {
|
|
422
|
+
"mycomponent": {
|
|
423
|
+
"type": "MyComponent",
|
|
424
|
+
"properties": {
|
|
425
|
+
"speed": 1
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Rules:
|
|
433
|
+
- Call `registerComponent(MyComponent)` before rendering `<PrefabEditor>` or `<PrefabRoot>` with prefab data that uses it.
|
|
434
|
+
- `type` must match the registered component name exactly (`name: 'MyComponent'` -> `"type": "MyComponent"`).
|
|
435
|
+
- Use `View` to render visible content, wrap `children`, or add runtime behavior with hooks like `useFrame`.
|
|
436
|
+
|
|
382
437
|
**Field types**: `vector3`, `number`, `string`, `color`, `boolean`, `select`, `custom`
|
|
383
438
|
|
|
384
439
|
## Game Events
|
|
@@ -48,6 +48,7 @@ Complete reference for `Physics` component properties:
|
|
|
48
48
|
| `lockRotations` | `boolean` | `false` | Freeze rotation |
|
|
49
49
|
| `enabledTranslations` | `[bool, bool, bool]` | `[true, true, true]` | Lock per axis (X, Y, Z) |
|
|
50
50
|
| `enabledRotations` | `[bool, bool, bool]` | `[true, true, true]` | Lock rotation per axis |
|
|
51
|
+
| `colliders` | `'hull'` \| `'trimesh'` \| `'cuboid'` \| `'ball'` | auto | Collider shape override (`fixed` defaults to `trimesh`, others to `hull`) |
|
|
51
52
|
| `ccd` | `boolean` | `false` | Continuous collision detection (fast objects) |
|
|
52
53
|
| `sensor` | `boolean` | `false` | Trigger only, no collision response |
|
|
53
54
|
| `activeCollisionTypes` | `'all'` | - | Enable kinematic/fixed collision detection (default: dynamic only) |
|
|
@@ -284,21 +285,21 @@ Objects will **slide off** the tilted surface.
|
|
|
284
285
|
|
|
285
286
|
## Instanced Physics
|
|
286
287
|
|
|
287
|
-
When using `"instanced": true` on models, physics behaves differently than standard objects.
|
|
288
|
+
When using `"instanced": true` on models, physics behaves differently than standard objects. Physics instancing is designed for batched `fixed` and `dynamic` bodies, where instances of the same model share an `InstancedRigidBodies` path for better performance.
|
|
288
289
|
|
|
289
290
|
### Standard vs Instanced Physics
|
|
290
291
|
|
|
291
292
|
| Aspect | Standard Physics | Instanced Physics |
|
|
292
293
|
|--------|------------------|-------------------|
|
|
293
|
-
| RigidBody Component | Individual `<RigidBody>` per object | Single `<InstancedRigidBodies>`
|
|
294
|
+
| RigidBody Component | Individual `<RigidBody>` per object | Single `<InstancedRigidBodies>` group per model + supported physics type |
|
|
294
295
|
| Ref Access | `rigidBodyRefs.get(nodeId)` returns single RigidBody | Not accessible via `rigidBodyRefs` |
|
|
295
296
|
| Force Application | Direct per-object | Must access via InstancedRigidBodies ref |
|
|
296
|
-
| Collider Type | `hull` (dynamic) or `trimesh` (fixed) |
|
|
297
|
+
| Collider Type | `hull` (dynamic) or `trimesh` (fixed) | Auto-selected by instanced physics path |
|
|
297
298
|
| Performance | One draw call per object | One draw call for all instances |
|
|
298
299
|
|
|
299
300
|
### Defining Instanced Objects
|
|
300
301
|
|
|
301
|
-
Set `"instanced": true` in the model component. **
|
|
302
|
+
Set `"instanced": true` in the model component. **Instances with the same model path and supported physics type are automatically batched**:
|
|
302
303
|
|
|
303
304
|
```json
|
|
304
305
|
{
|
|
@@ -326,7 +327,7 @@ Add multiple instances - they'll be automatically batched:
|
|
|
326
327
|
|
|
327
328
|
### Force Application on Instanced Objects
|
|
328
329
|
|
|
329
|
-
**Instanced physics bodies are not individually accessible.** For objects requiring force/impulse control, use non-instanced physics (`"instanced": false` or omit the property).
|
|
330
|
+
**Instanced physics bodies are not individually accessible.** For objects requiring force/impulse control, kinematic motion, or per-body refs, use non-instanced physics (`"instanced": false` or omit the property).
|
|
330
331
|
|
|
331
332
|
### When to Use Instanced Physics
|
|
332
333
|
|
|
@@ -345,6 +346,7 @@ Add multiple instances - they'll be automatically batched:
|
|
|
345
346
|
### Performance Notes
|
|
346
347
|
|
|
347
348
|
- **Batching**: All instances with the same `filename` and `physics.type` are rendered in a single draw call
|
|
349
|
+
- **Supported body types**: The instanced physics path is intended for `fixed` and `dynamic` bodies; use standard non-instanced physics for kinematic bodies
|
|
348
350
|
- **Scale handling**: Visual scale is applied per-instance, but collider scale may differ
|
|
349
351
|
- **Transform updates**: Use `updateNodeById` to move instances (triggers re-sync)
|
|
350
352
|
- **Memory**: One set of GPU buffers shared across all instances
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ export { entityEvents, useEntityEvent } from './tools/prefabeditor/GameEvents';
|
|
|
50
50
|
export type { EntityEventType, EntityEventPayload } from './tools/prefabeditor/GameEvents';
|
|
51
51
|
|
|
52
52
|
// Asset Tools
|
|
53
|
-
export
|
|
53
|
+
export * from './tools/dragdrop';
|
|
54
54
|
export {
|
|
55
55
|
TextureListViewer,
|
|
56
56
|
ModelListViewer,
|
|
@@ -2,7 +2,7 @@ import { Canvas } from "@react-three/fiber";
|
|
|
2
2
|
import { OrbitControls, Stage, View, PerspectiveCamera } from "@react-three/drei";
|
|
3
3
|
import { Component as ReactComponent, Suspense, useEffect, useState, useRef } from "react";
|
|
4
4
|
import { TextureLoader } from "three";
|
|
5
|
-
import { loadModel } from "../dragdrop
|
|
5
|
+
import { loadModel } from "../dragdrop";
|
|
6
6
|
|
|
7
7
|
class ErrorBoundary extends ReactComponent<{ onError?: () => void; children: React.ReactNode }, { hasError: boolean }> {
|
|
8
8
|
constructor(props: any) {
|
|
@@ -1,73 +1,136 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { ChangeEvent, useRef } from "react";
|
|
2
|
+
import type { DragEvent, HTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
3
|
+
import type { LoadedModel } from "./modelLoader";
|
|
4
|
+
import { canParseModelFile, parseModelFromFile } from "./modelLoader";
|
|
4
5
|
|
|
5
|
-
interface
|
|
6
|
-
onModelLoaded
|
|
6
|
+
export interface FileLoadOptions {
|
|
7
|
+
onModelLoaded?: (model: LoadedModel, filename: string, file: File) => void | Promise<void>;
|
|
8
|
+
onFileLoaded?: (file: File) => void | Promise<void>;
|
|
9
|
+
onFilesLoaded?: (files: File[]) => void | Promise<void>;
|
|
10
|
+
onModelError?: (error: unknown, filename: string, file: File) => void | Promise<void>;
|
|
11
|
+
parseModels?: boolean;
|
|
7
12
|
}
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
type DivProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "onDrop" | "onDragOver">;
|
|
15
|
+
|
|
16
|
+
export interface DragDropLoaderProps extends FileLoadOptions, DivProps {
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FilePickerProps extends FileLoadOptions, DivProps {
|
|
21
|
+
accept?: string;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getFiles(fileList?: FileList | null) {
|
|
27
|
+
return fileList ? Array.from(fileList) : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadFiles(
|
|
31
|
+
files: File[],
|
|
32
|
+
{ onModelLoaded, onFileLoaded, onFilesLoaded, onModelError, parseModels = true }: FileLoadOptions,
|
|
33
|
+
) {
|
|
34
|
+
await Promise.all(
|
|
35
|
+
files.map(async (file) => {
|
|
36
|
+
await onFileLoaded?.(file);
|
|
37
|
+
|
|
38
|
+
if (!parseModels || !canParseModelFile(file) || (!onModelLoaded && !onModelError)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = await parseModelFromFile(file);
|
|
43
|
+
|
|
44
|
+
if (result.success && result.model) {
|
|
45
|
+
await onModelLoaded?.(result.model, file.name, file);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (onModelError) {
|
|
50
|
+
await onModelError(result.error, file.name, file);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
console.error("Model parse error:", result.error);
|
|
16
|
-
}
|
|
17
|
-
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await onFilesLoaded?.(files);
|
|
18
59
|
}
|
|
19
60
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
function handleDrop(e: DragEvent) {
|
|
23
|
-
e.preventDefault();
|
|
24
|
-
e.stopPropagation();
|
|
25
|
-
const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
|
|
26
|
-
handleFiles(files, onModelLoaded);
|
|
27
|
-
}
|
|
28
|
-
function handleDragOver(e: DragEvent) {
|
|
29
|
-
e.preventDefault();
|
|
30
|
-
e.stopPropagation();
|
|
31
|
-
}
|
|
32
|
-
window.addEventListener("drop", handleDrop);
|
|
33
|
-
window.addEventListener("dragover", handleDragOver);
|
|
34
|
-
return () => {
|
|
35
|
-
window.removeEventListener("drop", handleDrop);
|
|
36
|
-
window.removeEventListener("dragover", handleDragOver);
|
|
37
|
-
};
|
|
38
|
-
}, [onModelLoaded]);
|
|
39
|
-
return null;
|
|
61
|
+
function reportFileLoadError(error: unknown) {
|
|
62
|
+
console.error("File load error:", error);
|
|
40
63
|
}
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
function createLoadHandlers(options: FileLoadOptions) {
|
|
66
|
+
return {
|
|
67
|
+
onFileLoaded: options.onFileLoaded,
|
|
68
|
+
onFilesLoaded: options.onFilesLoaded,
|
|
69
|
+
onModelError: options.onModelError,
|
|
70
|
+
onModelLoaded: options.onModelLoaded,
|
|
71
|
+
parseModels: options.parseModels,
|
|
72
|
+
} satisfies FileLoadOptions;
|
|
45
73
|
}
|
|
46
74
|
|
|
47
|
-
export function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
export function DragDropLoader({
|
|
76
|
+
children,
|
|
77
|
+
...divProps
|
|
78
|
+
}: DragDropLoaderProps) {
|
|
79
|
+
const loadOptions = createLoadHandlers(divProps);
|
|
80
|
+
|
|
81
|
+
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
event.stopPropagation();
|
|
84
|
+
|
|
85
|
+
void loadFiles(getFiles(event.dataTransfer?.files), loadOptions).catch(reportFileLoadError);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function handleDragOver(event: DragEvent<HTMLDivElement>) {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div {...divProps} onDrop={handleDrop} onDragOver={handleDragOver}>
|
|
95
|
+
{children}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function FilePicker({
|
|
101
|
+
accept = ".glb,.gltf,.fbx",
|
|
102
|
+
children,
|
|
103
|
+
multiple = true,
|
|
104
|
+
...divProps
|
|
105
|
+
}: FilePickerProps) {
|
|
106
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
107
|
+
const { onClick, ...wrapperProps } = divProps;
|
|
108
|
+
const loadOptions = createLoadHandlers(divProps);
|
|
109
|
+
|
|
110
|
+
function onChange(event: ChangeEvent<HTMLInputElement>) {
|
|
111
|
+
void loadFiles(getFiles(event.target.files), loadOptions).catch(reportFileLoadError);
|
|
112
|
+
event.target.value = "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function handleClick(event: MouseEvent<HTMLDivElement>) {
|
|
116
|
+
onClick?.(event);
|
|
117
|
+
|
|
118
|
+
if (!event.defaultPrevented) {
|
|
119
|
+
inputRef.current?.click();
|
|
120
|
+
}
|
|
51
121
|
}
|
|
52
|
-
|
|
53
|
-
const inputId = "file-picker-input";
|
|
122
|
+
|
|
54
123
|
return (
|
|
55
|
-
|
|
124
|
+
<div {...wrapperProps} onClick={handleClick}>
|
|
56
125
|
<input
|
|
57
|
-
|
|
126
|
+
ref={inputRef}
|
|
58
127
|
type="file"
|
|
59
|
-
accept=
|
|
60
|
-
multiple
|
|
128
|
+
accept={accept}
|
|
129
|
+
multiple={multiple}
|
|
61
130
|
onChange={onChange}
|
|
62
|
-
|
|
131
|
+
hidden
|
|
63
132
|
/>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
type="button"
|
|
67
|
-
onClick={() => document.getElementById(inputId)?.click()}
|
|
68
|
-
>
|
|
69
|
-
Select Files
|
|
70
|
-
</button>
|
|
71
|
-
</>
|
|
133
|
+
{children ?? "Select Files"}
|
|
134
|
+
</div>
|
|
72
135
|
);
|
|
73
136
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DragDropLoader, FilePicker, loadFiles } from "./DragDropLoader";
|
|
2
|
+
export type { DragDropLoaderProps, FileLoadOptions, FilePickerProps } from "./DragDropLoader";
|
|
3
|
+
export { loadModel, parseModelFromFile } from "./modelLoader";
|
|
4
|
+
export type { LoadedModel, ModelLoadResult, ProgressCallback } from "./modelLoader";
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Object3D } from "three";
|
|
2
|
+
import { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
|
|
3
|
+
|
|
4
|
+
export type LoadedModel = Object3D;
|
|
2
5
|
|
|
3
6
|
export type ModelLoadResult = {
|
|
4
7
|
success: boolean;
|
|
5
|
-
model?:
|
|
6
|
-
error?:
|
|
8
|
+
model?: LoadedModel;
|
|
9
|
+
error?: unknown;
|
|
7
10
|
};
|
|
8
11
|
|
|
9
12
|
export type ProgressCallback = (filename: string, loaded: number, total: number) => void;
|
|
10
13
|
|
|
11
|
-
// Singleton loader instances
|
|
12
14
|
const dracoLoader = new DRACOLoader();
|
|
13
15
|
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
|
|
14
16
|
|
|
@@ -17,37 +19,76 @@ gltfLoader.setDRACOLoader(dracoLoader);
|
|
|
17
19
|
|
|
18
20
|
const fbxLoader = new FBXLoader();
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
type ModelFileKind = "gltf" | "fbx";
|
|
23
|
+
|
|
24
|
+
function normalizeModelPath(name: string) {
|
|
25
|
+
return name.split(/[?#]/, 1)[0].toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getModelFileKind(name: string): ModelFileKind | null {
|
|
29
|
+
const normalizedName = normalizeModelPath(name);
|
|
30
|
+
|
|
31
|
+
if (normalizedName.endsWith(".glb") || normalizedName.endsWith(".gltf")) {
|
|
32
|
+
return "gltf";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (normalizedName.endsWith(".fbx")) {
|
|
36
|
+
return "fbx";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function canParseModelFile(file: File | string) {
|
|
43
|
+
const filename = typeof file === "string" ? file : file.name;
|
|
44
|
+
return getModelFileKind(filename) !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseModelBuffer(arrayBuffer: ArrayBuffer, sourceName: string): Promise<ModelLoadResult> {
|
|
48
|
+
const modelFileKind = getModelFileKind(sourceName);
|
|
49
|
+
|
|
50
|
+
if (modelFileKind === "gltf") {
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
gltfLoader.parse(
|
|
53
|
+
arrayBuffer,
|
|
54
|
+
"",
|
|
55
|
+
gltf => {
|
|
56
|
+
resolve({ success: true, model: gltf.scene });
|
|
57
|
+
},
|
|
58
|
+
error => {
|
|
59
|
+
resolve({ success: false, error });
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (modelFileKind === "fbx") {
|
|
66
|
+
try {
|
|
67
|
+
const model = fbxLoader.parse(arrayBuffer, "");
|
|
68
|
+
return Promise.resolve({ success: true, model });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return Promise.resolve({ success: false, error });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Promise.resolve({ success: false, error: new Error(`Unsupported file format: ${sourceName}`) });
|
|
75
|
+
}
|
|
76
|
+
|
|
24
77
|
export function parseModelFromFile(file: File): Promise<ModelLoadResult> {
|
|
25
|
-
return new Promise(
|
|
78
|
+
return new Promise(resolve => {
|
|
26
79
|
const reader = new FileReader();
|
|
27
|
-
|
|
80
|
+
|
|
81
|
+
reader.onload = event => {
|
|
28
82
|
const arrayBuffer = event.target?.result as ArrayBuffer;
|
|
83
|
+
|
|
29
84
|
if (!arrayBuffer) {
|
|
30
|
-
resolve({ success: false, error: new Error(
|
|
85
|
+
resolve({ success: false, error: new Error("Failed to read file") });
|
|
31
86
|
return;
|
|
32
87
|
}
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
}
|
|
88
|
+
|
|
89
|
+
void parseModelBuffer(arrayBuffer, file.name).then(resolve);
|
|
50
90
|
};
|
|
91
|
+
|
|
51
92
|
reader.onerror = () => resolve({ success: false, error: reader.error });
|
|
52
93
|
reader.readAsArrayBuffer(file);
|
|
53
94
|
});
|
|
@@ -55,45 +96,49 @@ export function parseModelFromFile(file: File): Promise<ModelLoadResult> {
|
|
|
55
96
|
|
|
56
97
|
export async function loadModel(
|
|
57
98
|
filename: string,
|
|
58
|
-
onProgress?: ProgressCallback
|
|
99
|
+
onProgress?: ProgressCallback,
|
|
59
100
|
): Promise<ModelLoadResult> {
|
|
60
101
|
try {
|
|
61
|
-
// Use filename directly (should already include leading /)
|
|
62
102
|
const fullPath = filename;
|
|
103
|
+
const modelFileKind = getModelFileKind(filename);
|
|
63
104
|
|
|
64
|
-
if (
|
|
65
|
-
return new Promise(
|
|
105
|
+
if (modelFileKind === "gltf") {
|
|
106
|
+
return new Promise(resolve => {
|
|
66
107
|
gltfLoader.load(
|
|
67
108
|
fullPath,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (onProgress) {
|
|
71
|
-
|
|
72
|
-
const total = progressEvent.total || progressEvent.loaded;
|
|
73
|
-
onProgress(filename, progressEvent.loaded, total);
|
|
109
|
+
gltf => resolve({ success: true, model: gltf.scene }),
|
|
110
|
+
progressEvent => {
|
|
111
|
+
if (!onProgress) {
|
|
112
|
+
return;
|
|
74
113
|
}
|
|
114
|
+
|
|
115
|
+
const total = progressEvent.total || progressEvent.loaded;
|
|
116
|
+
onProgress(filename, progressEvent.loaded, total);
|
|
75
117
|
},
|
|
76
|
-
|
|
118
|
+
error => resolve({ success: false, error }),
|
|
77
119
|
);
|
|
78
120
|
});
|
|
79
|
-
}
|
|
80
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (modelFileKind === "fbx") {
|
|
124
|
+
return new Promise(resolve => {
|
|
81
125
|
fbxLoader.load(
|
|
82
126
|
fullPath,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (onProgress) {
|
|
86
|
-
|
|
87
|
-
const total = progressEvent.total || progressEvent.loaded;
|
|
88
|
-
onProgress(filename, progressEvent.loaded, total);
|
|
127
|
+
model => resolve({ success: true, model }),
|
|
128
|
+
progressEvent => {
|
|
129
|
+
if (!onProgress) {
|
|
130
|
+
return;
|
|
89
131
|
}
|
|
132
|
+
|
|
133
|
+
const total = progressEvent.total || progressEvent.loaded;
|
|
134
|
+
onProgress(filename, progressEvent.loaded, total);
|
|
90
135
|
},
|
|
91
|
-
|
|
136
|
+
error => resolve({ success: false, error }),
|
|
92
137
|
);
|
|
93
138
|
});
|
|
94
|
-
} else {
|
|
95
|
-
return { success: false, error: new Error(`Unsupported file format: ${filename}`) };
|
|
96
139
|
}
|
|
140
|
+
|
|
141
|
+
return { success: false, error: new Error(`Unsupported file format: ${filename}`) };
|
|
97
142
|
} catch (error) {
|
|
98
143
|
return { success: false, error };
|
|
99
144
|
}
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
import { Physics, RigidBody } from "@react-three/rapier";
|
|
4
4
|
import { OrbitControls } from "@react-three/drei";
|
|
5
5
|
import { useState } from "react";
|
|
6
|
-
import { DragDropLoader } from "./
|
|
6
|
+
import { DragDropLoader } from "./index";
|
|
7
7
|
import GameCanvas from "../../shared/GameCanvas";
|
|
8
8
|
|
|
9
9
|
export default function Home() {
|
|
10
10
|
const [models, setModels] = useState<any[]>([]);
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
<DragDropLoader
|
|
14
|
+
onModelLoaded={model => setModels(prev => [...prev, model])}
|
|
15
|
+
className="w-full items-center justify-items-center min-h-screen"
|
|
16
|
+
style={{ height: "100vh" }}
|
|
17
|
+
>
|
|
15
18
|
<div className="w-full items-center justify-items-center min-h-screen" style={{ height: "100vh" }}>
|
|
16
19
|
<GameCanvas>
|
|
17
20
|
<Physics>
|
|
@@ -37,6 +40,6 @@ export default function Home() {
|
|
|
37
40
|
</Physics>
|
|
38
41
|
</GameCanvas>
|
|
39
42
|
</div>
|
|
40
|
-
|
|
43
|
+
</DragDropLoader>
|
|
41
44
|
);
|
|
42
45
|
}
|
|
@@ -7,7 +7,7 @@ import EditorUI from "./EditorUI";
|
|
|
7
7
|
import { base, toolbar } from "./styles";
|
|
8
8
|
import { EditorContext } from "./EditorContext";
|
|
9
9
|
import { exportGLB, createModelNode, createImageNode } from "./utils";
|
|
10
|
-
import { parseModelFromFile } from "../dragdrop
|
|
10
|
+
import { parseModelFromFile } from "../dragdrop";
|
|
11
11
|
|
|
12
12
|
export interface PrefabEditorRef {
|
|
13
13
|
screenshot: () => void;
|
|
@@ -6,7 +6,7 @@ import { ThreeEvent } from "@react-three/fiber";
|
|
|
6
6
|
import { Prefab, GameObject as GameObjectType } from "./types";
|
|
7
7
|
import { getComponent, registerComponent, getNonComposableKeys } from "./components/ComponentRegistry";
|
|
8
8
|
import components from "./components";
|
|
9
|
-
import { loadModel } from "../dragdrop
|
|
9
|
+
import { loadModel } from "../dragdrop";
|
|
10
10
|
import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
|
|
11
11
|
import { focusCameraOnObject, updateNode } from "./utils";
|
|
12
12
|
import { PhysicsProps } from "./components/PhysicsComponent";
|
|
@@ -155,8 +155,11 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
|
|
|
155
155
|
: `${basePath}/${file}`;
|
|
156
156
|
|
|
157
157
|
const res = await loadModel(path);
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
const model = res.model;
|
|
159
|
+
|
|
160
|
+
if (res.success && model) {
|
|
161
|
+
setModels(m => ({ ...m, [file]: model }));
|
|
162
|
+
}
|
|
160
163
|
});
|
|
161
164
|
|
|
162
165
|
const loader = new TextureLoader();
|