react-three-game 0.0.55 → 0.0.57

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 (68) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/shared/ContactShadow.d.ts +8 -0
  4. package/dist/shared/ContactShadow.js +32 -0
  5. package/dist/shared/GameCanvas.js +1 -3
  6. package/dist/tools/assetviewer/page.js +36 -15
  7. package/dist/tools/dragdrop/DragDropLoader.js +17 -40
  8. package/dist/tools/dragdrop/modelLoader.d.ts +5 -0
  9. package/dist/tools/dragdrop/modelLoader.js +39 -0
  10. package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
  11. package/dist/tools/prefabeditor/Dropdown.js +82 -0
  12. package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
  13. package/dist/tools/prefabeditor/EditorTree.js +139 -70
  14. package/dist/tools/prefabeditor/EditorUI.js +5 -10
  15. package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
  16. package/dist/tools/prefabeditor/PrefabEditor.js +70 -3
  17. package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
  18. package/dist/tools/prefabeditor/PrefabRoot.js +136 -35
  19. package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
  20. package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
  21. package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
  22. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
  23. package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
  24. package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
  25. package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
  26. package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
  27. package/dist/tools/prefabeditor/components/Input.js +100 -47
  28. package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
  29. package/dist/tools/prefabeditor/components/MaterialComponent.js +129 -14
  30. package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
  31. package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
  32. package/dist/tools/prefabeditor/components/SpotLightComponent.js +6 -11
  33. package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
  34. package/dist/tools/prefabeditor/components/TransformComponent.js +31 -19
  35. package/dist/tools/prefabeditor/components/index.js +5 -1
  36. package/dist/tools/prefabeditor/styles.d.ts +17 -4
  37. package/dist/tools/prefabeditor/styles.js +69 -32
  38. package/dist/tools/prefabeditor/utils.d.ts +8 -3
  39. package/dist/tools/prefabeditor/utils.js +92 -6
  40. package/package.json +1 -1
  41. package/react-three-game-skill/react-three-game/rules/LIGHTING.md +6 -0
  42. package/src/index.ts +7 -0
  43. package/src/shared/ContactShadow.tsx +74 -0
  44. package/src/shared/GameCanvas.tsx +0 -3
  45. package/src/tools/assetviewer/page.tsx +78 -46
  46. package/src/tools/dragdrop/DragDropLoader.tsx +7 -39
  47. package/src/tools/dragdrop/modelLoader.ts +36 -0
  48. package/src/tools/prefabeditor/Dropdown.tsx +112 -0
  49. package/src/tools/prefabeditor/EditorContext.tsx +5 -0
  50. package/src/tools/prefabeditor/EditorTree.tsx +237 -115
  51. package/src/tools/prefabeditor/EditorUI.tsx +6 -11
  52. package/src/tools/prefabeditor/PrefabEditor.tsx +77 -5
  53. package/src/tools/prefabeditor/PrefabRoot.tsx +228 -59
  54. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
  55. package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
  56. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
  57. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
  58. package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
  59. package/src/tools/prefabeditor/components/Input.tsx +247 -53
  60. package/src/tools/prefabeditor/components/MaterialComponent.tsx +191 -20
  61. package/src/tools/prefabeditor/components/ModelComponent.tsx +52 -5
  62. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
  63. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +14 -16
  64. package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
  65. package/src/tools/prefabeditor/components/TransformComponent.tsx +78 -20
  66. package/src/tools/prefabeditor/components/index.ts +5 -1
  67. package/src/tools/prefabeditor/styles.ts +71 -32
  68. package/src/tools/prefabeditor/utils.ts +96 -5
@@ -6,7 +6,8 @@ import { Physics } from "@react-three/rapier";
6
6
  import EditorUI from "./EditorUI";
7
7
  import { base, toolbar } from "./styles";
8
8
  import { EditorContext } from "./EditorContext";
9
- import { exportGLB } from "./utils";
9
+ import { exportGLB, createModelNode, createImageNode } from "./utils";
10
+ import { parseModelFromFile } from "../dragdrop/modelLoader";
10
11
 
11
12
  export interface PrefabEditorRef {
12
13
  screenshot: () => void;
@@ -35,13 +36,16 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
35
36
  initialPrefab?: Prefab;
36
37
  physics?: boolean;
37
38
  onPrefabChange?: (prefab: Prefab) => void;
39
+ uiPlugins?: React.ReactNode[] | React.ReactNode;
38
40
  children?: React.ReactNode;
39
- }>(({ basePath, initialPrefab, physics = true, onPrefabChange, children }, ref) => {
41
+ }>(({ basePath, initialPrefab, physics = true, onPrefabChange, uiPlugins, children }, ref) => {
40
42
  const [editMode, setEditMode] = useState(true);
41
43
  const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? DEFAULT_PREFAB);
42
44
  const [selectedId, setSelectedId] = useState<string | null>(null);
43
45
  const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
44
46
  const [snapResolution, setSnapResolution] = useState(0);
47
+ const [positionSnap, setPositionSnap] = useState(0.5);
48
+ const [rotationSnap, setRotationSnap] = useState(Math.PI / 4);
45
49
  const [history, setHistory] = useState<Prefab[]>([loadedPrefab]);
46
50
  const [historyIndex, setHistoryIndex] = useState(0);
47
51
  const throttleRef = useRef<NodeJS.Timeout | null>(null);
@@ -120,11 +124,73 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
120
124
  });
121
125
  };
122
126
 
127
+ const handleFocusNode = (nodeId: string) => {
128
+ prefabRootRef.current?.focusNode(nodeId);
129
+ };
130
+
123
131
  useEffect(() => {
124
132
  const canvas = document.querySelector('canvas');
125
133
  if (canvas) canvasRef.current = canvas;
126
134
  }, []);
127
135
 
136
+ // --- Drag & drop files to add nodes ---
137
+ useEffect(() => {
138
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'];
139
+ const MODEL_EXTS = ['glb', 'gltf', 'fbx'];
140
+
141
+ function handleDragOver(e: DragEvent) {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ }
145
+ function handleDrop(e: DragEvent) {
146
+ e.preventDefault();
147
+ e.stopPropagation();
148
+ const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
149
+ files.forEach(file => {
150
+ const ext = file.name.split('.').pop()?.toLowerCase();
151
+ if (!ext) return;
152
+
153
+ const baseName = file.name.replace(/\.[^.]+$/, '');
154
+
155
+ if (MODEL_EXTS.includes(ext)) {
156
+ const modelPath = `models/${file.name}`;
157
+ const newNode = createModelNode(modelPath, baseName);
158
+
159
+ updatePrefab(prev => ({
160
+ ...prev,
161
+ root: { ...prev.root, children: [...(prev.root.children ?? []), newNode] }
162
+ }));
163
+
164
+ parseModelFromFile(file).then(result => {
165
+ if (result.success && result.model) {
166
+ prefabRootRef.current?.injectModel(modelPath, result.model);
167
+ } else {
168
+ console.error('Drop parse error:', result.error);
169
+ }
170
+ });
171
+ } else if (IMAGE_EXTS.includes(ext)) {
172
+ const texturePath = `textures/${file.name}`;
173
+ const newNode = createImageNode(texturePath, baseName);
174
+
175
+ updatePrefab(prev => ({
176
+ ...prev,
177
+ root: { ...prev.root, children: [...(prev.root.children ?? []), newNode] }
178
+ }));
179
+
180
+ // Inject a blob URL texture so it renders immediately
181
+ prefabRootRef.current?.injectTexture(texturePath, file);
182
+ }
183
+ });
184
+ }
185
+
186
+ window.addEventListener('dragover', handleDragOver);
187
+ window.addEventListener('drop', handleDrop);
188
+ return () => {
189
+ window.removeEventListener('dragover', handleDragOver);
190
+ window.removeEventListener('drop', handleDrop);
191
+ };
192
+ }, [loadedPrefab]);
193
+
128
194
  useImperativeHandle(ref, () => ({
129
195
  screenshot: handleScreenshot,
130
196
  exportGLB: handleExportGLB,
@@ -155,10 +221,15 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
155
221
  setTransformMode,
156
222
  snapResolution,
157
223
  setSnapResolution,
224
+ positionSnap,
225
+ setPositionSnap,
226
+ rotationSnap,
227
+ setRotationSnap,
228
+ onFocusNode: handleFocusNode,
158
229
  onScreenshot: handleScreenshot,
159
230
  onExportGLB: handleExportGLB
160
231
  }}>
161
- <GameCanvas>
232
+ <GameCanvas camera={{ position: [0, 5, 15] }}>
162
233
  {physics ? (
163
234
  <Physics debug={editMode} paused={editMode}>
164
235
  {content}
@@ -170,8 +241,9 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
170
241
  <button style={base.btn} onClick={() => setEditMode(!editMode)}>
171
242
  {editMode ? "▶" : "⏸"}
172
243
  </button>
244
+ {uiPlugins}
173
245
  </div>
174
- {editMode && <EditorUI
246
+ <EditorUI
175
247
  prefabData={loadedPrefab}
176
248
  setPrefabData={updatePrefab}
177
249
  selectedId={selectedId}
@@ -181,7 +253,7 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
181
253
  onRedo={redo}
182
254
  canUndo={historyIndex > 0}
183
255
  canRedo={historyIndex < history.length - 1}
184
- />}
256
+ />
185
257
  </EditorContext.Provider>
186
258
  });
187
259
 
@@ -8,7 +8,7 @@ import { getComponent, registerComponent, getNonComposableKeys } from "./compone
8
8
  import components from "./components";
9
9
  import { loadModel } from "../dragdrop/modelLoader";
10
10
  import { GameInstance, GameInstanceProvider, useInstanceCheck } from "./InstanceProvider";
11
- import { updateNode } from "./utils";
11
+ import { focusCameraOnObject, updateNode } from "./utils";
12
12
  import { PhysicsProps } from "./components/PhysicsComponent";
13
13
  import { EditorContext } from "./EditorContext";
14
14
 
@@ -22,6 +22,9 @@ const IDENTITY = new Matrix4();
22
22
  export interface PrefabRootRef {
23
23
  root: Group | null;
24
24
  rigidBodyRefs: Map<string, any>; // RigidBody refs only populated when using physics
25
+ injectModel: (filename: string, model: Object3D) => void;
26
+ injectTexture: (filename: string, file: File) => void;
27
+ focusNode: (nodeId: string) => void;
25
28
  }
26
29
 
27
30
  export const PrefabRoot = forwardRef<PrefabRootRef, {
@@ -38,20 +41,50 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
38
41
  const editorContext = useContext(EditorContext);
39
42
  const transformMode = editorContext?.transformMode ?? "translate";
40
43
  const snapResolution = editorContext?.snapResolution ?? 0;
44
+ const positionSnap = editorContext?.positionSnap ?? 0.5;
45
+ const rotationSnap = editorContext?.rotationSnap ?? Math.PI / 4;
41
46
 
42
47
  // prefab root state
43
48
  const [models, setModels] = useState<Record<string, Object3D>>({});
44
49
  const [textures, setTextures] = useState<Record<string, Texture>>({});
45
50
  const loading = useRef(new Set<string>());
51
+ const failedTextures = useRef(new Set<string>());
46
52
  const objectRefs = useRef<Record<string, Object3D | null>>({});
47
53
  const rigidBodyRefs = useRef<Map<string, RapierRigidBody | null>>(new Map());
48
54
  const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
49
55
  const rootRef = useRef<Group>(null);
56
+ const controlsRef = useRef<any>(null);
57
+
58
+ const injectModel = useCallback((filename: string, model: Object3D) => {
59
+ setModels(m => ({ ...m, [filename]: model }));
60
+ }, []);
61
+
62
+ const injectTexture = useCallback((filename: string, file: File) => {
63
+ loading.current.add(filename);
64
+ const url = URL.createObjectURL(file);
65
+ const loader = new TextureLoader();
66
+ loader.load(url, tex => {
67
+ tex.colorSpace = SRGBColorSpace;
68
+ setTextures(t => ({ ...t, [filename]: tex }));
69
+ URL.revokeObjectURL(url);
70
+ }, undefined, () => URL.revokeObjectURL(url));
71
+ }, []);
50
72
 
51
73
  useImperativeHandle(ref, () => ({
52
74
  root: rootRef.current,
53
- rigidBodyRefs: rigidBodyRefs.current
54
- }), []);
75
+ rigidBodyRefs: rigidBodyRefs.current,
76
+ injectModel,
77
+ injectTexture,
78
+ focusNode: (nodeId: string) => {
79
+ const object = objectRefs.current[nodeId];
80
+ const controls = controlsRef.current;
81
+ const camera = controls?.object;
82
+
83
+ if (!object || !controls || !camera) return;
84
+
85
+ focusCameraOnObject(object, camera, controls.target, () => controls.update?.());
86
+ }
87
+ }), [injectModel, injectTexture]);
55
88
 
56
89
  const registerRef = useCallback((id: string, obj: Object3D | null) => {
57
90
  objectRefs.current[id] = obj;
@@ -109,6 +142,8 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
109
142
  modelsToLoad.add(node.components.model.properties.filename);
110
143
  node.components?.material?.properties?.texture &&
111
144
  texturesToLoad.add(node.components.material.properties.texture);
145
+ node.components?.material?.properties?.normalMapTexture &&
146
+ texturesToLoad.add(node.components.material.properties.normalMapTexture);
112
147
  });
113
148
 
114
149
  modelsToLoad.forEach(async file => {
@@ -126,7 +161,7 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
126
161
 
127
162
  const loader = new TextureLoader();
128
163
  texturesToLoad.forEach(file => {
129
- if (textures[file] || loading.current.has(file)) return;
164
+ if (textures[file] || loading.current.has(file) || failedTextures.current.has(file)) return;
130
165
  loading.current.add(file);
131
166
 
132
167
  // Handle full URLs (http/https) or regular paths
@@ -140,8 +175,9 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
140
175
  tex.colorSpace = SRGBColorSpace;
141
176
  setTextures(t => ({ ...t, [file]: tex }));
142
177
  }, undefined, (err) => {
143
- console.error(`Failed to load texture: ${path}`, err);
178
+ console.warn(`Failed to load texture: ${path}`, err);
144
179
  loading.current.delete(file);
180
+ failedTextures.current.add(file);
145
181
  });
146
182
  });
147
183
  }, [data, models, textures]);
@@ -171,16 +207,16 @@ export const PrefabRoot = forwardRef<PrefabRootRef, {
171
207
 
172
208
  {editMode && (
173
209
  <>
174
- <MapControls makeDefault />
210
+ <MapControls ref={controlsRef} makeDefault />
175
211
  {selectedObject && (
176
212
  <TransformControls
177
- key={`transform-${snapResolution}`}
213
+ key={`transform-${transformMode}-${positionSnap}-${rotationSnap}-${snapResolution}`}
178
214
  object={selectedObject}
179
215
  mode={transformMode}
180
216
  space="local"
181
217
  onObjectChange={onTransformChange}
182
- translationSnap={snapResolution > 0 ? snapResolution : undefined}
183
- rotationSnap={snapResolution > 0 ? snapResolution : undefined}
218
+ translationSnap={positionSnap > 0 ? positionSnap : undefined}
219
+ rotationSnap={rotationSnap > 0 ? rotationSnap : undefined}
184
220
  scaleSnap={snapResolution > 0 ? snapResolution : undefined}
185
221
  />
186
222
  )}
@@ -343,6 +379,19 @@ function StandardNode({
343
379
  const physicsDef = hasPhysics ? getComponent("Physics") : null;
344
380
  const isInstanced = gameObject.components?.model?.properties?.instanced;
345
381
  const physicsKey = `physics_${gameObject.id}_${isInstanced ? 'instanced' : 'standard'}`;
382
+ const renderCtx = createRenderContext(loadedModels, loadedTextures, editMode, selectedId, registerRef);
383
+ const childNodes = getChildHostComponents(gameObject).length > 0
384
+ ? renderHostedChildren(gameObject, renderCtx, world)
385
+ : renderSceneChildren(gameObject, world, {
386
+ selectedId,
387
+ onSelect,
388
+ onClick,
389
+ registerRef,
390
+ registerRigidBodyRef,
391
+ loadedModels,
392
+ loadedTextures,
393
+ editMode,
394
+ });
346
395
 
347
396
  const inner = (
348
397
  <group
@@ -350,22 +399,7 @@ function StandardNode({
350
399
  onPointerMove={editMode ? () => (clickValid.current = false) : undefined}
351
400
  onPointerUp={editMode ? onUp : undefined}
352
401
  >
353
- {renderCoreNode(gameObject, { loadedModels, loadedTextures, editMode, registerRef }, parentMatrix)}
354
- {gameObject.children?.map(child => (
355
- <GameObjectRenderer
356
- key={child.id}
357
- gameObject={child}
358
- selectedId={selectedId}
359
- onSelect={onSelect}
360
- onClick={onClick}
361
- registerRef={registerRef}
362
- registerRigidBodyRef={registerRigidBodyRef}
363
- loadedModels={loadedModels}
364
- loadedTextures={loadedTextures}
365
- editMode={editMode}
366
- parentMatrix={world}
367
- />
368
- ))}
402
+ {renderCompositionNode(gameObject, renderCtx, parentMatrix, childNodes)}
369
403
  </group>
370
404
  );
371
405
 
@@ -449,6 +483,74 @@ interface RendererProps {
449
483
  parentMatrix?: Matrix4;
450
484
  }
451
485
 
486
+ const CHILD_HOST_COMPONENT_TYPES = new Set(["Environment"]);
487
+
488
+ function isChildHostType(type: string) {
489
+ return CHILD_HOST_COMPONENT_TYPES.has(type);
490
+ }
491
+
492
+ function getChildHostComponents(gameObject: GameObjectType) {
493
+ return Object.entries(gameObject.components ?? {}).flatMap(([key, comp]) => {
494
+ if (!comp?.type || !isChildHostType(comp.type)) return [];
495
+
496
+ const def = getComponent(comp.type);
497
+ if (!def?.View) return [];
498
+
499
+ return { key, View: def.View, properties: comp.properties };
500
+ });
501
+ }
502
+
503
+ interface RenderContext {
504
+ loadedModels: Record<string, Object3D>;
505
+ loadedTextures: Record<string, Texture>;
506
+ editMode?: boolean;
507
+ selectedId?: string | null;
508
+ registerRef: (id: string, obj: Object3D | null) => void;
509
+ }
510
+
511
+ interface RuntimeChildRendererProps {
512
+ selectedId?: string | null;
513
+ onSelect?: (id: string) => void;
514
+ onClick?: (event: ThreeEvent<PointerEvent>, entity: GameObjectType) => void;
515
+ registerRef: (id: string, obj: Object3D | null) => void;
516
+ registerRigidBodyRef: (id: string, rb: any) => void;
517
+ loadedModels: Record<string, Object3D>;
518
+ loadedTextures: Record<string, Texture>;
519
+ editMode?: boolean;
520
+ }
521
+
522
+ function createRenderContext(
523
+ loadedModels: Record<string, Object3D>,
524
+ loadedTextures: Record<string, Texture>,
525
+ editMode: boolean | undefined,
526
+ selectedId: string | null | undefined,
527
+ registerRef: (id: string, obj: Object3D | null) => void,
528
+ ): RenderContext {
529
+ return { loadedModels, loadedTextures, editMode, selectedId, registerRef };
530
+ }
531
+
532
+ function renderSceneChildren(
533
+ gameObject: GameObjectType,
534
+ parentMatrix: Matrix4,
535
+ props: RuntimeChildRendererProps,
536
+ ) {
537
+ return gameObject.children?.map(child =>
538
+ <GameObjectRenderer
539
+ key={child.id}
540
+ gameObject={child}
541
+ selectedId={props.selectedId}
542
+ onSelect={props.onSelect}
543
+ onClick={props.onClick}
544
+ registerRef={props.registerRef}
545
+ registerRigidBodyRef={props.registerRigidBodyRef}
546
+ loadedModels={props.loadedModels}
547
+ loadedTextures={props.loadedTextures}
548
+ editMode={props.editMode}
549
+ parentMatrix={parentMatrix}
550
+ />
551
+ );
552
+ }
553
+
452
554
  function walk(node: GameObjectType, fn: (n: GameObjectType) => void) {
453
555
  fn(node);
454
556
  node.children?.forEach(c => walk(c, fn));
@@ -499,14 +601,55 @@ function computeParentWorldMatrix(root: GameObjectType, targetId: string): Matri
499
601
  return result ?? IDENTITY;
500
602
  }
501
603
 
502
- function renderCoreNode(
604
+ function renderCompositionSubtree(
503
605
  gameObject: GameObjectType,
504
- ctx: {
505
- loadedModels: Record<string, Object3D>;
506
- loadedTextures: Record<string, Texture>;
507
- editMode?: boolean;
508
- registerRef: (id: string, obj: Object3D | null) => void;
509
- },
606
+ ctx: RenderContext,
607
+ parentMatrix = IDENTITY
608
+ ): React.ReactNode {
609
+ if (!gameObject || gameObject.disabled) return null;
610
+
611
+ const transform = getNodeTransformProps(gameObject);
612
+ const world = parentMatrix.clone().multiply(compose(gameObject));
613
+ const childNodes = renderHostedChildren(gameObject, ctx, world);
614
+
615
+ return (
616
+ <group
617
+ key={gameObject.id}
618
+ position={transform.position}
619
+ rotation={transform.rotation}
620
+ scale={transform.scale}
621
+ >
622
+ {renderCompositionNode(gameObject, ctx, parentMatrix, childNodes)}
623
+ </group>
624
+ );
625
+ }
626
+
627
+ function renderHostedChildren(
628
+ gameObject: GameObjectType,
629
+ ctx: RenderContext,
630
+ parentMatrix: Matrix4,
631
+ ) {
632
+ return gameObject.children?.map(child =>
633
+ renderCompositionSubtree(child, ctx, parentMatrix)
634
+ );
635
+ }
636
+
637
+ function renderCompositionNode(
638
+ gameObject: GameObjectType,
639
+ ctx: RenderContext,
640
+ parentMatrix?: Matrix4,
641
+ childNodes?: React.ReactNode
642
+ ) {
643
+ const ownContent = renderNodeOwnContent(gameObject, ctx, parentMatrix);
644
+ const siblingContent = renderNodeSiblingComponents(gameObject, ctx, parentMatrix);
645
+ const subtree = <>{ownContent}{siblingContent}{childNodes}</>;
646
+
647
+ return wrapWithChildHosts(gameObject, ctx, parentMatrix, subtree);
648
+ }
649
+
650
+ function renderNodeOwnContent(
651
+ gameObject: GameObjectType,
652
+ ctx: RenderContext,
510
653
  parentMatrix?: Matrix4
511
654
  ) {
512
655
  const geometry = gameObject.components?.geometry;
@@ -523,31 +666,12 @@ function renderCoreNode(
523
666
  loadedModels: ctx.loadedModels,
524
667
  loadedTextures: ctx.loadedTextures,
525
668
  editMode: ctx.editMode,
669
+ isSelected: ctx.selectedId === gameObject.id,
670
+ nodeId: gameObject.id,
526
671
  parentMatrix,
527
672
  registerRef: ctx.registerRef,
528
673
  };
529
674
 
530
- const wrappers: Array<{ key: string; View: any; properties: any }> = [];
531
- const leaves: React.ReactNode[] = [];
532
-
533
- if (gameObject.components) {
534
- Object.entries(gameObject.components)
535
- .filter(([k]) => !getNonComposableKeys().includes(k))
536
- .forEach(([key, comp]) => {
537
- if (!comp?.type) return;
538
- const def = getComponent(comp.type);
539
- if (!def?.View) return;
540
-
541
- if (def.View.toString().includes("children")) {
542
- wrappers.push({ key, View: def.View, properties: comp.properties });
543
- } else {
544
- leaves.push(
545
- <def.View key={key} properties={comp.properties} {...contextProps} />
546
- );
547
- }
548
- });
549
- }
550
-
551
675
  let core: React.ReactNode;
552
676
 
553
677
  if (model && modelDef?.View) {
@@ -560,7 +684,6 @@ function renderCoreNode(
560
684
  {...contextProps}
561
685
  />
562
686
  )}
563
- {leaves}
564
687
  </modelDef.View>
565
688
  );
566
689
  } else if (geometry && geometryDef?.View) {
@@ -574,27 +697,73 @@ function renderCoreNode(
574
697
  {...contextProps}
575
698
  />
576
699
  )}
577
- {leaves}
578
700
  </mesh>
579
701
  );
580
702
  } else if (text && textDef?.View) {
581
703
  core = (
582
704
  <>
583
705
  <textDef.View properties={text.properties} {...contextProps} />
584
- {leaves}
585
706
  </>
586
707
  );
587
708
  } else {
588
- core = <>{leaves}</>;
709
+ core = null;
589
710
  }
590
711
 
591
- return wrappers.reduce(
712
+ return core;
713
+ }
714
+
715
+ function renderNodeSiblingComponents(
716
+ gameObject: GameObjectType,
717
+ ctx: RenderContext,
718
+ parentMatrix?: Matrix4
719
+ ) {
720
+ const contextProps = {
721
+ loadedModels: ctx.loadedModels,
722
+ loadedTextures: ctx.loadedTextures,
723
+ editMode: ctx.editMode,
724
+ isSelected: ctx.selectedId === gameObject.id,
725
+ nodeId: gameObject.id,
726
+ parentMatrix,
727
+ registerRef: ctx.registerRef,
728
+ };
729
+
730
+ return Object.entries(gameObject.components ?? {})
731
+ .filter(([key]) => !getNonComposableKeys().includes(key))
732
+ .flatMap(([key, comp]) => {
733
+ if (!comp?.type || isChildHostType(comp.type)) return [];
734
+
735
+ const def = getComponent(comp.type);
736
+ if (!def?.View) return [];
737
+
738
+ return <def.View key={key} properties={comp.properties} {...contextProps} />;
739
+ });
740
+ }
741
+
742
+ function wrapWithChildHosts(
743
+ gameObject: GameObjectType,
744
+ ctx: RenderContext,
745
+ parentMatrix: Matrix4 | undefined,
746
+ subtree: React.ReactNode
747
+ ) {
748
+ const contextProps = {
749
+ loadedModels: ctx.loadedModels,
750
+ loadedTextures: ctx.loadedTextures,
751
+ editMode: ctx.editMode,
752
+ isSelected: ctx.selectedId === gameObject.id,
753
+ nodeId: gameObject.id,
754
+ parentMatrix,
755
+ registerRef: ctx.registerRef,
756
+ };
757
+
758
+ const childHosts = getChildHostComponents(gameObject);
759
+
760
+ return childHosts.reduce(
592
761
  (acc, { key, View, properties }) => (
593
762
  <View key={key} properties={properties} {...contextProps}>
594
763
  {acc}
595
764
  </View>
596
765
  ),
597
- core
766
+ subtree
598
767
  );
599
768
  }
600
769
 
@@ -1,10 +1,5 @@
1
1
  import { Component } from "./ComponentRegistry";
2
- import { FieldRenderer, FieldDefinition } from "./Input";
3
-
4
- const ambientLightFields: FieldDefinition[] = [
5
- { name: 'color', type: 'color', label: 'Color' },
6
- { name: 'intensity', type: 'number', label: 'Intensity', step: 0.1, min: 0 },
7
- ];
2
+ import { ColorField, FieldGroup, NumberField } from "./Input";
8
3
 
9
4
  function AmbientLightComponentEditor({
10
5
  component,
@@ -14,11 +9,10 @@ function AmbientLightComponentEditor({
14
9
  onUpdate: (newProps: any) => void;
15
10
  }) {
16
11
  return (
17
- <FieldRenderer
18
- fields={ambientLightFields}
19
- values={component.properties}
20
- onChange={onUpdate}
21
- />
12
+ <FieldGroup>
13
+ <ColorField name="color" label="Color" values={component.properties} onChange={onUpdate} />
14
+ <NumberField name="intensity" label="Intensity" values={component.properties} onChange={onUpdate} min={0} step={0.1} fallback={1} />
15
+ </FieldGroup>
22
16
  );
23
17
  }
24
18
 
@@ -0,0 +1,80 @@
1
+ import { PerspectiveCamera, useHelper } from '@react-three/drei';
2
+ import { useRef } from 'react';
3
+ import { CameraHelper, PerspectiveCamera as ThreePerspectiveCamera } from 'three';
4
+ import { Component } from './ComponentRegistry';
5
+ import { FieldGroup, NumberField } from './Input';
6
+
7
+ function CameraComponentEditor({ component, onUpdate }: { component: any; onUpdate: (newComp: any) => void }) {
8
+ return (
9
+ <FieldGroup>
10
+ <NumberField
11
+ name="fov"
12
+ label="FOV"
13
+ values={component.properties}
14
+ onChange={onUpdate}
15
+ fallback={50}
16
+ min={1}
17
+ max={179}
18
+ step={1}
19
+ />
20
+ <NumberField
21
+ name="near"
22
+ label="Near"
23
+ values={component.properties}
24
+ onChange={onUpdate}
25
+ fallback={0.1}
26
+ min={0.001}
27
+ step={0.1}
28
+ />
29
+ <NumberField
30
+ name="zoom"
31
+ label="Zoom"
32
+ values={component.properties}
33
+ onChange={onUpdate}
34
+ fallback={1}
35
+ min={0.01}
36
+ step={0.1}
37
+ />
38
+ <NumberField
39
+ name="far"
40
+ label="Far"
41
+ values={component.properties}
42
+ onChange={onUpdate}
43
+ fallback={1000}
44
+ min={0.1}
45
+ step={1}
46
+ />
47
+ </FieldGroup>
48
+ );
49
+ }
50
+
51
+ function CameraComponentView({ properties, editMode, isSelected }: { properties: any; editMode?: boolean; isSelected?: boolean }) {
52
+ const fov = properties?.fov ?? 50;
53
+ const near = properties?.near ?? 0.1;
54
+ const zoom = properties?.zoom ?? 1;
55
+ const far = properties?.far ?? 1000;
56
+ const cameraRef = useRef<ThreePerspectiveCamera>(null);
57
+
58
+ useHelper(editMode && isSelected ? (cameraRef as React.RefObject<ThreePerspectiveCamera>) : null, CameraHelper);
59
+
60
+ return (
61
+ <>
62
+ <PerspectiveCamera ref={cameraRef} makeDefault={!editMode} fov={fov} near={near} zoom={zoom} far={far} />
63
+ {editMode && !isSelected ? (
64
+ <mesh>
65
+ <boxGeometry args={[0.34, 0.22, 0.18]} />
66
+ <meshBasicMaterial color="#22d3ee" wireframe />
67
+ </mesh>
68
+ ) : null}
69
+ </>
70
+ );
71
+ }
72
+
73
+ const CameraComponent: Component = {
74
+ name: 'Camera',
75
+ Editor: CameraComponentEditor,
76
+ View: CameraComponentView,
77
+ defaultProperties: {},
78
+ };
79
+
80
+ export default CameraComponent;
@@ -80,7 +80,7 @@ function DirectionalLightComponentEditor({ component, onUpdate }: { component: a
80
80
  );
81
81
  }
82
82
 
83
- function DirectionalLightView({ properties, editMode }: { properties: any; editMode?: boolean }) {
83
+ function DirectionalLightView({ properties, editMode, isSelected }: { properties: any; editMode?: boolean; isSelected?: boolean }) {
84
84
  const color = properties.color ?? '#ffffff';
85
85
  const intensity = properties.intensity ?? 1.0;
86
86
  const castShadow = properties.castShadow ?? true;
@@ -138,7 +138,7 @@ function DirectionalLightView({ properties, editMode }: { properties: any; editM
138
138
  />
139
139
  {/* Target object - rendered declaratively in scene graph */}
140
140
  <object3D ref={targetRef} />
141
- {editMode && (
141
+ {editMode && isSelected && (
142
142
  <>
143
143
  {/* Light source indicator */}
144
144
  <mesh>