react-three-game 0.0.36 → 0.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.js +5 -5
  3. package/dist/tools/prefabeditor/EditorContext.d.ts +11 -0
  4. package/dist/tools/prefabeditor/EditorContext.js +9 -0
  5. package/dist/tools/prefabeditor/EditorTree.d.ts +1 -3
  6. package/dist/tools/prefabeditor/EditorTree.js +38 -3
  7. package/dist/tools/prefabeditor/EditorUI.d.ts +1 -5
  8. package/dist/tools/prefabeditor/EditorUI.js +4 -2
  9. package/dist/tools/prefabeditor/ExportHelper.d.ts +7 -0
  10. package/dist/tools/prefabeditor/ExportHelper.js +55 -0
  11. package/dist/tools/prefabeditor/InstanceProvider.d.ts +1 -0
  12. package/dist/tools/prefabeditor/InstanceProvider.js +51 -7
  13. package/dist/tools/prefabeditor/PrefabEditor.d.ts +10 -2
  14. package/dist/tools/prefabeditor/PrefabEditor.js +60 -53
  15. package/dist/tools/prefabeditor/PrefabRoot.d.ts +7 -2
  16. package/dist/tools/prefabeditor/PrefabRoot.js +78 -49
  17. package/dist/tools/prefabeditor/components/Input.d.ts +2 -1
  18. package/dist/tools/prefabeditor/components/Input.js +9 -3
  19. package/dist/tools/prefabeditor/components/PhysicsComponent.js +8 -7
  20. package/dist/tools/prefabeditor/components/TransformComponent.js +11 -3
  21. package/dist/tools/prefabeditor/utils.d.ts +7 -1
  22. package/dist/tools/prefabeditor/utils.js +41 -0
  23. package/package.json +1 -1
  24. package/src/index.ts +12 -12
  25. package/src/tools/prefabeditor/EditorContext.tsx +20 -0
  26. package/src/tools/prefabeditor/EditorTree.tsx +83 -22
  27. package/src/tools/prefabeditor/EditorUI.tsx +2 -10
  28. package/src/tools/prefabeditor/InstanceProvider.tsx +60 -4
  29. package/src/tools/prefabeditor/PrefabEditor.tsx +80 -51
  30. package/src/tools/prefabeditor/PrefabRoot.tsx +181 -69
  31. package/src/tools/prefabeditor/components/Input.tsx +11 -3
  32. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +20 -7
  33. package/src/tools/prefabeditor/components/TransformComponent.tsx +25 -4
  34. package/src/tools/prefabeditor/utils.ts +43 -1
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { MapControls, TransformControls, useHelper } from "@react-three/drei";
4
- import { forwardRef, useCallback, useEffect, useRef, useState, } from "react";
4
+ import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react";
5
5
  import { BoxHelper, Euler, Group, Matrix4, Object3D, Quaternion, SRGBColorSpace, Texture, TextureLoader, Vector3, } from "three";
6
6
  import { ThreeEvent } from "@react-three/fiber";
7
7
 
@@ -9,63 +9,64 @@ 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
-
16
- /* -------------------------------------------------- */
17
- /* Setup */
18
- /* -------------------------------------------------- */
15
+ import { EditorContext } from "./EditorContext";
19
16
 
20
17
  components.forEach(registerComponent);
21
18
 
22
19
  const IDENTITY = new Matrix4();
23
20
 
24
- /* -------------------------------------------------- */
25
- /* PrefabRoot */
26
- /* -------------------------------------------------- */
21
+ export interface PrefabRootRef {
22
+ root: Group | null;
23
+ }
27
24
 
28
- export const PrefabRoot = forwardRef<Group, {
25
+ export const PrefabRoot = forwardRef<PrefabRootRef, {
29
26
  editMode?: boolean;
30
27
  data: Prefab;
31
28
  onPrefabChange?: (data: Prefab) => void;
32
29
  selectedId?: string | null;
33
30
  onSelect?: (id: string | null) => void;
34
- transformMode?: "translate" | "rotate" | "scale";
31
+ onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
35
32
  basePath?: string;
36
- }>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, basePath = "" }, ref) => {
33
+ }>(({ editMode, data, onPrefabChange, selectedId, onSelect, onClick, basePath = "" }, ref) => {
37
34
 
35
+ // optional editor context
36
+ const editorContext = useContext(EditorContext);
37
+ const transformMode = editorContext?.transformMode ?? "translate";
38
+ const snapResolution = editorContext?.snapResolution ?? 0;
39
+
40
+ // prefab root state
38
41
  const [models, setModels] = useState<Record<string, Object3D>>({});
39
42
  const [textures, setTextures] = useState<Record<string, Texture>>({});
40
43
  const loading = useRef(new Set<string>());
41
44
  const objectRefs = useRef<Record<string, Object3D | null>>({});
42
45
  const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
46
+ const rootRef = useRef<Group>(null);
47
+
48
+ useImperativeHandle(ref, () => ({
49
+ root: rootRef.current
50
+ }), []);
43
51
 
44
52
  const registerRef = useCallback((id: string, obj: Object3D | null) => {
45
53
  objectRefs.current[id] = obj;
46
54
  if (id === selectedId) setSelectedObject(obj);
47
55
  }, [selectedId]);
48
56
 
49
- // Suppress TransformControls scene graph warnings during transitions
50
57
  useEffect(() => {
51
58
  const originalError = console.error;
52
59
  console.error = (...args: any[]) => {
53
- if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph')) {
54
- return; // Suppress this specific error
55
- }
60
+ if (typeof args[0] === 'string' && args[0].includes('TransformControls') && args[0].includes('scene graph')) return;
56
61
  originalError.apply(console, args);
57
62
  };
58
- return () => {
59
- console.error = originalError;
60
- };
63
+ return () => { console.error = originalError; };
61
64
  }, []);
62
65
 
63
66
  useEffect(() => {
64
67
  setSelectedObject(selectedId ? objectRefs.current[selectedId] ?? null : null);
65
68
  }, [selectedId]);
66
69
 
67
- /* ---------------- Transform writeback ---------------- */
68
-
69
70
  const onTransformChange = () => {
70
71
  if (!selectedId || !onPrefabChange) return;
71
72
 
@@ -91,8 +92,6 @@ export const PrefabRoot = forwardRef<Group, {
91
92
  onPrefabChange({ ...data, root });
92
93
  };
93
94
 
94
- /* ---------------- Asset loading ---------------- */
95
-
96
95
  useEffect(() => {
97
96
  const modelsToLoad = new Set<string>();
98
97
  const texturesToLoad = new Set<string>();
@@ -133,10 +132,8 @@ export const PrefabRoot = forwardRef<Group, {
133
132
  });
134
133
  }, [data, models, textures]);
135
134
 
136
- /* ---------------- Render ---------------- */
137
-
138
135
  return (
139
- <group ref={ref}>
136
+ <group ref={rootRef}>
140
137
  <GameInstanceProvider
141
138
  models={models}
142
139
  selectedId={selectedId}
@@ -148,6 +145,7 @@ export const PrefabRoot = forwardRef<Group, {
148
145
  gameObject={data.root}
149
146
  selectedId={selectedId}
150
147
  onSelect={editMode ? onSelect : undefined}
148
+ onClick={onClick}
151
149
  registerRef={registerRef}
152
150
  loadedModels={models}
153
151
  loadedTextures={textures}
@@ -161,10 +159,14 @@ export const PrefabRoot = forwardRef<Group, {
161
159
  <MapControls makeDefault />
162
160
  {selectedObject && (
163
161
  <TransformControls
162
+ key={`transform-${snapResolution}`}
164
163
  object={selectedObject}
165
164
  mode={transformMode}
166
165
  space="local"
167
166
  onObjectChange={onTransformChange}
167
+ translationSnap={snapResolution > 0 ? snapResolution : undefined}
168
+ rotationSnap={snapResolution > 0 ? snapResolution : undefined}
169
+ scaleSnap={snapResolution > 0 ? snapResolution : undefined}
168
170
  />
169
171
  )}
170
172
  </>
@@ -173,54 +175,110 @@ export const PrefabRoot = forwardRef<Group, {
173
175
  );
174
176
  });
175
177
 
176
- /* -------------------------------------------------- */
177
- /* Renderer Switch */
178
- /* -------------------------------------------------- */
179
-
180
178
  export function GameObjectRenderer(props: RendererProps) {
181
179
  const node = props.gameObject;
182
180
  if (!node || node.hidden || node.disabled) return null;
183
- return node.components?.model?.properties?.instanced
184
- ? <InstancedNode {...props} />
185
- : <StandardNode {...props} />;
181
+
182
+ const isInstanced = node.components?.model?.properties?.instanced;
183
+ const prevInstancedRef = useRef<boolean | undefined>(undefined);
184
+ const [isTransitioning, setIsTransitioning] = useState(false);
185
+
186
+ useEffect(() => {
187
+ if (prevInstancedRef.current !== undefined && prevInstancedRef.current !== isInstanced) {
188
+ setIsTransitioning(true);
189
+ const timer = setTimeout(() => setIsTransitioning(false), 100);
190
+ return () => clearTimeout(timer);
191
+ }
192
+ prevInstancedRef.current = isInstanced;
193
+ }, [isInstanced]);
194
+
195
+ if (isTransitioning) return null;
196
+
197
+ const key = `${node.id}_${isInstanced ? 'instanced' : 'standard'}`;
198
+ return isInstanced
199
+ ? <InstancedNode key={key} {...props} />
200
+ : <StandardNode key={key} {...props} />;
186
201
  }
187
202
 
188
- /* -------------------------------------------------- */
189
- /* InstancedNode (terminal) */
190
- /* -------------------------------------------------- */
191
203
  function isPhysicsProps(v: any): v is PhysicsProps {
192
204
  return v?.type === "fixed" || v?.type === "dynamic";
193
205
  }
194
206
 
195
- function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode }: RendererProps) {
207
+ function InstancedNode({ gameObject, parentMatrix = IDENTITY, editMode, registerRef, selectedId: _selectedId, onSelect, onClick }: RendererProps) {
196
208
  const world = parentMatrix.clone().multiply(compose(gameObject));
197
- const { position, rotation, scale } = decompose(world);
209
+ const { position: worldPosition, rotation: worldRotation, scale: worldScale } = decompose(world);
210
+ const localTransform = getNodeTransformProps(gameObject);
211
+
198
212
  const physicsProps = isPhysicsProps(
199
213
  gameObject.components?.physics?.properties
200
214
  )
201
215
  ? gameObject.components?.physics?.properties
202
216
  : undefined;
203
217
 
218
+ const groupRef = useRef<Group>(null);
219
+ const clickValid = useRef(false);
220
+
221
+ useEffect(() => {
222
+ if (editMode) {
223
+ registerRef(gameObject.id, groupRef.current);
224
+ return () => registerRef(gameObject.id, null);
225
+ }
226
+ }, [gameObject.id, registerRef, editMode]);
227
+
228
+ const modelUrl = gameObject.components?.model?.properties?.filename;
229
+
230
+ if (editMode) {
231
+ return (
232
+ <>
233
+ <group
234
+ ref={groupRef}
235
+ position={localTransform.position}
236
+ rotation={localTransform.rotation}
237
+ scale={localTransform.scale}
238
+ onPointerDown={(e) => { e.stopPropagation(); clickValid.current = true; }}
239
+ onPointerMove={() => { clickValid.current = false; }}
240
+ onPointerUp={(e) => {
241
+ if (clickValid.current) {
242
+ e.stopPropagation();
243
+ onSelect?.(gameObject.id);
244
+ onClick?.(e, gameObject);
245
+ }
246
+ clickValid.current = false;
247
+ }}
248
+ >
249
+ <mesh visible={false}>
250
+ <boxGeometry args={[0.01, 0.01, 0.01]} />
251
+ </mesh>
252
+ </group>
253
+ <GameInstance
254
+ id={gameObject.id}
255
+ modelUrl={modelUrl}
256
+ position={worldPosition}
257
+ rotation={worldRotation}
258
+ scale={worldScale}
259
+ physics={physicsProps}
260
+ />
261
+ </>
262
+ );
263
+ }
264
+
204
265
  return (
205
266
  <GameInstance
206
267
  id={gameObject.id}
207
268
  modelUrl={gameObject.components?.model?.properties?.filename}
208
- position={position}
209
- rotation={rotation}
210
- scale={scale}
211
- physics={editMode ? undefined : physicsProps}
269
+ position={worldPosition}
270
+ rotation={worldRotation}
271
+ scale={worldScale}
272
+ physics={physicsProps}
212
273
  />
213
274
  );
214
275
  }
215
276
 
216
- /* -------------------------------------------------- */
217
- /* StandardNode */
218
- /* -------------------------------------------------- */
219
-
220
277
  function StandardNode({
221
278
  gameObject,
222
279
  selectedId,
223
280
  onSelect,
281
+ onClick,
224
282
  registerRef,
225
283
  loadedModels,
226
284
  loadedTextures,
@@ -229,12 +287,13 @@ function StandardNode({
229
287
  }: RendererProps) {
230
288
 
231
289
  const groupRef = useRef<Object3D | null>(null);
290
+ const helperRef = useRef<Object3D | null>(null);
232
291
  const clickValid = useRef(false);
233
292
  const isSelected = selectedId === gameObject.id;
234
- const helperRef = groupRef as React.RefObject<Object3D>;
293
+ const stillInstanced = useInstanceCheck(gameObject.id);
235
294
 
236
295
  useHelper(
237
- editMode && isSelected ? helperRef : null,
296
+ editMode && isSelected ? helperRef as React.RefObject<Object3D> : null,
238
297
  BoxHelper,
239
298
  "cyan"
240
299
  );
@@ -255,26 +314,34 @@ function StandardNode({
255
314
  if (clickValid.current) {
256
315
  e.stopPropagation();
257
316
  onSelect?.(gameObject.id);
317
+ onClick?.(e, gameObject);
258
318
  }
259
319
  clickValid.current = false;
260
320
  };
261
321
 
322
+ const physics = gameObject.components?.physics;
323
+ const ready = !gameObject.components?.model ||
324
+ loadedModels[gameObject.components.model.properties.filename];
325
+ const hasPhysics = physics && ready && !stillInstanced;
326
+ const transform = getNodeTransformProps(gameObject);
327
+ const physicsDef = hasPhysics ? getComponent("Physics") : null;
328
+ const isInstanced = gameObject.components?.model?.properties?.instanced;
329
+ const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
330
+
262
331
  const inner = (
263
332
  <group
264
- ref={groupRef}
265
- {...getNodeTransformProps(gameObject)}
266
- onPointerDown={onDown}
267
- onPointerMove={() => (clickValid.current = false)}
268
- onPointerUp={onUp}
333
+ onPointerDown={editMode ? onDown : undefined}
334
+ onPointerMove={editMode ? () => (clickValid.current = false) : undefined}
335
+ onPointerUp={editMode ? onUp : undefined}
269
336
  >
270
337
  {renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix)}
271
338
  {gameObject.children?.map(child => (
272
339
  <GameObjectRenderer
273
340
  key={child.id}
274
- {...{ child }}
275
341
  gameObject={child}
276
342
  selectedId={selectedId}
277
343
  onSelect={onSelect}
344
+ onClick={onClick}
278
345
  registerRef={registerRef}
279
346
  loadedModels={loadedModels}
280
347
  loadedTextures={loadedTextures}
@@ -285,28 +352,74 @@ function StandardNode({
285
352
  </group>
286
353
  );
287
354
 
288
- const physics = gameObject.components?.physics;
289
- const ready = !gameObject.components?.model ||
290
- loadedModels[gameObject.components.model.properties.filename];
355
+ if (editMode) {
356
+ return (
357
+ <>
358
+ <group
359
+ ref={groupRef}
360
+ position={transform.position}
361
+ rotation={transform.rotation}
362
+ scale={transform.scale}
363
+ >
364
+ <mesh visible={false}>
365
+ <boxGeometry args={[0.01, 0.01, 0.01]} />
366
+ </mesh>
367
+ </group>
368
+ <group
369
+ ref={helperRef}
370
+ position={transform.position}
371
+ rotation={transform.rotation}
372
+ scale={transform.scale}
373
+ >
374
+ {inner}
375
+ </group>
376
+ {hasPhysics && physicsDef?.View ? (
377
+ <physicsDef.View
378
+ key={physicsKey}
379
+ properties={physics.properties}
380
+ position={transform.position}
381
+ rotation={transform.rotation}
382
+ scale={transform.scale}
383
+ editMode={editMode}
384
+ >{inner}</physicsDef.View>
385
+ ) : null}
386
+ </>
387
+ );
388
+ }
291
389
 
292
- if (physics && !editMode && ready) {
293
- const def = getComponent("Physics");
294
- return def?.View
295
- ? <def.View properties={physics.properties}>{inner}</def.View>
296
- : inner;
390
+ if (hasPhysics && physicsDef?.View) {
391
+ return (
392
+ <physicsDef.View
393
+ key={physicsKey}
394
+ properties={physics.properties}
395
+ position={transform.position}
396
+ rotation={transform.rotation}
397
+ scale={transform.scale}
398
+ editMode={editMode}
399
+ >{inner}</physicsDef.View>
400
+ );
297
401
  }
298
402
 
299
- return inner;
403
+ return (
404
+ <group
405
+ ref={groupRef}
406
+ position={transform.position}
407
+ rotation={transform.rotation}
408
+ scale={transform.scale}
409
+ onPointerDown={onDown}
410
+ onPointerMove={() => (clickValid.current = false)}
411
+ onPointerUp={onUp}
412
+ >
413
+ {inner}
414
+ </group>
415
+ );
300
416
  }
301
417
 
302
- /* -------------------------------------------------- */
303
- /* Types & Helpers */
304
- /* -------------------------------------------------- */
305
-
306
418
  interface RendererProps {
307
- gameObject: GameObjectType; // ← no longer optional
419
+ gameObject: GameObjectType;
308
420
  selectedId?: string | null;
309
421
  onSelect?: (id: string) => void;
422
+ onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
310
423
  registerRef: (id: string, obj: Object3D | null) => void;
311
424
  loadedModels: Record<string, Object3D>;
312
425
  loadedTextures: Record<string, Texture>;
@@ -401,7 +514,6 @@ function renderCoreNode(
401
514
  const def = getComponent(comp.type);
402
515
  if (!def?.View) return;
403
516
 
404
- // crude but works with your existing component API
405
517
  if (def.View.toString().includes("children")) {
406
518
  wrappers.push({ key, View: def.View, properties: comp.properties });
407
519
  } else {
@@ -52,12 +52,19 @@ export function Label({ children }: { children: React.ReactNode }) {
52
52
  export function Vector3Input({
53
53
  label,
54
54
  value,
55
- onChange
55
+ onChange,
56
+ snap
56
57
  }: {
57
58
  label: string;
58
59
  value: [number, number, number];
59
60
  onChange: (v: [number, number, number]) => void;
61
+ snap?: number;
60
62
  }) {
63
+ const snapValue = (num: number) => {
64
+ if (!snap) return num;
65
+ return Math.round(num / snap) * snap;
66
+ };
67
+
61
68
  const [draft, setDraft] = useState<[string, string, string]>(
62
69
  () => value.map(v => v.toString()) as any
63
70
  );
@@ -77,7 +84,7 @@ export function Vector3Input({
77
84
  const num = parseFloat(draft[index]);
78
85
  if (Number.isFinite(num)) {
79
86
  const next = [...value] as [number, number, number];
80
- next[index] = num;
87
+ next[index] = snapValue(num);
81
88
  onChange(next);
82
89
  }
83
90
  };
@@ -105,7 +112,8 @@ export function Vector3Input({
105
112
  if (e.shiftKey) speed *= 0.1; // fine
106
113
  if (e.altKey) speed *= 5; // coarse
107
114
 
108
- const nextValue = startValue + dx * speed;
115
+ const rawValue = startValue + dx * speed;
116
+ const nextValue = snapValue(rawValue);
109
117
  const next = [...value] as [number, number, number];
110
118
  next[index] = nextValue;
111
119
 
@@ -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, editMode, children }: PhysicsViewProps) {
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
- // Remount RigidBody when collider/type changes to avoid Rapier hook dependency warnings
63
- const rbKey = `${properties.type || 'dynamic'}_${colliders}`;
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 key={rbKey} type={properties.type} colliders={colliders as any}>
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
  );
@@ -1,5 +1,6 @@
1
1
  import { Component } from "./ComponentRegistry";
2
2
  import { Vector3Input, Label } from "./Input";
3
+ import { useEditorContext } from "../EditorContext";
3
4
 
4
5
  const buttonStyle = {
5
6
  padding: '2px 6px',
@@ -18,10 +19,12 @@ function TransformComponentEditor({ component, onUpdate, transformMode, setTrans
18
19
  transformMode?: "translate" | "rotate" | "scale";
19
20
  setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
20
21
  }) {
22
+ const { snapResolution, setSnapResolution } = useEditorContext();
23
+
21
24
  return <div style={{ display: 'flex', flexDirection: 'column' }}>
22
25
  {transformMode && setTransformMode && (
23
26
  <div style={{ marginBottom: 8 }}>
24
- <Label>Transform Mode</Label>
27
+ <Label>Transform Mode {snapResolution > 0 && `(Snap: ${snapResolution})`}</Label>
25
28
  <div style={{ display: 'flex', gap: 6 }}>
26
29
  {["translate", "rotate", "scale"].map(mode => {
27
30
  const isActive = transformMode === mode;
@@ -45,11 +48,29 @@ function TransformComponentEditor({ component, onUpdate, transformMode, setTrans
45
48
  );
46
49
  })}
47
50
  </div>
51
+ <div style={{ marginTop: 6 }}>
52
+ <button
53
+ onClick={() => setSnapResolution(snapResolution > 0 ? 0 : 0.1)}
54
+ style={{
55
+ ...buttonStyle,
56
+ background: snapResolution > 0 ? 'rgba(255,255,255,0.10)' : 'transparent',
57
+ width: '100%',
58
+ }}
59
+ onPointerEnter={(e) => {
60
+ if (snapResolution === 0) e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
61
+ }}
62
+ onPointerLeave={(e) => {
63
+ if (snapResolution === 0) e.currentTarget.style.background = 'transparent';
64
+ }}
65
+ >
66
+ Snap: {snapResolution > 0 ? `ON (${snapResolution})` : 'OFF'}
67
+ </button>
68
+ </div>
48
69
  </div>
49
70
  )}
50
- <Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} />
51
- <Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} />
52
- <Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} />
71
+ <Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} snap={snapResolution} />
72
+ <Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} snap={snapResolution} />
73
+ <Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} snap={snapResolution} />
53
74
  </div>;
54
75
  }
55
76
 
@@ -1,4 +1,37 @@
1
- import { GameObject } from "./types";
1
+ import { GameObject, Prefab } from "./types";
2
+
3
+ /** Save a prefab as JSON file */
4
+ export function saveJson(data: Prefab, filename: string) {
5
+ const a = document.createElement('a');
6
+ a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
7
+ a.download = `${filename || 'prefab'}.json`;
8
+ a.click();
9
+ }
10
+
11
+ /** Load a prefab from JSON file */
12
+ export function loadJson(): Promise<Prefab | undefined> {
13
+ return new Promise(resolve => {
14
+ const input = document.createElement('input');
15
+ input.type = 'file';
16
+ input.accept = '.json,application/json';
17
+ input.onchange = e => {
18
+ const file = (e.target as HTMLInputElement).files?.[0];
19
+ if (!file) return resolve(undefined);
20
+ const reader = new FileReader();
21
+ reader.onload = e => {
22
+ try {
23
+ const text = e.target?.result;
24
+ if (typeof text === 'string') resolve(JSON.parse(text) as Prefab);
25
+ } catch (err) {
26
+ console.error('Error parsing prefab JSON:', err);
27
+ resolve(undefined);
28
+ }
29
+ };
30
+ reader.readAsText(file);
31
+ };
32
+ input.click();
33
+ });
34
+ }
2
35
 
3
36
  /** Find a node by ID in the tree */
4
37
  export function findNode(root: GameObject, id: string): GameObject | null {
@@ -74,6 +107,15 @@ export function cloneNode(node: GameObject): GameObject {
74
107
  };
75
108
  }
76
109
 
110
+ /** Recursively update all IDs in a node tree */
111
+ export function regenerateIds(node: GameObject): GameObject {
112
+ return {
113
+ ...node,
114
+ id: crypto.randomUUID(),
115
+ children: node.children?.map(regenerateIds)
116
+ };
117
+ }
118
+
77
119
  /** Get component data from a node */
78
120
  export function getComponent<T = any>(node: GameObject, type: string): T | undefined {
79
121
  const comp = Object.values(node.components ?? {}).find(c => c?.type === type);