react-three-game 0.0.9 → 0.0.11

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 CHANGED
@@ -40,7 +40,7 @@ Add the library path to your CSS entry point using the `@source` directive:
40
40
 
41
41
  ```css
42
42
  @import "tailwindcss";
43
- @source "../node_modules/react-three-game/src";
43
+ @source "../../node_modules/react-three-game/dist/**/*.{js,ts,jsx,tsx}";
44
44
  ```
45
45
 
46
46
  ### Tailwind v3
@@ -51,7 +51,7 @@ Add the library path to your `tailwind.config.js`:
51
51
  module.exports = {
52
52
  content: [
53
53
  // ...
54
- "./node_modules/react-three-game/src/**/*.{js,ts,jsx,tsx}",
54
+ "./node_modules/react-three-game/dist/**/*.{js,ts,jsx,tsx}",
55
55
  ],
56
56
  // ...
57
57
  }
package/dist/index.d.ts CHANGED
@@ -2,5 +2,5 @@ export { default as GameCanvas } from './shared/GameCanvas';
2
2
  export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
3
3
  export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
4
4
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
5
- export { default as AssetViewerPage, TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
5
+ export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
6
6
  export type { Prefab, GameObject } from './tools/prefabeditor/types';
package/dist/index.js CHANGED
@@ -3,4 +3,4 @@ export { default as GameCanvas } from './shared/GameCanvas';
3
3
  export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
4
4
  export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
5
5
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
6
- export { default as AssetViewerPage, TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
6
+ export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
@@ -1,6 +1,3 @@
1
- export default function AssetViewerPage({ basePath }?: {
2
- basePath?: string;
3
- }): import("react/jsx-runtime").JSX.Element;
4
1
  interface TextureListViewerProps {
5
2
  files: string[];
6
3
  selected?: string;
@@ -49,29 +49,6 @@ function useInView() {
49
49
  }, []);
50
50
  return { ref, isInView };
51
51
  }
52
- export default function AssetViewerPage({ basePath = "" } = {}) {
53
- const [textures, setTextures] = useState([]);
54
- const [models, setModels] = useState([]);
55
- const [sounds, setSounds] = useState([]);
56
- const [loading, setLoading] = useState(true);
57
- useEffect(() => {
58
- const base = basePath ? `${basePath}/` : '';
59
- Promise.all([
60
- fetch(`/${base}textures/manifest.json`).then(r => r.json()),
61
- fetch(`/${base}models/manifest.json`).then(r => r.json()),
62
- fetch(`/${base}sound/manifest.json`).then(r => r.json()).catch(() => [])
63
- ]).then(([textureData, modelData, soundData]) => {
64
- setTextures(textureData);
65
- setModels(modelData);
66
- setSounds(soundData);
67
- setLoading(false);
68
- });
69
- }, [basePath]);
70
- if (loading) {
71
- return _jsx("div", { className: "p-4 text-gray-300", children: "Loading manifests..." });
72
- }
73
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "p-2 text-gray-300 overflow-y-auto h-screen text-sm", children: [_jsx("h1", { className: "text-lg mb-2 font-bold", children: "Asset Viewer" }), _jsxs("h2", { className: "text-sm mt-4 mb-1 font-semibold", children: ["Textures (", textures.length, ")"] }), _jsx(TextureListViewer, { files: textures, basePath: basePath, onSelect: (file) => console.log('Selected texture:', file) }), _jsxs("h2", { className: "text-sm mt-4 mb-1 font-semibold", children: ["Models (", models.length, ")"] }), _jsx(ModelListViewer, { files: models, basePath: basePath, onSelect: (file) => console.log('Selected model:', file) }), sounds.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("h2", { className: "text-sm mt-4 mb-1 font-semibold", children: ["Sounds (", sounds.length, ")"] }), _jsx(SoundListViewer, { files: sounds, basePath: basePath, onSelect: (file) => console.log('Selected sound:', file) })] }))] }), _jsx(SharedCanvas, {})] }));
74
- }
75
52
  function AssetListViewer({ files, selected, onSelect, renderCard }) {
76
53
  const [currentPath, setCurrentPath] = useState('');
77
54
  const [showPicker, setShowPicker] = useState(false);
@@ -4,4 +4,4 @@ export type ModelLoadResult = {
4
4
  error?: any;
5
5
  };
6
6
  export type ProgressCallback = (filename: string, loaded: number, total: number) => void;
7
- export declare function loadModel(filename: string, resourcePath?: string, onProgress?: ProgressCallback): Promise<ModelLoadResult>;
7
+ export declare function loadModel(filename: string, onProgress?: ProgressCallback): Promise<ModelLoadResult>;
@@ -14,11 +14,11 @@ dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
14
14
  const gltfLoader = new GLTFLoader();
15
15
  gltfLoader.setDRACOLoader(dracoLoader);
16
16
  const fbxLoader = new FBXLoader();
17
- export function loadModel(filename_1) {
18
- return __awaiter(this, arguments, void 0, function* (filename, resourcePath = "", onProgress) {
17
+ export function loadModel(filename, onProgress) {
18
+ return __awaiter(this, void 0, void 0, function* () {
19
19
  try {
20
- // Construct full path - handle empty resourcePath to avoid double slashes
21
- const fullPath = resourcePath ? `${resourcePath}/${filename}` : filename;
20
+ // Use filename directly (should already include leading /)
21
+ const fullPath = filename;
22
22
  if (filename.endsWith('.glb') || filename.endsWith('.gltf')) {
23
23
  return new Promise((resolve) => {
24
24
  gltfLoader.load(fullPath, (gltf) => resolve({ success: true, model: gltf.scene }), (progressEvent) => {
@@ -136,7 +136,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
136
136
  const hasChildren = node.children && node.children.length > 0;
137
137
  return (_jsxs("div", { className: "select-none", children: [_jsxs("div", { className: `flex items-center py-0.5 px-1 cursor-pointer border-b border-cyan-500/10 ${isSelected ? 'bg-cyan-500/30 hover:bg-cyan-500/40 border-cyan-400/30' : 'hover:bg-cyan-500/10'}`, style: { paddingLeft: `${depth * 8 + 4}px` }, onClick: (e) => { e.stopPropagation(); setSelectedId(node.id); }, onContextMenu: (e) => handleContextMenu(e, node.id), draggable: node.id !== prefabData.root.id, onDragStart: (e) => handleDragStart(e, node.id), onDragOver: (e) => handleDragOver(e, node.id), onDrop: (e) => handleDrop(e, node.id), children: [_jsx("span", { className: `mr-0.5 w-3 text-center text-cyan-400/50 hover:text-cyan-400 cursor-pointer text-[8px] ${hasChildren ? '' : 'invisible'}`, onClick: (e) => hasChildren && toggleCollapse(e, node.id), children: isCollapsed ? '▶' : '▼' }), _jsx("span", { className: "text-[10px] truncate font-mono text-cyan-300", children: node.id })] }), !isCollapsed && node.children && (_jsx("div", { children: node.children.map(child => renderNode(child, depth + 1)) }))] }, node.id));
138
138
  };
139
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-black/70 backdrop-blur-sm text-white border border-cyan-500/30 max-h-[85vh] overflow-y-auto flex flex-col", style: { width: isTreeCollapsed ? 'auto' : '14rem' }, onClick: closeContextMenu, children: [_jsxs("div", { className: "px-1.5 py-1 font-mono text-[10px] bg-cyan-500/10 border-b border-cyan-500/30 sticky top-0 uppercase tracking-wider text-cyan-400/80 cursor-pointer hover:bg-cyan-500/20 flex items-center justify-between", onClick: (e) => { e.stopPropagation(); setIsTreeCollapsed(!isTreeCollapsed); }, children: [_jsx("span", { children: "Prefab Graph" }), _jsx("span", { className: "text-[8px]", children: isTreeCollapsed ? '▶' : '◀' })] }), !isTreeCollapsed && (_jsx("div", { className: "flex-1 py-0.5", children: renderNode(prefabData.root) }))] }), contextMenu && (_jsxs("div", { className: "fixed bg-black/90 backdrop-blur-sm border border-cyan-500/40 z-50 min-w-[100px]", style: { top: contextMenu.y, left: contextMenu.x }, onClick: (e) => e.stopPropagation(), children: [_jsx("button", { className: "w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20", onClick: () => handleAddChild(contextMenu.nodeId), children: "Add Child" }), contextMenu.nodeId !== prefabData.root.id && (_jsxs(_Fragment, { children: [_jsx("button", { className: "w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20", onClick: () => handleDuplicate(contextMenu.nodeId), children: "Duplicate" }), _jsx("button", { className: "w-full text-left px-2 py-1 hover:bg-red-500/20 text-[10px] text-red-400 font-mono", onClick: () => handleDelete(contextMenu.nodeId), children: "Delete" })] }))] }))] }));
139
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-black/70 backdrop-blur-sm text-white border border-cyan-500/30 max-h-[85vh] overflow-y-auto flex flex-col", style: { width: isTreeCollapsed ? 'auto' : '14rem' }, onClick: closeContextMenu, children: [_jsxs("div", { className: "px-1.5 py-1 font-mono text-[10px] bg-cyan-500/10 border-b border-cyan-500/30 sticky top-0 uppercase tracking-wider text-cyan-400/80 cursor-pointer hover:bg-cyan-500/20 flex items-center justify-between", onClick: (e) => { e.stopPropagation(); setIsTreeCollapsed(!isTreeCollapsed); }, children: [_jsx("span", { children: "Prefab Graph" }), _jsx("span", { className: "text-[8px]", children: isTreeCollapsed ? '▶' : '◀' })] }), !isTreeCollapsed && (_jsx("div", { className: "flex-1 py-0.5", children: renderNode(prefabData.root) }))] }), contextMenu && (_jsxs("div", { className: "fixed bg-black/90 backdrop-blur-sm border border-cyan-500/40 z-50 min-w-[100px]", style: { top: contextMenu.y, left: contextMenu.x }, onClick: (e) => e.stopPropagation(), children: [_jsx("button", { className: "w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20", onClick: () => handleAddChild(contextMenu.nodeId), onPointerLeave: closeContextMenu, children: "Add Child" }), contextMenu.nodeId !== prefabData.root.id && (_jsxs(_Fragment, { children: [_jsx("button", { className: "w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20", onClick: () => handleDuplicate(contextMenu.nodeId), children: "Duplicate" }), _jsx("button", { className: "w-full text-left px-2 py-1 hover:bg-red-500/20 text-[10px] text-red-400 font-mono", onClick: () => handleDelete(contextMenu.nodeId), children: "Delete" })] }))] }))] }));
140
140
  }
141
141
  // --- Helpers ---
142
142
  function findNode(root, id) {
@@ -1,7 +1,8 @@
1
1
  import { Prefab } from "./types";
2
- declare const PrefabEditor: ({ basePath, initialPrefab, children }: {
2
+ declare const PrefabEditor: ({ basePath, initialPrefab, onPrefabChange, children }: {
3
3
  basePath?: string;
4
4
  initialPrefab?: Prefab;
5
+ onPrefabChange?: (prefab: Prefab) => void;
5
6
  children?: React.ReactNode;
6
7
  }) => import("react/jsx-runtime").JSX.Element;
7
8
  export default PrefabEditor;
@@ -10,11 +10,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
12
12
  import GameCanvas from "../../shared/GameCanvas";
13
- import { useState, useRef, } from "react";
13
+ import { useState, useRef, useEffect } from "react";
14
14
  import PrefabRoot from "./PrefabRoot";
15
15
  import { Physics } from "@react-three/rapier";
16
16
  import EditorUI from "./EditorUI";
17
- const PrefabEditor = ({ basePath, initialPrefab, children }) => {
17
+ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }) => {
18
18
  const [editMode, setEditMode] = useState(true);
19
19
  const [loadedPrefab, setLoadedPrefab] = useState(initialPrefab !== null && initialPrefab !== void 0 ? initialPrefab : {
20
20
  "id": "prefab-default",
@@ -38,13 +38,25 @@ const PrefabEditor = ({ basePath, initialPrefab, children }) => {
38
38
  const [selectedId, setSelectedId] = useState(null);
39
39
  const [transformMode, setTransformMode] = useState("translate");
40
40
  const prefabRef = useRef(null);
41
+ // Sync internal state with external initialPrefab prop
42
+ useEffect(() => {
43
+ if (initialPrefab) {
44
+ setLoadedPrefab(initialPrefab);
45
+ }
46
+ }, [initialPrefab]);
47
+ // Wrapper to update prefab and notify parent
48
+ const updatePrefab = (newPrefab) => {
49
+ setLoadedPrefab(newPrefab);
50
+ const resolved = typeof newPrefab === 'function' ? newPrefab(loadedPrefab) : newPrefab;
51
+ onPrefabChange === null || onPrefabChange === void 0 ? void 0 : onPrefabChange(resolved);
52
+ };
41
53
  return _jsxs(_Fragment, { children: [_jsx(GameCanvas, { children: _jsxs(Physics, { paused: editMode, children: [_jsx("ambientLight", { intensity: 1.5 }), _jsx("gridHelper", { args: [10, 10], position: [0, -1, 0] }), _jsx(PrefabRoot, { data: loadedPrefab, ref: prefabRef,
42
54
  // props for edit mode
43
- editMode: editMode, onPrefabChange: setLoadedPrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }), children] }) }), _jsxs("div", { style: { position: "absolute", top: "0.5rem", left: "50%", transform: "translateX(-50%)" }, className: "bg-black/70 backdrop-blur-sm border border-cyan-500/30 px-2 py-1 flex items-center gap-1", children: [_jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => setEditMode(!editMode), children: editMode ? "▶" : "⏸" }), _jsx("span", { className: "text-cyan-500/30 text-[10px]", children: "|" }), _jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => __awaiter(void 0, void 0, void 0, function* () {
55
+ editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }), children] }) }), _jsxs("div", { style: { position: "absolute", top: "0.5rem", left: "50%", transform: "translateX(-50%)" }, className: "bg-black/70 backdrop-blur-sm border border-cyan-500/30 px-2 py-1 flex items-center gap-1", children: [_jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => setEditMode(!editMode), children: editMode ? "▶" : "⏸" }), _jsx("span", { className: "text-cyan-500/30 text-[10px]", children: "|" }), _jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => __awaiter(void 0, void 0, void 0, function* () {
44
56
  const prefab = yield loadJson();
45
57
  if (prefab)
46
58
  setLoadedPrefab(prefab);
47
- }), children: "\uD83D\uDCE5" }), _jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => saveJson(loadedPrefab, "prefab"), children: "\uD83D\uDCBE" })] }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: setLoadedPrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath })] });
59
+ }), children: "\uD83D\uDCE5" }), _jsx("button", { className: "px-1 py-0.5 text-[10px] font-mono text-cyan-300 hover:bg-cyan-500/20 border border-cyan-500/30", onClick: () => saveJson(loadedPrefab, "prefab"), children: "\uD83D\uDCBE" })] }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath })] });
48
60
  };
49
61
  const saveJson = (data, filename) => {
50
62
  const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
@@ -108,7 +108,9 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
108
108
  for (const filename of modelsToLoad) {
109
109
  if (!loadedModels[filename] && !loadingRefs.current.has(filename)) {
110
110
  loadingRefs.current.add(filename);
111
- const result = yield loadModel(filename, basePath);
111
+ // Load model directly from public root, prepend "/" if not present
112
+ const modelPath = filename.startsWith('/') ? filename : `/${filename}`;
113
+ const result = yield loadModel(modelPath);
112
114
  if (result.success && result.model) {
113
115
  setLoadedModels(prev => (Object.assign(Object.assign({}, prev), { [filename]: result.model })));
114
116
  }
@@ -118,7 +120,8 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
118
120
  for (const filename of texturesToLoad) {
119
121
  if (!loadedTextures[filename] && !loadingRefs.current.has(filename)) {
120
122
  loadingRefs.current.add(filename);
121
- const texturePath = basePath ? `${basePath}/${filename}` : filename;
123
+ // Load texture directly from public root, prepend "/" if not present
124
+ const texturePath = filename.startsWith('/') ? filename : `/${filename}`;
122
125
  textureLoader.load(texturePath, (texture) => {
123
126
  texture.colorSpace = SRGBColorSpace;
124
127
  setLoadedTextures(prev => (Object.assign(Object.assign({}, prev), { [filename]: texture })));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
package/src/index.ts CHANGED
@@ -4,7 +4,6 @@ export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
4
4
  export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
5
5
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
6
6
  export {
7
- default as AssetViewerPage,
8
7
  TextureListViewer,
9
8
  ModelListViewer,
10
9
  SoundListViewer,
@@ -3,7 +3,6 @@
3
3
  import { Canvas, useLoader } from "@react-three/fiber";
4
4
  import { OrbitControls, useGLTF, useFBX, Stage, View, PerspectiveCamera } from "@react-three/drei";
5
5
  import { Suspense, useEffect, useState, useRef } from "react";
6
- import * as React from "react";
7
6
  import { TextureLoader } from "three";
8
7
 
9
8
  // view models and textures in manifest, onselect callback
@@ -74,53 +73,6 @@ function useInView() {
74
73
  return { ref, isInView };
75
74
  }
76
75
 
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
76
  interface AssetListViewerProps {
125
77
  files: string[];
126
78
  selected?: string;
@@ -19,12 +19,11 @@ const fbxLoader = new FBXLoader();
19
19
 
20
20
  export async function loadModel(
21
21
  filename: string,
22
- resourcePath: string = "",
23
22
  onProgress?: ProgressCallback
24
23
  ): Promise<ModelLoadResult> {
25
24
  try {
26
- // Construct full path - handle empty resourcePath to avoid double slashes
27
- const fullPath = resourcePath ? `${resourcePath}/${filename}` : filename;
25
+ // Use filename directly (should already include leading /)
26
+ const fullPath = filename;
28
27
 
29
28
  if (filename.endsWith('.glb') || filename.endsWith('.gltf')) {
30
29
  return new Promise((resolve) => {
@@ -210,6 +210,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
210
210
  <button
211
211
  className="w-full text-left px-2 py-1 hover:bg-cyan-500/20 text-[10px] text-cyan-300 font-mono border-b border-cyan-500/20"
212
212
  onClick={() => handleAddChild(contextMenu.nodeId)}
213
+ onPointerLeave={closeContextMenu}
213
214
  >
214
215
  Add Child
215
216
  </button>
@@ -1,14 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import GameCanvas from "../../shared/GameCanvas";
4
- import { useState, useRef, } from "react";
4
+ import { useState, useRef, useEffect } from "react";
5
5
  import { Group, } from "three";
6
6
  import { Prefab, } from "./types";
7
7
  import PrefabRoot from "./PrefabRoot";
8
8
  import { Physics } from "@react-three/rapier";
9
9
  import EditorUI from "./EditorUI";
10
10
 
11
- const PrefabEditor = ({ basePath, initialPrefab, children }: { basePath?: string, initialPrefab?: Prefab, children?: React.ReactNode }) => {
11
+ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { basePath?: string, initialPrefab?: Prefab, onPrefabChange?: (prefab: Prefab) => void, children?: React.ReactNode }) => {
12
12
  const [editMode, setEditMode] = useState(true);
13
13
  const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? {
14
14
  "id": "prefab-default",
@@ -33,6 +33,20 @@ const PrefabEditor = ({ basePath, initialPrefab, children }: { basePath?: string
33
33
  const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
34
34
  const prefabRef = useRef<Group>(null);
35
35
 
36
+ // Sync internal state with external initialPrefab prop
37
+ useEffect(() => {
38
+ if (initialPrefab) {
39
+ setLoadedPrefab(initialPrefab);
40
+ }
41
+ }, [initialPrefab]);
42
+
43
+ // Wrapper to update prefab and notify parent
44
+ const updatePrefab = (newPrefab: Prefab | ((prev: Prefab) => Prefab)) => {
45
+ setLoadedPrefab(newPrefab);
46
+ const resolved = typeof newPrefab === 'function' ? newPrefab(loadedPrefab) : newPrefab;
47
+ onPrefabChange?.(resolved);
48
+ };
49
+
36
50
  return <>
37
51
  <GameCanvas>
38
52
  <Physics paused={editMode}>
@@ -44,7 +58,7 @@ const PrefabEditor = ({ basePath, initialPrefab, children }: { basePath?: string
44
58
 
45
59
  // props for edit mode
46
60
  editMode={editMode}
47
- onPrefabChange={setLoadedPrefab}
61
+ onPrefabChange={updatePrefab}
48
62
  selectedId={selectedId}
49
63
  onSelect={setSelectedId}
50
64
  transformMode={transformMode}
@@ -81,7 +95,7 @@ const PrefabEditor = ({ basePath, initialPrefab, children }: { basePath?: string
81
95
  </div>
82
96
  {editMode && <EditorUI
83
97
  prefabData={loadedPrefab}
84
- setPrefabData={setLoadedPrefab}
98
+ setPrefabData={updatePrefab}
85
99
  selectedId={selectedId}
86
100
  setSelectedId={setSelectedId}
87
101
  transformMode={transformMode}
@@ -133,7 +133,9 @@ export const PrefabRoot = forwardRef<Group, {
133
133
  for (const filename of modelsToLoad) {
134
134
  if (!loadedModels[filename] && !loadingRefs.current.has(filename)) {
135
135
  loadingRefs.current.add(filename);
136
- const result = await loadModel(filename, basePath);
136
+ // Load model directly from public root, prepend "/" if not present
137
+ const modelPath = filename.startsWith('/') ? filename : `/${filename}`;
138
+ const result = await loadModel(modelPath);
137
139
  if (result.success && result.model) {
138
140
  setLoadedModels(prev => ({ ...prev, [filename]: result.model }));
139
141
  }
@@ -144,7 +146,8 @@ export const PrefabRoot = forwardRef<Group, {
144
146
  for (const filename of texturesToLoad) {
145
147
  if (!loadedTextures[filename] && !loadingRefs.current.has(filename)) {
146
148
  loadingRefs.current.add(filename);
147
- const texturePath = basePath ? `${basePath}/${filename}` : filename;
149
+ // Load texture directly from public root, prepend "/" if not present
150
+ const texturePath = filename.startsWith('/') ? filename : `/${filename}`;
148
151
  textureLoader.load(texturePath, (texture) => {
149
152
  texture.colorSpace = SRGBColorSpace;
150
153
  setLoadedTextures(prev => ({ ...prev, [filename]: texture }));