react-three-game 0.0.25 → 0.0.27

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.
@@ -1,29 +1,32 @@
1
1
  import React from "react";
2
- import { RigidBodyProps } from "@react-three/rapier";
3
- import { Object3D } from "three";
2
+ import { Object3D, Group } from "three";
4
3
  export type InstanceData = {
5
4
  id: string;
6
- meshPath: string;
7
5
  position: [number, number, number];
8
6
  rotation: [number, number, number];
9
7
  scale: [number, number, number];
8
+ meshPath: string;
10
9
  physics?: {
11
- type: RigidBodyProps['type'];
10
+ type: 'dynamic' | 'fixed';
12
11
  };
13
12
  };
14
- export declare function GameInstanceProvider({ children, models, onSelect, registerRef }: {
13
+ export declare function GameInstanceProvider({ children, models, onSelect, registerRef, selectedId, editMode }: {
15
14
  children: React.ReactNode;
16
- models: Record<string, Object3D>;
15
+ models: {
16
+ [filename: string]: Object3D;
17
+ };
17
18
  onSelect?: (id: string | null) => void;
18
19
  registerRef?: (id: string, obj: Object3D | null) => void;
20
+ selectedId?: string | null;
21
+ editMode?: boolean;
19
22
  }): import("react/jsx-runtime").JSX.Element;
20
- export declare function GameInstance({ id, modelUrl, position, rotation, scale, physics }: {
23
+ export declare const GameInstance: React.ForwardRefExoticComponent<{
21
24
  id: string;
22
25
  modelUrl: string;
23
26
  position: [number, number, number];
24
27
  rotation: [number, number, number];
25
28
  scale: [number, number, number];
26
29
  physics?: {
27
- type: RigidBodyProps['type'];
30
+ type: "dynamic" | "fixed";
28
31
  };
29
- }): null;
32
+ } & React.RefAttributes<Group<import("three").Object3DEventMap>>>;
@@ -1,140 +1,194 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
3
- import { Merged } from '@react-three/drei';
2
+ import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
3
+ import { Merged, useHelper } from '@react-three/drei';
4
4
  import { InstancedRigidBodies } from "@react-three/rapier";
5
- import { Mesh, Matrix4, Quaternion, Vector3 } from "three";
6
- // --- Helpers ---
7
- const tupleEqual = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
8
- const instanceChanged = (a, b) => {
5
+ import { Mesh, Matrix4, Vector3, Quaternion, Euler, BoxHelper } from "three";
6
+ // Helper functions for comparison
7
+ function arrayEquals(a, b) {
8
+ if (a === b)
9
+ return true;
10
+ if (a.length !== b.length)
11
+ return false;
12
+ for (let i = 0; i < a.length; i++) {
13
+ if (a[i] !== b[i])
14
+ return false;
15
+ }
16
+ return true;
17
+ }
18
+ function instanceEquals(a, b) {
9
19
  var _a, _b;
10
- return a.meshPath !== b.meshPath ||
11
- ((_a = a.physics) === null || _a === void 0 ? void 0 : _a.type) !== ((_b = b.physics) === null || _b === void 0 ? void 0 : _b.type) ||
12
- !tupleEqual(a.position, b.position) ||
13
- !tupleEqual(a.rotation, b.rotation) ||
14
- !tupleEqual(a.scale, b.scale);
15
- };
16
- function extractMeshParts(model) {
17
- model.updateWorldMatrix(false, true);
18
- const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
19
- const parts = [];
20
- model.traverse(child => {
21
- if (child.isMesh) {
22
- const mesh = child;
23
- const geometry = mesh.geometry.clone();
24
- geometry.applyMatrix4(mesh.matrixWorld.clone().premultiply(rootInverse));
25
- parts.push(new Mesh(geometry, mesh.material));
26
- }
27
- });
28
- return parts;
20
+ return a.id === b.id &&
21
+ a.meshPath === b.meshPath &&
22
+ arrayEquals(a.position, b.position) &&
23
+ arrayEquals(a.rotation, b.rotation) &&
24
+ arrayEquals(a.scale, b.scale) &&
25
+ ((_a = a.physics) === null || _a === void 0 ? void 0 : _a.type) === ((_b = b.physics) === null || _b === void 0 ? void 0 : _b.type);
29
26
  }
30
- // --- Context ---
31
27
  const GameInstanceContext = createContext(null);
32
- // --- Provider ---
33
- export function GameInstanceProvider({ children, models, onSelect, registerRef }) {
28
+ export function GameInstanceProvider({ children, models, onSelect, registerRef, selectedId, editMode }) {
34
29
  const [instances, setInstances] = useState([]);
35
30
  const addInstance = useCallback((instance) => {
36
31
  setInstances(prev => {
37
32
  const idx = prev.findIndex(i => i.id === instance.id);
38
- if (idx === -1)
39
- return [...prev, instance];
40
- if (!instanceChanged(prev[idx], instance))
41
- return prev;
42
- const updated = [...prev];
43
- updated[idx] = instance;
44
- return updated;
33
+ if (idx !== -1) {
34
+ // Update existing if changed
35
+ if (instanceEquals(prev[idx], instance)) {
36
+ return prev;
37
+ }
38
+ const copy = [...prev];
39
+ copy[idx] = instance;
40
+ return copy;
41
+ }
42
+ // Add new
43
+ return [...prev, instance];
45
44
  });
46
45
  }, []);
47
46
  const removeInstance = useCallback((id) => {
48
- setInstances(prev => prev.filter(i => i.id !== id));
47
+ setInstances(prev => {
48
+ if (!prev.find(i => i.id === id))
49
+ return prev;
50
+ return prev.filter(i => i.id !== id);
51
+ });
49
52
  }, []);
50
- // Extract mesh parts from models with baked local transforms
51
- const { meshParts, partCounts } = useMemo(() => {
52
- const meshParts = {};
53
- const partCounts = {};
53
+ // Flatten all model meshes once (models flat mesh parts)
54
+ // Note: Geometry is cloned with baked transforms for instancing
55
+ const { flatMeshes, modelParts } = useMemo(() => {
56
+ const flatMeshes = {};
57
+ const modelParts = {};
54
58
  Object.entries(models).forEach(([modelKey, model]) => {
55
- const parts = extractMeshParts(model);
56
- parts.forEach((mesh, i) => {
57
- meshParts[`${modelKey}__${i}`] = mesh;
59
+ model.updateWorldMatrix(false, true);
60
+ const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
61
+ let partIndex = 0;
62
+ model.traverse((obj) => {
63
+ if (obj.isMesh) {
64
+ // Clone geometry and bake relative transform
65
+ const geom = obj.geometry.clone();
66
+ geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
67
+ const partKey = `${modelKey}__${partIndex}`;
68
+ flatMeshes[partKey] = new Mesh(geom, obj.material);
69
+ partIndex++;
70
+ }
58
71
  });
59
- partCounts[modelKey] = parts.length;
72
+ modelParts[modelKey] = partIndex;
60
73
  });
61
- return { meshParts, partCounts };
74
+ return { flatMeshes, modelParts };
62
75
  }, [models]);
63
- // Cleanup cloned geometries
64
- useEffect(() => () => {
65
- Object.values(meshParts).forEach(mesh => mesh.geometry.dispose());
66
- }, [meshParts]);
67
- // Group instances by model + physics type
76
+ // Cleanup geometries when models change
77
+ useEffect(() => {
78
+ return () => {
79
+ Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
80
+ };
81
+ }, [flatMeshes]);
82
+ // Group instances by meshPath + physics type for batch rendering
68
83
  const grouped = useMemo(() => {
84
+ var _a;
69
85
  const groups = {};
70
- instances.forEach(inst => {
71
- var _a, _b, _c;
72
- const physicsType = (_b = (_a = inst.physics) === null || _a === void 0 ? void 0 : _a.type) !== null && _b !== void 0 ? _b : 'none';
73
- const key = `${inst.meshPath}__${physicsType}`;
74
- (_c = groups[key]) !== null && _c !== void 0 ? _c : (groups[key] = { physicsType, instances: [] });
86
+ for (const inst of instances) {
87
+ const type = ((_a = inst.physics) === null || _a === void 0 ? void 0 : _a.type) || 'none';
88
+ const key = `${inst.meshPath}__${type}`;
89
+ if (!groups[key])
90
+ groups[key] = { physicsType: type, instances: [] };
75
91
  groups[key].instances.push(inst);
76
- });
92
+ }
77
93
  return groups;
78
94
  }, [instances]);
79
- const contextValue = useMemo(() => ({ addInstance, removeInstance }), [addInstance, removeInstance]);
80
- return (_jsxs(GameInstanceContext.Provider, { value: contextValue, children: [children, Object.entries(grouped).map(([key, group]) => {
81
- var _a;
95
+ return (_jsxs(GameInstanceContext.Provider, { value: {
96
+ addInstance,
97
+ removeInstance,
98
+ instances,
99
+ meshes: flatMeshes,
100
+ modelParts
101
+ }, children: [children, Object.entries(grouped).map(([key, group]) => {
102
+ if (group.physicsType === 'none')
103
+ return null;
104
+ const modelKey = group.instances[0].meshPath;
105
+ const partCount = modelParts[modelKey] || 0;
106
+ if (partCount === 0)
107
+ return null;
108
+ return (_jsx(InstancedRigidGroup, { group: group, modelKey: modelKey, partCount: partCount, flatMeshes: flatMeshes }, key));
109
+ }), Object.entries(grouped).map(([key, group]) => {
110
+ if (group.physicsType !== 'none')
111
+ return null;
82
112
  const modelKey = group.instances[0].meshPath;
83
- const partCount = (_a = partCounts[modelKey]) !== null && _a !== void 0 ? _a : 0;
113
+ const partCount = modelParts[modelKey] || 0;
84
114
  if (partCount === 0)
85
115
  return null;
86
- if (group.physicsType !== 'none') {
87
- return (_jsx(PhysicsInstances, { instances: group.instances, physicsType: group.physicsType, modelKey: modelKey, partCount: partCount, meshParts: meshParts }, key));
116
+ // Create mesh subset for this specific model
117
+ const meshesForModel = {};
118
+ for (let i = 0; i < partCount; i++) {
119
+ const partKey = `${modelKey}__${i}`;
120
+ meshesForModel[partKey] = flatMeshes[partKey];
88
121
  }
89
- const modelMeshes = Object.fromEntries(Array.from({ length: partCount }, (_, i) => [`${modelKey}__${i}`, meshParts[`${modelKey}__${i}`]]));
90
- return (_jsx(Merged, { meshes: modelMeshes, castShadow: true, receiveShadow: true, children: (Components) => (_jsx(StaticInstances, { instances: group.instances, modelKey: modelKey, partCount: partCount, Components: Components, onSelect: onSelect, registerRef: registerRef })) }, key));
122
+ return (_jsx(Merged, { meshes: meshesForModel, castShadow: true, receiveShadow: true, children: (instancesMap) => (_jsx(NonPhysicsInstancedGroup, { modelKey: modelKey, group: group, partCount: partCount, instancesMap: instancesMap, onSelect: onSelect, registerRef: registerRef, selectedId: selectedId, editMode: editMode })) }, key));
91
123
  })] }));
92
124
  }
93
- // --- Physics Instances ---
94
- // InstancedRigidBodies manages position/rotation from physics simulation.
95
- // We apply scale once when instances change via useEffect.
96
- function PhysicsInstances({ instances, physicsType, modelKey, partCount, meshParts }) {
125
+ // Render physics-enabled instances using InstancedRigidBodies
126
+ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes }) {
97
127
  const meshRefs = useRef([]);
98
- // InstancedRigidBodies needs position/rotation (scale handled separately)
99
- const rigidBodyInstances = useMemo(() => instances.map(({ id, position, rotation }) => ({ key: id, position, rotation })), [instances]);
100
- // Apply scale when instances change
128
+ const instances = useMemo(() => group.instances.map(inst => ({
129
+ key: inst.id,
130
+ position: inst.position,
131
+ rotation: inst.rotation,
132
+ scale: inst.scale,
133
+ })), [group.instances]);
134
+ // Apply scale to visual meshes (InstancedRigidBodies only scales colliders, not visuals)
101
135
  useEffect(() => {
102
136
  const matrix = new Matrix4();
103
137
  const pos = new Vector3();
104
138
  const quat = new Quaternion();
139
+ const euler = new Euler();
105
140
  const scl = new Vector3();
106
141
  meshRefs.current.forEach(mesh => {
107
142
  if (!mesh)
108
143
  return;
109
- instances.forEach((inst, i) => {
110
- mesh.getMatrixAt(i, matrix);
111
- matrix.decompose(pos, quat, scl);
144
+ group.instances.forEach((inst, i) => {
145
+ pos.set(...inst.position);
146
+ euler.set(...inst.rotation);
147
+ quat.setFromEuler(euler);
112
148
  scl.set(...inst.scale);
113
149
  matrix.compose(pos, quat, scl);
114
150
  mesh.setMatrixAt(i, matrix);
115
151
  });
116
152
  mesh.instanceMatrix.needsUpdate = true;
117
153
  });
118
- }, [instances]);
119
- return (_jsx(InstancedRigidBodies, { instances: rigidBodyInstances, type: physicsType, colliders: physicsType === 'fixed' ? 'trimesh' : 'hull', children: Array.from({ length: partCount }, (_, i) => {
120
- const mesh = meshParts[`${modelKey}__${i}`];
121
- return mesh ? (_jsx("instancedMesh", { ref: el => { meshRefs.current[i] = el; }, args: [mesh.geometry, mesh.material, instances.length], frustumCulled: false, castShadow: true, receiveShadow: true }, i)) : null;
154
+ }, [group.instances]);
155
+ const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
156
+ return (_jsx(InstancedRigidBodies, { instances: instances, colliders: colliders, type: group.physicsType, children: Array.from({ length: partCount }).map((_, i) => {
157
+ const mesh = flatMeshes[`${modelKey}__${i}`];
158
+ if (!mesh)
159
+ 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));
122
161
  }) }));
123
162
  }
124
- // --- Static Instances (non-physics) ---
125
- function StaticInstances({ instances, modelKey, partCount, Components, onSelect, registerRef }) {
126
- const Parts = useMemo(() => Array.from({ length: partCount }, (_, i) => Components[`${modelKey}__${i}`]).filter(Boolean), [Components, modelKey, partCount]);
127
- return (_jsx(_Fragment, { children: instances.map(inst => (_jsx(InstanceItem, { instance: inst, Parts: Parts, onSelect: onSelect, registerRef: registerRef }, inst.id))) }));
163
+ // Render non-physics instances using Merged (instancing without rigid bodies)
164
+ function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, onSelect, registerRef, selectedId, editMode }) {
165
+ // Pre-compute which Instance components exist for this model
166
+ const InstanceComponents = useMemo(() => Array.from({ length: partCount }, (_, i) => instancesMap[`${modelKey}__${i}`]).filter(Boolean), [instancesMap, modelKey, partCount]);
167
+ return (_jsx(_Fragment, { children: group.instances.map(inst => (_jsx(InstanceGroupItem, { instance: inst, InstanceComponents: InstanceComponents, onSelect: onSelect, registerRef: registerRef, selectedId: selectedId, editMode: editMode }, inst.id))) }));
128
168
  }
129
- // --- Single Instance ---
130
- function InstanceItem({ instance, Parts, onSelect, registerRef }) {
131
- const moved = useRef(false);
132
- return (_jsx("group", { ref: el => registerRef === null || registerRef === void 0 ? void 0 : registerRef(instance.id, el), position: instance.position, rotation: instance.rotation, scale: instance.scale, onPointerDown: e => { e.stopPropagation(); moved.current = false; }, onPointerMove: () => { moved.current = true; }, onPointerUp: e => { e.stopPropagation(); if (!moved.current)
133
- onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.id); }, children: Parts.map((Part, i) => _jsx(Part, {}, i)) }));
169
+ // Individual instance item with its own click state
170
+ function InstanceGroupItem({ instance, InstanceComponents, onSelect, registerRef, selectedId, editMode }) {
171
+ const clickValid = useRef(false);
172
+ const groupRef = useRef(null);
173
+ const isSelected = selectedId === instance.id;
174
+ // Use BoxHelper when object is selected in edit mode
175
+ useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
176
+ useEffect(() => {
177
+ registerRef === null || registerRef === void 0 ? void 0 : registerRef(instance.id, groupRef.current);
178
+ }, [instance.id, registerRef]);
179
+ return (_jsx("group", { ref: groupRef, position: instance.position, rotation: instance.rotation, scale: instance.scale, onPointerDown: (e) => { e.stopPropagation(); clickValid.current = true; }, onPointerMove: () => { clickValid.current = false; }, onPointerUp: (e) => {
180
+ if (clickValid.current) {
181
+ e.stopPropagation();
182
+ onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.id);
183
+ }
184
+ clickValid.current = false;
185
+ }, children: InstanceComponents.map((Instance, i) => _jsx(Instance, {}, i)) }));
134
186
  }
135
- // --- GameInstance (declarative registration) ---
136
- export function GameInstance({ id, modelUrl, position, rotation, scale, physics }) {
187
+ // GameInstance component: registers an instance for batch rendering (renders nothing itself)
188
+ export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
137
189
  const ctx = useContext(GameInstanceContext);
190
+ const addInstance = ctx === null || ctx === void 0 ? void 0 : ctx.addInstance;
191
+ const removeInstance = ctx === null || ctx === void 0 ? void 0 : ctx.removeInstance;
138
192
  const instance = useMemo(() => ({
139
193
  id,
140
194
  meshPath: modelUrl,
@@ -144,10 +198,13 @@ export function GameInstance({ id, modelUrl, position, rotation, scale, physics
144
198
  physics,
145
199
  }), [id, modelUrl, position, rotation, scale, physics]);
146
200
  useEffect(() => {
147
- if (!ctx)
201
+ if (!addInstance || !removeInstance)
148
202
  return;
149
- ctx.addInstance(instance);
150
- return () => ctx.removeInstance(id);
151
- }, [ctx, instance, id]);
203
+ addInstance(instance);
204
+ return () => {
205
+ removeInstance(instance.id);
206
+ };
207
+ }, [addInstance, removeInstance, instance]);
208
+ // No visual rendering - provider handles all instanced visuals
152
209
  return null;
153
- }
210
+ });
@@ -9,9 +9,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
12
- import { MapControls, TransformControls } from "@react-three/drei";
12
+ import { MapControls, TransformControls, useHelper } from "@react-three/drei";
13
13
  import { useState, useRef, useEffect, forwardRef, useCallback } from "react";
14
- import { Vector3, Euler, Quaternion, SRGBColorSpace, TextureLoader, Matrix4 } from "three";
14
+ import { Vector3, Euler, Quaternion, SRGBColorSpace, TextureLoader, Matrix4, BoxHelper } from "three";
15
15
  import { getComponent, registerComponent } from "./components/ComponentRegistry";
16
16
  import { loadModel } from "../dragdrop/modelLoader";
17
17
  import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
@@ -105,7 +105,7 @@ export const PrefabRoot = forwardRef(({ editMode, data, onPrefabChange, selected
105
105
  });
106
106
  loadAssets();
107
107
  }, [data, loadedModels, loadedTextures]);
108
- return _jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: loadedModels, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: new Matrix4() }) }), editMode && _jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedId && selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange }))] })] });
108
+ return _jsxs("group", { ref: ref, children: [_jsx(GameInstanceProvider, { models: loadedModels, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, selectedId: selectedId, editMode: editMode, children: _jsx(GameObjectRenderer, { gameObject: data.root, selectedId: selectedId, onSelect: editMode ? onSelect : undefined, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: new Matrix4() }) }), editMode && _jsxs(_Fragment, { children: [_jsx(MapControls, { makeDefault: true }), selectedId && selectedObject && (_jsx(TransformControls, { object: selectedObject, mode: transformMode, space: "local", onObjectChange: onTransformChange }))] })] });
109
109
  });
110
110
  function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loadedModels, loadedTextures, editMode, parentMatrix = new Matrix4(), }) {
111
111
  var _a, _b, _c, _d, _e;
@@ -146,8 +146,15 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
146
146
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
147
147
  // --- 5. Render children recursively (always relative transforms) ---
148
148
  const children = ((_d = gameObject.children) !== null && _d !== void 0 ? _d : []).map((child) => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: worldMatrix }, child.id)));
149
- // --- 6. Inner content group with full transform ---
150
- const innerGroup = (_jsxs("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [core, children] }));
149
+ // --- 6. Inner content group with full transform and selection helper ---
150
+ const groupRef = useRef(null);
151
+ const isSelected = selectedId === gameObject.id;
152
+ // Show BoxHelper when selected in edit mode
153
+ useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
154
+ useEffect(() => {
155
+ registerRef(gameObject.id, groupRef.current);
156
+ }, [gameObject.id, registerRef]);
157
+ const innerGroup = (_jsxs("group", { ref: groupRef, position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [core, children] }));
151
158
  // --- 7. Wrap with physics if needed (RigidBody as outer parent, no transform) ---
152
159
  const physics = (_e = gameObject.components) === null || _e === void 0 ? void 0 : _e.physics;
153
160
  if (physics && !editMode) {
@@ -183,7 +190,6 @@ function renderCoreNode(gameObject, ctx, parentMatrix) {
183
190
  const contextProps = {
184
191
  loadedModels: ctx.loadedModels,
185
192
  loadedTextures: ctx.loadedTextures,
186
- isSelected: ctx.selectedId === gameObject.id,
187
193
  editMode: ctx.editMode,
188
194
  parentMatrix,
189
195
  registerRef: ctx.registerRef,
@@ -25,7 +25,7 @@ function MaterialComponentEditor({ component, onUpdate, basePath = "" }) {
25
25
  import { useMemo } from 'react';
26
26
  import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace } from 'three';
27
27
  // View for Material component
28
- function MaterialComponentView({ properties, loadedTextures, isSelected }) {
28
+ function MaterialComponentView({ properties, loadedTextures }) {
29
29
  var _a;
30
30
  const textureName = properties === null || properties === void 0 ? void 0 : properties.texture;
31
31
  const repeat = properties === null || properties === void 0 ? void 0 : properties.repeat;
@@ -52,8 +52,7 @@ function MaterialComponentView({ properties, loadedTextures, isSelected }) {
52
52
  return _jsx("meshStandardMaterial", { color: "red", wireframe: true });
53
53
  }
54
54
  const { color, wireframe = false } = properties;
55
- const displayColor = isSelected ? "cyan" : color;
56
- return (_jsx("meshStandardMaterial", { color: displayColor, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture, side: DoubleSide }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
55
+ return (_jsx("meshStandardMaterial", { color: color, wireframe: wireframe, map: finalTexture, transparent: !!finalTexture, side: DoubleSide }, (_a = finalTexture === null || finalTexture === void 0 ? void 0 : finalTexture.uuid) !== null && _a !== void 0 ? _a : 'no-texture'));
57
56
  }
58
57
  const MaterialComponent = {
59
58
  name: 'Material',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -1,159 +1,198 @@
1
1
  import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
2
- import { Merged } from '@react-three/drei';
3
- import { InstancedRigidBodies, RigidBodyProps } from "@react-three/rapier";
4
- import { Mesh, Matrix4, Object3D, Quaternion, Vector3, InstancedMesh } from "three";
2
+ import { Merged, useHelper } from '@react-three/drei';
3
+ import { InstancedRigidBodies } from "@react-three/rapier";
4
+ import { Mesh, Matrix4, Object3D, Group, Vector3, Quaternion, Euler, InstancedMesh, BoxHelper } from "three";
5
5
 
6
6
  // --- Types ---
7
7
  export type InstanceData = {
8
8
  id: string;
9
- meshPath: string;
10
9
  position: [number, number, number];
11
10
  rotation: [number, number, number];
12
11
  scale: [number, number, number];
13
- physics?: { type: RigidBodyProps['type'] };
12
+ meshPath: string;
13
+ physics?: { type: 'dynamic' | 'fixed' };
14
14
  };
15
15
 
16
- type GroupedInstances = Record<string, {
17
- physicsType: string;
18
- instances: InstanceData[];
19
- }>;
16
+ // Helper functions for comparison
17
+ function arrayEquals(a: number[], b: number[]): boolean {
18
+ if (a === b) return true;
19
+ if (a.length !== b.length) return false;
20
+ for (let i = 0; i < a.length; i++) {
21
+ if (a[i] !== b[i]) return false;
22
+ }
23
+ return true;
24
+ }
20
25
 
26
+ function instanceEquals(a: InstanceData, b: InstanceData): boolean {
27
+ return a.id === b.id &&
28
+ a.meshPath === b.meshPath &&
29
+ arrayEquals(a.position, b.position) &&
30
+ arrayEquals(a.rotation, b.rotation) &&
31
+ arrayEquals(a.scale, b.scale) &&
32
+ a.physics?.type === b.physics?.type;
33
+ }
34
+
35
+ // --- Context ---
21
36
  type GameInstanceContextType = {
22
37
  addInstance: (instance: InstanceData) => void;
23
38
  removeInstance: (id: string) => void;
39
+ instances: InstanceData[];
40
+ meshes: Record<string, Mesh>;
41
+ modelParts?: Record<string, number>;
24
42
  };
25
-
26
- // --- Helpers ---
27
- const tupleEqual = (a: readonly number[], b: readonly number[]) =>
28
- a.length === b.length && a.every((v, i) => v === b[i]);
29
-
30
- const instanceChanged = (a: InstanceData, b: InstanceData) =>
31
- a.meshPath !== b.meshPath ||
32
- a.physics?.type !== b.physics?.type ||
33
- !tupleEqual(a.position, b.position) ||
34
- !tupleEqual(a.rotation, b.rotation) ||
35
- !tupleEqual(a.scale, b.scale);
36
-
37
- function extractMeshParts(model: Object3D): Mesh[] {
38
- model.updateWorldMatrix(false, true);
39
- const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
40
- const parts: Mesh[] = [];
41
-
42
- model.traverse(child => {
43
- if ((child as Mesh).isMesh) {
44
- const mesh = child as Mesh;
45
- const geometry = mesh.geometry.clone();
46
- geometry.applyMatrix4(mesh.matrixWorld.clone().premultiply(rootInverse));
47
- parts.push(new Mesh(geometry, mesh.material));
48
- }
49
- });
50
-
51
- return parts;
52
- }
53
-
54
- // --- Context ---
55
43
  const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
56
44
 
57
- // --- Provider ---
58
45
  export function GameInstanceProvider({
59
46
  children,
60
47
  models,
61
48
  onSelect,
62
- registerRef
49
+ registerRef,
50
+ selectedId,
51
+ editMode
63
52
  }: {
64
- children: React.ReactNode;
65
- models: Record<string, Object3D>;
66
- onSelect?: (id: string | null) => void;
67
- registerRef?: (id: string, obj: Object3D | null) => void;
53
+ children: React.ReactNode,
54
+ models: { [filename: string]: Object3D },
55
+ onSelect?: (id: string | null) => void,
56
+ registerRef?: (id: string, obj: Object3D | null) => void,
57
+ selectedId?: string | null,
58
+ editMode?: boolean
68
59
  }) {
69
60
  const [instances, setInstances] = useState<InstanceData[]>([]);
70
61
 
71
62
  const addInstance = useCallback((instance: InstanceData) => {
72
63
  setInstances(prev => {
73
64
  const idx = prev.findIndex(i => i.id === instance.id);
74
- if (idx === -1) return [...prev, instance];
75
- if (!instanceChanged(prev[idx], instance)) return prev;
76
- const updated = [...prev];
77
- updated[idx] = instance;
78
- return updated;
65
+ if (idx !== -1) {
66
+ // Update existing if changed
67
+ if (instanceEquals(prev[idx], instance)) {
68
+ return prev;
69
+ }
70
+ const copy = [...prev];
71
+ copy[idx] = instance;
72
+ return copy;
73
+ }
74
+ // Add new
75
+ return [...prev, instance];
79
76
  });
80
77
  }, []);
81
78
 
82
79
  const removeInstance = useCallback((id: string) => {
83
- setInstances(prev => prev.filter(i => i.id !== id));
80
+ setInstances(prev => {
81
+ if (!prev.find(i => i.id === id)) return prev;
82
+ return prev.filter(i => i.id !== id);
83
+ });
84
84
  }, []);
85
85
 
86
- // Extract mesh parts from models with baked local transforms
87
- const { meshParts, partCounts } = useMemo(() => {
88
- const meshParts: Record<string, Mesh> = {};
89
- const partCounts: Record<string, number> = {};
86
+ // Flatten all model meshes once (models flat mesh parts)
87
+ // Note: Geometry is cloned with baked transforms for instancing
88
+ const { flatMeshes, modelParts } = useMemo(() => {
89
+ const flatMeshes: Record<string, Mesh> = {};
90
+ const modelParts: Record<string, number> = {};
90
91
 
91
92
  Object.entries(models).forEach(([modelKey, model]) => {
92
- const parts = extractMeshParts(model);
93
- parts.forEach((mesh, i) => {
94
- meshParts[`${modelKey}__${i}`] = mesh;
93
+ model.updateWorldMatrix(false, true);
94
+ const rootInverse = new Matrix4().copy(model.matrixWorld).invert();
95
+
96
+ let partIndex = 0;
97
+ model.traverse((obj: any) => {
98
+ if (obj.isMesh) {
99
+ // Clone geometry and bake relative transform
100
+ const geom = obj.geometry.clone();
101
+ geom.applyMatrix4(obj.matrixWorld.clone().premultiply(rootInverse));
102
+
103
+ const partKey = `${modelKey}__${partIndex}`;
104
+ flatMeshes[partKey] = new Mesh(geom, obj.material);
105
+ partIndex++;
106
+ }
95
107
  });
96
- partCounts[modelKey] = parts.length;
108
+ modelParts[modelKey] = partIndex;
97
109
  });
98
110
 
99
- return { meshParts, partCounts };
111
+ return { flatMeshes, modelParts };
100
112
  }, [models]);
101
113
 
102
- // Cleanup cloned geometries
103
- useEffect(() => () => {
104
- Object.values(meshParts).forEach(mesh => mesh.geometry.dispose());
105
- }, [meshParts]);
106
-
107
- // Group instances by model + physics type
108
- const grouped = useMemo<GroupedInstances>(() => {
109
- const groups: GroupedInstances = {};
110
- instances.forEach(inst => {
111
- const physicsType = inst.physics?.type ?? 'none';
112
- const key = `${inst.meshPath}__${physicsType}`;
113
- groups[key] ??= { physicsType, instances: [] };
114
+ // Cleanup geometries when models change
115
+ useEffect(() => {
116
+ return () => {
117
+ Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
118
+ };
119
+ }, [flatMeshes]);
120
+
121
+ // Group instances by meshPath + physics type for batch rendering
122
+ const grouped = useMemo(() => {
123
+ const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
124
+ for (const inst of instances) {
125
+ const type = inst.physics?.type || 'none';
126
+ const key = `${inst.meshPath}__${type}`;
127
+ if (!groups[key]) groups[key] = { physicsType: type, instances: [] };
114
128
  groups[key].instances.push(inst);
115
- });
129
+ }
116
130
  return groups;
117
131
  }, [instances]);
118
132
 
119
- const contextValue = useMemo(() => ({ addInstance, removeInstance }), [addInstance, removeInstance]);
120
-
121
133
  return (
122
- <GameInstanceContext.Provider value={contextValue}>
134
+ <GameInstanceContext.Provider
135
+ value={{
136
+ addInstance,
137
+ removeInstance,
138
+ instances,
139
+ meshes: flatMeshes,
140
+ modelParts
141
+ }}
142
+ >
143
+ {/* Render normal prefab hierarchy (non-instanced objects) */}
123
144
  {children}
124
145
 
146
+ {/* Render physics-enabled instanced groups using InstancedRigidBodies */}
125
147
  {Object.entries(grouped).map(([key, group]) => {
148
+ if (group.physicsType === 'none') return null;
126
149
  const modelKey = group.instances[0].meshPath;
127
- const partCount = partCounts[modelKey] ?? 0;
150
+ const partCount = modelParts[modelKey] || 0;
128
151
  if (partCount === 0) return null;
129
152
 
130
- if (group.physicsType !== 'none') {
131
- return (
132
- <PhysicsInstances
133
- key={key}
134
- instances={group.instances}
135
- physicsType={group.physicsType as RigidBodyProps['type']}
136
- modelKey={modelKey}
137
- partCount={partCount}
138
- meshParts={meshParts}
139
- />
140
- );
141
- }
142
-
143
- const modelMeshes = Object.fromEntries(
144
- Array.from({ length: partCount }, (_, i) => [`${modelKey}__${i}`, meshParts[`${modelKey}__${i}`]])
153
+ return (
154
+ <InstancedRigidGroup
155
+ key={key}
156
+ group={group}
157
+ modelKey={modelKey}
158
+ partCount={partCount}
159
+ flatMeshes={flatMeshes}
160
+ />
145
161
  );
162
+ })}
163
+
164
+ {/* Render non-physics instanced visuals using Merged (one per model type) */}
165
+ {Object.entries(grouped).map(([key, group]) => {
166
+ if (group.physicsType !== 'none') return null;
167
+
168
+ const modelKey = group.instances[0].meshPath;
169
+ const partCount = modelParts[modelKey] || 0;
170
+ if (partCount === 0) return null;
171
+
172
+ // Create mesh subset for this specific model
173
+ const meshesForModel: Record<string, Mesh> = {};
174
+ for (let i = 0; i < partCount; i++) {
175
+ const partKey = `${modelKey}__${i}`;
176
+ meshesForModel[partKey] = flatMeshes[partKey];
177
+ }
146
178
 
147
179
  return (
148
- <Merged key={key} meshes={modelMeshes} castShadow receiveShadow>
149
- {(Components: Record<string, React.ComponentType>) => (
150
- <StaticInstances
151
- instances={group.instances}
180
+ <Merged
181
+ key={key}
182
+ meshes={meshesForModel}
183
+ castShadow
184
+ receiveShadow
185
+ >
186
+ {(instancesMap: any) => (
187
+ <NonPhysicsInstancedGroup
152
188
  modelKey={modelKey}
189
+ group={group}
153
190
  partCount={partCount}
154
- Components={Components}
191
+ instancesMap={instancesMap}
155
192
  onSelect={onSelect}
156
193
  registerRef={registerRef}
194
+ selectedId={selectedId}
195
+ editMode={editMode}
157
196
  />
158
197
  )}
159
198
  </Merged>
@@ -163,150 +202,190 @@ export function GameInstanceProvider({
163
202
  );
164
203
  }
165
204
 
166
- // --- Physics Instances ---
167
- // InstancedRigidBodies manages position/rotation from physics simulation.
168
- // We apply scale once when instances change via useEffect.
169
- function PhysicsInstances({
170
- instances,
171
- physicsType,
205
+ // Render physics-enabled instances using InstancedRigidBodies
206
+ function InstancedRigidGroup({
207
+ group,
172
208
  modelKey,
173
209
  partCount,
174
- meshParts
210
+ flatMeshes
175
211
  }: {
176
- instances: InstanceData[];
177
- physicsType: RigidBodyProps['type'];
178
- modelKey: string;
179
- partCount: number;
180
- meshParts: Record<string, Mesh>;
212
+ group: { physicsType: string, instances: InstanceData[] },
213
+ modelKey: string,
214
+ partCount: number,
215
+ flatMeshes: Record<string, Mesh>
181
216
  }) {
182
217
  const meshRefs = useRef<(InstancedMesh | null)[]>([]);
183
218
 
184
- // InstancedRigidBodies needs position/rotation (scale handled separately)
185
- const rigidBodyInstances = useMemo(() =>
186
- instances.map(({ id, position, rotation }) => ({ key: id, position, rotation })),
187
- [instances]
219
+ const instances = useMemo(
220
+ () => group.instances.map(inst => ({
221
+ key: inst.id,
222
+ position: inst.position,
223
+ rotation: inst.rotation,
224
+ scale: inst.scale,
225
+ })),
226
+ [group.instances]
188
227
  );
189
228
 
190
- // Apply scale when instances change
229
+ // Apply scale to visual meshes (InstancedRigidBodies only scales colliders, not visuals)
191
230
  useEffect(() => {
192
231
  const matrix = new Matrix4();
193
232
  const pos = new Vector3();
194
233
  const quat = new Quaternion();
234
+ const euler = new Euler();
195
235
  const scl = new Vector3();
196
236
 
197
237
  meshRefs.current.forEach(mesh => {
198
238
  if (!mesh) return;
199
239
 
200
- instances.forEach((inst, i) => {
201
- mesh.getMatrixAt(i, matrix);
202
- matrix.decompose(pos, quat, scl);
240
+ group.instances.forEach((inst, i) => {
241
+ pos.set(...inst.position);
242
+ euler.set(...inst.rotation);
243
+ quat.setFromEuler(euler);
203
244
  scl.set(...inst.scale);
204
245
  matrix.compose(pos, quat, scl);
205
246
  mesh.setMatrixAt(i, matrix);
206
247
  });
207
248
  mesh.instanceMatrix.needsUpdate = true;
208
249
  });
209
- }, [instances]);
250
+ }, [group.instances]);
251
+
252
+ const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
210
253
 
211
254
  return (
212
255
  <InstancedRigidBodies
213
- instances={rigidBodyInstances}
214
- type={physicsType}
215
- colliders={physicsType === 'fixed' ? 'trimesh' : 'hull'}
256
+ instances={instances}
257
+ colliders={colliders}
258
+ type={group.physicsType as 'dynamic' | 'fixed'}
216
259
  >
217
- {Array.from({ length: partCount }, (_, i) => {
218
- const mesh = meshParts[`${modelKey}__${i}`];
219
- return mesh ? (
260
+ {Array.from({ length: partCount }).map((_, i) => {
261
+ const mesh = flatMeshes[`${modelKey}__${i}`];
262
+ if (!mesh) return null;
263
+ return (
220
264
  <instancedMesh
221
265
  key={i}
222
266
  ref={el => { meshRefs.current[i] = el; }}
223
- args={[mesh.geometry, mesh.material, instances.length]}
224
- frustumCulled={false}
267
+ args={[mesh.geometry, mesh.material, group.instances.length]}
225
268
  castShadow
226
269
  receiveShadow
270
+ frustumCulled={false}
227
271
  />
228
- ) : null;
272
+ );
229
273
  })}
230
274
  </InstancedRigidBodies>
231
275
  );
232
276
  }
233
277
 
234
- // --- Static Instances (non-physics) ---
235
- function StaticInstances({
236
- instances,
278
+ // Render non-physics instances using Merged (instancing without rigid bodies)
279
+ function NonPhysicsInstancedGroup({
237
280
  modelKey,
281
+ group,
238
282
  partCount,
239
- Components,
283
+ instancesMap,
240
284
  onSelect,
241
- registerRef
285
+ registerRef,
286
+ selectedId,
287
+ editMode
242
288
  }: {
243
- instances: InstanceData[];
244
289
  modelKey: string;
290
+ group: { physicsType: string, instances: InstanceData[] };
245
291
  partCount: number;
246
- Components: Record<string, React.ComponentType>;
292
+ instancesMap: Record<string, React.ComponentType<any>>;
247
293
  onSelect?: (id: string | null) => void;
248
294
  registerRef?: (id: string, obj: Object3D | null) => void;
295
+ selectedId?: string | null;
296
+ editMode?: boolean;
249
297
  }) {
250
- const Parts = useMemo(() =>
251
- Array.from({ length: partCount }, (_, i) => Components[`${modelKey}__${i}`]).filter(Boolean),
252
- [Components, modelKey, partCount]
298
+ // Pre-compute which Instance components exist for this model
299
+ const InstanceComponents = useMemo(() =>
300
+ Array.from({ length: partCount }, (_, i) => instancesMap[`${modelKey}__${i}`]).filter(Boolean),
301
+ [instancesMap, modelKey, partCount]
253
302
  );
254
303
 
255
304
  return (
256
305
  <>
257
- {instances.map(inst => (
258
- <InstanceItem key={inst.id} instance={inst} Parts={Parts} onSelect={onSelect} registerRef={registerRef} />
306
+ {group.instances.map(inst => (
307
+ <InstanceGroupItem
308
+ key={inst.id}
309
+ instance={inst}
310
+ InstanceComponents={InstanceComponents}
311
+ onSelect={onSelect}
312
+ registerRef={registerRef}
313
+ selectedId={selectedId}
314
+ editMode={editMode}
315
+ />
259
316
  ))}
260
317
  </>
261
318
  );
262
319
  }
263
320
 
264
- // --- Single Instance ---
265
- function InstanceItem({
321
+ // Individual instance item with its own click state
322
+ function InstanceGroupItem({
266
323
  instance,
267
- Parts,
324
+ InstanceComponents,
268
325
  onSelect,
269
- registerRef
326
+ registerRef,
327
+ selectedId,
328
+ editMode
270
329
  }: {
271
330
  instance: InstanceData;
272
- Parts: React.ComponentType[];
331
+ InstanceComponents: React.ComponentType<any>[];
273
332
  onSelect?: (id: string | null) => void;
274
333
  registerRef?: (id: string, obj: Object3D | null) => void;
334
+ selectedId?: string | null;
335
+ editMode?: boolean;
275
336
  }) {
276
- const moved = useRef(false);
337
+ const clickValid = useRef(false);
338
+ const groupRef = useRef<Group>(null!);
339
+ const isSelected = selectedId === instance.id;
340
+
341
+ // Use BoxHelper when object is selected in edit mode
342
+ useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
343
+
344
+ useEffect(() => {
345
+ registerRef?.(instance.id, groupRef.current);
346
+ }, [instance.id, registerRef]);
277
347
 
278
348
  return (
279
349
  <group
280
- ref={el => registerRef?.(instance.id, el)}
350
+ ref={groupRef}
281
351
  position={instance.position}
282
352
  rotation={instance.rotation}
283
353
  scale={instance.scale}
284
- onPointerDown={e => { e.stopPropagation(); moved.current = false; }}
285
- onPointerMove={() => { moved.current = true; }}
286
- onPointerUp={e => { e.stopPropagation(); if (!moved.current) onSelect?.(instance.id); }}
354
+ onPointerDown={(e) => { e.stopPropagation(); clickValid.current = true; }}
355
+ onPointerMove={() => { clickValid.current = false; }}
356
+ onPointerUp={(e) => {
357
+ if (clickValid.current) {
358
+ e.stopPropagation();
359
+ onSelect?.(instance.id);
360
+ }
361
+ clickValid.current = false;
362
+ }}
287
363
  >
288
- {Parts.map((Part, i) => <Part key={i} />)}
364
+ {InstanceComponents.map((Instance, i) => <Instance key={i} />)}
289
365
  </group>
290
366
  );
291
367
  }
292
368
 
293
- // --- GameInstance (declarative registration) ---
294
- export function GameInstance({
295
- id,
296
- modelUrl,
297
- position,
298
- rotation,
299
- scale,
300
- physics
301
- }: {
369
+
370
+ // GameInstance component: registers an instance for batch rendering (renders nothing itself)
371
+ export const GameInstance = React.forwardRef<Group, {
302
372
  id: string;
303
373
  modelUrl: string;
304
374
  position: [number, number, number];
305
375
  rotation: [number, number, number];
306
376
  scale: [number, number, number];
307
- physics?: { type: RigidBodyProps['type'] };
308
- }) {
377
+ physics?: { type: 'dynamic' | 'fixed' };
378
+ }>(({
379
+ id,
380
+ modelUrl,
381
+ position,
382
+ rotation,
383
+ scale,
384
+ physics = undefined,
385
+ }, ref) => {
309
386
  const ctx = useContext(GameInstanceContext);
387
+ const addInstance = ctx?.addInstance;
388
+ const removeInstance = ctx?.removeInstance;
310
389
 
311
390
  const instance = useMemo<InstanceData>(() => ({
312
391
  id,
@@ -318,10 +397,13 @@ export function GameInstance({
318
397
  }), [id, modelUrl, position, rotation, scale, physics]);
319
398
 
320
399
  useEffect(() => {
321
- if (!ctx) return;
322
- ctx.addInstance(instance);
323
- return () => ctx.removeInstance(id);
324
- }, [ctx, instance, id]);
325
-
400
+ if (!addInstance || !removeInstance) return;
401
+ addInstance(instance);
402
+ return () => {
403
+ removeInstance(instance.id);
404
+ };
405
+ }, [addInstance, removeInstance, instance]);
406
+
407
+ // No visual rendering - provider handles all instanced visuals
326
408
  return null;
327
- }
409
+ });
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { MapControls, TransformControls } from "@react-three/drei";
3
+ import { MapControls, TransformControls, useHelper } from "@react-three/drei";
4
4
  import { useState, useRef, useEffect, forwardRef, useCallback } from "react";
5
- import { Vector3, Euler, Quaternion, Group, Object3D, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
5
+ import { Vector3, Euler, Quaternion, Group, Object3D, SRGBColorSpace, Texture, TextureLoader, Matrix4, BoxHelper } from "three";
6
6
  import { Prefab, GameObject as GameObjectType } from "./types";
7
7
  import { getComponent, registerComponent } from "./components/ComponentRegistry";
8
8
  import { ThreeEvent } from "@react-three/fiber";
@@ -128,7 +128,13 @@ export const PrefabRoot = forwardRef<Group, {
128
128
 
129
129
 
130
130
  return <group ref={ref}>
131
- <GameInstanceProvider models={loadedModels} onSelect={editMode ? onSelect : undefined} registerRef={registerRef}>
131
+ <GameInstanceProvider
132
+ models={loadedModels}
133
+ onSelect={editMode ? onSelect : undefined}
134
+ registerRef={registerRef}
135
+ selectedId={selectedId}
136
+ editMode={editMode}
137
+ >
132
138
  <GameObjectRenderer
133
139
  gameObject={data.root}
134
140
  selectedId={selectedId}
@@ -237,10 +243,20 @@ function GameObjectRenderer({
237
243
  />
238
244
  ));
239
245
 
240
- // --- 6. Inner content group with full transform ---
246
+ // --- 6. Inner content group with full transform and selection helper ---
247
+ const groupRef = useRef<Group>(null!);
248
+ const isSelected = selectedId === gameObject.id;
249
+
250
+ // Show BoxHelper when selected in edit mode
251
+ useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
252
+
253
+ useEffect(() => {
254
+ registerRef(gameObject.id, groupRef.current);
255
+ }, [gameObject.id, registerRef]);
256
+
241
257
  const innerGroup = (
242
258
  <group
243
- ref={(el) => registerRef(gameObject.id, el)}
259
+ ref={groupRef}
244
260
  position={transformProps.position}
245
261
  rotation={transformProps.rotation}
246
262
  scale={transformProps.scale}
@@ -304,7 +320,6 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
304
320
  const contextProps = {
305
321
  loadedModels: ctx.loadedModels,
306
322
  loadedTextures: ctx.loadedTextures,
307
- isSelected: ctx.selectedId === gameObject.id,
308
323
  editMode: ctx.editMode,
309
324
  parentMatrix,
310
325
  registerRef: ctx.registerRef,
@@ -102,7 +102,7 @@ import { useMemo } from 'react';
102
102
  import { DoubleSide, RepeatWrapping, ClampToEdgeWrapping, SRGBColorSpace, Texture } from 'three';
103
103
 
104
104
  // View for Material component
105
- function MaterialComponentView({ properties, loadedTextures, isSelected }: { properties: any, loadedTextures?: Record<string, Texture>, isSelected?: boolean }) {
105
+ function MaterialComponentView({ properties, loadedTextures }: { properties: any, loadedTextures?: Record<string, Texture> }) {
106
106
  const textureName = properties?.texture;
107
107
  const repeat = properties?.repeat;
108
108
  const repeatCount = properties?.repeatCount;
@@ -128,12 +128,11 @@ function MaterialComponentView({ properties, loadedTextures, isSelected }: { pro
128
128
  }
129
129
 
130
130
  const { color, wireframe = false } = properties;
131
- const displayColor = isSelected ? "cyan" : color;
132
131
 
133
132
  return (
134
133
  <meshStandardMaterial
135
134
  key={finalTexture?.uuid ?? 'no-texture'}
136
- color={displayColor}
135
+ color={color}
137
136
  wireframe={wireframe}
138
137
  map={finalTexture}
139
138
  transparent={!!finalTexture}