@scenoco-three/editor 0.5.4 → 0.6.0

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 (113) hide show
  1. package/README.md +31 -93
  2. package/dist/cli/index.js +49 -115
  3. package/dist-app/assets/EffectComposer-lekj9UUq.js +1 -0
  4. package/dist-app/assets/GLTFLoader-C2lUhzim.js +1 -0
  5. package/dist-app/assets/OutputPass-DZtd342R.js +72 -0
  6. package/dist-app/assets/Pass-BP3b3hlf.js +1 -0
  7. package/dist-app/assets/RenderPass-BjVchlYg.js +1 -0
  8. package/dist-app/assets/ShaderPass-DMXLcV9X.js +24 -0
  9. package/dist-app/assets/SkeletonUtils-Cxy_owx9.js +1 -0
  10. package/dist-app/assets/index-AxOpglZ-.css +1 -0
  11. package/dist-app/assets/index-D-Iq7tix.js +4135 -0
  12. package/dist-app/assets/inter-CaiW00F8.js +1 -0
  13. package/dist-app/assets/registry-BOM35tSr.js +1433 -0
  14. package/dist-app/assets/worker-_Lh8Vt-i.js +1270 -0
  15. package/dist-app/examples/breakout/assets/geometry/ball.geometry.xml +1 -0
  16. package/dist-app/examples/breakout/assets/geometry/brick.geometry.xml +1 -0
  17. package/dist-app/examples/breakout/assets/geometry/floor-sensor.geometry.xml +1 -0
  18. package/dist-app/examples/breakout/assets/geometry/paddle.geometry.xml +1 -0
  19. package/dist-app/examples/breakout/assets/geometry/wall-side.geometry.xml +1 -0
  20. package/dist-app/examples/breakout/assets/geometry/wall-top.geometry.xml +1 -0
  21. package/dist-app/examples/breakout/assets/materials/ball.material.xml +1 -0
  22. package/dist-app/examples/breakout/assets/materials/brick-firm.material.xml +1 -0
  23. package/dist-app/examples/breakout/assets/materials/brick-hard.material.xml +1 -0
  24. package/dist-app/examples/breakout/assets/materials/brick-soft.material.xml +1 -0
  25. package/dist-app/examples/breakout/assets/materials/paddle.material.xml +10 -0
  26. package/dist-app/examples/breakout/assets/materials/wall.material.xml +1 -0
  27. package/dist-app/examples/breakout/assets/render/neon.render.xml +14 -0
  28. package/dist-app/examples/breakout/assets/scenes/arena.prefab.xml +68 -0
  29. package/dist-app/examples/breakout/assets/scenes/brick.prefab.xml +15 -0
  30. package/dist-app/examples/breakout/assets/scenes/hud.scene.xml +56 -0
  31. package/dist-app/examples/breakout/assets/scenes/level-1.scene.xml +36 -0
  32. package/dist-app/examples/breakout/assets/scenes/level-2.scene.xml +36 -0
  33. package/dist-app/examples/breakout/assets/scenes/level-3.scene.xml +36 -0
  34. package/dist-app/examples/castle/assets/geometry/capsule.geometry.xml +3 -0
  35. package/dist-app/examples/castle/assets/geometry/cone.geometry.xml +2 -0
  36. package/dist-app/examples/castle/assets/geometry/cube.geometry.xml +3 -0
  37. package/dist-app/examples/castle/assets/geometry/cylinder.geometry.xml +3 -0
  38. package/dist-app/examples/castle/assets/geometry/plane.geometry.xml +4 -0
  39. package/dist-app/examples/castle/assets/geometry/quad.geometry.xml +3 -0
  40. package/dist-app/examples/castle/assets/geometry/sphere.geometry.xml +2 -0
  41. package/dist-app/examples/castle/assets/materials/grass.material.xml +1 -0
  42. package/dist-app/examples/castle/assets/materials/roof.material.xml +1 -0
  43. package/dist-app/examples/castle/assets/materials/stone.material.xml +1 -0
  44. package/dist-app/examples/castle/assets/materials/wood.material.xml +1 -0
  45. package/dist-app/examples/castle/assets/scenes/main.scene.xml +25 -0
  46. package/dist-app/examples/collector/assets/geometry/capsule.geometry.xml +3 -0
  47. package/dist-app/examples/collector/assets/geometry/cone.geometry.xml +2 -0
  48. package/dist-app/examples/collector/assets/geometry/cube.geometry.xml +3 -0
  49. package/dist-app/examples/collector/assets/geometry/cylinder.geometry.xml +3 -0
  50. package/dist-app/examples/collector/assets/geometry/plane.geometry.xml +4 -0
  51. package/dist-app/examples/collector/assets/geometry/quad.geometry.xml +3 -0
  52. package/dist-app/examples/collector/assets/geometry/sphere.geometry.xml +2 -0
  53. package/dist-app/examples/collector/assets/materials/crate.material.xml +1 -0
  54. package/dist-app/examples/collector/assets/materials/depot.material.xml +1 -0
  55. package/dist-app/examples/collector/assets/materials/ground.material.xml +1 -0
  56. package/dist-app/examples/collector/assets/materials/player.material.xml +1 -0
  57. package/dist-app/examples/collector/assets/scenes/crate.prefab.xml +9 -0
  58. package/dist-app/examples/collector/assets/scenes/main.scene.xml +83 -0
  59. package/dist-app/examples/playable/assets/geometry/capsule.geometry.xml +3 -0
  60. package/dist-app/examples/playable/assets/geometry/cone.geometry.xml +2 -0
  61. package/dist-app/examples/playable/assets/geometry/cube.geometry.xml +3 -0
  62. package/dist-app/examples/playable/assets/geometry/cylinder.geometry.xml +3 -0
  63. package/dist-app/examples/playable/assets/geometry/plane.geometry.xml +4 -0
  64. package/dist-app/examples/playable/assets/geometry/quad.geometry.xml +3 -0
  65. package/dist-app/examples/playable/assets/geometry/sphere.geometry.xml +2 -0
  66. package/dist-app/examples/playable/assets/materials/ground.material.xml +1 -0
  67. package/dist-app/examples/playable/assets/materials/target.material.xml +2 -0
  68. package/dist-app/examples/playable/assets/scenes/main.scene.xml +53 -0
  69. package/dist-app/examples/playable/assets/scenes/target.prefab.xml +9 -0
  70. package/dist-app/examples.json +68 -0
  71. package/{app → dist-app}/index.html +2 -2
  72. package/dist-app/model.json +1 -0
  73. package/package.json +25 -29
  74. package/app/cameraRig.ts +0 -118
  75. package/app/commands.ts +0 -78
  76. package/app/desktopControls.ts +0 -47
  77. package/app/document.ts +0 -547
  78. package/app/editLog.ts +0 -127
  79. package/app/editor.css +0 -51
  80. package/app/gizmo.ts +0 -169
  81. package/app/globals.d.ts +0 -9
  82. package/app/main.ts +0 -546
  83. package/app/meta.ts +0 -24
  84. package/app/picking.ts +0 -47
  85. package/app/session.ts +0 -612
  86. package/app/touchControls.ts +0 -127
  87. package/app/ui-react/AddMemberPopup.tsx +0 -114
  88. package/app/ui-react/BottomLauncher.tsx +0 -15
  89. package/app/ui-react/EditorUI.tsx +0 -22
  90. package/app/ui-react/FilePicker.tsx +0 -80
  91. package/app/ui-react/HeadBtn.tsx +0 -21
  92. package/app/ui-react/InspectorSheet.tsx +0 -475
  93. package/app/ui-react/NodeListSheet.tsx +0 -56
  94. package/app/ui-react/TopBar.tsx +0 -137
  95. package/app/ui-react/Tree.tsx +0 -106
  96. package/app/ui-react/cx.ts +0 -4
  97. package/app/ui-react/fields.tsx +0 -407
  98. package/app/ui-react/format.ts +0 -30
  99. package/app/ui-react/mount.tsx +0 -31
  100. package/app/ui-react/store.ts +0 -80
  101. package/app/ui-react/types.ts +0 -88
  102. package/app/ui-react/ui.module.css +0 -250
  103. package/app/ui-react/useSize.ts +0 -20
  104. package/app/values.ts +0 -137
  105. package/app/vite-env.d.ts +0 -7
  106. package/dist/index.d.ts +0 -10
  107. package/dist/index.js +0 -10
  108. package/dist/mcp.d.ts +0 -32
  109. package/dist/mcp.js +0 -191
  110. package/dist/protocol.d.ts +0 -170
  111. package/dist/protocol.js +0 -22
  112. package/dist/server.d.ts +0 -124
  113. package/dist/server.js +0 -440
@@ -1,106 +0,0 @@
1
- import { useMemo } from 'react';
2
- import { Tree as Arborist } from 'react-arborist';
3
- import type { NodeRendererProps } from 'react-arborist';
4
- import type { Object3D } from 'three';
5
- import { ChevronDown, ChevronRight } from 'lucide-react';
6
- import { getDeps, useUI } from './store.js';
7
- import { isEditorOnly } from '../meta.js';
8
- import { useSize } from './useSize.js';
9
- import { cx } from './cx.js';
10
- import styles from './ui.module.css';
11
-
12
- /** One scene node projected into the tree (react-arborist reads `id`/`children`). */
13
- interface TNode {
14
- id: string;
15
- name: string;
16
- type: string;
17
- obj: Object3D;
18
- children: TNode[];
19
- }
20
-
21
- /**
22
- * The element name the document wrote, not three.js's class.
23
- *
24
- * Every `@scenoco-three/ui` element is a `Mesh` subclass whose `.type` reads "Mesh", so a whole
25
- * HUD would otherwise be an indistinguishable stack of them.
26
- */
27
- function typeName(node: Object3D): string {
28
- return getDeps().getDoc()?.tagNameOf(node) ?? node.type ?? node.constructor.name;
29
- }
30
-
31
- function build(obj: Object3D): TNode {
32
- const children = obj.children.filter((c) => !isEditorOnly(c)).map(build);
33
- const id = getDeps().getDoc()?.nodeAttr(obj, 'id');
34
- return { id: obj.uuid, name: id ? `#${id}` : `(${typeName(obj)})`, type: typeName(obj), obj, children };
35
- }
36
-
37
- /**
38
- * The scene hierarchy as a react-arborist tree — touch-friendly rows, virtualized,
39
- * with drag scaffolding ready for reparenting (wired to `onMove`, a no-op until the
40
- * document gains structural edits). Selecting a row selects the node everywhere.
41
- */
42
- export function Tree(): React.JSX.Element {
43
- const rev = useUI((s) => s.rev);
44
- const selected = useUI((s) => s.selected);
45
- const select = useUI((s) => s.select);
46
- const { getScene, reparent } = getDeps();
47
- // Re-derive whenever the scene changes (rev bumps on open/edit/reload).
48
- const data = useMemo(() => {
49
- const scene = getScene();
50
- return scene ? [build(scene)] : [];
51
- }, [rev, getScene]);
52
- const [ref, { width, height }] = useSize<HTMLDivElement>();
53
-
54
- return (
55
- <div ref={ref} className={styles.tree} style={{ height: '100%' }}>
56
- <Arborist<TNode>
57
- data={data}
58
- openByDefault
59
- width={width}
60
- height={height}
61
- indent={16}
62
- rowHeight={30}
63
- selection={selected?.uuid}
64
- onSelect={(nodes) => {
65
- const n = nodes[0];
66
- if (n) select(n.data.obj);
67
- }}
68
- onMove={({ dragNodes, parentNode, index }) => {
69
- // Reparent under the drop target (the scene root when dropped at top level),
70
- // recorded as an undoable move + persisted comment-preservingly on save.
71
- const obj = dragNodes[0]?.data.obj;
72
- const root = parentNode?.data.obj ?? getScene();
73
- if (obj && root) reparent(obj, root, index);
74
- }}
75
- >
76
- {Node}
77
- </Arborist>
78
- </div>
79
- );
80
- }
81
-
82
- function Node({ node, style, dragHandle }: NodeRendererProps<TNode>): React.JSX.Element {
83
- const selectedUuid = useUI((s) => s.selected)?.uuid;
84
- const d = node.data;
85
- const hasKids = d.children.length > 0;
86
- return (
87
- <div
88
- style={style}
89
- ref={dragHandle}
90
- className={cx(styles.node, selectedUuid === node.id && styles.sel, node.willReceiveDrop && styles.drop)}
91
- onClick={() => node.select()}
92
- >
93
- <span
94
- className={styles.caret}
95
- onClick={(e) => {
96
- e.stopPropagation();
97
- node.toggle();
98
- }}
99
- >
100
- {hasKids ? node.isOpen ? <ChevronDown /> : <ChevronRight /> : null}
101
- </span>
102
- <span className={styles.nodeName}>{d.name}</span>
103
- {d.name.startsWith('#') ? <span className={styles.nodeType}>{d.type}</span> : null}
104
- </div>
105
- );
106
- }
@@ -1,4 +0,0 @@
1
- /** Join truthy class names (tiny clsx). */
2
- export function cx(...parts: (string | false | null | undefined)[]): string {
3
- return parts.filter(Boolean).join(' ');
4
- }
@@ -1,407 +0,0 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import type { PointerEvent as ReactPointerEvent } from 'react';
3
- import { Lock, X } from 'lucide-react';
4
- import { fmtNum } from './format.js';
5
- import { cx } from './cx.js';
6
- import styles from './ui.module.css';
7
-
8
- // React inspector field widgets (one per value shape). Each owns local editing
9
- // state and commits through the caller's `commit` closure (which routes to the
10
- // live SceneDocument). Ported from the old vanilla fields.ts, scrub logic intact.
11
-
12
- type Commit = (value: number, opts?: { coalesce?: boolean }) => void;
13
-
14
- /**
15
- * Every row accepts the registry `description` of the thing it edits and shows it
16
- * as the row's tooltip. Descriptions are mandatory on `@property`/`@node`/
17
- * `@attachment`, so this surfaces documentation the codebase already guarantees.
18
- */
19
- type Described<T> = T & {
20
- description?: string;
21
- /** The value is not written in the source — render it as a placeholder, not a value. */
22
- unset?: boolean;
23
- /** Present only for a clearable (source-written) attribute; renders the × button. */
24
- onClear?: () => void;
25
- };
26
-
27
- /** Removes an attribute from the source. Only rendered when the value is actually written. */
28
- function ClearBtn({ onClear }: { onClear: () => void }): React.JSX.Element {
29
- return (
30
- <button
31
- type="button"
32
- className={styles.clearBtn}
33
- title="Clear — remove this attribute from the file"
34
- aria-label="Clear value"
35
- onClick={onClear}
36
- >
37
- <X />
38
- </button>
39
- );
40
- }
41
-
42
- /** A read-only label/value row (purely informational — no "disabled" affordance). */
43
- export function ReadRow({ label, value, description, unset }: Described<{ label: string; value: string }>): React.JSX.Element {
44
- return (
45
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
46
- <span className={styles.k}>{label}</span>
47
- <span className={styles.v}>{value}</span>
48
- </div>
49
- );
50
- }
51
-
52
- /**
53
- * A read-only row for a value that *would* be editable but is disabled — decorated
54
- * with a lock and a `reason` tooltip so it reads as intentionally locked (prefab
55
- * content, a `src`-loaded asset, or a value type the inspector can't edit yet)
56
- * rather than a plain fact.
57
- */
58
- export function LockRow({ label, value, reason, description, unset }: Described<{ label: string; value: string; reason: string }>): React.JSX.Element {
59
- return (
60
- <div className={cx(styles.row, styles.locked)} title={description ? `${description}\n\n${reason}` : reason}>
61
- <span className={styles.k}>{label}</span>
62
- <span className={styles.v}>
63
- <Lock className={styles.lockIcon} aria-label={reason} />
64
- <span className={styles.lockVal}>{value}</span>
65
- </span>
66
- </div>
67
- );
68
- }
69
-
70
- /** A standalone scalar (no scrub handle). */
71
- export function NumberRow({
72
- label,
73
- value,
74
- commit,
75
- int,
76
- description,
77
- unset,
78
- onClear,
79
- }: Described<{
80
- label: string;
81
- value: number;
82
- /** `coalesce` folds a drag's many steps into one undo, as the vector rows do. */
83
- commit: (v: number, opts?: { coalesce?: boolean }) => void;
84
- int?: boolean;
85
- }>): React.JSX.Element {
86
- const [text, setText] = useState(() => fmtNum(value));
87
- const focused = useRef(false);
88
- useEffect(() => {
89
- if (!focused.current) setText(fmtNum(value));
90
- }, [value]);
91
- const doCommit = (): void => {
92
- if (text === fmtNum(value)) return; // untouched — never write on a bare focus/blur
93
- const v = parseFloat(text);
94
- if (Number.isFinite(v)) commit(int ? Math.round(v) : v);
95
- else setText(fmtNum(value));
96
- };
97
- return (
98
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
99
- <span className={styles.k}>{label}</span>
100
- <div className={styles.v}>
101
- <input
102
- type="number"
103
- inputMode={int ? 'numeric' : 'decimal'}
104
- className={cx(styles.input, styles.num)}
105
- value={text}
106
- onChange={(e) => setText(e.target.value)}
107
- onFocus={() => (focused.current = true)}
108
- onBlur={() => {
109
- focused.current = false;
110
- doCommit();
111
- }}
112
- onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
113
- />
114
- {onClear ? <ClearBtn onClear={onClear} /> : null}
115
- </div>
116
- </div>
117
- );
118
- }
119
-
120
- export function TextRow({
121
- label,
122
- value,
123
- commit,
124
- description,
125
- unset,
126
- onClear,
127
- }: Described<{
128
- label: string;
129
- value: string;
130
- commit: (v: string) => void;
131
- }>): React.JSX.Element {
132
- const [text, setText] = useState(() => value);
133
- const focused = useRef(false);
134
- useEffect(() => {
135
- if (!focused.current) setText(value);
136
- }, [value]);
137
- return (
138
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
139
- <span className={styles.k}>{label}</span>
140
- <div className={styles.v}>
141
- <input
142
- type="text"
143
- className={styles.input}
144
- value={text}
145
- onChange={(e) => setText(e.target.value)}
146
- onFocus={() => (focused.current = true)}
147
- onBlur={() => {
148
- focused.current = false;
149
- if (text !== value) commit(text); // untouched → no write
150
- }}
151
- onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
152
- />
153
- {onClear ? <ClearBtn onClear={onClear} /> : null}
154
- </div>
155
- </div>
156
- );
157
- }
158
-
159
- export function BoolRow({
160
- label,
161
- value,
162
- commit,
163
- description,
164
- unset,
165
- onClear,
166
- }: Described<{
167
- label: string;
168
- value: boolean;
169
- commit: (v: boolean) => void;
170
- }>): React.JSX.Element {
171
- return (
172
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
173
- <span className={styles.k}>{label}</span>
174
- <div className={styles.v}>
175
- <input type="checkbox" className={styles.toggle} checked={value} onChange={(e) => commit(e.target.checked)} />
176
- {onClear ? <ClearBtn onClear={onClear} /> : null}
177
- </div>
178
- </div>
179
- );
180
- }
181
-
182
- /** A dropdown of enum member names; maps the live native value ↔ its name. */
183
- export function EnumRow({
184
- label,
185
- options,
186
- value,
187
- commit,
188
- description,
189
- unset,
190
- onClear,
191
- }: Described<{
192
- label: string;
193
- /** The member names, which are also the values a document writes. */
194
- options: readonly string[];
195
- value: string;
196
- commit: (v: string) => void;
197
- }>): React.JSX.Element {
198
- const current = value;
199
- return (
200
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
201
- <span className={styles.k}>{label}</span>
202
- <div className={styles.v}>
203
- <select
204
- className={cx(styles.input, styles.select)}
205
- value={current}
206
- onChange={(e) => commit(e.target.value)}
207
- >
208
- {/* A blank first option is how a value gets cleared back to the default. */}
209
- <option value="">—</option>
210
- {options.map((n) => (
211
- <option key={n} value={n}>
212
- {n}
213
- </option>
214
- ))}
215
- </select>
216
- {onClear ? <ClearBtn onClear={onClear} /> : null}
217
- </div>
218
- </div>
219
- );
220
- }
221
-
222
- export function ColorRow({
223
- label,
224
- value,
225
- commit,
226
- description,
227
- unset,
228
- onClear,
229
- }: Described<{
230
- label: string;
231
- value: number;
232
- commit: (hex: number) => void;
233
- }>): React.JSX.Element {
234
- // Defensive: the inspector has no error boundary, so a bad value here would
235
- // blank the entire panel rather than one row.
236
- const hex24 = Number.isFinite(value) ? (value as number) & 0xffffff : 0;
237
- const css = '#' + hex24.toString(16).padStart(6, '0');
238
- const [hex, setHex] = useState(css);
239
- const focused = useRef(false);
240
- useEffect(() => {
241
- if (!focused.current) setHex(css);
242
- }, [css]);
243
- return (
244
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
245
- <span className={styles.k}>{label}</span>
246
- <div className={styles.v}>
247
- <div className={styles.color}>
248
- <input
249
- type="color"
250
- className={styles.swatch}
251
- value={css}
252
- onChange={(e) => {
253
- setHex(e.target.value);
254
- commit(parseInt(e.target.value.slice(1), 16));
255
- }}
256
- />
257
- <input
258
- type="text"
259
- className={cx(styles.input, styles.hex)}
260
- value={hex}
261
- onChange={(e) => setHex(e.target.value)}
262
- onFocus={() => (focused.current = true)}
263
- onBlur={() => {
264
- focused.current = false;
265
- if (hex === css) return; // untouched → no write
266
- const v = parseInt(hex.replace(/^#/, ''), 16);
267
- if (Number.isFinite(v)) commit(v);
268
- else setHex(css);
269
- }}
270
- onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
271
- />
272
- </div>
273
- {onClear ? <ClearBtn onClear={onClear} /> : null}
274
- </div>
275
- </div>
276
- );
277
- }
278
-
279
- /** A multi-axis vector editor (x/y/z), each axis a scrub handle + number input. */
280
- export function VecRow({
281
- label,
282
- value,
283
- commit,
284
- labels = ['x', 'y', 'z'],
285
- description,
286
- unset,
287
- onClear,
288
- }: Described<{
289
- label: string;
290
- value: number[];
291
- commit: (v: number[], opts?: { coalesce?: boolean }) => void;
292
- labels?: string[];
293
- }>): React.JSX.Element {
294
- return (
295
- <div className={cx(styles.row, unset && styles.unsetRow)} title={description}>
296
- <span className={styles.k}>{label}</span>
297
- <div className={cx(styles.v, styles.axes)}>
298
- {labels.map((axis, i) => (
299
- <AxisField
300
- key={axis}
301
- label={axis}
302
- value={value[i] ?? 0}
303
- commit={(v, opts) => {
304
- const next = value.slice();
305
- next[i] = v;
306
- commit(next, opts);
307
- }}
308
- />
309
- ))}
310
- {onClear ? <ClearBtn onClear={onClear} /> : null}
311
- </div>
312
- </div>
313
- );
314
- }
315
-
316
- /**
317
- * Euler editor — **degrees in, degrees out**, matching the authored DSL and the
318
- * compiled model (`transform.eulerAngles`, `ValueType.Euler` props). Callers
319
- * holding a live `THREE.Euler` (radians) convert at their own boundary, e.g. via
320
- * `node.eulerAngles.toArray()` or `eulerDeg(...)`; this row never converts, so
321
- * there is exactly one place per call site where units change.
322
- */
323
- export function EulerRow({
324
- label,
325
- value,
326
- commit,
327
- description,
328
- unset,
329
- onClear,
330
- }: Described<{
331
- label: string;
332
- value: number[]; // degrees
333
- commit: (degrees: number[], opts?: { coalesce?: boolean }) => void;
334
- }>): React.JSX.Element {
335
- return <VecRow label={label} value={value} commit={commit} description={description} />;
336
- }
337
-
338
- /** One axis: a draggable letter (scrub) + a number input. */
339
- function AxisField({ label, value, commit }: { label: string; value: number; commit: Commit }): React.JSX.Element {
340
- const [text, setText] = useState(() => fmtNum(value));
341
- const focused = useRef(false);
342
- useEffect(() => {
343
- if (!focused.current) setText(fmtNum(value));
344
- }, [value]);
345
- const doCommit = (): void => {
346
- if (text === fmtNum(value)) return; // untouched → no write
347
- const v = parseFloat(text);
348
- if (Number.isFinite(v)) commit(v);
349
- else setText(fmtNum(value));
350
- };
351
- const scrub = useScrub(
352
- () => parseFloat(text) || 0,
353
- (v) => {
354
- setText(fmtNum(v));
355
- commit(v, { coalesce: true });
356
- },
357
- );
358
- return (
359
- <div className={styles.axis}>
360
- <span className={cx(styles.axisK, styles.scrub)} {...scrub}>
361
- {label}
362
- </span>
363
- <input
364
- type="number"
365
- inputMode="decimal"
366
- className={cx(styles.input, styles.num)}
367
- value={text}
368
- onChange={(e) => setText(e.target.value)}
369
- onFocus={() => (focused.current = true)}
370
- onBlur={() => {
371
- focused.current = false;
372
- doCommit();
373
- }}
374
- onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
375
- />
376
- </div>
377
- );
378
- }
379
-
380
- /** Drag horizontally on an element to scrub a numeric value (pointer + touch). */
381
- function useScrub(getValue: () => number, onScrub: (v: number) => void, step = 0.05) {
382
- const start = useRef({ x: 0, v: 0 });
383
- const active = useRef(false);
384
- const end = (e: ReactPointerEvent): void => {
385
- if (!active.current) return;
386
- active.current = false;
387
- try {
388
- (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
389
- } catch {
390
- /* capture already gone */
391
- }
392
- };
393
- return {
394
- onPointerDown: (e: ReactPointerEvent) => {
395
- active.current = true;
396
- start.current = { x: e.clientX, v: getValue() };
397
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
398
- e.preventDefault();
399
- },
400
- onPointerMove: (e: ReactPointerEvent) => {
401
- if (!active.current) return;
402
- onScrub(start.current.v + (e.clientX - start.current.x) * step);
403
- },
404
- onPointerUp: end,
405
- onPointerCancel: end,
406
- };
407
- }
@@ -1,30 +0,0 @@
1
- import type { Color, Euler, Object3D, Vector2, Vector3 } from 'three';
2
- import { round, toDeg } from '../meta.js';
3
-
4
- // Pure value formatters for the inspector's read-only rows (ported unchanged from
5
- // the old fields.ts). No DOM — just value → display string.
6
-
7
- export function fmt(v: unknown): string {
8
- if (v == null) return '—';
9
- if (typeof v === 'number') return fmtNum(v);
10
- if (typeof v === 'boolean' || typeof v === 'string') return String(v);
11
- if (Array.isArray(v)) return `[${v.map(fmt).join(', ')}]`;
12
- const o = v as Record<string, unknown> & {
13
- isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; isObject3D?: boolean;
14
- };
15
- if (o.isVector3 || o.isVector2) return vec(o as unknown as Vector3);
16
- if (o.isEuler) return euler(o as unknown as Euler);
17
- if (o.isColor) return `#${(o as unknown as Color).getHexString()}`;
18
- // A reference to another node or component: its class is the useful thing to show, since the
19
- // document refers to it by an id the inspector already renders on its own row.
20
- if (o.isObject3D) return `(${(v as Object3D).type})`;
21
- if ('object3D' in o) return `(${(v as object).constructor.name})`;
22
- return String(v);
23
- }
24
-
25
- export const vec = (v: Vector3 | Vector2): string =>
26
- 'z' in v ? `${round(v.x)} ${round(v.y)} ${round((v as Vector3).z)}` : `${round(v.x)} ${round(v.y)}`;
27
- export const euler = (e: Euler): string => `${toDeg(e.x)} ${toDeg(e.y)} ${toDeg(e.z)}`;
28
- /** A live `Euler` (radians) as the degrees triple the DSL and inspector use. */
29
- export const eulerDeg = (e: Euler): number[] => [toDeg(e.x), toDeg(e.y), toDeg(e.z)];
30
- export const fmtNum = (n: number): string => String(round(n));
@@ -1,31 +0,0 @@
1
- import { createRoot } from 'react-dom/client';
2
- import { EditorUI } from './EditorUI.js';
3
- import { initStore, useUI } from './store.js';
4
- import type { EditorDeps, EditorUIHandle } from './types.js';
5
-
6
- /**
7
- * Mount the React editor chrome into `#sc-ui` and return the tiny imperative
8
- * surface `main.ts` drives — `refresh()` (re-read live engine/document state) and
9
- * `select(node)` (a viewport pick). Mirrors the old `buildPanels`/`buildToolbar`
10
- * handles so the host wiring stays minimal.
11
- */
12
- export function mountEditorUI(deps: EditorDeps): EditorUIHandle {
13
- initStore(deps);
14
-
15
- const host = document.getElementById('sc-ui');
16
- if (!host) throw new Error('#sc-ui root missing');
17
- // No StrictMode: its dev double-mount makes react-arborist (react-dnd HTML5
18
- // backend) throw "Cannot have two HTML5 backends at the same time".
19
- createRoot(host).render(<EditorUI />);
20
-
21
- // Track orientation so the store can enforce one-sheet-at-a-time on phones.
22
- const mq = window.matchMedia('(orientation: portrait)');
23
- const syncOrientation = (): void => useUI.getState().setPortrait(mq.matches);
24
- syncOrientation();
25
- mq.addEventListener('change', syncOrientation);
26
-
27
- return {
28
- refresh: () => useUI.getState().bump(),
29
- select: (node) => useUI.getState().select(node),
30
- };
31
- }
@@ -1,80 +0,0 @@
1
- import { create } from 'zustand';
2
- import type { Object3D } from 'three';
3
- import type { EditorDeps } from './types.js';
4
-
5
- /**
6
- * The editor chrome's UI state. There's exactly one editor per page, so the store
7
- * is a module singleton — `initStore` injects the host deps once (before the React
8
- * tree renders) and the actions relay to them.
9
- *
10
- * `rev` is a monotonic counter bumped on every `refresh()`: components that read
11
- * live engine/document values subscribe to it so an external change, an edit, or a
12
- * playback transition re-renders them without threading the data through props.
13
- */
14
- interface UIState {
15
- selected: Object3D | null;
16
- nodesOpen: boolean;
17
- inspectorOpen: boolean;
18
- /** Portrait forces the two sheets to be mutually exclusive (phones); landscape allows both. */
19
- portrait: boolean;
20
- /** The node the "add component" picker is open for, if any. */
21
- adding: Object3D | null;
22
- rev: number;
23
- select: (node: Object3D | null) => void;
24
- openNodes: () => void;
25
- closeNodes: () => void;
26
- openInspector: () => void;
27
- closeInspector: () => void;
28
- setPortrait: (portrait: boolean) => void;
29
- openAdd: (node: Object3D) => void;
30
- closeAdd: () => void;
31
- bump: () => void;
32
- }
33
-
34
- let deps: EditorDeps | undefined;
35
-
36
- /** Inject the host deps. Called by `mountEditorUI` before the first render. */
37
- export function initStore(d: EditorDeps): void {
38
- deps = d;
39
- }
40
-
41
- /** The host deps, once injected (actions run only after mount, so this is set). */
42
- export function getDeps(): EditorDeps {
43
- if (!deps) throw new Error('editor UI store used before initStore()');
44
- return deps;
45
- }
46
-
47
- export const useUI = create<UIState>((set, get) => ({
48
- selected: null,
49
- nodesOpen: false,
50
- inspectorOpen: false,
51
- portrait: false,
52
- adding: null,
53
- rev: 0,
54
-
55
- // `rev` moves with the selection because the toolbar's state is derived from it: the transform
56
- // modes are only meaningful with something selected, and `handlers.state()` is read at render
57
- // time rather than subscribed to. Without the bump, TopBar (which subscribes to `rev` alone)
58
- // keeps the enablement it was last drawn with, and Move/Rotate/Scale stay grey over a node
59
- // the gizmo is visibly attached to.
60
- select: (node) => {
61
- set((s) => ({ selected: node, rev: s.rev + 1 }));
62
- getDeps().onSelect(node); // host highlights + frames it in the viewport
63
- },
64
-
65
- // Opening one sheet closes the other only in portrait (a phone can't show both);
66
- // in landscape they coexist.
67
- openNodes: () => set((s) => ({ nodesOpen: true, inspectorOpen: s.portrait ? false : s.inspectorOpen })),
68
- closeNodes: () => set({ nodesOpen: false }),
69
- openInspector: () => set((s) => ({ inspectorOpen: true, nodesOpen: s.portrait ? false : s.nodesOpen })),
70
- closeInspector: () => set({ inspectorOpen: false }),
71
-
72
- openAdd: (node) => set({ adding: node }),
73
- closeAdd: () => set({ adding: null }),
74
-
75
- // On rotating to portrait with both open, drop the node list (keep the inspector focused).
76
- setPortrait: (portrait) =>
77
- set((s) => ({ portrait, nodesOpen: portrait && s.inspectorOpen ? false : s.nodesOpen })),
78
-
79
- bump: () => set({ rev: get().rev + 1 }),
80
- }));