react-three-game 0.0.52 → 0.0.53

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 (27) hide show
  1. package/dist/helpers/index.js +1 -1
  2. package/dist/tools/prefabeditor/EditorTree.js +48 -7
  3. package/dist/tools/prefabeditor/EditorUI.js +2 -2
  4. package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
  5. package/dist/tools/prefabeditor/PrefabEditor.js +4 -3
  6. package/dist/tools/prefabeditor/PrefabRoot.d.ts +2 -3
  7. package/dist/tools/prefabeditor/PrefabRoot.js +9 -3
  8. package/dist/tools/prefabeditor/components/GeometryComponent.js +8 -8
  9. package/dist/tools/prefabeditor/components/Input.d.ts +2 -1
  10. package/dist/tools/prefabeditor/components/Input.js +63 -11
  11. package/dist/tools/prefabeditor/components/MaterialComponent.js +4 -2
  12. package/dist/tools/prefabeditor/components/ModelComponent.js +3 -1
  13. package/dist/tools/prefabeditor/components/PhysicsComponent.js +10 -2
  14. package/dist/tools/prefabeditor/styles.d.ts +1 -4
  15. package/dist/tools/prefabeditor/styles.js +2 -0
  16. package/package.json +7 -7
  17. package/src/helpers/index.ts +1 -1
  18. package/src/tools/prefabeditor/EditorTree.tsx +92 -17
  19. package/src/tools/prefabeditor/EditorUI.tsx +2 -2
  20. package/src/tools/prefabeditor/PrefabEditor.tsx +24 -15
  21. package/src/tools/prefabeditor/PrefabRoot.tsx +14 -6
  22. package/src/tools/prefabeditor/components/GeometryComponent.tsx +13 -13
  23. package/src/tools/prefabeditor/components/Input.tsx +110 -20
  24. package/src/tools/prefabeditor/components/MaterialComponent.tsx +22 -6
  25. package/src/tools/prefabeditor/components/ModelComponent.tsx +9 -1
  26. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +10 -2
  27. package/src/tools/prefabeditor/styles.ts +3 -1
@@ -8,7 +8,7 @@
8
8
  * - Physics (fixed by default)
9
9
  */
10
10
  export function ground(options = {}) {
11
- const { id = "ground", size = 50, position = [0, 0, 0], rotation = [-Math.PI / 2, 0, 0], scale = [1, 1, 1], color = "white", texture, repeat = texture ? true : false, repeatCount = [25, 25], physicsType = "fixed", disabled = false, } = options;
11
+ const { id = "ground", size = 50, position = [0, 0, 0], rotation = [-Math.PI / 2, 0, 0], scale = [1, 1, 1], color = "#eeeeee", texture, repeat = texture ? true : false, repeatCount = [25, 25], physicsType = "fixed", disabled = false, } = options;
12
12
  return {
13
13
  id,
14
14
  disabled,
@@ -19,6 +19,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
19
19
  const [collapsedIds, setCollapsedIds] = useState(new Set());
20
20
  const [collapsed, setCollapsed] = useState(false);
21
21
  const [fileMenuOpen, setFileMenuOpen] = useState(false);
22
+ const [searchQuery, setSearchQuery] = useState('');
22
23
  if (!prefabData || !setPrefabData)
23
24
  return null;
24
25
  const handleContextMenu = (e, nodeId) => {
@@ -73,6 +74,10 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
73
74
  setSelectedId(null);
74
75
  setContextMenu(null);
75
76
  };
77
+ const handleToggleDisabled = (nodeId) => {
78
+ setPrefabData(prev => (Object.assign(Object.assign({}, prev), { root: updateNodeById(prev.root, nodeId, node => (Object.assign(Object.assign({}, node), { disabled: !node.disabled }))) })));
79
+ setContextMenu(null);
80
+ };
76
81
  const handleDragStart = (e, id) => {
77
82
  if (id === prefabData.root.id)
78
83
  return e.preventDefault();
@@ -105,23 +110,59 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
105
110
  });
106
111
  setDraggedId(null);
107
112
  };
113
+ const matchesSearch = (node, query) => {
114
+ var _a, _b, _c;
115
+ if (!query)
116
+ return true;
117
+ const lowerQuery = query.toLowerCase();
118
+ const nodeName = ((_a = node.name) !== null && _a !== void 0 ? _a : node.id).toLowerCase();
119
+ if (nodeName.includes(lowerQuery))
120
+ return true;
121
+ return (_c = (_b = node.children) === null || _b === void 0 ? void 0 : _b.some(child => matchesSearch(child, query))) !== null && _c !== void 0 ? _c : false;
122
+ };
108
123
  const renderNode = (node, depth = 0) => {
109
124
  var _a;
110
125
  if (!node)
111
126
  return null;
127
+ if (!matchesSearch(node, searchQuery))
128
+ return null;
112
129
  const isSelected = node.id === selectedId;
113
130
  const isCollapsed = collapsedIds.has(node.id);
114
131
  const hasChildren = node.children && node.children.length > 0;
115
132
  const isRoot = node.id === prefabData.root.id;
116
- return (_jsxs("div", { children: [_jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, tree.row), (isSelected ? tree.selected : {})), { paddingLeft: `${depth * 12 + 6}px` }), draggable: !isRoot, onClick: (e) => { e.stopPropagation(); setSelectedId(node.id); }, onContextMenu: (e) => handleContextMenu(e, node.id), onDragStart: (e) => handleDragStart(e, node.id), onDragEnd: () => setDraggedId(null), onDragOver: (e) => handleDragOver(e, node.id), onDrop: (e) => handleDrop(e, node.id), children: [_jsx("span", { style: {
117
- width: 12,
118
- opacity: 0.6,
119
- marginRight: 4,
133
+ return (_jsxs("div", { children: [_jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, tree.row), (isSelected ? tree.selected : {})), { paddingLeft: `${depth * 12 + 6}px`, opacity: node.disabled ? 0.4 : 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }), draggable: !isRoot, onClick: (e) => { e.stopPropagation(); setSelectedId(node.id); }, onContextMenu: (e) => handleContextMenu(e, node.id), onDragStart: (e) => handleDragStart(e, node.id), onDragEnd: () => setDraggedId(null), onDragOver: (e) => handleDragOver(e, node.id), onDrop: (e) => handleDrop(e, node.id), children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }, children: [_jsx("span", { style: {
134
+ width: 12,
135
+ opacity: 0.6,
136
+ marginRight: 4,
137
+ cursor: 'pointer',
138
+ visibility: hasChildren ? 'visible' : 'hidden'
139
+ }, onClick: (e) => hasChildren && toggleCollapse(e, node.id), children: isCollapsed ? '▶' : '▼' }), !isRoot && _jsx("span", { style: { marginRight: 4, opacity: 0.4 }, children: "\u22EE\u22EE" }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: (_a = node.name) !== null && _a !== void 0 ? _a : node.id })] }), !isRoot && (_jsx("button", { style: {
140
+ background: 'none',
141
+ border: 'none',
120
142
  cursor: 'pointer',
121
- visibility: hasChildren ? 'visible' : 'hidden'
122
- }, onClick: (e) => hasChildren && toggleCollapse(e, node.id), children: isCollapsed ? '▶' : '▼' }), !isRoot && _jsx("span", { style: { marginRight: 4, opacity: 0.4 }, children: "\u22EE\u22EE" }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: (_a = node.name) !== null && _a !== void 0 ? _a : node.id })] }), !isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))] }, node.id));
143
+ padding: '0 4px',
144
+ fontSize: 14,
145
+ opacity: node.disabled ? 0.5 : 0.7,
146
+ color: 'inherit',
147
+ }, onClick: (e) => {
148
+ e.stopPropagation();
149
+ handleToggleDisabled(node.id);
150
+ }, title: node.disabled ? 'Enable' : 'Disable', children: node.disabled ? '◎' : '◉' }))] }), !isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))] }, node.id));
123
151
  };
124
- return (_jsxs(_Fragment, { children: [_jsxs("div", { style: Object.assign(Object.assign({}, tree.panel), { width: collapsed ? 'auto' : 224 }), onClick: () => { setContextMenu(null); setFileMenuOpen(false); }, children: [_jsxs("div", { style: base.header, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: collapsed ? '▶' : '▼' }), _jsx("span", { children: "Scene" })] }), !collapsed && (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4 }, children: [_jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10, opacity: canUndo ? 1 : 0.4 }), onClick: (e) => { e.stopPropagation(); onUndo === null || onUndo === void 0 ? void 0 : onUndo(); }, disabled: !canUndo, title: "Undo", children: "\u21B6" }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10, opacity: canRedo ? 1 : 0.4 }), onClick: (e) => { e.stopPropagation(); onRedo === null || onRedo === void 0 ? void 0 : onRedo(); }, disabled: !canRedo, title: "Redo", children: "\u21B7" }), _jsxs("div", { style: { position: 'relative' }, children: [_jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10 }), onClick: (e) => { e.stopPropagation(); setFileMenuOpen(!fileMenuOpen); }, title: "File", children: "\u22EE" }), fileMenuOpen && (_jsx(FileMenu, { prefabData: prefabData, setPrefabData: setPrefabData, onClose: () => setFileMenuOpen(false) }))] })] }))] }), !collapsed && _jsx("div", { style: tree.scroll, children: renderNode(prefabData.root) })] }), contextMenu && (_jsxs("div", { style: Object.assign(Object.assign({}, menu.container), { top: contextMenu.y, left: contextMenu.x }), onClick: (e) => e.stopPropagation(), onPointerLeave: () => setContextMenu(null), children: [_jsx("button", { style: menu.item, onClick: () => handleAddChild(contextMenu.nodeId), children: "Add Child" }), contextMenu.nodeId !== prefabData.root.id && (_jsxs(_Fragment, { children: [_jsx("button", { style: menu.item, onClick: () => handleDuplicate(contextMenu.nodeId), children: "Duplicate" }), _jsx("button", { style: Object.assign(Object.assign({}, menu.item), menu.danger), onClick: () => handleDelete(contextMenu.nodeId), children: "Delete" })] }))] }))] }));
152
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
153
+ .tree-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
154
+ .tree-scroll::-webkit-scrollbar-track { background: transparent; }
155
+ .tree-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
156
+ ` }), _jsxs("div", { style: Object.assign(Object.assign({}, tree.panel), { width: collapsed ? 'auto' : 224 }), onClick: () => { setContextMenu(null); setFileMenuOpen(false); }, children: [_jsxs("div", { style: base.header, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: collapsed ? '▶' : '▼' }), _jsx("span", { children: "Scene" })] }), !collapsed && (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4 }, children: [_jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10, opacity: canUndo ? 1 : 0.4 }), onClick: (e) => { e.stopPropagation(); onUndo === null || onUndo === void 0 ? void 0 : onUndo(); }, disabled: !canUndo, title: "Undo", children: "\u21B6" }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10, opacity: canRedo ? 1 : 0.4 }), onClick: (e) => { e.stopPropagation(); onRedo === null || onRedo === void 0 ? void 0 : onRedo(); }, disabled: !canRedo, title: "Redo", children: "\u21B7" }), _jsxs("div", { style: { position: 'relative' }, children: [_jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10 }), onClick: (e) => { e.stopPropagation(); setFileMenuOpen(!fileMenuOpen); }, title: "File", children: "\u22EE" }), fileMenuOpen && (_jsx(FileMenu, { prefabData: prefabData, setPrefabData: setPrefabData, onClose: () => setFileMenuOpen(false) }))] })] }))] }), !collapsed && (_jsxs(_Fragment, { children: [_jsx("div", { style: { padding: '4px 6px', borderBottom: '1px solid rgba(255,255,255,0.1)' }, children: _jsx("input", { type: "text", placeholder: "Search nodes...", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), onClick: (e) => e.stopPropagation(), style: {
157
+ width: '100%',
158
+ padding: '4px 8px',
159
+ background: 'rgba(255,255,255,0.05)',
160
+ border: '1px solid rgba(255,255,255,0.1)',
161
+ borderRadius: 3,
162
+ color: 'inherit',
163
+ fontSize: 11,
164
+ outline: 'none',
165
+ } }) }), _jsx("div", { className: "tree-scroll", style: tree.scroll, children: renderNode(prefabData.root) })] }))] }), contextMenu && (_jsxs("div", { style: Object.assign(Object.assign({}, menu.container), { top: contextMenu.y, left: contextMenu.x }), onClick: (e) => e.stopPropagation(), onPointerLeave: () => setContextMenu(null), children: [_jsx("button", { style: menu.item, onClick: () => handleAddChild(contextMenu.nodeId), children: "Add Child" }), contextMenu.nodeId !== prefabData.root.id && (_jsxs(_Fragment, { children: [_jsx("button", { style: menu.item, onClick: () => handleDuplicate(contextMenu.nodeId), children: "Duplicate" }), _jsx("button", { style: Object.assign(Object.assign({}, menu.item), menu.danger), onClick: () => handleDelete(contextMenu.nodeId), children: "Delete" })] }))] }))] }));
125
166
  }
126
167
  function FileMenu({ prefabData, setPrefabData, onClose }) {
127
168
  const { onScreenshot, onExportGLB } = useEditorContext();
@@ -47,13 +47,13 @@ function NodeInspector({ node, updateNode, deleteNode, basePath }) {
47
47
  if (!newAvailable.includes(addType))
48
48
  setAddType(newAvailable[0] || "");
49
49
  }, [Object.keys(node.components || {}).join(',')]);
50
- return _jsxs("div", { style: inspector.content, className: "prefab-scroll", children: [_jsxs("div", { style: base.section, children: [_jsxs("div", { style: { display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }, children: [_jsx("div", { style: { fontSize: 10, color: '#888', wordBreak: 'break-all', border: '1px solid rgba(255,255,255,0.1)', padding: '2px 4px', borderRadius: 4, flex: 1 }, children: node.id }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), base.btnDanger), title: "Delete Node", onClick: deleteNode, children: "\u274C" })] }), _jsx("input", { style: base.input, value: (_a = node.name) !== null && _a !== void 0 ? _a : "", placeholder: 'Node name', onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { name: e.target.value }))) })] }), _jsxs("div", { style: base.section, children: [_jsx("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, children: _jsx("div", { style: base.label, children: "Components" }) }), node.components && Object.entries(node.components).map(([key, comp]) => {
50
+ return _jsxs("div", { style: Object.assign(Object.assign({}, inspector.content), { paddingRight: 2 }), className: "prefab-scroll", children: [_jsxs("div", { style: base.section, children: [_jsxs("div", { style: { display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }, children: [_jsx("div", { style: { fontSize: 10, color: '#888', wordBreak: 'break-all', border: '1px solid rgba(255,255,255,0.1)', padding: '2px 4px', borderRadius: 4, flex: 1 }, children: node.id }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), base.btnDanger), title: "Delete Node", onClick: deleteNode, children: "\u274C" })] }), _jsx("input", { style: base.input, value: (_a = node.name) !== null && _a !== void 0 ? _a : "", placeholder: 'Node name', onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { name: e.target.value }))) })] }), _jsxs("div", { style: base.section, children: [_jsx("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, children: _jsx("div", { style: base.label, children: "Components" }) }), node.components && Object.entries(node.components).map(([key, comp]) => {
51
51
  if (!comp)
52
52
  return null;
53
53
  const def = ALL_COMPONENTS[comp.type];
54
54
  if (!def)
55
55
  return _jsxs("div", { style: { color: '#ff8888', fontSize: 11 }, children: ["Unknown: ", comp.type] }, key);
56
- return (_jsxs("div", { style: { marginBottom: 8 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 500 }, children: key }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px' }), title: "Remove Component", onClick: () => updateNode(n => {
56
+ return (_jsxs("div", { style: { marginBottom: 8, backgroundColor: 'rgba(255, 255, 255, 0.1)', padding: 8, borderRadius: 4 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 500 }, children: key }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px' }), title: "Remove Component", onClick: () => updateNode(n => {
57
57
  const _a = n.components || {}, _b = key, _ = _a[_b], rest = __rest(_a, [typeof _b === "symbol" ? _b : _b + ""]);
58
58
  return Object.assign(Object.assign({}, n), { components: rest });
59
59
  }), children: "\u2715" })] }), def.Editor && (_jsx(def.Editor, { component: comp, node: node, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath }))] }, key));
@@ -10,6 +10,7 @@ export interface PrefabEditorRef {
10
10
  declare const PrefabEditor: import("react").ForwardRefExoticComponent<{
11
11
  basePath?: string;
12
12
  initialPrefab?: Prefab;
13
+ physics?: boolean;
13
14
  onPrefabChange?: (prefab: Prefab) => void;
14
15
  children?: React.ReactNode;
15
16
  } & import("react").RefAttributes<PrefabEditorRef>>;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import GameCanvas from "../../shared/GameCanvas";
3
3
  import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";
4
4
  import PrefabRoot from "./PrefabRoot";
@@ -20,7 +20,7 @@ const DEFAULT_PREFAB = {
20
20
  }
21
21
  }
22
22
  };
23
- const PrefabEditor = forwardRef(({ basePath, initialPrefab, onPrefabChange, children }, ref) => {
23
+ const PrefabEditor = forwardRef(({ basePath, initialPrefab, physics = true, onPrefabChange, children }, ref) => {
24
24
  const [editMode, setEditMode] = useState(true);
25
25
  const [loadedPrefab, setLoadedPrefab] = useState(initialPrefab !== null && initialPrefab !== void 0 ? initialPrefab : DEFAULT_PREFAB);
26
26
  const [selectedId, setSelectedId] = useState(null);
@@ -118,6 +118,7 @@ const PrefabEditor = forwardRef(({ basePath, initialPrefab, onPrefabChange, chil
118
118
  setPrefab: setLoadedPrefab,
119
119
  rootRef: prefabRootRef
120
120
  }), [loadedPrefab]);
121
+ const content = (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 1.5 }), _jsx("gridHelper", { args: [10, 10], position: [0, -1, 0] }), _jsx(PrefabRoot, { ref: prefabRootRef, data: loadedPrefab, editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, basePath: basePath }), children] }));
121
122
  return _jsxs(EditorContext.Provider, { value: {
122
123
  transformMode,
123
124
  setTransformMode,
@@ -125,7 +126,7 @@ const PrefabEditor = forwardRef(({ basePath, initialPrefab, onPrefabChange, chil
125
126
  setSnapResolution,
126
127
  onScreenshot: handleScreenshot,
127
128
  onExportGLB: handleExportGLB
128
- }, children: [_jsx(GameCanvas, { children: _jsxs(Physics, { debug: editMode, paused: editMode, children: [_jsx("ambientLight", { intensity: 1.5 }), _jsx("gridHelper", { args: [10, 10], position: [0, -1, 0] }), _jsx(PrefabRoot, { ref: prefabRootRef, data: loadedPrefab, editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, basePath: basePath }), children] }) }), _jsx("div", { style: toolbar.panel, children: _jsx("button", { style: base.btn, onClick: () => setEditMode(!editMode), children: editMode ? "▶" : "⏸" }) }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, basePath: basePath, onUndo: undo, onRedo: redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1 })] });
129
+ }, children: [_jsx(GameCanvas, { children: physics ? (_jsx(Physics, { debug: editMode, paused: editMode, children: content })) : content }), _jsx("div", { style: toolbar.panel, children: _jsx("button", { style: base.btn, onClick: () => setEditMode(!editMode), children: editMode ? "▶" : "⏸" }) }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, basePath: basePath, onUndo: undo, onRedo: redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1 })] });
129
130
  });
130
131
  PrefabEditor.displayName = "PrefabEditor";
131
132
  export default PrefabEditor;
@@ -1,10 +1,9 @@
1
1
  import { Group, Matrix4, Object3D, Texture } from "three";
2
2
  import { ThreeEvent } from "@react-three/fiber";
3
3
  import { Prefab, GameObject as GameObjectType } from "./types";
4
- import type { RapierRigidBody } from "@react-three/rapier";
5
4
  export interface PrefabRootRef {
6
5
  root: Group | null;
7
- rigidBodyRefs: Map<string, RapierRigidBody | null>;
6
+ rigidBodyRefs: Map<string, any>;
8
7
  }
9
8
  export declare const PrefabRoot: import("react").ForwardRefExoticComponent<{
10
9
  editMode?: boolean;
@@ -22,7 +21,7 @@ interface RendererProps {
22
21
  onSelect?: (id: string) => void;
23
22
  onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
24
23
  registerRef: (id: string, obj: Object3D | null) => void;
25
- registerRigidBodyRef: (id: string, rb: RapierRigidBody | null) => void;
24
+ registerRigidBodyRef: (id: string, rb: any) => void;
26
25
  loadedModels: Record<string, Object3D>;
27
26
  loadedTextures: Record<string, Texture>;
28
27
  editMode?: boolean;
@@ -99,12 +99,18 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
99
99
  if (textures[file] || loading.current.has(file))
100
100
  return;
101
101
  loading.current.add(file);
102
- const path = file.startsWith("/")
103
- ? `${basePath}${file}`
104
- : `${basePath}/${file}`;
102
+ // Handle full URLs (http/https) or regular paths
103
+ const path = file.startsWith("http://") || file.startsWith("https://")
104
+ ? file
105
+ : file.startsWith("/")
106
+ ? `${basePath}${file}`
107
+ : `${basePath}/${file}`;
105
108
  loader.load(path, tex => {
106
109
  tex.colorSpace = SRGBColorSpace;
107
110
  setTextures(t => (Object.assign(Object.assign({}, t), { [file]: tex })));
111
+ }, undefined, (err) => {
112
+ console.error(`Failed to load texture: ${path}`, err);
113
+ loading.current.delete(file);
108
114
  });
109
115
  });
110
116
  }, [data, models, textures]);
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { FieldRenderer, Input, Label } from "./Input";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { FieldRenderer, Input } from "./Input";
3
3
  const GEOMETRY_ARGS = {
4
4
  box: {
5
5
  labels: ["Width", "Height", "Depth"],
@@ -41,13 +41,13 @@ function GeometryComponentEditor({ component, onUpdate, }) {
41
41
  const currentType = values.geometryType;
42
42
  const currentSchema = GEOMETRY_ARGS[currentType];
43
43
  const currentArgs = values.args || currentSchema.defaults;
44
- return (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 8 }, children: currentSchema.labels.map((label, i) => {
44
+ return (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: currentSchema.labels.map((label, i) => {
45
45
  var _a;
46
- return (_jsxs("div", { children: [_jsx(Label, { children: label }), _jsx(Input, { value: (_a = currentArgs[i]) !== null && _a !== void 0 ? _a : currentSchema.defaults[i], step: 0.1, onChange: value => {
47
- const next = [...currentArgs];
48
- next[i] = value;
49
- onChangeMultiple({ args: next });
50
- } })] }, label));
46
+ return (_jsx(Input, { label: label, value: (_a = currentArgs[i]) !== null && _a !== void 0 ? _a : currentSchema.defaults[i], step: 0.1, min: 0.01, onChange: value => {
47
+ const next = [...currentArgs];
48
+ next[i] = value;
49
+ onChangeMultiple({ args: next });
50
+ } }, label));
51
51
  }) }));
52
52
  },
53
53
  },
@@ -52,8 +52,9 @@ interface InputProps {
52
52
  min?: number;
53
53
  max?: number;
54
54
  style?: React.CSSProperties;
55
+ label?: string;
55
56
  }
56
- export declare function Input({ value, onChange, step, min, max, style }: InputProps): import("react/jsx-runtime").JSX.Element;
57
+ export declare function Input({ value, onChange, step, min, max, style, label }: InputProps): import("react/jsx-runtime").JSX.Element;
57
58
  export declare function Label({ children }: {
58
59
  children: React.ReactNode;
59
60
  }): import("react/jsx-runtime").JSX.Element;
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react';
6
6
  // Shared styles
7
7
  const styles = {
8
8
  input: {
9
- width: '100%',
9
+ width: '80px',
10
10
  backgroundColor: 'rgba(0, 0, 0, 0.4)',
11
11
  border: '1px solid rgba(34, 211, 238, 0.3)',
12
12
  padding: '2px 4px',
@@ -14,17 +14,18 @@ const styles = {
14
14
  color: 'rgba(165, 243, 252, 1)',
15
15
  fontFamily: 'monospace',
16
16
  outline: 'none',
17
+ textAlign: 'right',
17
18
  },
18
19
  label: {
19
20
  display: 'block',
20
21
  fontSize: '9px',
21
- color: 'rgba(34, 211, 238, 0.6)',
22
+ color: 'rgba(34, 211, 238, 0.9)',
22
23
  textTransform: 'uppercase',
23
24
  letterSpacing: '0.05em',
24
25
  marginBottom: 2,
25
26
  },
26
27
  };
27
- export function Input({ value, onChange, step, min, max, style }) {
28
+ export function Input({ value, onChange, step, min, max, style, label }) {
28
29
  const [draft, setDraft] = useState(() => value.toString());
29
30
  useEffect(() => {
30
31
  setDraft(value.toString());
@@ -43,6 +44,55 @@ export function Input({ value, onChange, step, min, max, style }) {
43
44
  setDraft(value.toString());
44
45
  }
45
46
  };
47
+ const dragState = useRef(null);
48
+ const startScrub = (e) => {
49
+ if (!label)
50
+ return;
51
+ e.preventDefault();
52
+ dragState.current = {
53
+ startX: e.clientX,
54
+ startValue: value
55
+ };
56
+ e.target.setPointerCapture(e.pointerId);
57
+ document.body.style.cursor = "ew-resize";
58
+ };
59
+ const onScrubMove = (e) => {
60
+ if (!dragState.current)
61
+ return;
62
+ const { startX, startValue } = dragState.current;
63
+ const dx = e.clientX - startX;
64
+ let speed = 0.02;
65
+ if (e.shiftKey)
66
+ speed *= 0.1; // fine
67
+ if (e.altKey)
68
+ speed *= 5; // coarse
69
+ let nextValue = startValue + dx * speed;
70
+ // Apply min/max constraints
71
+ if (min !== undefined && nextValue < min)
72
+ nextValue = min;
73
+ if (max !== undefined && nextValue > max)
74
+ nextValue = max;
75
+ setDraft(nextValue.toFixed(3));
76
+ onChange(nextValue);
77
+ };
78
+ const endScrub = (e) => {
79
+ if (!dragState.current)
80
+ return;
81
+ dragState.current = null;
82
+ document.body.style.cursor = "";
83
+ e.target.releasePointerCapture(e.pointerId);
84
+ };
85
+ if (label) {
86
+ return (_jsxs("div", { style: {
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ justifyContent: 'space-between',
90
+ }, children: [_jsx("span", { style: Object.assign(Object.assign({}, styles.label), { marginBottom: 0, cursor: 'ew-resize', userSelect: 'none', flex: '0 0 auto', minWidth: 20 }), onPointerDown: startScrub, onPointerMove: onScrubMove, onPointerUp: endScrub, children: label }), _jsx("input", { type: "text", value: draft, onChange: handleChange, onBlur: handleBlur, onKeyDown: e => {
91
+ if (e.key === 'Enter') {
92
+ e.target.blur();
93
+ }
94
+ }, step: step, min: min, max: max, style: Object.assign(Object.assign({}, styles.input), style) })] }));
95
+ }
46
96
  return (_jsx("input", { type: "text", value: draft, onChange: handleChange, onBlur: handleBlur, onKeyDown: e => {
47
97
  if (e.key === 'Enter') {
48
98
  e.target.blur();
@@ -156,20 +206,22 @@ export function Vector3Input({ label, value, onChange, snap }) {
156
206
  // Additional Input Components
157
207
  // ============================================================================
158
208
  export function ColorInput({ label, value, onChange }) {
159
- return (_jsxs("div", { children: [label && _jsx(Label, { children: label }), _jsxs("div", { style: { display: 'flex', gap: 2 }, children: [_jsx("input", { type: "color", style: {
160
- height: 20,
161
- width: 20,
209
+ return (_jsxs("div", { children: [label && _jsx(Label, { children: label }), _jsxs("div", { style: { display: 'flex', gap: 4, justifyContent: 'space-between' }, children: [_jsx("input", { type: "color", style: {
210
+ height: 32,
211
+ width: 48,
162
212
  backgroundColor: 'transparent',
163
- border: 'none',
213
+ border: '1px solid rgba(34, 211, 238, 0.3)',
214
+ borderRadius: 4,
164
215
  cursor: 'pointer',
165
216
  padding: 0,
166
- }, value: value, onChange: e => onChange(e.target.value) }), _jsx("input", { type: "text", style: Object.assign(Object.assign({}, styles.input), { flex: 1 }), value: value, onChange: e => onChange(e.target.value) })] })] }));
217
+ flexShrink: 0,
218
+ }, value: value, onChange: e => onChange(e.target.value) }), _jsx("input", { type: "text", style: Object.assign({}, styles.input), value: value, onChange: e => onChange(e.target.value) })] })] }));
167
219
  }
168
220
  export function StringInput({ label, value, onChange, placeholder }) {
169
221
  return (_jsxs("div", { children: [label && _jsx(Label, { children: label }), _jsx("input", { type: "text", style: styles.input, value: value, onChange: e => onChange(e.target.value), placeholder: placeholder })] }));
170
222
  }
171
223
  export function BooleanInput({ label, value, onChange }) {
172
- return (_jsxs("div", { children: [label && _jsx(Label, { children: label }), _jsx("input", { type: "checkbox", style: {
224
+ return (_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between' }, children: [label && _jsx(Label, { children: label }), _jsx("input", { type: "checkbox", style: {
173
225
  height: 16,
174
226
  width: 16,
175
227
  backgroundColor: 'rgba(0, 0, 0, 0.4)',
@@ -178,7 +230,7 @@ export function BooleanInput({ label, value, onChange }) {
178
230
  }, checked: value, onChange: e => onChange(e.target.checked) })] }));
179
231
  }
180
232
  export function SelectInput({ label, value, onChange, options }) {
181
- return (_jsxs("div", { children: [label && _jsx(Label, { children: label }), _jsx("select", { style: styles.input, value: value, onChange: e => onChange(e.target.value), children: options.map(opt => (_jsx("option", { value: opt.value, children: opt.label }, opt.value))) })] }));
233
+ return (_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between' }, children: [label && _jsx(Label, { children: label }), _jsx("select", { style: styles.input, value: value, onChange: e => onChange(e.target.value), children: options.map(opt => (_jsx("option", { value: opt.value, children: opt.label }, opt.value))) })] }));
182
234
  }
183
235
  export function FieldRenderer({ fields, values, onChange }) {
184
236
  const updateField = (name, value) => {
@@ -191,7 +243,7 @@ export function FieldRenderer({ fields, values, onChange }) {
191
243
  case 'vector3':
192
244
  return (_jsx(Vector3Input, { label: field.label, value: value !== null && value !== void 0 ? value : [0, 0, 0], onChange: v => updateField(field.name, v), snap: field.snap }, field.name));
193
245
  case 'number':
194
- return (_jsxs("div", { children: [_jsx(Label, { children: field.label }), _jsx(Input, { value: value !== null && value !== void 0 ? value : 0, onChange: v => updateField(field.name, v), min: field.min, max: field.max, step: field.step })] }, field.name));
246
+ return (_jsx(Input, { label: field.label, value: value !== null && value !== void 0 ? value : 0, onChange: v => updateField(field.name, v), min: field.min, max: field.max, step: field.step }, field.name));
195
247
  case 'string':
196
248
  return (_jsx(StringInput, { label: field.label, value: value !== null && value !== void 0 ? value : '', onChange: v => updateField(field.name, v), placeholder: field.placeholder }, field.name));
197
249
  case 'color':
@@ -25,7 +25,9 @@ function TexturePicker({ value, onChange, basePath }) {
25
25
  .then(data => setTextureFiles(Array.isArray(data) ? data : data.files || []))
26
26
  .catch(console.error);
27
27
  }, [basePath]);
28
- return (_jsxs("div", { style: { maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleTextureViewer, { file: value || undefined, basePath: basePath }), _jsx("button", { onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Hide' : 'Change' }), showPicker && (_jsx("div", { style: { position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }, children: _jsx(TextureListViewer, { files: textureFiles, selected: value || undefined, onSelect: (file) => {
28
+ return (_jsxs("div", { style: { maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleTextureViewer, { file: value || undefined, basePath: basePath }), _jsx("button", { onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Cancel' : 'Change' }), _jsx("button", { onClick: () => {
29
+ onChange(undefined);
30
+ }, style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4, marginLeft: 4 }, children: "Clear" }), showPicker && (_jsx("div", { style: { position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }, children: _jsx(TextureListViewer, { files: textureFiles, selected: value || undefined, onSelect: (file) => {
29
31
  onChange(file);
30
32
  setShowPicker(false);
31
33
  }, basePath: basePath }) }))] }));
@@ -58,7 +60,7 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
58
60
  label: 'Repeat (X, Y)',
59
61
  render: ({ value, onChange }) => {
60
62
  var _a, _b;
61
- return (_jsxs("div", { style: { display: 'flex', gap: 2 }, children: [_jsx(Input, { value: (_a = value === null || value === void 0 ? void 0 : value[0]) !== null && _a !== void 0 ? _a : 1, onChange: v => { var _a; return onChange([v, (_a = value === null || value === void 0 ? void 0 : value[1]) !== null && _a !== void 0 ? _a : 1]); } }), _jsx(Input, { value: (_b = value === null || value === void 0 ? void 0 : value[1]) !== null && _b !== void 0 ? _b : 1, onChange: v => { var _a; return onChange([(_a = value === null || value === void 0 ? void 0 : value[0]) !== null && _a !== void 0 ? _a : 1, v]); } })] }));
63
+ return (_jsxs("div", { style: { display: 'flex', gap: 2 }, children: [_jsx(Input, { label: "X", value: (_a = value === null || value === void 0 ? void 0 : value[0]) !== null && _a !== void 0 ? _a : 1, onChange: v => { var _a; return onChange([v, (_a = value === null || value === void 0 ? void 0 : value[1]) !== null && _a !== void 0 ? _a : 1]); }, min: 0.01, max: 100, step: 0.1 }), _jsx(Input, { label: "Y", value: (_b = value === null || value === void 0 ? void 0 : value[1]) !== null && _b !== void 0 ? _b : 1, onChange: v => { var _a; return onChange([(_a = value === null || value === void 0 ? void 0 : value[0]) !== null && _a !== void 0 ? _a : 1, v]); }, min: 0.01, max: 100, step: 0.1 })] }));
62
64
  },
63
65
  }] : []),
64
66
  { name: 'generateMipmaps', type: 'boolean', label: 'Generate Mipmaps' },
@@ -17,7 +17,9 @@ function ModelPicker({ value, onChange, basePath, nodeId }) {
17
17
  onChange(filename);
18
18
  setShowPicker(false);
19
19
  };
20
- return (_jsxs("div", { style: { maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleModelViewer, { file: value ? `/${value}` : undefined, basePath: basePath }), _jsx("button", { onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Hide' : 'Change' }), showPicker && (_jsx("div", { style: { position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }, children: _jsx(ModelListViewer, { files: modelFiles, selected: value ? `/${value}` : undefined, onSelect: handleModelSelect, basePath: basePath }, nodeId) }))] }));
20
+ return (_jsxs("div", { style: { maxHeight: 128, overflowY: 'auto', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleModelViewer, { file: value ? `/${value}` : undefined, basePath: basePath }), _jsx("button", { onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Cancel' : 'Change' }), _jsx("button", { onClick: () => {
21
+ onChange(undefined);
22
+ }, style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4, marginLeft: 4 }, children: "Clear" }), showPicker && (_jsx("div", { style: { position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }, children: _jsx(ModelListViewer, { files: modelFiles, selected: value ? `/${value}` : undefined, onSelect: handleModelSelect, basePath: basePath }, nodeId) }))] }));
21
23
  }
22
24
  function ModelComponentEditor({ component, node, onUpdate, basePath = "" }) {
23
25
  const fields = [
@@ -99,7 +99,15 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
99
99
  const { type, colliders, sensor, activeCollisionTypes } = properties, otherProps = __rest(properties, ["type", "colliders", "sensor", "activeCollisionTypes"]);
100
100
  const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
101
101
  const rigidBodyRef = useRef(null);
102
- const { rapier } = useRapier();
102
+ // Try to get rapier context - will be null if not inside <Physics>
103
+ let rapier = null;
104
+ try {
105
+ const rapierContext = useRapier();
106
+ rapier = rapierContext.rapier;
107
+ }
108
+ catch (e) {
109
+ // Not inside Physics context - that's ok, just won't have rapier features
110
+ }
103
111
  // Register RigidBody ref when it's available
104
112
  useEffect(() => {
105
113
  if (nodeId && registerRigidBodyRef && rigidBodyRef.current) {
@@ -113,7 +121,7 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
113
121
  }, [nodeId, registerRigidBodyRef]);
114
122
  // Configure active collision types for kinematic/sensor bodies
115
123
  useEffect(() => {
116
- if (activeCollisionTypes === 'all' && rigidBodyRef.current) {
124
+ if (activeCollisionTypes === 'all' && rigidBodyRef.current && rapier) {
117
125
  const rb = rigidBodyRef.current;
118
126
  // Apply to all colliders on this rigid body
119
127
  for (let i = 0; i < rb.numColliders(); i++) {
@@ -1755,10 +1755,7 @@ export declare const tree: {
1755
1755
  colorRendering?: import("csstype").Property.ColorRendering | undefined;
1756
1756
  glyphOrientationVertical?: import("csstype").Property.GlyphOrientationVertical | undefined;
1757
1757
  };
1758
- scroll: {
1759
- overflowY: "auto";
1760
- padding: number;
1761
- };
1758
+ scroll: React.CSSProperties;
1762
1759
  row: React.CSSProperties;
1763
1760
  selected: {
1764
1761
  background: string;
@@ -99,6 +99,8 @@ export const tree = {
99
99
  scroll: {
100
100
  overflowY: 'auto',
101
101
  padding: 4,
102
+ scrollbarWidth: 'thin',
103
+ scrollbarColor: 'rgba(255,255,255,0.06) transparent',
102
104
  },
103
105
  row: {
104
106
  display: 'flex',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -29,19 +29,19 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@react-three/drei": "^10.7.7",
32
- "@react-three/fiber": "^9.4.2",
32
+ "@react-three/fiber": "^9.5.0",
33
33
  "@react-three/rapier": "^2.2.0",
34
- "@types/react": "^19.2.6",
34
+ "@types/react": "^19.2.9",
35
35
  "@types/react-dom": "^19.2.3",
36
- "@types/three": "^0.181.0",
36
+ "@types/three": "^0.182.0",
37
37
  "concurrently": "^9.2.1",
38
- "react": "^19.2.0",
39
- "react-dom": "^19.2.0",
38
+ "react": "^19.2.4",
39
+ "react-dom": "^19.2.4",
40
40
  "three": "^0.182.0",
41
41
  "typescript": "^5.9.3",
42
42
  "vite": "^7.3.1"
43
43
  },
44
44
  "dependencies": {
45
- "react-error-boundary": "^6.0.0"
45
+ "react-error-boundary": "^6.1.0"
46
46
  }
47
47
  }
@@ -45,7 +45,7 @@ export function ground(options: GroundOptions = {}): GameObject {
45
45
  position = [0, 0, 0],
46
46
  rotation = [-Math.PI / 2, 0, 0],
47
47
  scale = [1, 1, 1],
48
- color = "white",
48
+ color = "#eeeeee",
49
49
  texture,
50
50
  repeat = texture ? true : false,
51
51
  repeatCount = [25, 25],
@@ -29,6 +29,7 @@ export default function EditorTree({
29
29
  const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
30
30
  const [collapsed, setCollapsed] = useState(false);
31
31
  const [fileMenuOpen, setFileMenuOpen] = useState(false);
32
+ const [searchQuery, setSearchQuery] = useState('');
32
33
 
33
34
  if (!prefabData || !setPrefabData) return null;
34
35
 
@@ -91,6 +92,17 @@ export default function EditorTree({
91
92
  setContextMenu(null);
92
93
  };
93
94
 
95
+ const handleToggleDisabled = (nodeId: string) => {
96
+ setPrefabData(prev => ({
97
+ ...prev,
98
+ root: updateNodeById(prev.root, nodeId, node => ({
99
+ ...node,
100
+ disabled: !node.disabled
101
+ }))
102
+ }));
103
+ setContextMenu(null);
104
+ };
105
+
94
106
  const handleDragStart = (e: React.DragEvent, id: string) => {
95
107
  if (id === prefabData.root.id) return e.preventDefault();
96
108
  e.dataTransfer.effectAllowed = "move";
@@ -128,8 +140,17 @@ export default function EditorTree({
128
140
  };
129
141
 
130
142
 
143
+ const matchesSearch = (node: GameObject, query: string): boolean => {
144
+ if (!query) return true;
145
+ const lowerQuery = query.toLowerCase();
146
+ const nodeName = (node.name ?? node.id).toLowerCase();
147
+ if (nodeName.includes(lowerQuery)) return true;
148
+ return node.children?.some(child => matchesSearch(child, query)) ?? false;
149
+ };
150
+
131
151
  const renderNode = (node: GameObject, depth = 0): React.ReactNode => {
132
152
  if (!node) return null;
153
+ if (!matchesSearch(node, searchQuery)) return null;
133
154
 
134
155
  const isSelected = node.id === selectedId;
135
156
  const isCollapsed = collapsedIds.has(node.id);
@@ -143,6 +164,10 @@ export default function EditorTree({
143
164
  ...tree.row,
144
165
  ...(isSelected ? tree.selected : {}),
145
166
  paddingLeft: `${depth * 12 + 6}px`,
167
+ opacity: node.disabled ? 0.4 : 1,
168
+ display: 'flex',
169
+ alignItems: 'center',
170
+ justifyContent: 'space-between',
146
171
  }}
147
172
  draggable={!isRoot}
148
173
  onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
@@ -152,22 +177,44 @@ export default function EditorTree({
152
177
  onDragOver={(e) => handleDragOver(e, node.id)}
153
178
  onDrop={(e) => handleDrop(e, node.id)}
154
179
  >
155
- <span
156
- style={{
157
- width: 12,
158
- opacity: 0.6,
159
- marginRight: 4,
160
- cursor: 'pointer',
161
- visibility: hasChildren ? 'visible' : 'hidden'
162
- }}
163
- onClick={(e) => hasChildren && toggleCollapse(e, node.id)}
164
- >
165
- {isCollapsed ? '▶' : '▼'}
166
- </span>
167
- {!isRoot && <span style={{ marginRight: 4, opacity: 0.4 }}>⋮⋮</span>}
168
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
169
- {node.name ?? node.id}
170
- </span>
180
+ <div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
181
+ <span
182
+ style={{
183
+ width: 12,
184
+ opacity: 0.6,
185
+ marginRight: 4,
186
+ cursor: 'pointer',
187
+ visibility: hasChildren ? 'visible' : 'hidden'
188
+ }}
189
+ onClick={(e) => hasChildren && toggleCollapse(e, node.id)}
190
+ >
191
+ {isCollapsed ? '▶' : '▼'}
192
+ </span>
193
+ {!isRoot && <span style={{ marginRight: 4, opacity: 0.4 }}>⋮⋮</span>}
194
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
195
+ {node.name ?? node.id}
196
+ </span>
197
+ </div>
198
+ {!isRoot && (
199
+ <button
200
+ style={{
201
+ background: 'none',
202
+ border: 'none',
203
+ cursor: 'pointer',
204
+ padding: '0 4px',
205
+ fontSize: 14,
206
+ opacity: node.disabled ? 0.5 : 0.7,
207
+ color: 'inherit',
208
+ }}
209
+ onClick={(e) => {
210
+ e.stopPropagation();
211
+ handleToggleDisabled(node.id);
212
+ }}
213
+ title={node.disabled ? 'Enable' : 'Disable'}
214
+ >
215
+ {node.disabled ? '◎' : '◉'}
216
+ </button>
217
+ )}
171
218
  </div>
172
219
  {!isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))}
173
220
  </div>
@@ -176,6 +223,11 @@ export default function EditorTree({
176
223
 
177
224
  return (
178
225
  <>
226
+ <style>{`
227
+ .tree-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
228
+ .tree-scroll::-webkit-scrollbar-track { background: transparent; }
229
+ .tree-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
230
+ `}</style>
179
231
  <div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }} onClick={() => { setContextMenu(null); setFileMenuOpen(false); }}>
180
232
  <div style={base.header}>
181
233
  <div style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }} onClick={() => setCollapsed(!collapsed)}>
@@ -219,7 +271,30 @@ export default function EditorTree({
219
271
  </div>
220
272
  )}
221
273
  </div>
222
- {!collapsed && <div style={tree.scroll}>{renderNode(prefabData.root)}</div>}
274
+ {!collapsed && (
275
+ <>
276
+ <div style={{ padding: '4px 6px', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
277
+ <input
278
+ type="text"
279
+ placeholder="Search nodes..."
280
+ value={searchQuery}
281
+ onChange={(e) => setSearchQuery(e.target.value)}
282
+ onClick={(e) => e.stopPropagation()}
283
+ style={{
284
+ width: '100%',
285
+ padding: '4px 8px',
286
+ background: 'rgba(255,255,255,0.05)',
287
+ border: '1px solid rgba(255,255,255,0.1)',
288
+ borderRadius: 3,
289
+ color: 'inherit',
290
+ fontSize: 11,
291
+ outline: 'none',
292
+ }}
293
+ />
294
+ </div>
295
+ <div className="tree-scroll" style={tree.scroll}>{renderNode(prefabData.root)}</div>
296
+ </>
297
+ )}
223
298
  </div>
224
299
 
225
300
  {contextMenu && (
@@ -102,7 +102,7 @@ function NodeInspector({
102
102
  if (!newAvailable.includes(addType)) setAddType(newAvailable[0] || "");
103
103
  }, [Object.keys(node.components || {}).join(',')]);
104
104
 
105
- return <div style={inspector.content} className="prefab-scroll">
105
+ return <div style={{ ...inspector.content, paddingRight: 2 }} className="prefab-scroll">
106
106
  {/* Node Name */}
107
107
  <div style={base.section}>
108
108
  <div style={{ display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }}>
@@ -136,7 +136,7 @@ function NodeInspector({
136
136
  </div>;
137
137
 
138
138
  return (
139
- <div key={key} style={{ marginBottom: 8 }}>
139
+ <div key={key} style={{ marginBottom: 8, backgroundColor: 'rgba(255, 255, 255, 0.1)', padding: 8, borderRadius: 4 }}>
140
140
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
141
141
  <div style={{ fontSize: 11, fontWeight: 500 }}>{key}</div>
142
142
  <button
@@ -33,9 +33,10 @@ const DEFAULT_PREFAB: Prefab = {
33
33
  const PrefabEditor = forwardRef<PrefabEditorRef, {
34
34
  basePath?: string;
35
35
  initialPrefab?: Prefab;
36
+ physics?: boolean;
36
37
  onPrefabChange?: (prefab: Prefab) => void;
37
38
  children?: React.ReactNode;
38
- }>(({ basePath, initialPrefab, onPrefabChange, children }, ref) => {
39
+ }>(({ basePath, initialPrefab, physics = true, onPrefabChange, children }, ref) => {
39
40
  const [editMode, setEditMode] = useState(true);
40
41
  const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? DEFAULT_PREFAB);
41
42
  const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -132,6 +133,23 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
132
133
  rootRef: prefabRootRef
133
134
  }), [loadedPrefab]);
134
135
 
136
+ const content = (
137
+ <>
138
+ <ambientLight intensity={1.5} />
139
+ <gridHelper args={[10, 10]} position={[0, -1, 0]} />
140
+ <PrefabRoot
141
+ ref={prefabRootRef}
142
+ data={loadedPrefab}
143
+ editMode={editMode}
144
+ onPrefabChange={updatePrefab}
145
+ selectedId={selectedId}
146
+ onSelect={setSelectedId}
147
+ basePath={basePath}
148
+ />
149
+ {children}
150
+ </>
151
+ );
152
+
135
153
  return <EditorContext.Provider value={{
136
154
  transformMode,
137
155
  setTransformMode,
@@ -141,20 +159,11 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
141
159
  onExportGLB: handleExportGLB
142
160
  }}>
143
161
  <GameCanvas>
144
- <Physics debug={editMode} paused={editMode}>
145
- <ambientLight intensity={1.5} />
146
- <gridHelper args={[10, 10]} position={[0, -1, 0]} />
147
- <PrefabRoot
148
- ref={prefabRootRef}
149
- data={loadedPrefab}
150
- editMode={editMode}
151
- onPrefabChange={updatePrefab}
152
- selectedId={selectedId}
153
- onSelect={setSelectedId}
154
- basePath={basePath}
155
- />
156
- {children}
157
- </Physics>
162
+ {physics ? (
163
+ <Physics debug={editMode} paused={editMode}>
164
+ {content}
165
+ </Physics>
166
+ ) : content}
158
167
  </GameCanvas>
159
168
 
160
169
  <div style={toolbar.panel}>
@@ -11,7 +11,9 @@ import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./Instance
11
11
  import { updateNode } from "./utils";
12
12
  import { PhysicsProps } from "./components/PhysicsComponent";
13
13
  import { EditorContext } from "./EditorContext";
14
- import type { RapierRigidBody } from "@react-three/rapier";
14
+
15
+ // Dynamic type to avoid requiring @react-three/rapier when not using physics
16
+ type RapierRigidBody = any;
15
17
 
16
18
  components.forEach(registerComponent);
17
19
 
@@ -19,7 +21,7 @@ const IDENTITY = new Matrix4();
19
21
 
20
22
  export interface PrefabRootRef {
21
23
  root: Group | null;
22
- rigidBodyRefs: Map<string, RapierRigidBody | null>;
24
+ rigidBodyRefs: Map<string, any>; // RigidBody refs only populated when using physics
23
25
  }
24
26
 
25
27
  export const PrefabRoot = forwardRef<PrefabRootRef, {
@@ -56,7 +58,7 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
56
58
  if (id === selectedId) setSelectedObject(obj);
57
59
  }, [selectedId]);
58
60
 
59
- const registerRigidBodyRef = useCallback((id: string, rb: RapierRigidBody | null) => {
61
+ const registerRigidBodyRef = useCallback((id: string, rb: any) => {
60
62
  rigidBodyRefs.current.set(id, rb);
61
63
  }, []);
62
64
 
@@ -126,14 +128,20 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
126
128
  texturesToLoad.forEach(file => {
127
129
  if (textures[file] || loading.current.has(file)) return;
128
130
  loading.current.add(file);
129
- const path =
130
- file.startsWith("/")
131
+
132
+ // Handle full URLs (http/https) or regular paths
133
+ const path = file.startsWith("http://") || file.startsWith("https://")
134
+ ? file
135
+ : file.startsWith("/")
131
136
  ? `${basePath}${file}`
132
137
  : `${basePath}/${file}`;
133
138
 
134
139
  loader.load(path, tex => {
135
140
  tex.colorSpace = SRGBColorSpace;
136
141
  setTextures(t => ({ ...t, [file]: tex }));
142
+ }, undefined, (err) => {
143
+ console.error(`Failed to load texture: ${path}`, err);
144
+ loading.current.delete(file);
137
145
  });
138
146
  });
139
147
  }, [data, models, textures]);
@@ -434,7 +442,7 @@ interface RendererProps {
434
442
  onSelect?: (id: string) => void;
435
443
  onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
436
444
  registerRef: (id: string, obj: Object3D | null) => void;
437
- registerRigidBodyRef: (id: string, rb: RapierRigidBody | null) => void;
445
+ registerRigidBodyRef: (id: string, rb: any) => void;
438
446
  loadedModels: Record<string, Object3D>;
439
447
  loadedTextures: Record<string, Texture>;
440
448
  editMode?: boolean;
@@ -55,20 +55,20 @@ function GeometryComponentEditor({
55
55
  const currentArgs = values.args || currentSchema.defaults;
56
56
 
57
57
  return (
58
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
58
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
59
59
  {currentSchema.labels.map((label, i) => (
60
- <div key={label}>
61
- <Label>{label}</Label>
62
- <Input
63
- value={currentArgs[i] ?? currentSchema.defaults[i]}
64
- step={0.1}
65
- onChange={value => {
66
- const next = [...currentArgs];
67
- next[i] = value;
68
- onChangeMultiple({ args: next });
69
- }}
70
- />
71
- </div>
60
+ <Input
61
+ key={label}
62
+ label={label}
63
+ value={currentArgs[i] ?? currentSchema.defaults[i]}
64
+ step={0.1}
65
+ min={0.01}
66
+ onChange={value => {
67
+ const next = [...currentArgs];
68
+ next[i] = value;
69
+ onChangeMultiple({ args: next });
70
+ }}
71
+ />
72
72
  ))}
73
73
  </div>
74
74
  );
@@ -67,7 +67,7 @@ export type FieldDefinition =
67
67
  // Shared styles
68
68
  const styles = {
69
69
  input: {
70
- width: '100%',
70
+ width: '80px',
71
71
  backgroundColor: 'rgba(0, 0, 0, 0.4)',
72
72
  border: '1px solid rgba(34, 211, 238, 0.3)',
73
73
  padding: '2px 4px',
@@ -75,11 +75,12 @@ const styles = {
75
75
  color: 'rgba(165, 243, 252, 1)',
76
76
  fontFamily: 'monospace',
77
77
  outline: 'none',
78
+ textAlign: 'right',
78
79
  } as React.CSSProperties,
79
80
  label: {
80
81
  display: 'block',
81
82
  fontSize: '9px',
82
- color: 'rgba(34, 211, 238, 0.6)',
83
+ color: 'rgba(34, 211, 238, 0.9)',
83
84
  textTransform: 'uppercase',
84
85
  letterSpacing: '0.05em',
85
86
  marginBottom: 2,
@@ -93,9 +94,10 @@ interface InputProps {
93
94
  min?: number;
94
95
  max?: number;
95
96
  style?: React.CSSProperties;
97
+ label?: string;
96
98
  }
97
99
 
98
- export function Input({ value, onChange, step, min, max, style }: InputProps) {
100
+ export function Input({ value, onChange, step, min, max, style, label }: InputProps) {
99
101
  const [draft, setDraft] = useState<string>(() => value.toString());
100
102
 
101
103
  useEffect(() => {
@@ -119,6 +121,93 @@ export function Input({ value, onChange, step, min, max, style }: InputProps) {
119
121
  }
120
122
  };
121
123
 
124
+ const dragState = useRef<{
125
+ startX: number;
126
+ startValue: number;
127
+ } | null>(null);
128
+
129
+ const startScrub = (e: React.PointerEvent) => {
130
+ if (!label) return;
131
+ e.preventDefault();
132
+
133
+ dragState.current = {
134
+ startX: e.clientX,
135
+ startValue: value
136
+ };
137
+
138
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
139
+ document.body.style.cursor = "ew-resize";
140
+ };
141
+
142
+ const onScrubMove = (e: React.PointerEvent) => {
143
+ if (!dragState.current) return;
144
+
145
+ const { startX, startValue } = dragState.current;
146
+ const dx = e.clientX - startX;
147
+
148
+ let speed = 0.02;
149
+ if (e.shiftKey) speed *= 0.1; // fine
150
+ if (e.altKey) speed *= 5; // coarse
151
+
152
+ let nextValue = startValue + dx * speed;
153
+
154
+ // Apply min/max constraints
155
+ if (min !== undefined && nextValue < min) nextValue = min;
156
+ if (max !== undefined && nextValue > max) nextValue = max;
157
+
158
+ setDraft(nextValue.toFixed(3));
159
+ onChange(nextValue);
160
+ };
161
+
162
+ const endScrub = (e: React.PointerEvent) => {
163
+ if (!dragState.current) return;
164
+
165
+ dragState.current = null;
166
+ document.body.style.cursor = "";
167
+ (e.target as HTMLElement).releasePointerCapture(e.pointerId);
168
+ };
169
+
170
+ if (label) {
171
+ return (
172
+ <div style={{
173
+ display: 'flex',
174
+ alignItems: 'center',
175
+ justifyContent: 'space-between',
176
+ }}>
177
+ <span
178
+ style={{
179
+ ...styles.label,
180
+ marginBottom: 0,
181
+ cursor: 'ew-resize',
182
+ userSelect: 'none',
183
+ flex: '0 0 auto',
184
+ minWidth: 20,
185
+ }}
186
+ onPointerDown={startScrub}
187
+ onPointerMove={onScrubMove}
188
+ onPointerUp={endScrub}
189
+ >
190
+ {label}
191
+ </span>
192
+ <input
193
+ type="text"
194
+ value={draft}
195
+ onChange={handleChange}
196
+ onBlur={handleBlur}
197
+ onKeyDown={e => {
198
+ if (e.key === 'Enter') {
199
+ (e.target as HTMLInputElement).blur();
200
+ }
201
+ }}
202
+ step={step}
203
+ min={min}
204
+ max={max}
205
+ style={{ ...styles.input, ...style }}
206
+ />
207
+ </div>
208
+ );
209
+ }
210
+
122
211
  return (
123
212
  <input
124
213
  type="text"
@@ -316,23 +405,25 @@ export function ColorInput({
316
405
  return (
317
406
  <div>
318
407
  {label && <Label>{label}</Label>}
319
- <div style={{ display: 'flex', gap: 2 }}>
408
+ <div style={{ display: 'flex', gap: 4, justifyContent: 'space-between' }}>
320
409
  <input
321
410
  type="color"
322
411
  style={{
323
- height: 20,
324
- width: 20,
412
+ height: 32,
413
+ width: 48,
325
414
  backgroundColor: 'transparent',
326
- border: 'none',
415
+ border: '1px solid rgba(34, 211, 238, 0.3)',
416
+ borderRadius: 4,
327
417
  cursor: 'pointer',
328
418
  padding: 0,
419
+ flexShrink: 0,
329
420
  }}
330
421
  value={value}
331
422
  onChange={e => onChange(e.target.value)}
332
423
  />
333
424
  <input
334
425
  type="text"
335
- style={{ ...styles.input, flex: 1 }}
426
+ style={{ ...styles.input, }}
336
427
  value={value}
337
428
  onChange={e => onChange(e.target.value)}
338
429
  />
@@ -376,7 +467,7 @@ export function BooleanInput({
376
467
  onChange: (value: boolean) => void;
377
468
  }) {
378
469
  return (
379
- <div>
470
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
380
471
  {label && <Label>{label}</Label>}
381
472
  <input
382
473
  type="checkbox"
@@ -406,7 +497,7 @@ export function SelectInput({
406
497
  options: { value: string; label: string }[];
407
498
  }) {
408
499
  return (
409
- <div>
500
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
410
501
  {label && <Label>{label}</Label>}
411
502
  <select
412
503
  style={styles.input as React.CSSProperties}
@@ -457,16 +548,15 @@ export function FieldRenderer({ fields, values, onChange }: FieldRendererProps)
457
548
 
458
549
  case 'number':
459
550
  return (
460
- <div key={field.name}>
461
- <Label>{field.label}</Label>
462
- <Input
463
- value={value ?? 0}
464
- onChange={v => updateField(field.name, v)}
465
- min={field.min}
466
- max={field.max}
467
- step={field.step}
468
- />
469
- </div>
551
+ <Input
552
+ key={field.name}
553
+ label={field.label}
554
+ value={value ?? 0}
555
+ onChange={v => updateField(field.name, v)}
556
+ min={field.min}
557
+ max={field.max}
558
+ step={field.step}
559
+ />
470
560
  );
471
561
 
472
562
  case 'string':
@@ -55,7 +55,15 @@ function TexturePicker({
55
55
  onClick={() => setShowPicker(!showPicker)}
56
56
  style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
57
57
  >
58
- {showPicker ? 'Hide' : 'Change'}
58
+ {showPicker ? 'Cancel' : 'Change'}
59
+ </button>
60
+ <button
61
+ onClick={() => {
62
+ onChange(undefined as any);
63
+ }}
64
+ style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4, marginLeft: 4 }}
65
+ >
66
+ Clear
59
67
  </button>
60
68
  {showPicker && (
61
69
  <div style={{ position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }}>
@@ -106,12 +114,20 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }: { compo
106
114
  render: ({ value, onChange }: { value: [number, number] | undefined; onChange: (v: [number, number]) => void }) => (
107
115
  <div style={{ display: 'flex', gap: 2 }}>
108
116
  <Input
117
+ label="X"
109
118
  value={value?.[0] ?? 1}
110
119
  onChange={v => onChange([v, value?.[1] ?? 1])}
120
+ min={0.01}
121
+ max={100}
122
+ step={0.1}
111
123
  />
112
124
  <Input
125
+ label="Y"
113
126
  value={value?.[1] ?? 1}
114
127
  onChange={v => onChange([value?.[0] ?? 1, v])}
128
+ min={0.01}
129
+ max={100}
130
+ step={0.1}
115
131
  />
116
132
  </div>
117
133
  ),
@@ -162,15 +178,15 @@ function MaterialComponentView({ properties, loadedTextures }: { properties: Mat
162
178
  const texture = textureName && loadedTextures ? loadedTextures[textureName] : undefined;
163
179
 
164
180
  // Destructure all material props and separate custom texture handling props
165
- const {
166
- texture: _texture,
167
- repeat: _repeat,
168
- repeatCount: _repeatCount,
181
+ const {
182
+ texture: _texture,
183
+ repeat: _repeat,
184
+ repeatCount: _repeatCount,
169
185
  generateMipmaps: _generateMipmaps,
170
186
  minFilter: _minFilter,
171
187
  magFilter: _magFilter,
172
188
  map: _map, // Filter out map since we set it explicitly
173
- ...materialProps
189
+ ...materialProps
174
190
  } = properties || {};
175
191
 
176
192
  const minFilterMap: Record<string, MinificationTextureFilter> = {
@@ -39,7 +39,15 @@ function ModelPicker({
39
39
  onClick={() => setShowPicker(!showPicker)}
40
40
  style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }}
41
41
  >
42
- {showPicker ? 'Hide' : 'Change'}
42
+ {showPicker ? 'Cancel' : 'Change'}
43
+ </button>
44
+ <button
45
+ onClick={() => {
46
+ onChange(undefined as any);
47
+ }}
48
+ style={{ padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4, marginLeft: 4 }}
49
+ >
50
+ Clear
43
51
  </button>
44
52
  {showPicker && (
45
53
  <div style={{ position: 'fixed', left: '-10px', top: '50%', transform: 'translate(-100%, -50%)', background: 'rgba(0,0,0,0.9)', padding: 16, border: '1px solid rgba(34, 211, 238, 0.3)', maxHeight: '80vh', overflowY: 'auto', zIndex: 1000 }}>
@@ -115,7 +115,15 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
115
115
  const { type, colliders, sensor, activeCollisionTypes, ...otherProps } = properties;
116
116
  const colliderType = colliders || (type === 'fixed' ? 'trimesh' : 'hull');
117
117
  const rigidBodyRef = useRef<RapierRigidBody>(null);
118
- const { rapier } = useRapier();
118
+
119
+ // Try to get rapier context - will be null if not inside <Physics>
120
+ let rapier: any = null;
121
+ try {
122
+ const rapierContext = useRapier();
123
+ rapier = rapierContext.rapier;
124
+ } catch (e) {
125
+ // Not inside Physics context - that's ok, just won't have rapier features
126
+ }
119
127
 
120
128
  // Register RigidBody ref when it's available
121
129
  useEffect(() => {
@@ -131,7 +139,7 @@ function PhysicsComponentView({ properties, children, position, rotation, scale,
131
139
 
132
140
  // Configure active collision types for kinematic/sensor bodies
133
141
  useEffect(() => {
134
- if (activeCollisionTypes === 'all' && rigidBodyRef.current) {
142
+ if (activeCollisionTypes === 'all' && rigidBodyRef.current && rapier) {
135
143
  const rb = rigidBodyRef.current;
136
144
  // Apply to all colliders on this rigid body
137
145
  for (let i = 0; i < rb.numColliders(); i++) {
@@ -124,7 +124,9 @@ export const tree = {
124
124
  scroll: {
125
125
  overflowY: 'auto' as const,
126
126
  padding: 4,
127
- },
127
+ scrollbarWidth: 'thin' as const,
128
+ scrollbarColor: 'rgba(255,255,255,0.06) transparent',
129
+ } as React.CSSProperties,
128
130
  row: {
129
131
  display: 'flex',
130
132
  alignItems: 'center',