@scenoco-three/editor 0.5.0 → 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 (112) 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 +26 -30
  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/main.ts +0 -543
  82. package/app/meta.ts +0 -24
  83. package/app/picking.ts +0 -47
  84. package/app/session.ts +0 -572
  85. package/app/touchControls.ts +0 -127
  86. package/app/ui-react/AddMemberPopup.tsx +0 -114
  87. package/app/ui-react/BottomLauncher.tsx +0 -15
  88. package/app/ui-react/EditorUI.tsx +0 -22
  89. package/app/ui-react/FilePicker.tsx +0 -80
  90. package/app/ui-react/HeadBtn.tsx +0 -21
  91. package/app/ui-react/InspectorSheet.tsx +0 -475
  92. package/app/ui-react/NodeListSheet.tsx +0 -56
  93. package/app/ui-react/TopBar.tsx +0 -137
  94. package/app/ui-react/Tree.tsx +0 -106
  95. package/app/ui-react/cx.ts +0 -4
  96. package/app/ui-react/fields.tsx +0 -407
  97. package/app/ui-react/format.ts +0 -30
  98. package/app/ui-react/mount.tsx +0 -31
  99. package/app/ui-react/store.ts +0 -80
  100. package/app/ui-react/types.ts +0 -88
  101. package/app/ui-react/ui.module.css +0 -250
  102. package/app/ui-react/useSize.ts +0 -20
  103. package/app/values.ts +0 -137
  104. package/app/vite-env.d.ts +0 -7
  105. package/dist/index.d.ts +0 -10
  106. package/dist/index.js +0 -10
  107. package/dist/mcp.d.ts +0 -32
  108. package/dist/mcp.js +0 -191
  109. package/dist/protocol.d.ts +0 -170
  110. package/dist/protocol.js +0 -22
  111. package/dist/server.d.ts +0 -124
  112. package/dist/server.js +0 -437
@@ -1,475 +0,0 @@
1
- import { useEffect, useReducer, useRef, useState, type ReactNode } from 'react';
2
- import { ChevronDown, ChevronRight, Plus, Trash2, X } from 'lucide-react';
3
- import type { Object3D } from 'three';
4
- import type { PropInfo } from '@scenoco-three/compiler';
5
- import type { SceneDocument } from '../document.js';
6
- import { shownValue, widgetFor, type Scalar } from '../values.js';
7
- import { getDeps, useUI } from './store.js';
8
- import { cx } from './cx.js';
9
- import { BoolRow, ColorRow, EnumRow, EulerRow, LockRow, NumberRow, TextRow, VecRow } from './fields.js';
10
- import { HeadBtn } from './HeadBtn.js';
11
- import styles from './ui.module.css';
12
-
13
- /**
14
- * The inspector.
15
- *
16
- * ### What it shows
17
- *
18
- * Everything the open document **can** write, with what it *does* write marked and clearable. A
19
- * field left unwritten inherits whatever the class decides later; one written with today's default
20
- * is pinned to today's value — so the distinction is worth showing, and it is why `PropInfo`
21
- * carries `default` separately from the current value.
22
- *
23
- * ### Where the schema comes from
24
- *
25
- * `ProjectModel`, read from the project's own TypeScript: field names, types, defaults, doc
26
- * comments. v1 read a runtime registry that decorators populated, so the inspector could only
27
- * show what had been restated in a `@property`. Nothing is restated now, and nothing can drift.
28
- *
29
- * Attachments are gone with `<Attachments>` — a geometry and a material are assets a mesh points
30
- * at, so they show as ordinary attributes.
31
- */
32
- const PREFAB_REASON = 'From another document — open it to edit this.';
33
- const TYPE_REASON = 'No attribute can express this type.';
34
-
35
- /** Sections the user has folded away, by title. Module state: one inspector per page. */
36
- const closedSections = new Set<string>();
37
- /** Sections already shown once, so `startClosed` folds them the first time and never again. */
38
- const seenSections = new Set<string>();
39
-
40
- export function InspectorSheet(): React.JSX.Element | null {
41
- const open = useUI((s) => s.inspectorOpen);
42
- useUI((s) => s.rev); // re-read live values on refresh
43
- const selected = useUI((s) => s.selected);
44
- const closeInspector = useUI((s) => s.closeInspector);
45
- if (!open) return null;
46
-
47
- return (
48
- <div className={cx(styles.sheet, styles.sheetInspector)}>
49
- <div className={styles.head}>
50
- <span className={styles.title}>Inspector</span>
51
- <div className={styles.actions}>
52
- <HeadBtn label="Close" onClick={closeInspector}>
53
- <X />
54
- </HeadBtn>
55
- </div>
56
- </div>
57
- <div className={styles.body}>
58
- {selected ? (
59
- <InspectorBody node={selected} />
60
- ) : (
61
- <div className={styles.inspector}>
62
- <div className={styles.empty}>Select a node — tap it in the viewport or the node list.</div>
63
- </div>
64
- )}
65
- </div>
66
- </div>
67
- );
68
- }
69
-
70
- function InspectorBody({ node }: { node: Object3D }): React.JSX.Element {
71
- const { getDoc } = getDeps();
72
- const doc = getDoc();
73
- const foreign = doc?.foreignSource(node);
74
- const mine = doc?.canEditTransform(node) ?? false;
75
- const sections: ReactNode[] = [];
76
-
77
- const tag = doc?.tagOf(node);
78
- const tagName = doc?.tagNameOf(node) ?? node.type;
79
-
80
- const renderNodeProp = (prop: PropInfo): React.JSX.Element => (
81
- <PropRow
82
- key={prop.name}
83
- prop={prop}
84
- written={doc?.nodeAttr(node, prop.name)}
85
- editable={mine}
86
- commit={(v, c) => doc?.setNodeValue(node, prop, v, c)}
87
- clear={() => doc?.clearNodeAttr(node, prop.name)}
88
- />
89
- );
90
- const tagProps = split([...(tag?.parameters ?? []), ...(tag?.properties ?? [])], (p) =>
91
- doc?.nodeAttr(node, p.name) !== undefined,
92
- );
93
-
94
- // Placement, which every node takes. Written from the live object, so a gizmo drag and a typed
95
- // number are the same edit — in the document's units, which for rotation means degrees.
96
- const placement = (
97
- <Section key="placement" title="Placement">
98
- {mine ? (
99
- <>
100
- <VecRow
101
- label="position"
102
- value={node.position.toArray()}
103
- commit={(v, c) => commitVector(doc, node, 'position', v, c)}
104
- description="Local position"
105
- />
106
- <EulerRow
107
- label="eulerAngles"
108
- value={node.eulerAngles.toArray()}
109
- commit={(v, c) => commitVector(doc, node, 'eulerAngles', v, c)}
110
- description="Local rotation, in degrees"
111
- />
112
- <VecRow
113
- label="scale"
114
- value={node.scale.toArray()}
115
- commit={(v, c) => commitVector(doc, node, 'scale', v, c)}
116
- description="Local scale"
117
- />
118
- </>
119
- ) : (
120
- <>
121
- <LockRow label="position" value={fmtVec(node.position.toArray())} reason={PREFAB_REASON} />
122
- <LockRow label="eulerAngles" value={fmtVec(node.eulerAngles.toArray())} reason={PREFAB_REASON} />
123
- <LockRow label="scale" value={fmtVec(node.scale.toArray())} reason={PREFAB_REASON} />
124
- </>
125
- )}
126
- </Section>
127
- );
128
-
129
- // The components on this node, each with what its class declares.
130
- for (const [i, { component, info }] of (doc?.componentsOf(node) ?? []).entries()) {
131
- const name = component.constructor.name;
132
- // Per component, not per node: a prefab instance sits in this document while its components
133
- // are declared in the prefab's, and only the second question decides whether a field writes.
134
- const editableHere = doc?.canEditComponent(component) ?? false;
135
- sections.push(
136
- <Section
137
- key={`comp-${i}`}
138
- title={name}
139
- {...(info?.description ? { description: info.description } : {})}
140
- actions={
141
- editableHere ? <MemberActions onDelete={() => doc?.removeComponent(component)} /> : undefined
142
- }
143
- >
144
- {(() => {
145
- const render = (prop: PropInfo): React.JSX.Element => (
146
- <PropRow
147
- key={prop.name}
148
- prop={prop}
149
- written={doc?.componentProp(component, prop.name)}
150
- editable={editableHere}
151
- {...(editableHere ? {} : { live: liveText(component, prop.name) })}
152
- commit={(v, c) => doc?.setComponentValue(component, prop, v, c)}
153
- clear={() => doc?.clearComponentProp(component, prop.name)}
154
- />
155
- );
156
- const fields = split(info?.fields ?? [], (p) => doc?.componentProp(component, p.name) !== undefined);
157
- return (
158
- <>
159
- {fields.written.map(render)}
160
- {fields.rest.length > 0 ? (
161
- <Section title={`${fields.rest.length} more on ${name}`} startClosed>
162
- {fields.rest.map(render)}
163
- </Section>
164
- ) : null}
165
- </>
166
- );
167
- })()}
168
- </Section>,
169
- );
170
- }
171
- if (doc?.canEditComponents(node)) sections.push(<AddRow key="add-comp" node={node} />);
172
-
173
- return (
174
- <div className={styles.inspector}>
175
- <div className={cx(styles.secHead, styles.secHeadPlain)}>
176
- {tagName}
177
- {foreign ? <span className={styles.secDesc}>from {foreign}</span> : null}
178
- </div>
179
-
180
- {/* Unity's order, and for Unity's reason: what a node *is* — its name and whether it is
181
- on — then where it is, then what it does. Identity and placement are what you reach for
182
- on almost every selection; a tag's own attributes and its components are what you came
183
- for on some of them. */}
184
- {mine ? (
185
- <IdentityRow
186
- id={doc?.nodeAttr(node, 'id') ?? ''}
187
- active={doc?.nodeAttr(node, 'active') !== 'false'}
188
- setId={(v) => doc?.setNodeAttr(node, 'id', v === '' ? null : v)}
189
- setActive={(v) => doc?.setNodeAttr(node, 'active', v ? null : 'false')}
190
- />
191
- ) : null}
192
- {mine ? (
193
- <BoolRow
194
- label="visible"
195
- value={node.visible}
196
- commit={(v) => doc?.setVisible(node, v)}
197
- unset={doc?.nodeAttr(node, 'visible') === undefined}
198
- description="Hides the node while it keeps updating. `active` turns the whole subtree off."
199
- {...(doc?.nodeAttr(node, 'visible') !== undefined
200
- ? { onClear: () => doc?.clearNodeAttr(node, 'visible') }
201
- : {})}
202
- />
203
- ) : null}
204
-
205
- {placement}
206
-
207
- {/* The tag's own attributes — a camera's `fov`, a light's `intensity` — read from
208
- three.js's declarations rather than from a table in this repo.
209
-
210
- What the document *writes* stays in front; the rest folds away. `Mesh` alone declares
211
- some thirty settable properties and a package that augments `Object3D` adds its own to
212
- every tag, so showing all of them inline buries the three that were authored. */}
213
- {tagProps.written.length > 0 || tagProps.rest.length > 0 ? (
214
- <Section title={`<${tagName}>`}>
215
- {tagProps.written.map(renderNodeProp)}
216
- {tagProps.rest.length > 0 ? (
217
- <Section title={`${tagProps.rest.length} more on <${tagName}>`} startClosed>
218
- {tagProps.rest.map(renderNodeProp)}
219
- </Section>
220
- ) : null}
221
- </Section>
222
- ) : null}
223
-
224
- {sections}
225
- </div>
226
- );
227
- }
228
-
229
- /**
230
- * One attribute, dispatched by its **shape** — the single question the whole DSL rests on, and
231
- * the one the compiler already answered by reading the field's type.
232
- *
233
- * v1 dispatched on a `ValueType` the author restated in a decorator. There is no decorator now,
234
- * so a field is a colour because it is declared `Color`.
235
- */
236
- function PropRow({
237
- prop,
238
- written,
239
- editable,
240
- live,
241
- commit,
242
- clear,
243
- }: {
244
- prop: PropInfo;
245
- written: string | undefined;
246
- editable: boolean;
247
- /** What the object actually holds — shown on a locked row, where `written` says nothing. */
248
- live?: string | undefined;
249
- commit: (value: Scalar, opts?: { coalesce?: boolean }) => void;
250
- clear: () => void;
251
- }): React.JSX.Element | null {
252
- const shape = widgetFor(prop);
253
- const value = shownValue(prop, written);
254
- const unset = written === undefined;
255
- const description = `${prop.description ?? prop.type}${unset ? '\n\nDefault — not written in the file.' : ''}`;
256
- const onClear = !unset && editable ? clear : undefined;
257
-
258
- if (shape === null) return <LockRow label={prop.name} value={String(value)} reason={TYPE_REASON} description={prop.description} />;
259
- // A locked row prefers the live value. The document has nothing written for something another
260
- // file declares, and falling back to the class default *looks* like a fact: a crate whose
261
- // prefab sets `gravityScale` to 1 would read 0 here, which is worse than saying nothing.
262
- if (!editable) {
263
- return (
264
- <LockRow
265
- label={prop.name}
266
- // Blank rather than the class default when the live value is not something a row can
267
- // show: the document says nothing about this field, so neither should the inspector.
268
- value={live ?? ''}
269
- reason={PREFAB_REASON}
270
- description={prop.description}
271
- />
272
- );
273
- }
274
-
275
- const common = { label: prop.name, description, unset, ...(onClear ? { onClear } : {}) };
276
- switch (shape) {
277
- case 'number':
278
- return <NumberRow {...common} value={value as number} commit={(v, c) => commit(v, c)} />;
279
- case 'boolean':
280
- return <BoolRow {...common} value={value as boolean} commit={(v) => commit(v)} />;
281
- case 'color':
282
- return <ColorRow {...common} value={value as number} commit={(v) => commit(v)} />;
283
- case 'vector2':
284
- return <VecRow {...common} value={value as number[]} labels={['x', 'y']} commit={(v, c) => commit(v, c)} />;
285
- case 'vector3':
286
- case 'vector4':
287
- return <VecRow {...common} value={value as number[]} commit={(v, c) => commit(v, c)} />;
288
- case 'euler':
289
- return <EulerRow {...common} value={value as number[]} commit={(v, c) => commit(v, c)} />;
290
- case 'enum':
291
- return (
292
- <EnumRow
293
- {...common}
294
- value={String(value)}
295
- options={(prop.options ?? []).map((o) => o.replace(/^['"]|['"]$/g, ''))}
296
- commit={(v) => commit(v)}
297
- />
298
- );
299
- default:
300
- // `string`, and the two reference shapes — a `#id` and an asset path are both text a
301
- // person types, and the compiler is what checks them.
302
- return <TextRow {...common} value={String(value)} commit={(v) => commit(v)} />;
303
- }
304
- }
305
-
306
- /**
307
- * The node's identity: whether it is on, and what it is called — one row, as Unity has it.
308
- *
309
- * `active` is written **only when false**. A document that says nothing is active, so writing
310
- * `active="true"` would pin today's default into the file for no gain; unticking and re-ticking
311
- * therefore leaves the document exactly as it was found.
312
- */
313
- function IdentityRow({
314
- id,
315
- active,
316
- setId,
317
- setActive,
318
- }: {
319
- id: string;
320
- active: boolean;
321
- setId: (value: string) => void;
322
- setActive: (value: boolean) => void;
323
- }): React.JSX.Element {
324
- const [text, setText] = useState(id);
325
- const focused = useRef(false);
326
- useEffect(() => {
327
- if (!focused.current) setText(id);
328
- }, [id]);
329
- return (
330
- <div className={styles.identity}>
331
- <input
332
- type="checkbox"
333
- className={styles.toggle}
334
- checked={active}
335
- title="Active — off turns the whole subtree off"
336
- onChange={(e) => setActive(e.target.checked)}
337
- />
338
- <input
339
- type="text"
340
- className={cx(styles.input, styles.identityId)}
341
- value={text}
342
- placeholder="id"
343
- title="A typed field on the generated class, and what #id refers to"
344
- onChange={(e) => setText(e.target.value)}
345
- onFocus={() => (focused.current = true)}
346
- onBlur={() => {
347
- focused.current = false;
348
- if (text !== id) setId(text.trim());
349
- }}
350
- onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
351
- />
352
- </div>
353
- );
354
- }
355
-
356
- /** A gizmo drag and a typed number take the same path: read the live object, write the document. */
357
- function commitVector(
358
- doc: SceneDocument | undefined,
359
- node: Object3D,
360
- channel: 'position' | 'eulerAngles' | 'scale',
361
- value: number[],
362
- opts?: { coalesce?: boolean },
363
- ): void {
364
- const target = channel === 'eulerAngles' ? node.eulerAngles : node[channel];
365
- target.set(value[0] ?? 0, value[1] ?? 0, value[2] ?? 0);
366
- if (channel === 'eulerAngles') node.eulerAngles = target;
367
- doc?.setTransform(node, channel, opts);
368
- }
369
-
370
- function fmtVec(values: number[]): string {
371
- return values.map((n) => Math.round(n * 1000) / 1000).join(', ');
372
- }
373
-
374
- /** The "+ Add component" row, which opens the picker. */
375
- function AddRow({ node }: { node: Object3D }): React.JSX.Element {
376
- const openAdd = useUI((s) => s.openAdd);
377
- return (
378
- <button type="button" className={styles.addRow} onClick={() => openAdd(node)}>
379
- <Plus />
380
- Add component
381
- </button>
382
- );
383
- }
384
-
385
- /**
386
- * Per-member actions.
387
- *
388
- * Only delete, for now: the compiler can reorder elements, but a component's order in a
389
- * `<Components>` block does not change what it does, so there is nothing to reorder *for*.
390
- */
391
- function MemberActions({ onDelete }: { onDelete: () => void }): React.JSX.Element {
392
- const stop = (fn: () => void) => (e: React.MouseEvent) => {
393
- e.stopPropagation(); // the header itself toggles the fold
394
- fn();
395
- };
396
- return (
397
- <button
398
- type="button"
399
- className={cx(styles.memberBtn, styles.memberDelete)}
400
- title="Delete"
401
- aria-label="Delete"
402
- onClick={stop(onDelete)}
403
- >
404
- <Trash2 />
405
- </button>
406
- );
407
- }
408
-
409
- /** What the document writes, and everything else — the inspector's ordering everywhere. */
410
- function split<T>(items: readonly T[], written: (item: T) => boolean): { written: T[]; rest: T[] } {
411
- return { written: items.filter(written), rest: items.filter((i) => !written(i)) };
412
- }
413
-
414
- function Section({
415
- title,
416
- description,
417
- actions,
418
- startClosed,
419
- children,
420
- }: {
421
- title: string;
422
- description?: string;
423
- actions?: ReactNode;
424
- /** Folded on first sight — for the "…more" groups nobody opened deliberately. */
425
- startClosed?: boolean;
426
- children: ReactNode;
427
- }): React.JSX.Element {
428
- const [, force] = useReducer((x: number) => x + 1, 0);
429
- if (startClosed && !seenSections.has(title)) {
430
- seenSections.add(title);
431
- closedSections.add(title);
432
- }
433
- const closed = closedSections.has(title);
434
- const toggle = (): void => {
435
- if (closed) closedSections.delete(title);
436
- else closedSections.add(title);
437
- force();
438
- };
439
- return (
440
- <div className={styles.section}>
441
- <div className={styles.secHead} onClick={toggle}>
442
- <span className={styles.secCaret}>{closed ? <ChevronRight /> : <ChevronDown />}</span>
443
- {title}
444
- {actions ? <span className={styles.secActions}>{actions}</span> : null}
445
- {description ? <span className={styles.secDesc}>{description}</span> : null}
446
- </div>
447
- {!closed && <div className={styles.secBody}>{children}</div>}
448
- </div>
449
- );
450
- }
451
-
452
- /**
453
- * What a component actually holds for one field, as text a locked row can show.
454
- *
455
- * Undefined for anything an attribute could not have written in the first place — a live physics
456
- * body, a texture — where the honest row is the name and the lock, not `[object Object]`.
457
- */
458
- function liveText(target: object, name: string): string | undefined {
459
- const value = (target as Record<string, unknown>)[name];
460
- if (value === null || value === undefined) return undefined;
461
- if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return String(value);
462
- const v = value as {
463
- isColor?: boolean;
464
- isVector2?: boolean;
465
- isVector3?: boolean;
466
- x?: number;
467
- y?: number;
468
- z?: number;
469
- getHexString?: () => string;
470
- };
471
- if (v.isColor) return `#${v.getHexString?.() ?? ''}`;
472
- if (v.isVector3) return `x: ${v.x}; y: ${v.y}; z: ${v.z}`;
473
- if (v.isVector2) return `x: ${v.x}; y: ${v.y}`;
474
- return undefined;
475
- }
@@ -1,56 +0,0 @@
1
- import { ClipboardPaste, Copy, Focus, Scissors, SlidersHorizontal, Trash2, X } from 'lucide-react';
2
- import { getDeps, useUI } from './store.js';
3
- import { HeadBtn } from './HeadBtn.js';
4
- import { Tree } from './Tree.js';
5
- import { cx } from './cx.js';
6
- import styles from './ui.module.css';
7
-
8
- /**
9
- * The node list: a bottom sheet with the scene tree and an actions row —
10
- * **Focus** / **Open inspector** on the selection, the tree-edit actions
11
- * **Copy / Cut / Paste / Delete**, and close. Paste keys off the clipboard (read
12
- * on each refresh via `rev`); the rest need a selection.
13
- */
14
- export function NodeListSheet(): React.JSX.Element | null {
15
- const open = useUI((s) => s.nodesOpen);
16
- const selected = useUI((s) => s.selected);
17
- useUI((s) => s.rev); // re-read clipboard state (Paste enablement) on refresh
18
- const openInspector = useUI((s) => s.openInspector);
19
- const closeNodes = useUI((s) => s.closeNodes);
20
- const { focusSelected, copy, cut, paste, remove, hasClipboard } = getDeps();
21
- if (!open) return null;
22
-
23
- return (
24
- <div className={cx(styles.sheet, styles.sheetNodes)}>
25
- <div className={styles.head}>
26
- <span className={styles.title}>Nodes</span>
27
- <div className={styles.actions}>
28
- <HeadBtn label="Focus selected" disabled={!selected} onClick={() => selected && focusSelected(selected)}>
29
- <Focus />
30
- </HeadBtn>
31
- <HeadBtn label="Open inspector" disabled={!selected} onClick={openInspector}>
32
- <SlidersHorizontal />
33
- </HeadBtn>
34
- <HeadBtn label="Copy" disabled={!selected} onClick={() => selected && copy(selected)}>
35
- <Copy />
36
- </HeadBtn>
37
- <HeadBtn label="Cut" disabled={!selected} onClick={() => selected && cut(selected)}>
38
- <Scissors />
39
- </HeadBtn>
40
- <HeadBtn label="Paste" disabled={!hasClipboard()} onClick={() => paste(selected)}>
41
- <ClipboardPaste />
42
- </HeadBtn>
43
- <HeadBtn label="Delete" disabled={!selected} onClick={() => selected && remove(selected)}>
44
- <Trash2 />
45
- </HeadBtn>
46
- <HeadBtn label="Close" onClick={closeNodes}>
47
- <X />
48
- </HeadBtn>
49
- </div>
50
- </div>
51
- <div className={styles.body}>
52
- <Tree />
53
- </div>
54
- </div>
55
- );
56
- }
@@ -1,137 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import {
3
- Axis3d,
4
- Globe,
5
- Move,
6
- PanelTop,
7
- Pause,
8
- Play,
9
- Redo2,
10
- Rotate3d,
11
- Save,
12
- Scaling,
13
- Square,
14
- StepForward,
15
- Undo2,
16
- } from 'lucide-react';
17
- import { getDeps, useUI } from './store.js';
18
- import { cx } from './cx.js';
19
- import styles from './ui.module.css';
20
-
21
- /**
22
- * One responsive toolbar: transport, gizmo mode (move/rotate/scale), the two view switches,
23
- * undo/redo, and save. Fits any width — it caps at the viewport and scrolls horizontally rather
24
- * than overflowing (see `.topbar`).
25
- *
26
- * Each mode shows only what it can do. Editing offers Play and nothing else from the transport,
27
- * because Pause, Step and Stop have no meaning until something is running; playing offers the
28
- * transport and nothing else, because there is no editing while the sim runs. A permanently
29
- * greyed row of buttons is a worse answer than a row that is the length of what is possible.
30
- */
31
- export function TopBar(): React.JSX.Element {
32
- useUI((s) => s.rev); // re-read handler state on every refresh
33
- const { handlers } = getDeps();
34
- const s = handlers.state();
35
- const editing = s.mode === 'edit';
36
- const paused = s.mode === 'play' && !s.playing;
37
- const mode = handlers.getMode();
38
- const space = handlers.getSpace();
39
- const showUi = handlers.getShowUi();
40
-
41
- return (
42
- <div className={styles.topbar}>
43
- <TBtn label="Play" onClick={() => handlers.play()} disabled={s.playing} active={s.playing}>
44
- <Play />
45
- </TBtn>
46
- {!editing && (
47
- <>
48
- <TBtn label="Pause" onClick={() => handlers.pause()} disabled={!s.playing}>
49
- <Pause />
50
- </TBtn>
51
- <TBtn label="Step one frame" onClick={() => handlers.step()} disabled={!paused}>
52
- <StepForward />
53
- </TBtn>
54
- <TBtn label="Stop" onClick={() => handlers.stop()}>
55
- <Square />
56
- </TBtn>
57
- </>
58
- )}
59
-
60
- {editing && (
61
- <>
62
- <div className={styles.sep} />
63
- {/* Always live. The mode is a property of the editor, not of the selection: choosing
64
- Rotate before picking what to rotate is the ordinary way round, and a mode you can
65
- only set once something is selected makes the first click of every session fail. */}
66
- <TBtn label="Move (G)" onClick={() => handlers.setMode('translate')} active={mode === 'translate'}>
67
- <Move />
68
- </TBtn>
69
- <TBtn label="Rotate (R)" onClick={() => handlers.setMode('rotate')} active={mode === 'rotate'}>
70
- <Rotate3d />
71
- </TBtn>
72
- <TBtn label="Scale (Y)" onClick={() => handlers.setMode('scale')} active={mode === 'scale'}>
73
- <Scaling />
74
- </TBtn>
75
- <div className={styles.sep} />
76
- <TBtn
77
- label={space === 'world' ? 'Handles: World — switch to Local' : 'Handles: Local — switch to World'}
78
- onClick={() => handlers.setSpace(space === 'world' ? 'local' : 'world')}
79
- >
80
- {space === 'world' ? <Globe /> : <Axis3d />}
81
- </TBtn>
82
- <TBtn
83
- label={showUi ? 'Hide the scene’s UI' : 'Show the scene’s UI'}
84
- onClick={() => handlers.setShowUi(!showUi)}
85
- active={showUi}
86
- >
87
- <PanelTop />
88
- </TBtn>
89
- <div className={styles.sep} />
90
- <TBtn label="Undo" onClick={() => handlers.undo()} disabled={!s.canUndo}>
91
- <Undo2 />
92
- </TBtn>
93
- <TBtn label="Redo" onClick={() => handlers.redo()} disabled={!s.canRedo}>
94
- <Redo2 />
95
- </TBtn>
96
- <div className={styles.sep} />
97
- <TBtn
98
- label="Save"
99
- onClick={() => handlers.save()}
100
- disabled={!s.dirty || s.saving}
101
- className={cx(styles.save, s.dirty && styles.dirty, s.saving && styles.saving)}
102
- >
103
- <Save />
104
- </TBtn>
105
- </>
106
- )}
107
- </div>
108
- );
109
- }
110
-
111
- function TBtn({
112
- label,
113
- onClick,
114
- disabled,
115
- active,
116
- className,
117
- children,
118
- }: {
119
- label: string;
120
- onClick: () => void;
121
- disabled?: boolean;
122
- active?: boolean;
123
- className?: string;
124
- children: ReactNode;
125
- }): React.JSX.Element {
126
- return (
127
- <button
128
- className={cx(styles.tbtn, active && styles.active, className)}
129
- title={label}
130
- aria-label={label}
131
- onClick={onClick}
132
- disabled={disabled}
133
- >
134
- {children}
135
- </button>
136
- );
137
- }