react-three-game 0.0.36 → 0.0.37
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/dist/tools/prefabeditor/InstanceProvider.d.ts +1 -0
- package/dist/tools/prefabeditor/InstanceProvider.js +51 -7
- package/dist/tools/prefabeditor/PrefabEditor.js +1 -1
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +74 -22
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +8 -7
- package/package.json +1 -1
- package/src/tools/prefabeditor/InstanceProvider.tsx +60 -4
- package/src/tools/prefabeditor/PrefabEditor.tsx +1 -1
- package/src/tools/prefabeditor/PrefabRoot.tsx +177 -27
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +20 -7
|
@@ -19,6 +19,7 @@ export declare function GameInstanceProvider({ children, models, onSelect, regis
|
|
|
19
19
|
selectedId?: string | null;
|
|
20
20
|
editMode?: boolean;
|
|
21
21
|
}): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
export declare function useInstanceCheck(id: string): boolean;
|
|
22
23
|
export declare const GameInstance: React.ForwardRefExoticComponent<{
|
|
23
24
|
id: string;
|
|
24
25
|
modelUrl: string;
|
|
@@ -50,6 +50,9 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
|
|
|
50
50
|
return prev.filter(i => i.id !== id);
|
|
51
51
|
});
|
|
52
52
|
}, []);
|
|
53
|
+
const hasInstance = useCallback((id) => {
|
|
54
|
+
return instances.some(i => i.id === id);
|
|
55
|
+
}, [instances]);
|
|
53
56
|
// Flatten all model meshes once (models → flat mesh parts)
|
|
54
57
|
// Note: Geometry is cloned with baked transforms for instancing
|
|
55
58
|
const { flatMeshes, modelParts } = useMemo(() => {
|
|
@@ -97,7 +100,8 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
|
|
|
97
100
|
removeInstance,
|
|
98
101
|
instances,
|
|
99
102
|
meshes: flatMeshes,
|
|
100
|
-
modelParts
|
|
103
|
+
modelParts,
|
|
104
|
+
hasInstance
|
|
101
105
|
}, children: [children, Object.entries(grouped).map(([key, group]) => {
|
|
102
106
|
if (group.physicsType === 'none')
|
|
103
107
|
return null;
|
|
@@ -105,7 +109,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
|
|
|
105
109
|
const partCount = modelParts[modelKey] || 0;
|
|
106
110
|
if (partCount === 0)
|
|
107
111
|
return null;
|
|
108
|
-
return (_jsx(InstancedRigidGroup, { group: group, modelKey: modelKey, partCount: partCount, flatMeshes: flatMeshes }, key));
|
|
112
|
+
return (_jsx(InstancedRigidGroup, { group: group, modelKey: modelKey, partCount: partCount, flatMeshes: flatMeshes, onSelect: onSelect, editMode: editMode }, key));
|
|
109
113
|
}), Object.entries(grouped).map(([key, group]) => {
|
|
110
114
|
if (group.physicsType !== 'none')
|
|
111
115
|
return null;
|
|
@@ -123,8 +127,9 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
|
|
|
123
127
|
})] }));
|
|
124
128
|
}
|
|
125
129
|
// Render physics-enabled instances using InstancedRigidBodies
|
|
126
|
-
function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
|
|
130
|
+
function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes, onSelect, editMode }) {
|
|
127
131
|
const meshRefs = useRef([]);
|
|
132
|
+
const rigidBodiesRef = useRef(null);
|
|
128
133
|
const instances = useMemo(() => group.instances.map(inst => ({
|
|
129
134
|
key: inst.id,
|
|
130
135
|
position: inst.position,
|
|
@@ -151,14 +156,47 @@ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
|
|
|
151
156
|
});
|
|
152
157
|
mesh.instanceMatrix.needsUpdate = true;
|
|
153
158
|
});
|
|
159
|
+
// Update rigid body positions when instances change
|
|
160
|
+
if (rigidBodiesRef.current) {
|
|
161
|
+
try {
|
|
162
|
+
group.instances.forEach((inst, i) => {
|
|
163
|
+
var _a;
|
|
164
|
+
const body = (_a = rigidBodiesRef.current) === null || _a === void 0 ? void 0 : _a.at(i);
|
|
165
|
+
if (body && body.setTranslation && body.setRotation) {
|
|
166
|
+
pos.set(...inst.position);
|
|
167
|
+
euler.set(...inst.rotation);
|
|
168
|
+
quat.setFromEuler(euler);
|
|
169
|
+
body.setTranslation(pos, false);
|
|
170
|
+
body.setRotation(quat, false);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
// Ignore errors when switching between instanced/non-instanced states
|
|
176
|
+
console.warn('Failed to update rigidbody positions:', error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
154
179
|
}, [group.instances]);
|
|
155
180
|
const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
|
|
156
|
-
|
|
181
|
+
// Handle click on instanced mesh in edit mode
|
|
182
|
+
const handleClick = (e) => {
|
|
183
|
+
if (!editMode || !onSelect)
|
|
184
|
+
return;
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
// Get the instance index from the intersection
|
|
187
|
+
const instanceId = e.instanceId;
|
|
188
|
+
if (instanceId !== undefined && group.instances[instanceId]) {
|
|
189
|
+
onSelect(group.instances[instanceId].id);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
// Add key to force remount when instance count changes significantly (helps with cleanup)
|
|
193
|
+
const rigidBodyKey = `rb_${modelKey}_${group.physicsType}_${group.instances.length}`;
|
|
194
|
+
return (_jsx(InstancedRigidBodies, { ref: rigidBodiesRef, instances: instances, colliders: colliders, type: group.physicsType, children: Array.from({ length: partCount }).map((_, i) => {
|
|
157
195
|
const mesh = flatMeshes[`${modelKey}__${i}`];
|
|
158
196
|
if (!mesh)
|
|
159
197
|
return null;
|
|
160
|
-
return (_jsx("instancedMesh", { ref: el => { meshRefs.current[i] = el; }, args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false }, i));
|
|
161
|
-
}) }));
|
|
198
|
+
return (_jsx("instancedMesh", { ref: el => { meshRefs.current[i] = el; }, args: [mesh.geometry, mesh.material, group.instances.length], castShadow: true, receiveShadow: true, frustumCulled: false, onClick: editMode ? handleClick : undefined }, i));
|
|
199
|
+
}) }, rigidBodyKey));
|
|
162
200
|
}
|
|
163
201
|
// Render non-physics instances using Merged (instancing without rigid bodies)
|
|
164
202
|
function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, onSelect, registerRef, selectedId, editMode }) {
|
|
@@ -184,6 +222,12 @@ function InstanceGroupItem({ instance, InstanceComponents, onSelect, registerRef
|
|
|
184
222
|
clickValid.current = false;
|
|
185
223
|
}, children: InstanceComponents.map((Instance, i) => _jsx(Instance, {}, i)) }));
|
|
186
224
|
}
|
|
225
|
+
// Hook to check if an instance exists
|
|
226
|
+
export function useInstanceCheck(id) {
|
|
227
|
+
var _a;
|
|
228
|
+
const ctx = useContext(GameInstanceContext);
|
|
229
|
+
return (_a = ctx === null || ctx === void 0 ? void 0 : ctx.hasInstance(id)) !== null && _a !== void 0 ? _a : false;
|
|
230
|
+
}
|
|
187
231
|
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
188
232
|
export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
|
|
189
233
|
const ctx = useContext(GameInstanceContext);
|
|
@@ -196,7 +240,7 @@ export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation
|
|
|
196
240
|
rotation,
|
|
197
241
|
scale,
|
|
198
242
|
physics,
|
|
199
|
-
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
243
|
+
}), [id, modelUrl, JSON.stringify(position), JSON.stringify(rotation), JSON.stringify(scale), JSON.stringify(physics)]);
|
|
200
244
|
useEffect(() => {
|
|
201
245
|
if (!addInstance || !removeInstance)
|
|
202
246
|
return;
|
|
@@ -97,7 +97,7 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }) =>
|
|
|
97
97
|
lastDataRef.current = JSON.stringify(prefab);
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
|
-
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, editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, 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, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath, onSave: () => saveJson(loadedPrefab, "prefab"), onLoad: handleLoad, onUndo: undo, onRedo: redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1 })] });
|
|
100
|
+
return _jsxs(_Fragment, { 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, { data: loadedPrefab, editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, 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, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath, onSave: () => saveJson(loadedPrefab, "prefab"), onLoad: handleLoad, onUndo: undo, onRedo: redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1 })] });
|
|
101
101
|
};
|
|
102
102
|
const saveJson = (data, filename) => {
|
|
103
103
|
const a = document.createElement('a');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Group, Matrix4, Object3D, Texture } from "three";
|
|
2
|
+
import { ThreeEvent } from "@react-three/fiber";
|
|
2
3
|
import { Prefab, GameObject as GameObjectType } from "./types";
|
|
3
4
|
export declare const PrefabRoot: import("react").ForwardRefExoticComponent<{
|
|
4
5
|
editMode?: boolean;
|
|
@@ -6,6 +7,7 @@ export declare const PrefabRoot: import("react").ForwardRefExoticComponent<{
|
|
|
6
7
|
onPrefabChange?: (data: Prefab) => void;
|
|
7
8
|
selectedId?: string | null;
|
|
8
9
|
onSelect?: (id: string | null) => void;
|
|
10
|
+
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
9
11
|
transformMode?: "translate" | "rotate" | "scale";
|
|
10
12
|
basePath?: string;
|
|
11
13
|
} & import("react").RefAttributes<Group<import("three").Object3DEventMap>>>;
|
|
@@ -14,6 +16,7 @@ interface RendererProps {
|
|
|
14
16
|
gameObject: GameObjectType;
|
|
15
17
|
selectedId?: string | null;
|
|
16
18
|
onSelect?: (id: string) => void;
|
|
19
|
+
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
17
20
|
registerRef: (id: string, obj: Object3D | null) => void;
|
|
18
21
|
loadedModels: Record<string, Object3D>;
|
|
19
22
|
loadedTextures: Record<string, Texture>;
|
|
@@ -15,7 +15,7 @@ import { BoxHelper, Euler, Matrix4, Quaternion, SRGBColorSpace, TextureLoader, V
|
|
|
15
15
|
import { getComponent, registerComponent } from "./components/ComponentRegistry";
|
|
16
16
|
import components from "./components";
|
|
17
17
|
import { loadModel } from "../dragdrop/modelLoader";
|
|
18
|
-
import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
|
|
18
|
+
import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
|
|
19
19
|
import { updateNode } from "./utils";
|
|
20
20
|
/* -------------------------------------------------- */
|
|
21
21
|
/* Setup */
|
|
@@ -25,7 +25,7 @@ const IDENTITY = new Matrix4();
|
|
|
25
25
|
/* -------------------------------------------------- */
|
|
26
26
|
/* PrefabRoot */
|
|
27
27
|
/* -------------------------------------------------- */
|
|
28
|
-
export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, basePath = "" }, ref) => {
|
|
28
|
+
export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, transformMode, basePath = "" }, ref) => {
|
|
29
29
|
const [models, setModels] = useState({});
|
|
30
30
|
const [textures, setTextures] = useState({});
|
|
31
31
|
const loading = useRef(new Set());
|
|
@@ -106,7 +106,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
|
|
|
106
106
|
});
|
|
107
107
|
}, [data, models, textures]);
|
|
108
108
|
/* ---------------- Render ---------------- */
|
|
109
|
-
return (_jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: models, selectedId: selectedId, editMode: editMode, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, loadedModels: models, loadedTextures: textures, editMode: editMode, parentMatrix: IDENTITY }) }), editMode && (_jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange }))] }))] }));
|
|
109
|
+
return (_jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: models, selectedId: selectedId, editMode: editMode, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, onClick: onClick, registerRef: registerRef, loadedModels: models, loadedTextures: textures, editMode: editMode, parentMatrix: IDENTITY }) }), editMode && (_jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange }))] }))] }));
|
|
110
110
|
});
|
|
111
111
|
/* -------------------------------------------------- */
|
|
112
112
|
/* Renderer Switch */
|
|
@@ -116,9 +116,26 @@ export function GameObjectRenderer(props) {
|
|
|
116
116
|
const node = props.gameObject;
|
|
117
117
|
if (!node || node.hidden || node.disabled)
|
|
118
118
|
return null;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
const isInstanced = (_c = (_b = (_a = node.components) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.properties) === null || _c === void 0 ? void 0 : _c.instanced;
|
|
120
|
+
const prevInstancedRef = useRef(undefined);
|
|
121
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
// Detect instanced mode change
|
|
124
|
+
if (prevInstancedRef.current !== undefined && prevInstancedRef.current !== isInstanced) {
|
|
125
|
+
setIsTransitioning(true);
|
|
126
|
+
// Wait for cleanup, then allow new mode to render
|
|
127
|
+
const timer = setTimeout(() => setIsTransitioning(false), 100);
|
|
128
|
+
return () => clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
prevInstancedRef.current = isInstanced;
|
|
131
|
+
}, [isInstanced]);
|
|
132
|
+
// Don't render during transition to avoid physics conflicts
|
|
133
|
+
if (isTransitioning)
|
|
134
|
+
return null;
|
|
135
|
+
const key = `${node.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
136
|
+
return isInstanced
|
|
137
|
+
? _jsx(InstancedNode, Object.assign({}, props), key)
|
|
138
|
+
: _jsx(StandardNode, Object.assign({}, props), key);
|
|
122
139
|
}
|
|
123
140
|
/* -------------------------------------------------- */
|
|
124
141
|
/* InstancedNode (terminal) */
|
|
@@ -126,24 +143,50 @@ export function GameObjectRenderer(props) {
|
|
|
126
143
|
function isPhysicsProps(v) {
|
|
127
144
|
return (v === null || v === void 0 ? void 0 : v.type) === "fixed" || (v === null || v === void 0 ? void 0 : v.type) === "dynamic";
|
|
128
145
|
}
|
|
129
|
-
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode }) {
|
|
130
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
146
|
+
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }) {
|
|
147
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
131
148
|
const world = parentMatrix.clone().multiply(compose(gameObject));
|
|
132
|
-
const { position, rotation, scale } = decompose(world);
|
|
149
|
+
const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
|
|
150
|
+
// Get local transform for proxy group (used by transform controls)
|
|
151
|
+
const localTransform = getNodeTransformProps(gameObject);
|
|
133
152
|
const physicsProps = isPhysicsProps((_b = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.physics) === null || _b === void 0 ? void 0 : _b.properties)
|
|
134
153
|
? (_d = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.physics) === null || _d === void 0 ? void 0 : _d.properties
|
|
135
154
|
: undefined;
|
|
136
|
-
|
|
155
|
+
const groupRef = useRef(null);
|
|
156
|
+
const clickValid = useRef(false);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (editMode) {
|
|
159
|
+
registerRef(gameObject.id, groupRef.current);
|
|
160
|
+
return () => registerRef(gameObject.id, null);
|
|
161
|
+
}
|
|
162
|
+
}, [gameObject.id, registerRef, editMode]);
|
|
163
|
+
const modelUrl = (_g = (_f = (_e = gameObject.components) === null || _e === void 0 ? void 0 : _e.model) === null || _f === void 0 ? void 0 : _f.properties) === null || _g === void 0 ? void 0 : _g.filename;
|
|
164
|
+
// In edit mode, create a proxy group at the same position for transform controls
|
|
165
|
+
// The GameInstance still needs the actual position so it renders correctly
|
|
166
|
+
if (editMode) {
|
|
167
|
+
return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef, position: localTransform.position, rotation: localTransform.rotation, scale: localTransform.scale, onPointerDown: (e) => { e.stopPropagation(); clickValid.current = true; }, onPointerMove: () => { clickValid.current = false; }, onPointerUp: (e) => {
|
|
168
|
+
if (clickValid.current) {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
onSelect === null || onSelect === void 0 ? void 0 : onSelect(gameObject.id);
|
|
171
|
+
onClick === null || onClick === void 0 ? void 0 : onClick(e, gameObject);
|
|
172
|
+
}
|
|
173
|
+
clickValid.current = false;
|
|
174
|
+
}, children: _jsx("mesh", { visible: false, children: _jsx("boxGeometry", { args: [0.01, 0.01, 0.01] }) }) }), _jsx(GameInstance, { id: gameObject.id, modelUrl: modelUrl, position: worldPosition, rotation: worldRotation, scale: worldScale, physics: physicsProps })] }));
|
|
175
|
+
}
|
|
176
|
+
return (_jsx(GameInstance, { id: gameObject.id, modelUrl: (_k = (_j = (_h = gameObject.components) === null || _h === void 0 ? void 0 : _h.model) === null || _j === void 0 ? void 0 : _j.properties) === null || _k === void 0 ? void 0 : _k.filename, position: worldPosition, rotation: worldRotation, scale: worldScale, physics: physicsProps }));
|
|
137
177
|
}
|
|
138
178
|
/* -------------------------------------------------- */
|
|
139
179
|
/* StandardNode */
|
|
140
180
|
/* -------------------------------------------------- */
|
|
141
|
-
function StandardNode({ gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode, parentMatrix = IDENTITY, }) {
|
|
142
|
-
var _a, _b, _c;
|
|
181
|
+
function StandardNode({ gameObject, selectedId, onSelect, onClick, registerRef, loadedModels, loadedTextures, editMode, parentMatrix = IDENTITY, }) {
|
|
182
|
+
var _a, _b, _c, _d, _e, _f;
|
|
143
183
|
const groupRef = useRef(null);
|
|
184
|
+
const helperRef = useRef(null);
|
|
144
185
|
const clickValid = useRef(false);
|
|
145
186
|
const isSelected = selectedId === gameObject.id;
|
|
146
|
-
|
|
187
|
+
// Check if this object still exists as an instance (to prevent physics overlap)
|
|
188
|
+
const stillInstanced = useInstanceCheck(gameObject.id);
|
|
189
|
+
// Use helperRef for BoxHelper (shows actual content bounds at correct position)
|
|
147
190
|
useHelper(editMode && isSelected ? helperRef : null, BoxHelper, "cyan");
|
|
148
191
|
useEffect(() => {
|
|
149
192
|
registerRef(gameObject.id, groupRef.current);
|
|
@@ -158,20 +201,29 @@ function StandardNode({ gameObject, selectedId, onSelect, registerRef, loadedMod
|
|
|
158
201
|
if (clickValid.current) {
|
|
159
202
|
e.stopPropagation();
|
|
160
203
|
onSelect === null || onSelect === void 0 ? void 0 : onSelect(gameObject.id);
|
|
204
|
+
onClick === null || onClick === void 0 ? void 0 : onClick(e, gameObject);
|
|
161
205
|
}
|
|
162
206
|
clickValid.current = false;
|
|
163
207
|
};
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const ready = !((_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.model) ||
|
|
208
|
+
const physics = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.physics;
|
|
209
|
+
const ready = !((_b = gameObject.components) === null || _b === void 0 ? void 0 : _b.model) ||
|
|
167
210
|
loadedModels[gameObject.components.model.properties.filename];
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
211
|
+
const hasPhysics = physics && ready && !stillInstanced;
|
|
212
|
+
const transform = getNodeTransformProps(gameObject);
|
|
213
|
+
// Prepare physics wrapper if needed
|
|
214
|
+
const physicsDef = hasPhysics ? getComponent("Physics") : null;
|
|
215
|
+
const isInstanced = (_e = (_d = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.model) === null || _d === void 0 ? void 0 : _d.properties) === null || _e === void 0 ? void 0 : _e.instanced;
|
|
216
|
+
const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
217
|
+
const inner = (_jsxs("group", { onPointerDown: editMode ? onDown : undefined, onPointerMove: editMode ? () => (clickValid.current = false) : undefined, onPointerUp: editMode ? onUp : undefined, children: [renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix), (_f = gameObject.children) === null || _f === void 0 ? void 0 : _f.map(child => (_jsx(GameObjectRenderer, { child, gameObject: child, selectedId: selectedId, onSelect: onSelect, onClick: onClick, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: world }, child.id)))] }));
|
|
218
|
+
// In edit mode, use proxy group pattern
|
|
219
|
+
if (editMode) {
|
|
220
|
+
return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, children: _jsx("mesh", { visible: false, children: _jsx("boxGeometry", { args: [0.01, 0.01, 0.01] }) }) }), _jsx("group", { ref: helperRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, children: inner }), hasPhysics && (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View) ? (_jsx(physicsDef.View, { properties: physics.properties, position: transform.position, rotation: transform.rotation, scale: transform.scale, editMode: editMode, children: inner }, physicsKey)) : null] }));
|
|
221
|
+
}
|
|
222
|
+
// In play mode, apply transform directly to content
|
|
223
|
+
if (hasPhysics && (physicsDef === null || physicsDef === void 0 ? void 0 : physicsDef.View)) {
|
|
224
|
+
return (_jsx(physicsDef.View, { properties: physics.properties, position: transform.position, rotation: transform.rotation, scale: transform.scale, editMode: editMode, children: inner }, physicsKey));
|
|
173
225
|
}
|
|
174
|
-
return inner;
|
|
226
|
+
return (_jsx("group", { ref: groupRef, position: transform.position, rotation: transform.rotation, scale: transform.scale, onPointerDown: onDown, onPointerMove: () => (clickValid.current = false), onPointerUp: onUp, children: inner }));
|
|
175
227
|
}
|
|
176
228
|
function walk(node, fn) {
|
|
177
229
|
var _a;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { RigidBody } from "@react-three/rapier";
|
|
3
3
|
import { Label } from "./Input";
|
|
4
4
|
function PhysicsComponentEditor({ component, onUpdate }) {
|
|
@@ -15,13 +15,14 @@ function PhysicsComponentEditor({ component, onUpdate }) {
|
|
|
15
15
|
};
|
|
16
16
|
return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 8 }, children: [_jsxs("div", { children: [_jsx(Label, { children: "Type" }), _jsxs("select", { style: selectStyle, value: type, onChange: e => onUpdate({ type: e.target.value }), children: [_jsx("option", { value: "dynamic", children: "Dynamic" }), _jsx("option", { value: "fixed", children: "Fixed" })] })] }), _jsxs("div", { children: [_jsx(Label, { children: "Collider" }), _jsxs("select", { style: selectStyle, value: collider, onChange: e => onUpdate({ collider: e.target.value }), children: [_jsx("option", { value: "hull", children: "Hull (convex)" }), _jsx("option", { value: "trimesh", children: "Trimesh (exact)" }), _jsx("option", { value: "cuboid", children: "Cuboid (box)" }), _jsx("option", { value: "ball", children: "Ball (sphere)" })] })] })] }));
|
|
17
17
|
}
|
|
18
|
-
function PhysicsComponentView({ properties,
|
|
19
|
-
if (editMode)
|
|
20
|
-
return _jsx(_Fragment, { children: children });
|
|
18
|
+
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }) {
|
|
21
19
|
const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
21
|
+
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
22
|
+
const rbKey = editMode
|
|
23
|
+
? `${properties.type || 'dynamic'}_${colliders}_${position === null || position === void 0 ? void 0 : position.join(',')}_${rotation === null || rotation === void 0 ? void 0 : rotation.join(',')}`
|
|
24
|
+
: `${properties.type || 'dynamic'}_${colliders}`;
|
|
25
|
+
return (_jsx(RigidBody, { type: properties.type, colliders: colliders, position: position, rotation: rotation, scale: scale, children: children }, rbKey));
|
|
25
26
|
}
|
|
26
27
|
const PhysicsComponent = {
|
|
27
28
|
name: 'Physics',
|
package/package.json
CHANGED
|
@@ -40,6 +40,7 @@ type GameInstanceContextType = {
|
|
|
40
40
|
instances: InstanceData[];
|
|
41
41
|
meshes: Record<string, Mesh>;
|
|
42
42
|
modelParts?: Record<string, number>;
|
|
43
|
+
hasInstance: (id: string) => boolean;
|
|
43
44
|
};
|
|
44
45
|
const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
|
|
45
46
|
|
|
@@ -84,6 +85,10 @@ export function GameInstanceProvider({
|
|
|
84
85
|
});
|
|
85
86
|
}, []);
|
|
86
87
|
|
|
88
|
+
const hasInstance = useCallback((id: string) => {
|
|
89
|
+
return instances.some(i => i.id === id);
|
|
90
|
+
}, [instances]);
|
|
91
|
+
|
|
87
92
|
// Flatten all model meshes once (models → flat mesh parts)
|
|
88
93
|
// Note: Geometry is cloned with baked transforms for instancing
|
|
89
94
|
const { flatMeshes, modelParts } = useMemo(() => {
|
|
@@ -138,7 +143,8 @@ export function GameInstanceProvider({
|
|
|
138
143
|
removeInstance,
|
|
139
144
|
instances,
|
|
140
145
|
meshes: flatMeshes,
|
|
141
|
-
modelParts
|
|
146
|
+
modelParts,
|
|
147
|
+
hasInstance
|
|
142
148
|
}}
|
|
143
149
|
>
|
|
144
150
|
{/* Render normal prefab hierarchy (non-instanced objects) */}
|
|
@@ -158,6 +164,8 @@ export function GameInstanceProvider({
|
|
|
158
164
|
modelKey={modelKey}
|
|
159
165
|
partCount={partCount}
|
|
160
166
|
flatMeshes={flatMeshes}
|
|
167
|
+
onSelect={onSelect}
|
|
168
|
+
editMode={editMode}
|
|
161
169
|
/>
|
|
162
170
|
);
|
|
163
171
|
})}
|
|
@@ -208,14 +216,19 @@ function InstancedRigidGroup({
|
|
|
208
216
|
group,
|
|
209
217
|
modelKey,
|
|
210
218
|
partCount,
|
|
211
|
-
flatMeshes
|
|
219
|
+
flatMeshes,
|
|
220
|
+
onSelect,
|
|
221
|
+
editMode
|
|
212
222
|
}: {
|
|
213
223
|
group: { physicsType: string, instances: InstanceData[] },
|
|
214
224
|
modelKey: string,
|
|
215
225
|
partCount: number,
|
|
216
|
-
flatMeshes: Record<string, Mesh
|
|
226
|
+
flatMeshes: Record<string, Mesh>,
|
|
227
|
+
onSelect?: (id: string | null) => void,
|
|
228
|
+
editMode?: boolean
|
|
217
229
|
}) {
|
|
218
230
|
const meshRefs = useRef<(InstancedMesh | null)[]>([]);
|
|
231
|
+
const rigidBodiesRef = useRef<any>(null);
|
|
219
232
|
|
|
220
233
|
const instances = useMemo(
|
|
221
234
|
() => group.instances.map(inst => ({
|
|
@@ -248,12 +261,48 @@ function InstancedRigidGroup({
|
|
|
248
261
|
});
|
|
249
262
|
mesh.instanceMatrix.needsUpdate = true;
|
|
250
263
|
});
|
|
264
|
+
|
|
265
|
+
// Update rigid body positions when instances change
|
|
266
|
+
if (rigidBodiesRef.current) {
|
|
267
|
+
try {
|
|
268
|
+
group.instances.forEach((inst, i) => {
|
|
269
|
+
const body = rigidBodiesRef.current?.at(i);
|
|
270
|
+
if (body && body.setTranslation && body.setRotation) {
|
|
271
|
+
pos.set(...inst.position);
|
|
272
|
+
euler.set(...inst.rotation);
|
|
273
|
+
quat.setFromEuler(euler);
|
|
274
|
+
body.setTranslation(pos, false);
|
|
275
|
+
body.setRotation(quat, false);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// Ignore errors when switching between instanced/non-instanced states
|
|
280
|
+
console.warn('Failed to update rigidbody positions:', error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
251
283
|
}, [group.instances]);
|
|
252
284
|
|
|
253
285
|
const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
|
|
254
286
|
|
|
287
|
+
// Handle click on instanced mesh in edit mode
|
|
288
|
+
const handleClick = (e: any) => {
|
|
289
|
+
if (!editMode || !onSelect) return;
|
|
290
|
+
e.stopPropagation();
|
|
291
|
+
|
|
292
|
+
// Get the instance index from the intersection
|
|
293
|
+
const instanceId = e.instanceId;
|
|
294
|
+
if (instanceId !== undefined && group.instances[instanceId]) {
|
|
295
|
+
onSelect(group.instances[instanceId].id);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Add key to force remount when instance count changes significantly (helps with cleanup)
|
|
300
|
+
const rigidBodyKey = `rb_${modelKey}_${group.physicsType}_${group.instances.length}`;
|
|
301
|
+
|
|
255
302
|
return (
|
|
256
303
|
<InstancedRigidBodies
|
|
304
|
+
key={rigidBodyKey}
|
|
305
|
+
ref={rigidBodiesRef}
|
|
257
306
|
instances={instances}
|
|
258
307
|
colliders={colliders}
|
|
259
308
|
type={group.physicsType as 'dynamic' | 'fixed'}
|
|
@@ -269,6 +318,7 @@ function InstancedRigidGroup({
|
|
|
269
318
|
castShadow
|
|
270
319
|
receiveShadow
|
|
271
320
|
frustumCulled={false}
|
|
321
|
+
onClick={editMode ? handleClick : undefined}
|
|
272
322
|
/>
|
|
273
323
|
);
|
|
274
324
|
})}
|
|
@@ -368,6 +418,12 @@ function InstanceGroupItem({
|
|
|
368
418
|
}
|
|
369
419
|
|
|
370
420
|
|
|
421
|
+
// Hook to check if an instance exists
|
|
422
|
+
export function useInstanceCheck(id: string): boolean {
|
|
423
|
+
const ctx = useContext(GameInstanceContext);
|
|
424
|
+
return ctx?.hasInstance(id) ?? false;
|
|
425
|
+
}
|
|
426
|
+
|
|
371
427
|
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
372
428
|
export const GameInstance = React.forwardRef<Group, {
|
|
373
429
|
id: string;
|
|
@@ -395,7 +451,7 @@ export const GameInstance = React.forwardRef<Group, {
|
|
|
395
451
|
rotation,
|
|
396
452
|
scale,
|
|
397
453
|
physics,
|
|
398
|
-
}), [id, modelUrl, position, rotation, scale, physics]);
|
|
454
|
+
}), [id, modelUrl, JSON.stringify(position), JSON.stringify(rotation), JSON.stringify(scale), JSON.stringify(physics)]);
|
|
399
455
|
|
|
400
456
|
useEffect(() => {
|
|
401
457
|
if (!addInstance || !removeInstance) return;
|
|
@@ -97,7 +97,7 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: {
|
|
|
97
97
|
|
|
98
98
|
return <>
|
|
99
99
|
<GameCanvas>
|
|
100
|
-
<Physics paused={editMode}>
|
|
100
|
+
<Physics debug={editMode} paused={editMode}>
|
|
101
101
|
<ambientLight intensity={1.5} />
|
|
102
102
|
<gridHelper args={[10, 10]} position={[0, -1, 0]} />
|
|
103
103
|
<PrefabRoot
|
|
@@ -9,7 +9,7 @@ import { Prefab, GameObject as GameObjectType } from "./types";
|
|
|
9
9
|
import { getComponent, registerComponent } from "./components/ComponentRegistry";
|
|
10
10
|
import components from "./components";
|
|
11
11
|
import { loadModel } from "../dragdrop/modelLoader";
|
|
12
|
-
import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
|
|
12
|
+
import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
|
|
13
13
|
import { updateNode } from "./utils";
|
|
14
14
|
import { PhysicsProps } from "./components/PhysicsComponent";
|
|
15
15
|
|
|
@@ -31,9 +31,10 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
31
31
|
onPrefabChange?: (data: Prefab) => void;
|
|
32
32
|
selectedId?: string | null;
|
|
33
33
|
onSelect?: (id: string | null) => void;
|
|
34
|
+
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
34
35
|
transformMode?: "translate" | "rotate" | "scale";
|
|
35
36
|
basePath?: string;
|
|
36
|
-
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, basePath = "" }, ref) => {
|
|
37
|
+
}>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, transformMode, basePath = "" }, ref) => {
|
|
37
38
|
|
|
38
39
|
const [models, setModels] = useState<Record<string, Object3D>>({});
|
|
39
40
|
const [textures, setTextures] = useState<Record<string, Texture>>({});
|
|
@@ -148,6 +149,7 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
148
149
|
gameObject={data.root}
|
|
149
150
|
selectedId={selectedId}
|
|
150
151
|
onSelect={editMode ? onSelect : undefined}
|
|
152
|
+
onClick={onClick}
|
|
151
153
|
registerRef={registerRef}
|
|
152
154
|
loadedModels={models}
|
|
153
155
|
loadedTextures={textures}
|
|
@@ -180,9 +182,29 @@ export const PrefabRoot = forwardRef<Group, {
|
|
|
180
182
|
export function GameObjectRenderer(props: RendererProps) {
|
|
181
183
|
const node = props.gameObject;
|
|
182
184
|
if (!node || node.hidden || node.disabled) return null;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
|
|
186
|
+
const isInstanced = node.components?.model?.properties?.instanced;
|
|
187
|
+
const prevInstancedRef = useRef<boolean | undefined>(undefined);
|
|
188
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
// Detect instanced mode change
|
|
192
|
+
if (prevInstancedRef.current !== undefined && prevInstancedRef.current !== isInstanced) {
|
|
193
|
+
setIsTransitioning(true);
|
|
194
|
+
// Wait for cleanup, then allow new mode to render
|
|
195
|
+
const timer = setTimeout(() => setIsTransitioning(false), 100);
|
|
196
|
+
return () => clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
prevInstancedRef.current = isInstanced;
|
|
199
|
+
}, [isInstanced]);
|
|
200
|
+
|
|
201
|
+
// Don't render during transition to avoid physics conflicts
|
|
202
|
+
if (isTransitioning) return null;
|
|
203
|
+
|
|
204
|
+
const key = `${node.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
205
|
+
return isInstanced
|
|
206
|
+
? <InstancedNode key={key} {...props} />
|
|
207
|
+
: <StandardNode key={key} {...props} />;
|
|
186
208
|
}
|
|
187
209
|
|
|
188
210
|
/* -------------------------------------------------- */
|
|
@@ -192,23 +214,79 @@ function isPhysicsProps(v: any): v is PhysicsProps {
|
|
|
192
214
|
return v?.type === "fixed" || v?.type === "dynamic";
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode }: RendererProps) {
|
|
217
|
+
function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }: RendererProps) {
|
|
196
218
|
const world = parentMatrix.clone().multiply(compose(gameObject));
|
|
197
|
-
const { position, rotation, scale } = decompose(world);
|
|
219
|
+
const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
|
|
220
|
+
|
|
221
|
+
// Get local transform for proxy group (used by transform controls)
|
|
222
|
+
const localTransform = getNodeTransformProps(gameObject);
|
|
223
|
+
|
|
198
224
|
const physicsProps = isPhysicsProps(
|
|
199
225
|
gameObject.components?.physics?.properties
|
|
200
226
|
)
|
|
201
227
|
? gameObject.components?.physics?.properties
|
|
202
228
|
: undefined;
|
|
203
229
|
|
|
230
|
+
const groupRef = useRef<Group>(null);
|
|
231
|
+
const clickValid = useRef(false);
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (editMode) {
|
|
235
|
+
registerRef(gameObject.id, groupRef.current);
|
|
236
|
+
return () => registerRef(gameObject.id, null);
|
|
237
|
+
}
|
|
238
|
+
}, [gameObject.id, registerRef, editMode]);
|
|
239
|
+
|
|
240
|
+
const modelUrl = gameObject.components?.model?.properties?.filename;
|
|
241
|
+
|
|
242
|
+
// In edit mode, create a proxy group at the same position for transform controls
|
|
243
|
+
// The GameInstance still needs the actual position so it renders correctly
|
|
244
|
+
if (editMode) {
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
{/* Proxy group for transform controls - uses LOCAL transform */}
|
|
248
|
+
<group
|
|
249
|
+
ref={groupRef}
|
|
250
|
+
position={localTransform.position}
|
|
251
|
+
rotation={localTransform.rotation}
|
|
252
|
+
scale={localTransform.scale}
|
|
253
|
+
onPointerDown={(e) => { e.stopPropagation(); clickValid.current = true; }}
|
|
254
|
+
onPointerMove={() => { clickValid.current = false; }}
|
|
255
|
+
onPointerUp={(e) => {
|
|
256
|
+
if (clickValid.current) {
|
|
257
|
+
e.stopPropagation();
|
|
258
|
+
onSelect?.(gameObject.id);
|
|
259
|
+
onClick?.(e, gameObject);
|
|
260
|
+
}
|
|
261
|
+
clickValid.current = false;
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{/* Tiny invisible mesh for raycasting/selection */}
|
|
265
|
+
<mesh visible={false}>
|
|
266
|
+
<boxGeometry args={[0.01, 0.01, 0.01]} />
|
|
267
|
+
</mesh>
|
|
268
|
+
</group>
|
|
269
|
+
{/* Actual instance rendered by provider - uses WORLD transform */}
|
|
270
|
+
<GameInstance
|
|
271
|
+
id={gameObject.id}
|
|
272
|
+
modelUrl={modelUrl}
|
|
273
|
+
position={worldPosition}
|
|
274
|
+
rotation={worldRotation}
|
|
275
|
+
scale={worldScale}
|
|
276
|
+
physics={physicsProps}
|
|
277
|
+
/>
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
204
282
|
return (
|
|
205
283
|
<GameInstance
|
|
206
284
|
id={gameObject.id}
|
|
207
285
|
modelUrl={gameObject.components?.model?.properties?.filename}
|
|
208
|
-
position={
|
|
209
|
-
rotation={
|
|
210
|
-
scale={
|
|
211
|
-
physics={
|
|
286
|
+
position={worldPosition}
|
|
287
|
+
rotation={worldRotation}
|
|
288
|
+
scale={worldScale}
|
|
289
|
+
physics={physicsProps}
|
|
212
290
|
/>
|
|
213
291
|
);
|
|
214
292
|
}
|
|
@@ -221,6 +299,7 @@ function StandardNode({
|
|
|
221
299
|
gameObject,
|
|
222
300
|
selectedId,
|
|
223
301
|
onSelect,
|
|
302
|
+
onClick,
|
|
224
303
|
registerRef,
|
|
225
304
|
loadedModels,
|
|
226
305
|
loadedTextures,
|
|
@@ -229,12 +308,16 @@ function StandardNode({
|
|
|
229
308
|
}: RendererProps) {
|
|
230
309
|
|
|
231
310
|
const groupRef = useRef<Object3D | null>(null);
|
|
311
|
+
const helperRef = useRef<Object3D | null>(null);
|
|
232
312
|
const clickValid = useRef(false);
|
|
233
313
|
const isSelected = selectedId === gameObject.id;
|
|
234
|
-
const helperRef = groupRef as React.RefObject<Object3D>;
|
|
235
314
|
|
|
315
|
+
// Check if this object still exists as an instance (to prevent physics overlap)
|
|
316
|
+
const stillInstanced = useInstanceCheck(gameObject.id);
|
|
317
|
+
|
|
318
|
+
// Use helperRef for BoxHelper (shows actual content bounds at correct position)
|
|
236
319
|
useHelper(
|
|
237
|
-
editMode && isSelected ? helperRef : null,
|
|
320
|
+
editMode && isSelected ? helperRef as React.RefObject<Object3D> : null,
|
|
238
321
|
BoxHelper,
|
|
239
322
|
"cyan"
|
|
240
323
|
);
|
|
@@ -255,17 +338,27 @@ function StandardNode({
|
|
|
255
338
|
if (clickValid.current) {
|
|
256
339
|
e.stopPropagation();
|
|
257
340
|
onSelect?.(gameObject.id);
|
|
341
|
+
onClick?.(e, gameObject);
|
|
258
342
|
}
|
|
259
343
|
clickValid.current = false;
|
|
260
344
|
};
|
|
261
345
|
|
|
346
|
+
const physics = gameObject.components?.physics;
|
|
347
|
+
const ready = !gameObject.components?.model ||
|
|
348
|
+
loadedModels[gameObject.components.model.properties.filename];
|
|
349
|
+
const hasPhysics = physics && ready && !stillInstanced;
|
|
350
|
+
const transform = getNodeTransformProps(gameObject);
|
|
351
|
+
|
|
352
|
+
// Prepare physics wrapper if needed
|
|
353
|
+
const physicsDef = hasPhysics ? getComponent("Physics") : null;
|
|
354
|
+
const isInstanced = gameObject.components?.model?.properties?.instanced;
|
|
355
|
+
const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
|
|
356
|
+
|
|
262
357
|
const inner = (
|
|
263
358
|
<group
|
|
264
|
-
|
|
265
|
-
{
|
|
266
|
-
|
|
267
|
-
onPointerMove={() => (clickValid.current = false)}
|
|
268
|
-
onPointerUp={onUp}
|
|
359
|
+
onPointerDown={editMode ? onDown : undefined}
|
|
360
|
+
onPointerMove={editMode ? () => (clickValid.current = false) : undefined}
|
|
361
|
+
onPointerUp={editMode ? onUp : undefined}
|
|
269
362
|
>
|
|
270
363
|
{renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix)}
|
|
271
364
|
{gameObject.children?.map(child => (
|
|
@@ -275,6 +368,7 @@ function StandardNode({
|
|
|
275
368
|
gameObject={child}
|
|
276
369
|
selectedId={selectedId}
|
|
277
370
|
onSelect={onSelect}
|
|
371
|
+
onClick={onClick}
|
|
278
372
|
registerRef={registerRef}
|
|
279
373
|
loadedModels={loadedModels}
|
|
280
374
|
loadedTextures={loadedTextures}
|
|
@@ -285,18 +379,73 @@ function StandardNode({
|
|
|
285
379
|
</group>
|
|
286
380
|
);
|
|
287
381
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
382
|
+
// In edit mode, use proxy group pattern
|
|
383
|
+
if (editMode) {
|
|
384
|
+
return (
|
|
385
|
+
<>
|
|
386
|
+
{/* Proxy group for transform controls - uses LOCAL transform */}
|
|
387
|
+
<group
|
|
388
|
+
ref={groupRef}
|
|
389
|
+
position={transform.position}
|
|
390
|
+
rotation={transform.rotation}
|
|
391
|
+
scale={transform.scale}
|
|
392
|
+
>
|
|
393
|
+
{/* Tiny invisible mesh for raycasting/selection */}
|
|
394
|
+
<mesh visible={false}>
|
|
395
|
+
<boxGeometry args={[0.01, 0.01, 0.01]} />
|
|
396
|
+
</mesh>
|
|
397
|
+
</group>
|
|
398
|
+
{/* Helper group for BoxHelper - same transform as proxy, contains actual geometry */}
|
|
399
|
+
<group
|
|
400
|
+
ref={helperRef}
|
|
401
|
+
position={transform.position}
|
|
402
|
+
rotation={transform.rotation}
|
|
403
|
+
scale={transform.scale}
|
|
404
|
+
>
|
|
405
|
+
{inner}
|
|
406
|
+
</group>
|
|
407
|
+
{/* Actual content with physics wrapper if needed */}
|
|
408
|
+
{hasPhysics && physicsDef?.View ? (
|
|
409
|
+
<physicsDef.View
|
|
410
|
+
key={physicsKey}
|
|
411
|
+
properties={physics.properties}
|
|
412
|
+
position={transform.position}
|
|
413
|
+
rotation={transform.rotation}
|
|
414
|
+
scale={transform.scale}
|
|
415
|
+
editMode={editMode}
|
|
416
|
+
>{inner}</physicsDef.View>
|
|
417
|
+
) : null}
|
|
418
|
+
</>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
291
421
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return
|
|
295
|
-
|
|
296
|
-
|
|
422
|
+
// In play mode, apply transform directly to content
|
|
423
|
+
if (hasPhysics && physicsDef?.View) {
|
|
424
|
+
return (
|
|
425
|
+
<physicsDef.View
|
|
426
|
+
key={physicsKey}
|
|
427
|
+
properties={physics.properties}
|
|
428
|
+
position={transform.position}
|
|
429
|
+
rotation={transform.rotation}
|
|
430
|
+
scale={transform.scale}
|
|
431
|
+
editMode={editMode}
|
|
432
|
+
>{inner}</physicsDef.View>
|
|
433
|
+
);
|
|
297
434
|
}
|
|
298
435
|
|
|
299
|
-
return
|
|
436
|
+
return (
|
|
437
|
+
<group
|
|
438
|
+
ref={groupRef}
|
|
439
|
+
position={transform.position}
|
|
440
|
+
rotation={transform.rotation}
|
|
441
|
+
scale={transform.scale}
|
|
442
|
+
onPointerDown={onDown}
|
|
443
|
+
onPointerMove={() => (clickValid.current = false)}
|
|
444
|
+
onPointerUp={onUp}
|
|
445
|
+
>
|
|
446
|
+
{inner}
|
|
447
|
+
</group>
|
|
448
|
+
);
|
|
300
449
|
}
|
|
301
450
|
|
|
302
451
|
/* -------------------------------------------------- */
|
|
@@ -307,6 +456,7 @@ interface RendererProps {
|
|
|
307
456
|
gameObject: GameObjectType; // ← no longer optional
|
|
308
457
|
selectedId?: string | null;
|
|
309
458
|
onSelect?: (id: string) => void;
|
|
459
|
+
onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
|
|
310
460
|
registerRef: (id: string, obj: Object3D | null) => void;
|
|
311
461
|
loadedModels: Record<string, Object3D>;
|
|
312
462
|
loadedTextures: Record<string, Texture>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { RigidBody } from "@react-three/rapier";
|
|
1
|
+
import { RigidBody, RapierRigidBody } from "@react-three/rapier";
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
3
4
|
import { Component } from "./ComponentRegistry";
|
|
4
5
|
import { Label } from "./Input";
|
|
6
|
+
import { Quaternion, Euler } from 'three';
|
|
5
7
|
|
|
6
8
|
export interface PhysicsProps {
|
|
7
9
|
type: "fixed" | "dynamic";
|
|
@@ -52,18 +54,29 @@ interface PhysicsViewProps {
|
|
|
52
54
|
properties: { type?: 'dynamic' | 'fixed'; collider?: string };
|
|
53
55
|
editMode?: boolean;
|
|
54
56
|
children?: ReactNode;
|
|
57
|
+
position?: [number, number, number];
|
|
58
|
+
rotation?: [number, number, number];
|
|
59
|
+
scale?: [number, number, number];
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
function PhysicsComponentView({ properties,
|
|
58
|
-
if (editMode) return <>{children}</>;
|
|
59
|
-
|
|
62
|
+
function PhysicsComponentView({ properties, children, position, rotation, scale, editMode }: PhysicsViewProps) {
|
|
60
63
|
const colliders = properties.collider || (properties.type === 'fixed' ? 'trimesh' : 'hull');
|
|
61
64
|
|
|
62
|
-
//
|
|
63
|
-
|
|
65
|
+
// In edit mode, include position/rotation in key to force remount when transform changes
|
|
66
|
+
// This ensures the RigidBody debug visualization updates even when physics is paused
|
|
67
|
+
const rbKey = editMode
|
|
68
|
+
? `${properties.type || 'dynamic'}_${colliders}_${position?.join(',')}_${rotation?.join(',')}`
|
|
69
|
+
: `${properties.type || 'dynamic'}_${colliders}`;
|
|
64
70
|
|
|
65
71
|
return (
|
|
66
|
-
<RigidBody
|
|
72
|
+
<RigidBody
|
|
73
|
+
key={rbKey}
|
|
74
|
+
type={properties.type}
|
|
75
|
+
colliders={colliders as any}
|
|
76
|
+
position={position}
|
|
77
|
+
rotation={rotation}
|
|
78
|
+
scale={scale}
|
|
79
|
+
>
|
|
67
80
|
{children}
|
|
68
81
|
</RigidBody>
|
|
69
82
|
);
|