react-three-game 0.0.63 → 0.0.65

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.
@@ -114,14 +114,14 @@ function TextureSphere({ url, onError }) {
114
114
  export function ModelListViewer({ files, selected, onSelect, basePath = "" }) {
115
115
  return (_jsxs("div", { style: { position: 'relative', width: '100%', height: '100%' }, children: [_jsx("div", { style: { width: '100%', height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 4 }, children: _jsx(AssetListViewer, { files: files, selected: selected, onSelect: onSelect, renderCard: (file, onSelectHandler) => (_jsx(ModelCard, { file: file, basePath: basePath, onSelect: onSelectHandler })) }) }), _jsx(SharedCanvas, {})] }));
116
116
  }
117
- function ModelCard({ file, onSelect, basePath = "" }) {
117
+ function ModelCard({ file, onSelect, basePath = "", size = 60, }) {
118
118
  const [error, setError] = useState(false);
119
119
  const { ref, isInView } = useInView();
120
120
  const fullPath = basePath ? `/${basePath}${file}` : file;
121
121
  if (error) {
122
122
  return (_jsx("div", { ref: ref, style: { aspectRatio: '1 / 1', backgroundColor: '#374151', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }, onClick: () => onSelect(file), children: _jsx("div", { style: styles.errorIcon, children: "\u2717" }) }));
123
123
  }
124
- return (_jsxs("div", { ref: ref, style: { maxWidth: 60, aspectRatio: '1 / 1', backgroundColor: '#111827', color: '#f9fafb', cursor: 'pointer', display: 'flex', flexDirection: 'column' }, onClick: () => onSelect(file), children: [_jsx("div", { style: styles.flexFillRelative, children: isInView ? (_jsxs(View, { style: { width: '100%', height: '100%' }, children: [_jsx(PerspectiveCamera, { makeDefault: true, position: [0, 1, 3], fov: 50 }), _jsxs(Suspense, { fallback: null, children: [_jsx("ambientLight", { intensity: 1 }), _jsx("pointLight", { position: [5, 5, 5], intensity: 0.5 }), _jsx(ModelPreview, { url: fullPath, onError: () => setError(true) }), _jsx(OrbitControls, { enableZoom: false })] })] })) : null }), _jsx("div", { style: styles.bottomLabel, children: file.split('/').pop() })] }));
124
+ return (_jsxs("div", { ref: ref, style: { width: size, aspectRatio: '1 / 1', backgroundColor: '#111827', color: '#f9fafb', cursor: 'pointer', display: 'flex', flexDirection: 'column' }, onClick: () => onSelect(file), children: [_jsx("div", { style: styles.flexFillRelative, children: isInView ? (_jsxs(View, { style: { width: '100%', height: '100%' }, children: [_jsx(PerspectiveCamera, { makeDefault: true, position: [0, 1, 3], fov: 50 }), _jsxs(Suspense, { fallback: null, children: [_jsx("ambientLight", { intensity: 1 }), _jsx("pointLight", { position: [5, 5, 5], intensity: 0.5 }), _jsx(ModelPreview, { url: fullPath, onError: () => setError(true) }), _jsx(OrbitControls, { enableZoom: false })] })] })) : null }), _jsx("div", { style: styles.bottomLabel, children: file.split('/').pop() })] }));
125
125
  }
126
126
  function ModelPreview({ url, onError }) {
127
127
  const [model, setModel] = useState(null);
@@ -164,7 +164,7 @@ export function SingleTextureViewer({ file, basePath = "" }) {
164
164
  export function SingleModelViewer({ file, basePath = "" }) {
165
165
  if (!file)
166
166
  return null;
167
- return (_jsxs(_Fragment, { children: [_jsx(ModelCard, { file: file, basePath: basePath, onSelect: () => { } }), _jsx(SharedCanvas, {})] }));
167
+ return (_jsxs(_Fragment, { children: [_jsx(ModelCard, { file: file, basePath: basePath, onSelect: () => { }, size: 112 }), _jsx(SharedCanvas, {})] }));
168
168
  }
169
169
  export function SingleSoundViewer({ file, basePath = "" }) {
170
170
  if (!file)
@@ -1,8 +1,17 @@
1
1
  import React from "react";
2
2
  import { Object3D, Group } from "three";
3
3
  import { PhysicsProps } from "./components/PhysicsComponent";
4
+ export type RepeatAxisConfig = {
5
+ axis: 'x' | 'y' | 'z';
6
+ count: number;
7
+ offset: number;
8
+ };
9
+ export declare const DEFAULT_REPEAT_AXES: RepeatAxisConfig[];
10
+ export declare function normalizeRepeatAxes(value: unknown): RepeatAxisConfig[];
11
+ export declare function getRepeatAxesFromModelProperties(properties: Record<string, any>): RepeatAxisConfig[];
4
12
  export type InstanceData = {
5
13
  id: string;
14
+ sourceId: string;
6
15
  position: [number, number, number];
7
16
  rotation: [number, number, number];
8
17
  scale: [number, number, number];
@@ -22,6 +31,7 @@ export declare function GameInstanceProvider({ children, models, onSelect, regis
22
31
  export declare function useInstanceCheck(id: string): boolean;
23
32
  export declare const GameInstance: React.ForwardRefExoticComponent<{
24
33
  id: string;
34
+ sourceId?: string;
25
35
  modelUrl: string;
26
36
  position: [number, number, number];
27
37
  rotation: [number, number, number];
@@ -1,8 +1,73 @@
1
+ var __rest = (this && this.__rest) || function (s, e) {
2
+ var t = {};
3
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
+ t[p] = s[p];
5
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
+ t[p[i]] = s[p[i]];
9
+ }
10
+ return t;
11
+ };
1
12
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
13
  import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
3
14
  import { Merged, useHelper } from '@react-three/drei';
4
15
  import { InstancedRigidBodies } from "@react-three/rapier";
16
+ import { ActiveCollisionTypes } from "@dimforge/rapier3d-compat";
5
17
  import { Mesh, Matrix4, Vector3, Quaternion, Euler, BoxHelper } from "three";
18
+ import { gameEvents, getEntityIdFromRigidBody } from "./GameEvents";
19
+ export const DEFAULT_REPEAT_AXES = [{ axis: 'x', count: 1, offset: 1 }];
20
+ export function normalizeRepeatAxes(value) {
21
+ if (!Array.isArray(value)) {
22
+ return DEFAULT_REPEAT_AXES;
23
+ }
24
+ const seen = new Set();
25
+ const normalized = value.flatMap((entry) => {
26
+ if (!entry || typeof entry !== 'object')
27
+ return [];
28
+ const axisValue = entry.axis;
29
+ if (axisValue !== 'x' && axisValue !== 'y' && axisValue !== 'z')
30
+ return [];
31
+ if (seen.has(axisValue))
32
+ return [];
33
+ seen.add(axisValue);
34
+ const countValue = Number(entry.count);
35
+ const offsetValue = Number(entry.offset);
36
+ return [{
37
+ axis: axisValue,
38
+ count: Number.isFinite(countValue) ? Math.max(1, Math.floor(countValue)) : 1,
39
+ offset: Number.isFinite(offsetValue) ? offsetValue : 1,
40
+ }];
41
+ });
42
+ return normalized.length > 0 ? normalized : DEFAULT_REPEAT_AXES;
43
+ }
44
+ function toVector3Tuple(value, fallback) {
45
+ if (!Array.isArray(value) || value.length !== 3)
46
+ return fallback;
47
+ return value.map((entry, index) => {
48
+ const next = typeof entry === 'number' ? entry : Number(entry);
49
+ return Number.isFinite(next) ? next : fallback[index];
50
+ });
51
+ }
52
+ export function getRepeatAxesFromModelProperties(properties) {
53
+ var _a;
54
+ if (Array.isArray(properties.repeatAxes)) {
55
+ return normalizeRepeatAxes(properties.repeatAxes);
56
+ }
57
+ const repeatCount = toVector3Tuple(properties.repeatCount, [1, 1, 1]).map(value => Math.max(1, Math.floor(value)));
58
+ const repeatOffset = toVector3Tuple(properties.repeatOffset, [1, 1, 1]);
59
+ const legacyAxes = [];
60
+ if ((_a = properties.repeatX) !== null && _a !== void 0 ? _a : true) {
61
+ legacyAxes.push({ axis: 'x', count: repeatCount[0], offset: repeatOffset[0] });
62
+ }
63
+ if (properties.repeatY) {
64
+ legacyAxes.push({ axis: 'y', count: repeatCount[1], offset: repeatOffset[1] });
65
+ }
66
+ if (properties.repeatZ) {
67
+ legacyAxes.push({ axis: 'z', count: repeatCount[2], offset: repeatOffset[2] });
68
+ }
69
+ return legacyAxes.length > 0 ? legacyAxes : DEFAULT_REPEAT_AXES;
70
+ }
6
71
  // Helper functions for comparison
7
72
  function arrayEquals(a, b) {
8
73
  if (a === b)
@@ -15,14 +80,63 @@ function arrayEquals(a, b) {
15
80
  }
16
81
  return true;
17
82
  }
83
+ function stableSerialize(value) {
84
+ if (Array.isArray(value)) {
85
+ return `[${value.map(stableSerialize).join(',')}]`;
86
+ }
87
+ if (value && typeof value === 'object') {
88
+ const entries = Object.entries(value)
89
+ .sort(([a], [b]) => a.localeCompare(b))
90
+ .map(([key, entry]) => `${key}:${stableSerialize(entry)}`);
91
+ return `{${entries.join(',')}}`;
92
+ }
93
+ return JSON.stringify(value);
94
+ }
95
+ function getPhysicsSignature(physics) {
96
+ return physics ? stableSerialize(physics) : 'none';
97
+ }
98
+ function hasPhysics(instance) {
99
+ return Boolean(instance.physics);
100
+ }
101
+ function getColliderType(physics) {
102
+ return physics.colliders || (physics.type === 'fixed' ? 'trimesh' : 'hull');
103
+ }
104
+ function emitSensorEnter(sourceId, payload) {
105
+ gameEvents.emit('sensor:enter', {
106
+ sourceEntityId: sourceId,
107
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
108
+ targetRigidBody: payload.other.rigidBody,
109
+ });
110
+ }
111
+ function emitSensorExit(sourceId, payload) {
112
+ gameEvents.emit('sensor:exit', {
113
+ sourceEntityId: sourceId,
114
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
115
+ targetRigidBody: payload.other.rigidBody,
116
+ });
117
+ }
118
+ function emitCollisionEnter(sourceId, payload) {
119
+ gameEvents.emit('collision:enter', {
120
+ sourceEntityId: sourceId,
121
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
122
+ targetRigidBody: payload.other.rigidBody,
123
+ });
124
+ }
125
+ function emitCollisionExit(sourceId, payload) {
126
+ gameEvents.emit('collision:exit', {
127
+ sourceEntityId: sourceId,
128
+ targetEntityId: getEntityIdFromRigidBody(payload.other.rigidBody),
129
+ targetRigidBody: payload.other.rigidBody,
130
+ });
131
+ }
18
132
  function instanceEquals(a, b) {
19
- var _a, _b;
20
133
  return a.id === b.id &&
134
+ a.sourceId === b.sourceId &&
21
135
  a.meshPath === b.meshPath &&
22
136
  arrayEquals(a.position, b.position) &&
23
137
  arrayEquals(a.rotation, b.rotation) &&
24
138
  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);
139
+ getPhysicsSignature(a.physics) === getPhysicsSignature(b.physics);
26
140
  }
27
141
  const GameInstanceContext = createContext(null);
28
142
  export function GameInstanceProvider({ children, models, onSelect, registerRef, selectedId, editMode }) {
@@ -51,7 +165,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
51
165
  });
52
166
  }, []);
53
167
  const hasInstance = useCallback((id) => {
54
- return instances.some(i => i.id === id);
168
+ return instances.some(i => i.id === id || i.sourceId === id);
55
169
  }, [instances]);
56
170
  // Flatten all model meshes once (models → flat mesh parts)
57
171
  // Note: Geometry is cloned with baked transforms for instancing
@@ -82,17 +196,18 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
82
196
  Object.values(flatMeshes).forEach(mesh => mesh.geometry.dispose());
83
197
  };
84
198
  }, [flatMeshes]);
85
- // Group instances by meshPath + physics type for batch rendering
199
+ // Group instances by meshPath and physics presence for batch rendering.
86
200
  const grouped = useMemo(() => {
87
- var _a;
88
201
  const groups = {};
89
202
  for (const inst of instances) {
90
- const type = ((_a = inst.physics) === null || _a === void 0 ? void 0 : _a.type) || 'none';
91
- const key = `${inst.meshPath}__${type}`;
203
+ const key = `${inst.meshPath}__${inst.physics ? 'physics' : 'visual'}`;
92
204
  if (!groups[key])
93
- groups[key] = { physicsType: type, instances: [] };
205
+ groups[key] = { hasPhysics: Boolean(inst.physics), instances: [] };
94
206
  groups[key].instances.push(inst);
95
207
  }
208
+ Object.values(groups).forEach(group => {
209
+ group.instances.sort((a, b) => a.id.localeCompare(b.id));
210
+ });
96
211
  return groups;
97
212
  }, [instances]);
98
213
  return (_jsxs(GameInstanceContext.Provider, { value: {
@@ -103,7 +218,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
103
218
  modelParts,
104
219
  hasInstance
105
220
  }, children: [children, Object.entries(grouped).map(([key, group]) => {
106
- if (group.physicsType === 'none')
221
+ if (!group.hasPhysics)
107
222
  return null;
108
223
  const modelKey = group.instances[0].meshPath;
109
224
  const partCount = modelParts[modelKey] || 0;
@@ -111,7 +226,7 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
111
226
  return null;
112
227
  return (_jsx(InstancedRigidGroup, { group: group, modelKey: modelKey, partCount: partCount, flatMeshes: flatMeshes, onSelect: onSelect, editMode: editMode }, key));
113
228
  }), Object.entries(grouped).map(([key, group]) => {
114
- if (group.physicsType !== 'none')
229
+ if (group.hasPhysics)
115
230
  return null;
116
231
  const modelKey = group.instances[0].meshPath;
117
232
  const partCount = modelParts[modelKey] || 0;
@@ -130,12 +245,10 @@ export function GameInstanceProvider({ children, models, onSelect, registerRef,
130
245
  function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes, onSelect, editMode }) {
131
246
  const meshRefs = useRef([]);
132
247
  const rigidBodiesRef = useRef(null);
133
- const instances = useMemo(() => group.instances.map(inst => ({
134
- key: inst.id,
135
- position: inst.position,
136
- rotation: inst.rotation,
137
- scale: inst.scale,
138
- })), [group.instances]);
248
+ const instances = useMemo(() => group.instances.filter(hasPhysics).map(inst => {
249
+ const _a = inst.physics, { activeCollisionTypes: _activeCollisionTypes, colliders: _colliders, userData } = _a, rigidBodyProps = __rest(_a, ["activeCollisionTypes", "colliders", "userData"]);
250
+ return Object.assign(Object.assign({ key: inst.id, position: inst.position, rotation: inst.rotation, scale: inst.scale }, rigidBodyProps), { colliders: getColliderType(inst.physics), userData: Object.assign(Object.assign({}, userData), { entityId: inst.sourceId }), onIntersectionEnter: (payload) => emitSensorEnter(inst.sourceId, payload), onIntersectionExit: (payload) => emitSensorExit(inst.sourceId, payload), onCollisionEnter: (payload) => emitCollisionEnter(inst.sourceId, payload), onCollisionExit: (payload) => emitCollisionExit(inst.sourceId, payload) });
251
+ }), [group.instances]);
139
252
  // Apply scale to visual meshes (InstancedRigidBodies only scales colliders, not visuals)
140
253
  useEffect(() => {
141
254
  const matrix = new Matrix4();
@@ -161,7 +274,7 @@ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes, onSelect,
161
274
  try {
162
275
  group.instances.forEach((inst, i) => {
163
276
  var _a;
164
- const body = (_a = rigidBodiesRef.current) === null || _a === void 0 ? void 0 : _a.at(i);
277
+ const body = (_a = rigidBodiesRef.current) === null || _a === void 0 ? void 0 : _a[i];
165
278
  if (body && body.setTranslation && body.setRotation) {
166
279
  pos.set(...inst.position);
167
280
  euler.set(...inst.rotation);
@@ -177,7 +290,22 @@ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes, onSelect,
177
290
  }
178
291
  }
179
292
  }, [group.instances]);
180
- const colliders = group.physicsType === 'fixed' ? 'trimesh' : 'hull';
293
+ useEffect(() => {
294
+ group.instances.forEach((inst, i) => {
295
+ var _a, _b;
296
+ if (!inst.physics || inst.physics.activeCollisionTypes !== 'all')
297
+ return;
298
+ const body = (_a = rigidBodiesRef.current) === null || _a === void 0 ? void 0 : _a[i];
299
+ if (!body || !body.numColliders || !body.collider)
300
+ return;
301
+ for (let colliderIndex = 0; colliderIndex < body.numColliders(); colliderIndex++) {
302
+ const collider = body.collider(colliderIndex);
303
+ (_b = collider.setActiveCollisionTypes) === null || _b === void 0 ? void 0 : _b.call(collider, ActiveCollisionTypes.DEFAULT |
304
+ ActiveCollisionTypes.KINEMATIC_FIXED |
305
+ ActiveCollisionTypes.KINEMATIC_KINEMATIC);
306
+ }
307
+ });
308
+ }, [group.instances]);
181
309
  // Handle click on instanced mesh in edit mode
182
310
  const handleClick = (e) => {
183
311
  if (!editMode || !onSelect)
@@ -186,12 +314,12 @@ function InstancedRigidGroup({ group, modelKey, partCount, flatMeshes, onSelect,
186
314
  // Get the instance index from the intersection
187
315
  const instanceId = e.instanceId;
188
316
  if (instanceId !== undefined && group.instances[instanceId]) {
189
- onSelect(group.instances[instanceId].id);
317
+ onSelect(group.instances[instanceId].sourceId);
190
318
  }
191
319
  };
192
320
  // 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) => {
321
+ const rigidBodyKey = `rb_${modelKey}_${group.instances.map(inst => `${inst.id}:${getPhysicsSignature(inst.physics)}`).join('|')}`;
322
+ return (_jsx(InstancedRigidBodies, { ref: rigidBodiesRef, instances: instances, children: Array.from({ length: partCount }).map((_, i) => {
195
323
  const mesh = flatMeshes[`${modelKey}__${i}`];
196
324
  if (!mesh)
197
325
  return null;
@@ -208,16 +336,19 @@ function NonPhysicsInstancedGroup({ modelKey, group, partCount, instancesMap, on
208
336
  function InstanceGroupItem({ instance, InstanceComponents, onSelect, registerRef, selectedId, editMode }) {
209
337
  const clickValid = useRef(false);
210
338
  const groupRef = useRef(null);
211
- const isSelected = selectedId === instance.id;
339
+ const isSelected = selectedId === instance.id || selectedId === instance.sourceId;
212
340
  // Use BoxHelper when object is selected in edit mode
213
341
  useHelper(editMode && isSelected ? groupRef : null, BoxHelper, 'cyan');
214
342
  useEffect(() => {
343
+ if (editMode)
344
+ return;
215
345
  registerRef === null || registerRef === void 0 ? void 0 : registerRef(instance.id, groupRef.current);
216
- }, [instance.id, registerRef]);
346
+ return () => registerRef === null || registerRef === void 0 ? void 0 : registerRef(instance.id, null);
347
+ }, [editMode, instance.id, registerRef]);
217
348
  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) => {
218
349
  if (clickValid.current) {
219
350
  e.stopPropagation();
220
- onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.id);
351
+ onSelect === null || onSelect === void 0 ? void 0 : onSelect(instance.sourceId);
221
352
  }
222
353
  clickValid.current = false;
223
354
  }, children: InstanceComponents.map((Instance, i) => _jsx(Instance, {}, i)) }));
@@ -229,18 +360,19 @@ export function useInstanceCheck(id) {
229
360
  return (_a = ctx === null || ctx === void 0 ? void 0 : ctx.hasInstance(id)) !== null && _a !== void 0 ? _a : false;
230
361
  }
231
362
  // GameInstance component: registers an instance for batch rendering (renders nothing itself)
232
- export const GameInstance = React.forwardRef(({ id, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
363
+ export const GameInstance = React.forwardRef(({ id, sourceId, modelUrl, position, rotation, scale, physics = undefined, }, ref) => {
233
364
  const ctx = useContext(GameInstanceContext);
234
365
  const addInstance = ctx === null || ctx === void 0 ? void 0 : ctx.addInstance;
235
366
  const removeInstance = ctx === null || ctx === void 0 ? void 0 : ctx.removeInstance;
236
367
  const instance = useMemo(() => ({
237
368
  id,
369
+ sourceId: sourceId !== null && sourceId !== void 0 ? sourceId : id,
238
370
  meshPath: modelUrl,
239
371
  position,
240
372
  rotation,
241
373
  scale,
242
374
  physics,
243
- }), [id, modelUrl, JSON.stringify(position), JSON.stringify(rotation), JSON.stringify(scale), JSON.stringify(physics)]);
375
+ }), [id, sourceId, modelUrl, JSON.stringify(position), JSON.stringify(rotation), JSON.stringify(scale), getPhysicsSignature(physics)]);
244
376
  useEffect(() => {
245
377
  if (!addInstance || !removeInstance)
246
378
  return;
@@ -12,6 +12,7 @@ export interface PrefabEditorRef {
12
12
  screenshot: () => void;
13
13
  exportGLB: (options?: ExportGLBOptions) => Promise<ArrayBuffer | object | undefined>;
14
14
  exportGLBData: () => Promise<ArrayBuffer | undefined>;
15
+ clearSelection: () => Promise<void>;
15
16
  prefab: Prefab;
16
17
  setPrefab: (prefab: Prefab) => void;
17
18
  replacePrefab: (prefab: Prefab) => void;
@@ -162,8 +162,19 @@ const PrefabEditor = forwardRef(({ basePath, initialPrefab, physics = true, onPr
162
162
  URL.revokeObjectURL(url);
163
163
  });
164
164
  };
165
+ const clearSelection = () => __awaiter(void 0, void 0, void 0, function* () {
166
+ if (!selectedId)
167
+ return;
168
+ setSelectedId(null);
169
+ yield new Promise(resolve => {
170
+ requestAnimationFrame(() => {
171
+ requestAnimationFrame(() => resolve());
172
+ });
173
+ });
174
+ });
165
175
  const handleExportGLB = (...args_1) => __awaiter(void 0, [...args_1], void 0, function* (options = {}) {
166
176
  var _a;
177
+ yield clearSelection();
167
178
  const sceneRoot = (_a = prefabRootRef.current) === null || _a === void 0 ? void 0 : _a.root;
168
179
  if (!sceneRoot)
169
180
  return;
@@ -171,6 +182,7 @@ const PrefabEditor = forwardRef(({ basePath, initialPrefab, physics = true, onPr
171
182
  });
172
183
  const handleExportGLBData = () => __awaiter(void 0, void 0, void 0, function* () {
173
184
  var _a;
185
+ yield clearSelection();
174
186
  const sceneRoot = (_a = prefabRootRef.current) === null || _a === void 0 ? void 0 : _a.root;
175
187
  if (!sceneRoot)
176
188
  return;
@@ -220,6 +232,7 @@ const PrefabEditor = forwardRef(({ basePath, initialPrefab, physics = true, onPr
220
232
  screenshot: handleScreenshot,
221
233
  exportGLB: handleExportGLB,
222
234
  exportGLBData: handleExportGLBData,
235
+ clearSelection,
223
236
  prefab: loadedPrefab,
224
237
  setPrefab: replacePrefab,
225
238
  replacePrefab,
@@ -14,7 +14,7 @@ import { BoxHelper, Euler, Matrix4, Quaternion, SRGBColorSpace, TextureLoader, V
14
14
  import { getComponent, registerComponent, getNonComposableKeys } from "./components/ComponentRegistry";
15
15
  import components from "./components";
16
16
  import { loadModel } from "../dragdrop";
17
- import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
17
+ import { GameInstance, GameInstanceProvider, getRepeatAxesFromModelProperties, useInstanceCheck } from "./InstanceProvider";
18
18
  import { focusCameraOnObject, updateNode } from "./utils";
19
19
  import { EditorContext } from "./EditorContext";
20
20
  components.forEach(registerComponent);
@@ -159,16 +159,16 @@ export function GameObjectRenderer(props) {
159
159
  : _jsx(StandardNode, Object.assign({}, props), key);
160
160
  }
161
161
  function isPhysicsProps(v) {
162
- return (v === null || v === void 0 ? void 0 : v.type) === "fixed" || (v === null || v === void 0 ? void 0 : v.type) === "dynamic";
162
+ return (v === null || v === void 0 ? void 0 : v.type) === "fixed" || (v === null || v === void 0 ? void 0 : v.type) === "dynamic" || (v === null || v === void 0 ? void 0 : v.type) === "kinematicPosition" || (v === null || v === void 0 ? void 0 : v.type) === "kinematicVelocity";
163
163
  }
164
164
  function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }) {
165
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
166
- const world = parentMatrix.clone().multiply(compose(gameObject));
167
- const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
165
+ var _a, _b, _c, _d, _e, _f, _g;
168
166
  const localTransform = getNodeTransformProps(gameObject);
169
167
  const physicsProps = isPhysicsProps((_b = (_a = gameObject.components) === null || _a === void 0 ? void 0 : _a.physics) === null || _b === void 0 ? void 0 : _b.properties)
170
168
  ? (_d = (_c = gameObject.components) === null || _c === void 0 ? void 0 : _c.physics) === null || _d === void 0 ? void 0 : _d.properties
171
169
  : undefined;
170
+ 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;
171
+ const instances = useMemo(() => buildRepeatedInstances(gameObject, parentMatrix, modelUrl, physicsProps), [gameObject, modelUrl, parentMatrix, physicsProps]);
172
172
  const groupRef = useRef(null);
173
173
  const clickValid = useRef(false);
174
174
  useEffect(() => {
@@ -177,7 +177,6 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
177
177
  return () => registerRef(gameObject.id, null);
178
178
  }
179
179
  }, [gameObject.id, registerRef, editMode]);
180
- 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;
181
180
  if (editMode) {
182
181
  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) => {
183
182
  if (clickValid.current) {
@@ -186,9 +185,9 @@ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, register
186
185
  onClick === null || onClick === void 0 ? void 0 : onClick(e, gameObject);
187
186
  }
188
187
  clickValid.current = false;
189
- }, 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 })] }));
188
+ }, children: _jsx("mesh", { visible: false, children: _jsx("boxGeometry", { args: [0.01, 0.01, 0.01] }) }) }), instances.map(instance => (_jsx(GameInstance, { id: instance.id, sourceId: gameObject.id, modelUrl: instance.modelUrl, position: instance.position, rotation: instance.rotation, scale: instance.scale, physics: instance.physics }, instance.id)))] }));
190
189
  }
191
- 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 }));
190
+ return (_jsx(_Fragment, { children: instances.map(instance => (_jsx(GameInstance, { id: instance.id, sourceId: gameObject.id, modelUrl: instance.modelUrl, position: instance.position, rotation: instance.rotation, scale: instance.scale, physics: instance.physics }, instance.id))) }));
192
191
  }
193
192
  function StandardNode({ gameObject, selectedId, onSelect, onClick, registerRef, registerRigidBodyRef, loadedModels, loadedTextures, editMode, parentMatrix = IDENTITY, }) {
194
193
  var _a, _b, _c, _d, _e;
@@ -276,6 +275,58 @@ function compose(node) {
276
275
  const { position, rotation, scale } = getNodeTransformProps(node);
277
276
  return new Matrix4().compose(new Vector3(...position), new Quaternion().setFromEuler(new Euler(...rotation)), new Vector3(...scale));
278
277
  }
278
+ function getModelRepeatSettings(node) {
279
+ var _a, _b, _c;
280
+ const properties = (_c = (_b = (_a = node === null || node === void 0 ? void 0 : node.components) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.properties) !== null && _c !== void 0 ? _c : {};
281
+ return {
282
+ repeat: Boolean(properties.repeat),
283
+ repeatAxes: getRepeatAxesFromModelProperties(properties),
284
+ };
285
+ }
286
+ function buildRepeatedInstances(gameObject, parentMatrix, modelUrl, physics) {
287
+ if (!modelUrl)
288
+ return [];
289
+ const transform = getNodeTransformProps(gameObject);
290
+ const repeat = getModelRepeatSettings(gameObject);
291
+ const counts = [1, 1, 1];
292
+ const offsets = [0, 0, 0];
293
+ if (repeat.repeat) {
294
+ for (const entry of repeat.repeatAxes) {
295
+ const axisIndex = entry.axis === 'x' ? 0 : entry.axis === 'y' ? 1 : 2;
296
+ counts[axisIndex] = entry.count;
297
+ offsets[axisIndex] = entry.offset;
298
+ }
299
+ }
300
+ const baseTranslation = new Matrix4().makeTranslation(transform.position[0], transform.position[1], transform.position[2]);
301
+ const baseRotation = new Matrix4().makeRotationFromEuler(new Euler(...transform.rotation));
302
+ const baseScale = new Matrix4().makeScale(transform.scale[0], transform.scale[1], transform.scale[2]);
303
+ const offsetMatrix = new Matrix4();
304
+ const worldMatrix = new Matrix4();
305
+ const instances = [];
306
+ for (let x = 0; x < counts[0]; x++) {
307
+ for (let y = 0; y < counts[1]; y++) {
308
+ for (let z = 0; z < counts[2]; z++) {
309
+ offsetMatrix.makeTranslation(x * offsets[0], y * offsets[1], z * offsets[2]);
310
+ worldMatrix.copy(parentMatrix)
311
+ .multiply(baseTranslation)
312
+ .multiply(baseRotation)
313
+ .multiply(offsetMatrix)
314
+ .multiply(baseScale);
315
+ const { position, rotation, scale } = decompose(worldMatrix);
316
+ const isBaseInstance = x === 0 && y === 0 && z === 0;
317
+ instances.push({
318
+ id: isBaseInstance ? gameObject.id : `${gameObject.id}__repeat_${x}_${y}_${z}`,
319
+ modelUrl,
320
+ position,
321
+ rotation,
322
+ scale,
323
+ physics,
324
+ });
325
+ }
326
+ }
327
+ }
328
+ return instances;
329
+ }
279
330
  function decompose(m) {
280
331
  const p = new Vector3(), q = new Quaternion(), s = new Vector3();
281
332
  m.decompose(p, q, s);
@@ -1,10 +1,76 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ModelListViewer, SingleModelViewer } from '../../assetviewer/page';
3
- import { useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react';
3
+ import { useContext, useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
- import { FieldRenderer } from './Input';
5
+ import { BooleanField, FieldGroup, Input, Label, SelectInput } from './Input';
6
+ import { EditorContext } from '../EditorContext';
7
+ import { DEFAULT_REPEAT_AXES, getRepeatAxesFromModelProperties, normalizeRepeatAxes } from '../InstanceProvider';
8
+ import { colors } from '../styles';
6
9
  const PICKER_POPUP_WIDTH = 260;
7
10
  const PICKER_POPUP_HEIGHT = 360;
11
+ const AXIS_OPTIONS = [
12
+ { value: 'x', label: 'X' },
13
+ { value: 'y', label: 'Y' },
14
+ { value: 'z', label: 'Z' },
15
+ ];
16
+ function quantize(value, step) {
17
+ if (!Number.isFinite(value))
18
+ return 0;
19
+ if (!Number.isFinite(step) || step <= 0)
20
+ return value;
21
+ return Math.round(value / step) * step;
22
+ }
23
+ function RepeatAxisEditor({ axes, onChange, positionSnap, }) {
24
+ const addAxis = () => {
25
+ const used = new Set(axes.map(axis => axis.axis));
26
+ const nextAxis = AXIS_OPTIONS.find(option => !used.has(option.value));
27
+ if (!nextAxis)
28
+ return;
29
+ onChange([...axes, { axis: nextAxis.value, count: 1, offset: 1 }]);
30
+ };
31
+ const updateAxis = (index, patch) => {
32
+ const nextAxes = axes.map((axis, axisIndex) => axisIndex === index ? Object.assign(Object.assign({}, axis), patch) : axis);
33
+ onChange(normalizeRepeatAxes(nextAxes));
34
+ };
35
+ const removeAxis = (index) => {
36
+ onChange(axes.filter((_, axisIndex) => axisIndex !== index));
37
+ };
38
+ const canAddAxis = axes.length < AXIS_OPTIONS.length;
39
+ return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 8 }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Label, { children: "Repeat Axes" }), _jsx("button", { type: "button", onClick: addAxis, disabled: !canAddAxis, style: {
40
+ width: 22,
41
+ height: 22,
42
+ borderRadius: 3,
43
+ border: `1px solid ${canAddAxis ? colors.accentBorder : colors.border}`,
44
+ background: canAddAxis ? colors.accentBg : colors.bgSurface,
45
+ color: canAddAxis ? colors.accent : colors.textMuted,
46
+ cursor: canAddAxis ? 'pointer' : 'not-allowed',
47
+ fontSize: 14,
48
+ lineHeight: 1,
49
+ padding: 0,
50
+ }, title: canAddAxis ? 'Add repeat axis' : 'All axes already in use', children: "+" })] }), axes.map((axisConfig, index) => {
51
+ const usedByOthers = new Set(axes.filter((_, axisIndex) => axisIndex !== index).map(axis => axis.axis));
52
+ const axisOptions = AXIS_OPTIONS.filter(option => option.value === axisConfig.axis || !usedByOthers.has(option.value));
53
+ return (_jsxs("div", { style: {
54
+ display: 'flex',
55
+ flexDirection: 'column',
56
+ gap: 6,
57
+ padding: 8,
58
+ border: `1px solid ${colors.border}`,
59
+ borderRadius: 4,
60
+ background: colors.bgSurface,
61
+ }, children: [_jsxs("div", { style: { display: 'flex', gap: 6, alignItems: 'end' }, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: _jsx(SelectInput, { label: "Axis", value: axisConfig.axis, onChange: (axis) => updateAxis(index, { axis: axis }), options: axisOptions }) }), index > 0 ? (_jsx("button", { type: "button", onClick: () => removeAxis(index), style: {
62
+ height: 24,
63
+ width: 28,
64
+ borderRadius: 3,
65
+ border: `1px solid ${colors.border}`,
66
+ background: colors.bgInput,
67
+ color: colors.text,
68
+ cursor: 'pointer',
69
+ padding: 0,
70
+ flexShrink: 0,
71
+ }, title: "Remove repeat axis", children: "\u00D7" })) : null] }), _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 6 }, children: [_jsx(Input, { label: "Count", value: axisConfig.count, onChange: (count) => updateAxis(index, { count: Math.max(1, Math.floor(count)) }), step: 1, min: 1, style: { width: '100%', minWidth: 0, boxSizing: 'border-box' } }), _jsx(Input, { label: "Offset", value: axisConfig.offset, onChange: (offset) => updateAxis(index, { offset: quantize(offset, positionSnap) }), step: positionSnap > 0 ? positionSnap : 0.1, style: { width: '100%', minWidth: 0, boxSizing: 'border-box' } })] })] }, `${axisConfig.axis}-${index}`));
72
+ })] }));
73
+ }
8
74
  function ModelPicker({ value, onChange, basePath, nodeId }) {
9
75
  const [modelFiles, setModelFiles] = useState([]);
10
76
  const [showPicker, setShowPicker] = useState(false);
@@ -57,21 +123,32 @@ function ModelPicker({ value, onChange, basePath, nodeId }) {
57
123
  onChange(filename);
58
124
  setShowPicker(false);
59
125
  };
60
- return (_jsxs("div", { style: { maxHeight: 128, overflow: 'visible', position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(SingleModelViewer, { file: value ? `/${value}` : undefined, basePath: basePath }), _jsx("button", { ref: triggerRef, onClick: () => setShowPicker(!showPicker), style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4 }, children: showPicker ? 'Cancel' : 'Change' }), _jsx("button", { onClick: () => {
61
- onChange(undefined);
62
- }, style: { padding: '4px 8px', backgroundColor: '#1f2937', color: 'inherit', fontSize: 10, cursor: 'pointer', border: '1px solid rgba(34, 211, 238, 0.3)', marginTop: 4, marginLeft: 4 }, children: "Clear" }), showPicker && popupStyle && typeof document !== 'undefined' && createPortal(_jsx("div", { style: popupStyle, onMouseLeave: () => setShowPicker(false), children: _jsx(ModelListViewer, { files: modelFiles, selected: value ? `/${value}` : undefined, onSelect: handleModelSelect, basePath: basePath }, nodeId) }), document.body)] }));
126
+ return (_jsxs("div", { style: { maxHeight: 160, overflow: 'visible', position: 'relative', display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }, children: [_jsx("div", { style: { flex: '0 0 auto' }, children: _jsx(SingleModelViewer, { file: value ? `/${value}` : undefined, basePath: basePath }) }), _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 6, flex: '0 0 84px', minWidth: 84, justifyContent: 'flex-end' }, children: [_jsx("button", { ref: triggerRef, onClick: () => setShowPicker(!showPicker), style: {
127
+ width: '100%',
128
+ padding: '6px 8px',
129
+ backgroundColor: '#1f2937',
130
+ color: 'inherit',
131
+ fontSize: 10,
132
+ cursor: 'pointer',
133
+ border: '1px solid rgba(34, 211, 238, 0.3)',
134
+ }, children: showPicker ? 'Cancel' : 'Change' }), _jsx("button", { onClick: () => {
135
+ onChange(undefined);
136
+ }, style: {
137
+ width: '100%',
138
+ padding: '6px 8px',
139
+ backgroundColor: '#1f2937',
140
+ color: 'inherit',
141
+ fontSize: 10,
142
+ cursor: 'pointer',
143
+ border: '1px solid rgba(34, 211, 238, 0.3)',
144
+ }, children: "Clear" })] }), showPicker && popupStyle && typeof document !== 'undefined' && createPortal(_jsx("div", { style: popupStyle, onMouseLeave: () => setShowPicker(false), children: _jsx(ModelListViewer, { files: modelFiles, selected: value ? `/${value}` : undefined, onSelect: handleModelSelect, basePath: basePath }, nodeId) }), document.body)] }));
63
145
  }
64
146
  function ModelComponentEditor({ component, node, onUpdate, basePath = "" }) {
65
- const fields = [
66
- {
67
- name: 'filename',
68
- type: 'custom',
69
- label: 'Model File',
70
- render: ({ value, onChange }) => (_jsx(ModelPicker, { value: value, onChange: onChange, basePath: basePath, nodeId: node === null || node === void 0 ? void 0 : node.id })),
71
- },
72
- { name: 'instanced', type: 'boolean', label: 'Instanced' },
73
- ];
74
- return (_jsx(FieldRenderer, { fields: fields, values: component.properties, onChange: onUpdate }));
147
+ var _a;
148
+ const editorContext = useContext(EditorContext);
149
+ const positionSnap = (_a = editorContext === null || editorContext === void 0 ? void 0 : editorContext.positionSnap) !== null && _a !== void 0 ? _a : 0.5;
150
+ const repeatAxes = getRepeatAxesFromModelProperties(component.properties);
151
+ return (_jsxs(FieldGroup, { children: [_jsx(ModelPicker, { value: component.properties.filename, onChange: (filename) => onUpdate({ filename }), basePath: basePath, nodeId: node === null || node === void 0 ? void 0 : node.id }), _jsx(BooleanField, { name: "instanced", label: "Instanced", values: component.properties, onChange: onUpdate, fallback: false }), component.properties.instanced && (_jsxs(_Fragment, { children: [_jsx(BooleanField, { name: "repeat", label: "Repeat", values: component.properties, onChange: onUpdate, fallback: false }), component.properties.repeat && (_jsx(RepeatAxisEditor, { axes: repeatAxes, onChange: (nextAxes) => onUpdate({ repeatAxes: nextAxes }), positionSnap: positionSnap }))] }))] }));
75
152
  }
76
153
  // View for Model component
77
154
  function ModelComponentView({ properties, loadedModels, children }) {
@@ -103,7 +180,9 @@ const ModelComponent = {
103
180
  nonComposable: true,
104
181
  defaultProperties: {
105
182
  filename: '',
106
- instanced: false
183
+ instanced: false,
184
+ repeat: false,
185
+ repeatAxes: DEFAULT_REPEAT_AXES
107
186
  }
108
187
  };
109
188
  export default ModelComponent;
@@ -255,7 +255,12 @@ export function createModelNode(filename, name) {
255
255
  },
256
256
  model: {
257
257
  type: 'Model',
258
- properties: { filename, instanced: false }
258
+ properties: {
259
+ filename,
260
+ instanced: false,
261
+ repeat: false,
262
+ repeatAxes: [{ axis: 'x', count: 1, offset: 1 }]
263
+ }
259
264
  }
260
265
  }
261
266
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "description": "high performance 3D game engine for React",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",