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
@@ -1,5 +1,5 @@
1
1
  import { Component } from "./ComponentRegistry";
2
- import { FieldRenderer, FieldDefinition } from "./Input";
2
+ import { ColorField, FieldGroup, NumberField, SelectField, StringField } from "./Input";
3
3
  import { Text } from 'three-text/three/react';
4
4
  import { useRef, useState, useCallback } from 'react';
5
5
  import { BufferGeometry, Mesh } from "three";
@@ -14,63 +14,64 @@ function TextComponentEditor({
14
14
  component: any;
15
15
  onUpdate: (newProps: any) => void;
16
16
  }) {
17
- const fields: FieldDefinition[] = [
18
- {
19
- name: 'text',
20
- type: 'string',
21
- label: 'Text',
22
- placeholder: 'Enter text...',
23
- },
24
- {
25
- name: 'color',
26
- type: 'color',
27
- label: 'Color',
28
- },
29
- {
30
- name: 'font',
31
- type: 'string',
32
- label: 'Font',
33
- placeholder: '/fonts/NotoSans-Regular.ttf',
34
- },
35
- {
36
- name: 'size',
37
- type: 'number',
38
- label: 'Size',
39
- min: 0.01,
40
- step: 0.1,
41
- },
42
- {
43
- name: 'depth',
44
- type: 'number',
45
- label: 'Depth',
46
- min: 0,
47
- step: 0.1,
48
- },
49
- {
50
- name: 'width',
51
- type: 'number',
52
- label: 'Width',
53
- min: 0,
54
- step: 0.5,
55
- },
56
- {
57
- name: 'align',
58
- type: 'select',
59
- label: 'Align',
60
- options: [
61
- { value: 'left', label: 'Left' },
62
- { value: 'center', label: 'Center' },
63
- { value: 'right', label: 'Right' },
64
- ],
65
- },
66
- ];
67
-
68
17
  return (
69
- <FieldRenderer
70
- fields={fields}
71
- values={component.properties}
72
- onChange={onUpdate}
73
- />
18
+ <FieldGroup>
19
+ <StringField
20
+ name="text"
21
+ label="Text"
22
+ values={component.properties}
23
+ onChange={onUpdate}
24
+ placeholder="Enter text..."
25
+ />
26
+ <ColorField
27
+ name="color"
28
+ label="Color"
29
+ values={component.properties}
30
+ onChange={onUpdate}
31
+ />
32
+ <StringField
33
+ name="font"
34
+ label="Font"
35
+ values={component.properties}
36
+ onChange={onUpdate}
37
+ placeholder="/fonts/NotoSans-Regular.ttf"
38
+ />
39
+ <NumberField
40
+ name="size"
41
+ label="Size"
42
+ values={component.properties}
43
+ onChange={onUpdate}
44
+ min={0.01}
45
+ step={0.1}
46
+ />
47
+ <NumberField
48
+ name="depth"
49
+ label="Depth"
50
+ values={component.properties}
51
+ onChange={onUpdate}
52
+ min={0}
53
+ step={0.1}
54
+ />
55
+ <NumberField
56
+ name="width"
57
+ label="Width"
58
+ values={component.properties}
59
+ onChange={onUpdate}
60
+ min={0}
61
+ step={0.5}
62
+ />
63
+ <SelectField
64
+ name="align"
65
+ label="Align"
66
+ values={component.properties}
67
+ onChange={onUpdate}
68
+ options={[
69
+ { value: 'left', label: 'Left' },
70
+ { value: 'center', label: 'Center' },
71
+ { value: 'right', label: 'Right' },
72
+ ]}
73
+ />
74
+ </FieldGroup>
74
75
  );
75
76
  }
76
77
 
@@ -1,15 +1,17 @@
1
1
  import { Component } from "./ComponentRegistry";
2
- import { FieldRenderer, FieldDefinition, Label } from "./Input";
2
+ import { Label, Vector3Field, Vector3Input } from "./Input";
3
3
  import { useEditorContext } from "../EditorContext";
4
+ import { colors } from "../styles";
4
5
 
5
6
  const buttonStyle = {
6
- padding: '2px 6px',
7
- background: 'transparent',
8
- color: 'rgba(255,255,255,0.9)',
9
- border: '1px solid rgba(255,255,255,0.14)',
10
- borderRadius: 4,
7
+ padding: '4px 8px',
8
+ background: colors.bgSurface,
9
+ color: colors.text,
10
+ border: `1px solid ${colors.border}`,
11
+ borderRadius: 3,
11
12
  cursor: 'pointer',
12
13
  font: 'inherit',
14
+ fontSize: 11,
13
15
  flex: 1,
14
16
  };
15
17
 
@@ -36,13 +38,15 @@ function TransformModeSelector({
36
38
  onClick={() => setTransformMode(mode as any)}
37
39
  style={{
38
40
  ...buttonStyle,
39
- background: isActive ? 'rgba(255,255,255,0.10)' : 'transparent',
41
+ background: isActive ? colors.accentBg : colors.bgSurface,
42
+ borderColor: isActive ? colors.accentBorder : colors.border,
43
+ color: isActive ? colors.accent : colors.text,
40
44
  }}
41
45
  onPointerEnter={(e) => {
42
- if (!isActive) e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
46
+ if (!isActive) e.currentTarget.style.background = colors.bgHover;
43
47
  }}
44
48
  onPointerLeave={(e) => {
45
- if (!isActive) e.currentTarget.style.background = 'transparent';
49
+ if (!isActive) e.currentTarget.style.background = colors.bgSurface;
46
50
  }}
47
51
  >
48
52
  {mode}
@@ -55,14 +59,16 @@ function TransformModeSelector({
55
59
  onClick={() => setSnapResolution(snapResolution > 0 ? 0 : 0.1)}
56
60
  style={{
57
61
  ...buttonStyle,
58
- background: snapResolution > 0 ? 'rgba(255,255,255,0.10)' : 'transparent',
62
+ background: snapResolution > 0 ? colors.accentBg : colors.bgSurface,
63
+ borderColor: snapResolution > 0 ? colors.accentBorder : colors.border,
64
+ color: snapResolution > 0 ? colors.accent : colors.text,
59
65
  width: '100%',
60
66
  }}
61
67
  onPointerEnter={(e) => {
62
- if (snapResolution === 0) e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
68
+ if (snapResolution === 0) e.currentTarget.style.background = colors.bgHover;
63
69
  }}
64
70
  onPointerLeave={(e) => {
65
- if (snapResolution === 0) e.currentTarget.style.background = 'transparent';
71
+ if (snapResolution === 0) e.currentTarget.style.background = colors.bgSurface;
66
72
  }}
67
73
  >
68
74
  Snap: {snapResolution > 0 ? `ON (${snapResolution})` : 'OFF'}
@@ -72,17 +78,41 @@ function TransformModeSelector({
72
78
  );
73
79
  }
74
80
 
81
+ const snapLockBtnStyle: React.CSSProperties = {
82
+ background: 'none',
83
+ border: 'none',
84
+ cursor: 'pointer',
85
+ padding: '0 2px',
86
+ fontSize: 12,
87
+ lineHeight: 1,
88
+ color: colors.textMuted,
89
+ };
90
+
91
+ function SnapLockButton({ locked, onToggle, title }: { locked: boolean; onToggle: () => void; title: string }) {
92
+ return (
93
+ <button style={snapLockBtnStyle} onClick={onToggle} title={title}>
94
+ {locked ? '🔒' : '🔓'}
95
+ </button>
96
+ );
97
+ }
98
+
75
99
  function TransformComponentEditor({ component, onUpdate }: {
76
100
  component: any;
77
101
  onUpdate: (newComp: any) => void;
78
102
  }) {
79
- const { transformMode, setTransformMode, snapResolution, setSnapResolution } = useEditorContext();
103
+ const {
104
+ transformMode,
105
+ setTransformMode,
106
+ snapResolution,
107
+ setSnapResolution,
108
+ positionSnap,
109
+ setPositionSnap,
110
+ rotationSnap,
111
+ setRotationSnap
112
+ } = useEditorContext();
80
113
 
81
- const fields: FieldDefinition[] = [
82
- { name: 'position', type: 'vector3', label: 'Position', snap: snapResolution },
83
- { name: 'rotation', type: 'vector3', label: 'Rotation', snap: snapResolution },
84
- { name: 'scale', type: 'vector3', label: 'Scale', snap: snapResolution },
85
- ];
114
+ const positionSnapped = positionSnap > 0;
115
+ const rotationSnapped = rotationSnap > 0;
86
116
 
87
117
  return (
88
118
  <div style={{ display: 'flex', flexDirection: 'column' }}>
@@ -92,10 +122,38 @@ function TransformComponentEditor({ component, onUpdate }: {
92
122
  snapResolution={snapResolution}
93
123
  setSnapResolution={setSnapResolution}
94
124
  />
95
- <FieldRenderer
96
- fields={fields}
125
+ <Vector3Input
126
+ label="Position"
127
+ value={component.properties.position ?? [0, 0, 0]}
128
+ onChange={v => onUpdate({ position: v })}
129
+ snap={positionSnap}
130
+ labelExtra={
131
+ <SnapLockButton
132
+ locked={positionSnapped}
133
+ onToggle={() => setPositionSnap(positionSnapped ? 0 : 0.5)}
134
+ title={positionSnapped ? `Snap ON (0.5) — click to disable` : `Snap OFF — click to enable (0.5)`}
135
+ />
136
+ }
137
+ />
138
+ <Vector3Input
139
+ label="Rotation"
140
+ value={component.properties.rotation ?? [0, 0, 0]}
141
+ onChange={v => onUpdate({ rotation: v })}
142
+ snap={rotationSnap}
143
+ labelExtra={
144
+ <SnapLockButton
145
+ locked={rotationSnapped}
146
+ onToggle={() => setRotationSnap(rotationSnapped ? 0 : Math.PI / 4)}
147
+ title={rotationSnapped ? `Snap ON (π/4) — click to disable` : `Snap OFF — click to enable (π/4)`}
148
+ />
149
+ }
150
+ />
151
+ <Vector3Field
152
+ name="scale"
153
+ label="Scale"
97
154
  values={component.properties}
98
155
  onChange={onUpdate}
156
+ fallback={[1, 1, 1]}
99
157
  />
100
158
  </div>
101
159
  );
@@ -7,6 +7,8 @@ import DirectionalLightComponent from './DirectionalLightComponent';
7
7
  import AmbientLightComponent from './AmbientLightComponent';
8
8
  import ModelComponent from './ModelComponent';
9
9
  import TextComponent from './TextComponent';
10
+ import EnvironmentComponent from './EnvironmentComponent';
11
+ import CameraComponent from './CameraComponent';
10
12
 
11
13
  export default [
12
14
  GeometryComponent,
@@ -17,6 +19,8 @@ export default [
17
19
  DirectionalLightComponent,
18
20
  AmbientLightComponent,
19
21
  ModelComponent,
20
- TextComponent
22
+ TextComponent,
23
+ EnvironmentComponent,
24
+ CameraComponent,
21
25
  ];
22
26
 
@@ -1,21 +1,27 @@
1
1
  // Shared editor styles - single source of truth for all prefab editor UI
2
2
 
3
3
  export const colors = {
4
- bg: 'rgba(0,0,0,0.6)',
5
- bgLight: 'rgba(255,255,255,0.06)',
6
- bgHover: 'rgba(255,255,255,0.1)',
7
- border: 'rgba(255,255,255,0.15)',
8
- borderLight: 'rgba(255,255,255,0.1)',
9
- borderFaint: 'rgba(255,255,255,0.05)',
10
- text: '#fff',
11
- textMuted: 'rgba(255,255,255,0.7)',
12
- danger: '#ffaaaa',
13
- dangerBg: 'rgba(255,80,80,0.2)',
14
- dangerBorder: 'rgba(255,80,80,0.4)',
4
+ bg: '#1e1e1e',
5
+ bgSurface: '#252526',
6
+ bgLight: '#2d2d2d',
7
+ bgHover: '#2a2d2e',
8
+ bgInput: '#1a1a1a',
9
+ border: '#3c3c3c',
10
+ borderLight: '#333333',
11
+ borderFaint: '#2a2a2a',
12
+ text: '#cccccc',
13
+ textMuted: '#999999',
14
+ textDim: '#666666',
15
+ accent: '#4c9eff',
16
+ accentBg: 'rgba(76, 158, 255, 0.12)',
17
+ accentBorder: 'rgba(76, 158, 255, 0.4)',
18
+ danger: '#f44747',
19
+ dangerBg: 'rgba(244, 71, 71, 0.12)',
20
+ dangerBorder: 'rgba(244, 71, 71, 0.35)',
15
21
  };
16
22
 
17
23
  export const fonts = {
18
- family: 'system-ui, sans-serif',
24
+ family: 'system-ui, -apple-system, sans-serif',
19
25
  size: 11,
20
26
  sizeSm: 10,
21
27
  };
@@ -27,13 +33,13 @@ export const base = {
27
33
  color: colors.text,
28
34
  border: `1px solid ${colors.border}`,
29
35
  borderRadius: 4,
30
- backdropFilter: 'blur(8px)',
31
36
  fontFamily: fonts.family,
32
37
  fontSize: fonts.size,
38
+ boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
33
39
  } as React.CSSProperties,
34
40
 
35
41
  header: {
36
- padding: '6px 8px',
42
+ padding: '7px 10px',
37
43
  display: 'flex',
38
44
  alignItems: 'center',
39
45
  justifyContent: 'space-between',
@@ -41,24 +47,25 @@ export const base = {
41
47
  background: colors.bgLight,
42
48
  borderBottom: `1px solid ${colors.borderLight}`,
43
49
  fontSize: fonts.size,
44
- fontWeight: 500,
50
+ fontWeight: 600,
45
51
  textTransform: 'uppercase',
46
- letterSpacing: 0.5,
52
+ letterSpacing: 0.8,
53
+ color: colors.text,
47
54
  } as React.CSSProperties,
48
55
 
49
56
  input: {
50
57
  width: '100%',
51
- background: colors.bgHover,
58
+ background: colors.bgInput,
52
59
  border: `1px solid ${colors.border}`,
53
60
  borderRadius: 3,
54
- padding: '4px 6px',
61
+ padding: '5px 8px',
55
62
  color: colors.text,
56
63
  fontSize: fonts.size,
57
64
  outline: 'none',
58
65
  } as React.CSSProperties,
59
66
 
60
67
  btn: {
61
- background: colors.bgHover,
68
+ background: colors.bgLight,
62
69
  border: `1px solid ${colors.border}`,
63
70
  borderRadius: 3,
64
71
  padding: '4px 8px',
@@ -76,10 +83,11 @@ export const base = {
76
83
 
77
84
  label: {
78
85
  fontSize: fonts.sizeSm,
79
- opacity: 0.7,
86
+ color: colors.textMuted,
80
87
  marginBottom: 4,
81
88
  textTransform: 'uppercase',
82
89
  letterSpacing: 0.5,
90
+ fontWeight: 500,
83
91
  } as React.CSSProperties,
84
92
 
85
93
  row: {
@@ -107,6 +115,8 @@ export const inspector = {
107
115
  padding: 8,
108
116
  maxHeight: '80vh',
109
117
  overflowY: 'auto' as const,
118
+ overflowX: 'hidden' as const,
119
+ boxSizing: 'border-box' as const,
110
120
  display: 'flex',
111
121
  flexDirection: 'column' as const,
112
122
  gap: 8,
@@ -125,18 +135,22 @@ export const tree = {
125
135
  overflowY: 'auto' as const,
126
136
  padding: 4,
127
137
  scrollbarWidth: 'thin' as const,
128
- scrollbarColor: 'rgba(255,255,255,0.06) transparent',
138
+ scrollbarColor: `${colors.bgLight} transparent`,
129
139
  } as React.CSSProperties,
130
140
  row: {
131
141
  display: 'flex',
132
142
  alignItems: 'center',
133
143
  padding: '3px 6px',
134
- borderBottom: `1px solid ${colors.borderFaint}`,
144
+ borderBottomWidth: 1,
145
+ borderBottomStyle: 'solid',
146
+ borderBottomColor: colors.borderFaint,
135
147
  cursor: 'pointer',
136
148
  whiteSpace: 'nowrap' as const,
149
+ borderRadius: 2,
137
150
  } as React.CSSProperties,
138
151
  selected: {
139
- background: 'rgba(255,255,255,0.12)',
152
+ background: colors.accentBg,
153
+ borderBottomColor: colors.accentBorder,
140
154
  },
141
155
  };
142
156
 
@@ -144,22 +158,24 @@ export const menu = {
144
158
  container: {
145
159
  position: 'fixed' as const,
146
160
  zIndex: 50,
147
- minWidth: 120,
148
- background: 'rgba(0,0,0,0.85)',
161
+ minWidth: 'auto',
162
+ width: 'max-content',
163
+ maxWidth: 'min(240px, calc(100vw - 16px))',
164
+ background: colors.bgSurface,
149
165
  border: `1px solid ${colors.border}`,
150
166
  borderRadius: 4,
151
167
  overflow: 'hidden',
152
- boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
153
- backdropFilter: 'blur(8px)',
168
+ boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
154
169
  },
155
170
  item: {
156
171
  width: '100%',
157
172
  textAlign: 'left' as const,
158
- padding: '6px 8px',
173
+ padding: '7px 12px',
159
174
  background: 'transparent',
160
175
  border: 'none',
161
176
  color: colors.text,
162
177
  fontSize: fonts.size,
178
+ whiteSpace: 'nowrap' as const,
163
179
  cursor: 'pointer',
164
180
  outline: 'none',
165
181
  } as React.CSSProperties,
@@ -172,8 +188,7 @@ export const toolbar = {
172
188
  panel: {
173
189
  position: 'absolute' as const,
174
190
  top: 8,
175
- left: '50%',
176
- transform: 'translateX(-50%)',
191
+ left: '240px',
177
192
  display: 'flex',
178
193
  gap: 6,
179
194
  padding: '4px 6px',
@@ -183,14 +198,38 @@ export const toolbar = {
183
198
  color: colors.text,
184
199
  fontFamily: fonts.family,
185
200
  fontSize: fonts.size,
186
- backdropFilter: 'blur(8px)',
201
+ boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
187
202
  },
188
203
  divider: {
189
204
  width: 1,
190
- background: 'rgba(255,255,255,0.2)',
205
+ background: colors.borderLight,
191
206
  },
192
207
  disabled: {
193
208
  opacity: 0.4,
194
209
  cursor: 'not-allowed',
195
210
  },
196
211
  };
212
+
213
+ // Shared scrollbar CSS (inject via <style> tag since CSS can't be bundled)
214
+ export const scrollbarCSS = `
215
+ .prefab-scroll::-webkit-scrollbar,
216
+ .tree-scroll::-webkit-scrollbar { width: 6px; height: 6px; }
217
+ .prefab-scroll::-webkit-scrollbar-track,
218
+ .tree-scroll::-webkit-scrollbar-track { background: transparent; }
219
+ .prefab-scroll::-webkit-scrollbar-thumb,
220
+ .tree-scroll::-webkit-scrollbar-thumb { background: ${colors.border}; border-radius: 3px; }
221
+ .prefab-scroll::-webkit-scrollbar-thumb:hover,
222
+ .tree-scroll::-webkit-scrollbar-thumb:hover { background: #555; }
223
+ .prefab-scroll { scrollbar-width: thin; scrollbar-color: ${colors.border} transparent; }
224
+ `;
225
+
226
+ // Reusable component card style for inspector sections
227
+ export const componentCard = {
228
+ container: {
229
+ marginBottom: 8,
230
+ backgroundColor: colors.bgSurface,
231
+ padding: 8,
232
+ borderRadius: 4,
233
+ border: `1px solid ${colors.border}`,
234
+ } as React.CSSProperties,
235
+ };
@@ -1,6 +1,6 @@
1
1
  import { GameObject, Prefab } from "./types";
2
2
  import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
3
- import { Object3D } from 'three';
3
+ import { Box3, Object3D, PerspectiveCamera, Quaternion, Vector3 } from 'three';
4
4
 
5
5
  export interface ExportGLBOptions {
6
6
  filename?: string;
@@ -9,10 +9,26 @@ export interface ExportGLBOptions {
9
9
  onError?: (error: any) => void;
10
10
  }
11
11
 
12
- /** Save a prefab as JSON file */
13
- export function saveJson(data: Prefab, filename: string) {
12
+ /** Save a prefab as JSON file, showing a Save As dialog when supported */
13
+ export async function saveJson(data: Prefab, filename: string) {
14
+ const json = JSON.stringify(data, null, 2);
15
+ if ('showSaveFilePicker' in window) {
16
+ try {
17
+ const handle = await (window as any).showSaveFilePicker({
18
+ suggestedName: `${filename || 'prefab'}.json`,
19
+ types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }],
20
+ });
21
+ const writable = await handle.createWritable();
22
+ await writable.write(json);
23
+ await writable.close();
24
+ return;
25
+ } catch (e: any) {
26
+ if (e?.name === 'AbortError') return; // user cancelled
27
+ }
28
+ }
29
+ // Fallback for browsers without File System Access API
14
30
  const a = document.createElement('a');
15
- a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
31
+ a.href = "data:text/json;charset=utf-8," + encodeURIComponent(json);
16
32
  a.download = `${filename || 'prefab'}.json`;
17
33
  a.click();
18
34
  }
@@ -102,6 +118,41 @@ export async function exportGLBData(sceneRoot: Object3D): Promise<ArrayBuffer> {
102
118
  return result as ArrayBuffer;
103
119
  }
104
120
 
121
+ export function focusCameraOnObject(
122
+ object: Object3D,
123
+ camera: Object3D,
124
+ target: Vector3,
125
+ update?: () => void,
126
+ ) {
127
+ const bounds = new Box3().setFromObject(object);
128
+ const center = new Vector3();
129
+ const size = new Vector3();
130
+ const quaternion = new Quaternion();
131
+ object.getWorldQuaternion(quaternion);
132
+
133
+ if (bounds.isEmpty()) {
134
+ object.getWorldPosition(center);
135
+ size.setScalar(1);
136
+ } else {
137
+ bounds.getCenter(center);
138
+ bounds.getSize(size);
139
+ }
140
+
141
+ const radius = Math.max(size.length() * 0.5, 1);
142
+ const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion).normalize();
143
+ const worldUp = new Vector3(0, 1, 0);
144
+ const elevatedDirection = forward.clone().addScaledVector(worldUp, 0.65).normalize();
145
+ const distance = camera instanceof PerspectiveCamera
146
+ ? Math.max(radius / Math.tan((camera.fov * Math.PI) / 360) * 1.8, radius * 3.5)
147
+ : radius * 4.5;
148
+ const nextPosition = center.clone().add(elevatedDirection.multiplyScalar(distance));
149
+
150
+ camera.position.copy(nextPosition);
151
+ camera.lookAt(center);
152
+ target.copy(center);
153
+ update?.();
154
+ }
155
+
105
156
  /** Find a node by ID in the tree */
106
157
  export function findNode(root: GameObject, id: string): GameObject | null {
107
158
  if (root.id === id) return root;
@@ -171,7 +222,7 @@ export function cloneNode(node: GameObject): GameObject {
171
222
  return {
172
223
  ...node,
173
224
  id: crypto.randomUUID(),
174
- name: `${node.name ?? "Node"} Copy`,
225
+ name: `${node.name ?? node.id} Copy`,
175
226
  children: node.children?.map(cloneNode)
176
227
  };
177
228
  }
@@ -219,3 +270,43 @@ export function updateNodeById(
219
270
  children: newChildren
220
271
  };
221
272
  }
273
+
274
+ /** Create a GameObject node for a 3D model file */
275
+ export function createModelNode(filename: string, name?: string): GameObject {
276
+ return {
277
+ id: crypto.randomUUID(),
278
+ name: name ?? filename.replace(/^.*[\/]/, '').replace(/\.[^.]+$/, ''),
279
+ components: {
280
+ transform: {
281
+ type: 'Transform',
282
+ properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
283
+ },
284
+ model: {
285
+ type: 'Model',
286
+ properties: { filename, instanced: false }
287
+ }
288
+ }
289
+ };
290
+ }
291
+
292
+ /** Create a GameObject node for an image as a textured plane */
293
+ export function createImageNode(texturePath: string, name?: string): GameObject {
294
+ return {
295
+ id: crypto.randomUUID(),
296
+ name: name ?? texturePath.replace(/^.*[\/]/, '').replace(/\.[^.]+$/, ''),
297
+ components: {
298
+ transform: {
299
+ type: 'Transform',
300
+ properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
301
+ },
302
+ geometry: {
303
+ type: 'Geometry',
304
+ properties: { geometryType: 'plane', args: [1, 1] }
305
+ },
306
+ material: {
307
+ type: 'Material',
308
+ properties: { color: '#ffffff', texture: texturePath }
309
+ }
310
+ }
311
+ };
312
+ }