@scenoco-three/editor 0.4.0 → 0.5.4

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/globals.d.ts +9 -0
  11. package/app/index.html +6 -122
  12. package/app/main.ts +491 -304
  13. package/app/meta.ts +24 -0
  14. package/app/picking.ts +47 -0
  15. package/app/session.ts +513 -308
  16. package/app/touchControls.ts +85 -62
  17. package/app/ui-react/AddMemberPopup.tsx +114 -0
  18. package/app/ui-react/BottomLauncher.tsx +15 -0
  19. package/app/ui-react/EditorUI.tsx +22 -0
  20. package/app/ui-react/FilePicker.tsx +80 -0
  21. package/app/ui-react/HeadBtn.tsx +21 -0
  22. package/app/ui-react/InspectorSheet.tsx +475 -0
  23. package/app/ui-react/NodeListSheet.tsx +56 -0
  24. package/app/ui-react/TopBar.tsx +137 -0
  25. package/app/ui-react/Tree.tsx +106 -0
  26. package/app/ui-react/cx.ts +4 -0
  27. package/app/ui-react/fields.tsx +407 -0
  28. package/app/ui-react/format.ts +30 -0
  29. package/app/ui-react/mount.tsx +31 -0
  30. package/app/ui-react/store.ts +80 -0
  31. package/app/ui-react/types.ts +88 -0
  32. package/app/ui-react/ui.module.css +250 -0
  33. package/app/ui-react/useSize.ts +20 -0
  34. package/app/values.ts +137 -0
  35. package/dist/cli/index.js +93 -70
  36. package/dist/index.d.ts +10 -3
  37. package/dist/index.js +9 -5
  38. package/dist/mcp.d.ts +28 -12
  39. package/dist/mcp.js +172 -131
  40. package/dist/protocol.d.ts +74 -65
  41. package/dist/protocol.js +17 -4
  42. package/dist/server.d.ts +106 -39
  43. package/dist/server.js +389 -361
  44. package/package.json +24 -9
  45. package/app/filePicker.ts +0 -64
  46. package/app/panels.ts +0 -553
  47. package/app/toolbar.ts +0 -131
  48. package/app/wsClient.ts +0 -92
  49. package/dist/args.d.ts +0 -8
  50. package/dist/args.js +0 -28
package/app/document.ts CHANGED
@@ -1,500 +1,547 @@
1
- import { Color, Euler, Vector2, Vector3, type Object3D } from 'three';
2
- import {
3
- Registry,
4
- elementType,
5
- getAttachmentDef,
6
- isArrayType,
7
- type Component,
8
- type CompiledScene,
9
- type EnumDef,
10
- type PropType,
11
- type PropertyType,
12
- type SceneGraph,
13
- } from '@scenoco-three/core';
14
- import type { NodeOrigin, SceneProvenance } from '@scenoco-three/compiler';
15
- import type { EditOp, EditTarget } from '../dist/protocol.js';
16
- import { UndoStack } from './commands.js';
17
-
18
- export type TransformChannel = 'position' | 'rotation' | 'scale';
19
- type Scalar = number | boolean | string | number[];
20
- type AttrMap = Record<string, Scalar>;
21
-
22
- /** A node's attachment slot as the inspector needs it. */
23
- export interface AttachmentView {
24
- slot: number;
1
+ import type { Object3D, Vector3 } from 'three';
2
+ import { provenanceOf, type Component, type Provenance } from '@scenoco-three/core';
3
+ import type { ComponentInfo, ElementAt, ProjectModel, PropInfo, TagInfo, ValueShape } from '@scenoco-three/compiler';
4
+ import { EditLog } from './editLog.js';
5
+ import { formatValue, parseValue, type Scalar } from './values.js';
6
+
7
+ /** A pending change, in the shape the server splices. */
8
+ export type Edit =
9
+ | { op: 'setAttribute'; at: ElementAt; name: string; value: string | null }
10
+ | { op: 'removeElement'; at: ElementAt }
11
+ | { op: 'insertChild'; at: ElementAt; xml: string; index?: number }
12
+ | { op: 'moveElement'; at: ElementAt; parent: ElementAt; index?: number };
13
+
14
+ /** The three placement channels a gizmo drives. */
15
+ export type TransformChannel = 'position' | 'eulerAngles' | 'scale';
16
+
17
+ /** One element of the open document, as the inspector needs it. */
18
+ interface ElementView {
25
19
  tag: string;
26
- attrs: AttrMap;
27
- /** Inline attr props are editable (authored scene node, not src-loaded). */
28
- editable: boolean;
29
- /** `src=` file the attachment was loaded from (props read-only, src editable), else null. */
30
- src: string | null;
20
+ at: ElementAt;
21
+ /** Byte range of the whole element in the document text — what a copy lifts. */
22
+ start: number;
23
+ end: number;
24
+ attributes: Map<string, string>;
31
25
  }
32
26
 
33
27
  /**
34
- * The editable model of the loaded scene and the single funnel for every change.
28
+ * The open document, and every change made to it since it was opened.
35
29
  *
36
- * Each mutator builds an undoable command that writes to **both** the live
37
- * Three.js object (so the viewport updates) **and** the compiled scene (the
38
- * serialization source of truth). Net edits to save are derived by diffing the
39
- * live compiled scene against a snapshot taken at load — so undo/redo, which
40
- * rewinds the compiled scene, is reflected automatically with no extra bookkeeping.
30
+ * ### What replaced what
31
+ *
32
+ * v1 held a compiled scene bundle and mirrored every edit into a copy-on-write shadow of it —
33
+ * `cowAttachments`, `srcEdits`, `removedComponents`, a structural op log, and index arithmetic to
34
+ * keep the shadow aligned with the live objects. That existed because the browser had a *bundle*
35
+ * and no way back to the source.
36
+ *
37
+ * Here the source **is** the model. A node knows which element built it, because the compiler
38
+ * stamped it; an edit is a splice addressed by that stamp; the file on disk is the only state.
39
+ * So this class holds the document text, the element index, and a list of pending splices.
40
+ *
41
+ * Edits are also applied to the live objects immediately, which is not redundant: the round trip
42
+ * through the server and a rebuild takes long enough to feel wrong under a gizmo drag, and an
43
+ * inspector that only updated after a save would be unusable.
41
44
  */
42
45
  export class SceneDocument {
43
- private readonly compiled: CompiledScene;
44
- /** Pristine copy at load — diffed against `compiled` to produce save edits. */
45
- private original: CompiledScene;
46
- private readonly undo: UndoStack;
47
- private readonly listeners = new Set<() => void>();
48
- /** Attachment pool indices this document created via copy-on-write (mutate in place). */
49
- private readonly cowAttachments = new Set<number>();
50
- /** `src` edits (prefab instance / material) — no compiled home, tracked explicitly. */
51
- private readonly srcEdits = new Map<string, EditOp>();
52
-
53
- constructor(
54
- private readonly graph: SceneGraph,
55
- private readonly provenance?: SceneProvenance,
56
- ) {
57
- this.compiled = graph.compiled;
58
- this.original = structuredClone(graph.compiled);
59
- this.undo = new UndoStack(() => this.emit());
60
- }
61
-
62
- // ── editability (driven by provenance) ─────────────────────────────────────────
63
-
64
- /** Where a node came from — authored scene, a prefab instance root, or prefab-internal. */
65
- nodeOrigin(node: Object3D): NodeOrigin {
66
- const i = this.graph.nodeIndexOf.get(node);
67
- return (i !== undefined && this.provenance?.nodes[i]) || { kind: 'scene' };
68
- }
69
- /** Transform is editable on authored nodes and prefab-instance roots (their own override). */
46
+ /** Project-relative path — and the `file` every editable node is stamped with. */
47
+ readonly path: string;
48
+
49
+ private text: string;
50
+ private readonly model: ProjectModel;
51
+ private elements: ElementView[] = [];
52
+ /** What has changed since the document was opened, and how to take it back. */
53
+ private readonly log = new EditLog();
54
+
55
+ constructor(path: string, text: string, elements: readonly RawElement[], model: ProjectModel) {
56
+ this.path = path;
57
+ this.text = text;
58
+ this.model = model;
59
+ this.adopt(elements);
60
+ }
61
+
62
+ // --- what may be edited ------------------------------------------------------------------
63
+
64
+ /**
65
+ * Where this node came from, or null if nothing authored it.
66
+ *
67
+ * Null covers two cases that look the same from here and are both correctly read-only: a node
68
+ * some component spawned at runtime, and a node from a *different* document — the inside of a
69
+ * spawned prefab, which is stamped with the prefab's own file.
70
+ */
71
+ originOf(node: Object3D): Provenance | null {
72
+ return provenanceOf(node);
73
+ }
74
+
75
+ /** True when this node belongs to the document being edited. */
76
+ private mine(node: Object3D): ElementAt | null {
77
+ const origin = provenanceOf(node);
78
+ if (!origin || origin.file !== this.path) return null;
79
+ return { line: origin.line, column: origin.column };
80
+ }
81
+
70
82
  canEditTransform(node: Object3D): boolean {
71
- const k = this.nodeOrigin(node).kind;
72
- return k === 'scene' || k === 'prefab-root';
83
+ return this.mine(node) !== null;
73
84
  }
74
- /** Components are editable only on authored scene nodes. */
85
+
86
+ /**
87
+ * Whether this document can *declare* components on a node — what an "Add component" needs.
88
+ *
89
+ * A spawned prefab cannot take one. The compiler skips a `<Prefab>` element's children when it
90
+ * walks a document for components, so a `<Components>` block written inside one compiles to
91
+ * nothing: the button would appear to work and change nothing, which is worse than not
92
+ * offering it. The prefab's own file is where its components are declared.
93
+ */
75
94
  canEditComponents(node: Object3D): boolean {
76
- return this.nodeOrigin(node).kind === 'scene';
95
+ return this.mine(node) !== null && this.tagNameOf(node) !== 'Prefab';
77
96
  }
78
- /** The prefab `src` of a prefab-instance root, else undefined. */
79
- prefabSrc(node: Object3D): string | undefined {
80
- const o = this.nodeOrigin(node);
81
- return o.kind === 'prefab-root' ? o.src : undefined;
97
+
98
+ /**
99
+ * Whether *this* document declares the component, as opposed to a prefab it spawns.
100
+ *
101
+ * A prefab instance is the case that needs it: the `<Prefab>` element is in this file, so the
102
+ * node is ours and its placement is editable — but the components came with the prefab and are
103
+ * declared in that file. Asking the node is the wrong question, and answering it with the node
104
+ * gave an inspector full of live-looking fields whose writes went nowhere: `setComponentProp`
105
+ * finds no element and returns, so typing into them did nothing at all.
106
+ */
107
+ canEditComponent(component: Component): boolean {
108
+ return this.componentElement(component) !== undefined;
109
+ }
110
+
111
+ canEditStructure(node: Object3D): boolean {
112
+ return this.mine(node) !== null;
82
113
  }
83
- private attachmentSrcOf(poolIndex: number): string | null {
84
- return this.provenance?.attachments[poolIndex] ?? null;
114
+
115
+ /** The document a node came from, when it is not this one — what to open to edit it. */
116
+ foreignSource(node: Object3D): string | null {
117
+ const origin = provenanceOf(node);
118
+ return origin && origin.file !== this.path ? origin.file : null;
85
119
  }
86
120
 
87
- // ── subscription ───────────────────────────────────────────────────────────
121
+ // --- reading -----------------------------------------------------------------------------
88
122
 
89
- onChange(cb: () => void): () => void {
90
- this.listeners.add(cb);
91
- return () => this.listeners.delete(cb);
123
+ /** The element that built this node. */
124
+ private elementOf(node: Object3D): ElementView | undefined {
125
+ const at = this.mine(node);
126
+ if (!at) return undefined;
127
+ return this.elements.find((e) => e.at.line === at.line && e.at.column === at.column);
92
128
  }
93
- private emit(): void {
94
- for (const cb of this.listeners) cb();
129
+
130
+ /** What the document says for one of a node's attributes, or undefined if it says nothing. */
131
+ nodeAttr(node: Object3D, attr: string): string | undefined {
132
+ return this.elementOf(node)?.attributes.get(attr);
95
133
  }
96
134
 
97
- get canUndo(): boolean {
98
- return this.undo.canUndo;
135
+ /** True when the document writes this attribute — as opposed to leaving it at its default. */
136
+ isNodeAttrSet(node: Object3D, attr: string): boolean {
137
+ return this.nodeAttr(node, attr) !== undefined;
99
138
  }
100
- get canRedo(): boolean {
101
- return this.undo.canRedo;
139
+
140
+ /** The element name the document wrote — `Panel`, not `Mesh`. */
141
+ tagNameOf(node: Object3D): string | undefined {
142
+ return this.elementOf(node)?.tag;
102
143
  }
103
- /** Any unsaved change (cheap: scenes are small). */
104
- get isDirty(): boolean {
105
- return this.netEdits().length > 0;
144
+
145
+ /** The tag the compiler knows this node's element by, with its authorable attributes. */
146
+ tagOf(node: Object3D): TagInfo | undefined {
147
+ const tag = this.elementOf(node)?.tag;
148
+ return tag ? this.model.tags.find((t) => t.name === tag) : undefined;
106
149
  }
107
150
 
108
- undoLast(): void {
109
- this.undo.undo();
151
+ /** The components on a node, paired with what the compiler knows about each class. */
152
+ componentsOf(node: Object3D): Array<{ component: Component; info: ComponentInfo | undefined }> {
153
+ return node.components.map((component) => ({
154
+ component,
155
+ info: this.model.components.find((c) => c.name === component.constructor.name),
156
+ }));
110
157
  }
111
- redoLast(): void {
112
- this.undo.redo();
113
- }
114
-
115
- // ── transform edits ──────────────────────────────────────────────────────────
116
-
117
- /** Set a node's position/scale (world units) or rotation (radians, XYZ). */
118
- setTransform(node: Object3D, channel: TransformChannel, value: number[], opts?: { coalesce?: boolean }): void {
119
- if (!this.canEditTransform(node)) return;
120
- const i = this.graph.nodeIndexOf.get(node);
121
- if (i === undefined) return;
122
- const t = this.compiled.nodes[i]!.transform;
123
- const prev = t[channel].slice();
124
- const next = value.slice();
125
- if (numbersEqual(prev, next)) return; // no-op (e.g. an unchanged channel after a drag)
126
- this.undo.run({
127
- label: `${channel} ${node.name || node.type}`,
128
- coalesceKey: opts?.coalesce ? `node:${i}:${channel}` : undefined,
129
- apply: () => {
130
- t[channel] = next.slice();
131
- applyTransform(node, channel, next);
132
- },
133
- invert: () => {
134
- t[channel] = prev.slice();
135
- applyTransform(node, channel, prev);
136
- },
158
+
159
+ /** The element of one component — the Nth `<Type>` inside this node's `<Components>`. */
160
+ private componentElement(component: Component): ElementView | undefined {
161
+ const owner = this.elementOf(component.object3D);
162
+ if (!owner) return undefined;
163
+ const name = component.constructor.name;
164
+ const ordinal = component.object3D.components
165
+ .filter((c) => c.constructor.name === name)
166
+ .indexOf(component);
167
+ // Elements are in document order, so the components of a node are the ones that follow it
168
+ // before the next node's element — and the Nth of a name is the Nth match.
169
+ const start = this.elements.indexOf(owner);
170
+ const matches = this.elements.slice(start + 1).filter((e) => e.tag === name);
171
+ return matches[ordinal];
172
+ }
173
+
174
+ componentProp(component: Component, name: string): string | undefined {
175
+ return this.componentElement(component)?.attributes.get(name);
176
+ }
177
+
178
+ isComponentPropSet(component: Component, name: string): boolean {
179
+ return this.componentProp(component, name) !== undefined;
180
+ }
181
+
182
+ /**
183
+ * One attribute as a **value**, for the inspector's widgets.
184
+ *
185
+ * The document stores text and the widgets edit numbers, flags and axis lists; this pair is the
186
+ * only place the two meet, so a scrubbed number and a hand-typed one land in the file the same
187
+ * way. Undefined when the document does not write it — which is what the row needs in order to
188
+ * show the default, greyed.
189
+ */
190
+ nodeValue(node: Object3D, prop: PropInfo): Scalar | undefined {
191
+ return parseValue(prop.shape, this.nodeAttr(node, prop.name));
192
+ }
193
+
194
+ componentValue(component: Component, prop: PropInfo): Scalar | undefined {
195
+ return parseValue(prop.shape, this.componentProp(component, prop.name));
196
+ }
197
+
198
+ setNodeValue(node: Object3D, prop: PropInfo, value: Scalar, opts?: { coalesce?: boolean }): void {
199
+ this.setNodeAttr(node, prop.name, formatValue(prop.shape, value), { ...opts, shape: prop.shape });
200
+ }
201
+
202
+ setComponentValue(
203
+ component: Component,
204
+ prop: PropInfo,
205
+ value: Scalar,
206
+ opts?: { coalesce?: boolean },
207
+ ): void {
208
+ this.setComponentProp(component, prop.name, formatValue(prop.shape, value), {
209
+ ...opts,
210
+ shape: prop.shape,
137
211
  });
138
212
  }
139
213
 
140
- setVisible(node: Object3D, value: boolean): void {
141
- if (!this.canEditTransform(node)) return;
142
- const i = this.graph.nodeIndexOf.get(node);
143
- if (i === undefined) return;
144
- const t = this.compiled.nodes[i]!.transform;
145
- const prev = t.visible;
146
- if (prev === value) return;
147
- this.undo.run({
148
- label: `visible ${node.name || node.type}`,
149
- apply: () => {
150
- t.visible = value;
151
- node.visible = value;
214
+ // --- writing -----------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Set or clear one of a node's own attributes.
218
+ *
219
+ * `null` clears it, which is a real instruction rather than "write the default": a document
220
+ * that says nothing inherits whatever the class decides later, and one that writes the current
221
+ * default is pinned to today's value.
222
+ */
223
+ setNodeAttr(
224
+ node: Object3D,
225
+ attr: string,
226
+ value: string | null,
227
+ opts?: { coalesce?: boolean; shape?: ValueShape; from?: unknown },
228
+ ): void {
229
+ const at = this.mine(node);
230
+ if (!at) return;
231
+ const before = this.nodeAttr(node, attr) ?? null;
232
+ // `from` when the caller knows the value from before the change — a gizmo does, because the
233
+ // drag it is reporting has already moved the object. Sampling here would be a frame late.
234
+ const live = opts?.from ?? snapshot(node, attr);
235
+ this.log.commit(
236
+ `${attr} on <${this.elementOf(node)?.tag ?? '?'}>`,
237
+ () => {
238
+ this.writeAttribute(at, attr, value);
239
+ this.applyLive(node, attr, value, opts?.shape, live);
152
240
  },
153
- invert: () => {
154
- t.visible = prev;
155
- node.visible = prev;
241
+ () => {
242
+ this.writeAttribute(at, attr, before);
243
+ this.applyLive(node, attr, before, opts?.shape, live);
156
244
  },
157
- });
245
+ opts?.coalesce === true,
246
+ );
158
247
  }
159
248
 
160
- // ── component property edits ──────────────────────────────────────────────────
249
+ clearNodeAttr(node: Object3D, attr: string): void {
250
+ this.setNodeAttr(node, attr, null);
251
+ }
161
252
 
162
- /** Set a scalar/vector/color/bool/string `@property` in compiled form. */
163
253
  setComponentProp(
164
254
  component: Component,
165
255
  name: string,
166
- value: number | boolean | string | number[],
167
- opts?: { coalesce?: boolean },
256
+ value: string | null,
257
+ opts?: { coalesce?: boolean; shape?: ValueShape },
168
258
  ): void {
169
- const ci = this.graph.componentIndexOf.get(component);
170
- if (ci === undefined) return;
171
- const cc = this.compiled.components[ci]!;
172
- if ((this.provenance?.nodes[cc.node]?.kind ?? 'scene') !== 'scene') return; // prefab content read-only
173
- const meta = Registry.getComponent(cc.type)?.meta.properties.get(name);
174
- if (!meta) return;
175
- const elem = elementType(meta.type);
176
-
177
- const field = component as unknown as Record<string, unknown>;
178
- const had = name in cc.props;
179
- if (had && valuesEqual(cc.props[name], value)) return; // no-op
180
- const prevCompiled = had ? structuredClone(cc.props[name]!) : undefined;
181
- const prevRuntime = cloneRuntime(field[name]);
182
- const nextRuntime = toRuntime(meta.type, value);
183
- this.undo.run({
184
- label: `${cc.type}.${name}`,
185
- coalesceKey: opts?.coalesce ? `comp:${ci}:${name}` : undefined,
186
- apply: () => {
187
- cc.props[name] = structuredClone(value);
188
- field[name] = cloneRuntime(nextRuntime);
259
+ const element = this.componentElement(component);
260
+ if (!element) return;
261
+ const before = element.attributes.get(name) ?? null;
262
+ const live = snapshot(component, name);
263
+ this.log.commit(
264
+ `${name} on <${element.tag}>`,
265
+ () => {
266
+ this.writeAttribute(element.at, name, value);
267
+ this.applyLive(component, name, value, opts?.shape, live);
189
268
  },
190
- invert: () => {
191
- if (had) cc.props[name] = structuredClone(prevCompiled!);
192
- else delete cc.props[name];
193
- field[name] = cloneRuntime(prevRuntime);
269
+ () => {
270
+ this.writeAttribute(element.at, name, before);
271
+ this.applyLive(component, name, before, opts?.shape, live);
194
272
  },
195
- });
196
- void elem; // reserved: array-element handling lands with array editing
273
+ opts?.coalesce === true,
274
+ );
197
275
  }
198
276
 
199
- // ── attachment edits (geometry / material / scene settings) ─────────────────────
277
+ clearComponentProp(component: Component, name: string): void {
278
+ this.setComponentProp(component, name, null);
279
+ }
200
280
 
201
- /** The attachment slots on a node, with editability + src provenance for the inspector. */
202
- attachmentsOf(node: Object3D): AttachmentView[] {
203
- const i = this.graph.nodeIndexOf.get(node);
204
- if (i === undefined) return [];
205
- const sceneNode = this.nodeOrigin(node).kind === 'scene';
206
- return this.compiled.nodes[i]!.attachments.map((idx, slot) => {
207
- const asset = this.compiled.attachments[idx]!;
208
- const src = this.attachmentSrcOf(idx);
209
- return { slot, tag: asset.tag, attrs: asset.attrs as AttrMap, editable: sceneNode && src === null, src };
281
+ /**
282
+ * Write a transform channel from the live object — what a gizmo drag commits.
283
+ *
284
+ * In the document's own units, so `eulerAngles` is degrees, and in the document's own grammar,
285
+ * so a vector is `x: 1; y: 2; z: 3`. A gizmo produces a great many of these, which is what
286
+ * `coalesce` is for: one drag is one undo step.
287
+ */
288
+ setTransform(
289
+ node: Object3D,
290
+ channel: TransformChannel,
291
+ opts?: { coalesce?: boolean; from?: Vector3 },
292
+ ): void {
293
+ const vector = channel === 'eulerAngles' ? node.eulerAngles : node[channel];
294
+ this.setNodeAttr(node, channel, formatVector(vector.x, vector.y, vector.z), {
295
+ ...opts,
296
+ shape: channel === 'eulerAngles' ? 'euler' : 'vector3',
297
+ // Cloned: the gizmo reuses one vector per channel across drags, and this one has to hold
298
+ // still inside an undo step for as long as the step exists.
299
+ ...(opts?.from ? { from: opts.from.clone() } : {}),
210
300
  });
211
301
  }
212
302
 
213
- /** Current compiled value of an attachment attr (fresh — editors read it per change). */
214
- attachmentAttr(node: Object3D, slot: number, attr: string): Scalar | undefined {
215
- const i = this.graph.nodeIndexOf.get(node);
216
- if (i === undefined) return undefined;
217
- const idx = this.compiled.nodes[i]!.attachments[slot];
218
- return idx === undefined ? undefined : (this.compiled.attachments[idx]!.attrs[attr] as Scalar | undefined);
303
+ setVisible(node: Object3D, value: boolean): void {
304
+ node.visible = value;
305
+ this.setNodeAttr(node, 'visible', String(value));
306
+ }
307
+
308
+ /** Delete a node's element, and everything under it. */
309
+ removeNode(node: Object3D): void {
310
+ const at = this.mine(node);
311
+ if (!at) return;
312
+ this.log.push({ op: 'removeElement', at }, `remove <${this.elementOf(node)?.tag ?? 'node'}>`);
313
+ }
314
+
315
+ /** Reparent — what a drag in the tree produces. */
316
+ moveNode(node: Object3D, newParent: Object3D, index?: number): void {
317
+ const at = this.mine(node);
318
+ const parent = this.mine(newParent);
319
+ if (!at || !parent) return;
320
+ this.log.push(
321
+ { op: 'moveElement', at, parent, ...(index !== undefined ? { index } : {}) },
322
+ `move <${this.elementOf(node)?.tag ?? 'node'}>`,
323
+ );
219
324
  }
220
325
 
221
326
  /**
222
- * Set an inline attachment attribute, **copy-on-write**: the first edit clones the
223
- * pooled `CompiledAsset` into a private entry (so sibling nodes sharing it are
224
- * untouched); later edits mutate the private copy in place. The live resource is
225
- * rebuilt from the attachment's own builder, so it's unshared automatically.
327
+ * The source text of a node's element — what a copy puts on the clipboard.
328
+ *
329
+ * The authored text, not a re-serialization of the live objects: a paste should reproduce what
330
+ * was written, comments and formatting and all, and anything reconstructed from the runtime
331
+ * would have lost both.
226
332
  */
227
- setAttachmentProp(node: Object3D, slot: number, attr: string, value: Scalar, opts?: { coalesce?: boolean }): void {
228
- const i = this.graph.nodeIndexOf.get(node);
229
- if (i === undefined) return;
230
- const cnode = this.compiled.nodes[i]!;
231
- const curIdx = cnode.attachments[slot];
232
- if (curIdx === undefined) return;
233
- const src = this.compiled.attachments[curIdx]!;
234
- if (this.nodeOrigin(node).kind !== 'scene' || this.attachmentSrcOf(curIdx) !== null) return; // read-only
235
- const tag = src.tag;
236
- const reattach = (attrs: AttrMap) => getAttachmentDef(tag)?.make(attrs).attach({ node });
237
-
238
- if (this.cowAttachments.has(curIdx)) {
239
- // Already our private copy — mutate in place.
240
- const asset = src;
241
- const had = attr in asset.attrs;
242
- if (had && valuesEqual(asset.attrs[attr], value)) return;
243
- const prev = had ? structuredClone(asset.attrs[attr]!) : undefined;
244
- this.undo.run({
245
- label: `${tag}.${attr}`,
246
- coalesceKey: opts?.coalesce ? `att:${curIdx}:${attr}` : undefined,
247
- apply: () => {
248
- asset.attrs[attr] = structuredClone(value);
249
- reattach(asset.attrs as AttrMap);
250
- },
251
- invert: () => {
252
- if (had) asset.attrs[attr] = structuredClone(prev!);
253
- else delete asset.attrs[attr];
254
- reattach(asset.attrs as AttrMap);
255
- },
256
- });
257
- return;
258
- }
333
+ sourceOf(node: Object3D): string | null {
334
+ const element = this.elementOf(node);
335
+ return element ? this.text.slice(element.start, element.end) : null;
336
+ }
259
337
 
260
- if (valuesEqual(src.attrs[attr], value)) return; // no-op against the shared value
261
- const newIdx = this.compiled.attachments.length;
262
- const newAttrs = { ...structuredClone(src.attrs), [attr]: structuredClone(value) };
263
- this.undo.run({
264
- label: `${tag}.${attr}`,
265
- coalesceKey: opts?.coalesce ? `att:slot:${i}:${slot}:${attr}` : undefined,
266
- apply: () => {
267
- this.compiled.attachments[newIdx] = { tag, attrs: newAttrs };
268
- cnode.attachments[slot] = newIdx;
269
- this.cowAttachments.add(newIdx);
270
- reattach(newAttrs as AttrMap);
271
- },
272
- invert: () => {
273
- cnode.attachments[slot] = curIdx;
274
- this.cowAttachments.delete(newIdx);
275
- reattach(src.attrs as AttrMap);
276
- },
277
- });
338
+ /** Paste a fragment under a node. */
339
+ insertNode(parent: Object3D, xml: string): void {
340
+ const at = this.mine(parent);
341
+ if (!at) return;
342
+ this.log.push({ op: 'insertChild', at, xml }, 'paste');
278
343
  }
279
344
 
280
- /** Edit a prefab-instance node's `src` (save-only — reopen to see the swapped content). */
281
- setNodeSrc(node: Object3D, src: string): void {
282
- const i = this.graph.nodeIndexOf.get(node);
283
- if (i === undefined || this.nodeOrigin(node).kind !== 'prefab-root') return;
284
- const cnode = this.compiled.nodes[i]!;
285
- this.recordSrcEdit(`node:${i}:src`, { kind: 'node', nodeIndex: i, ...(cnode.id ? { id: cnode.id } : {}) }, src);
286
- }
287
-
288
- /** Edit a src-loaded attachment's `src` (save-only — reopen to see the swapped content). */
289
- setAttachmentSrc(node: Object3D, slot: number, src: string): void {
290
- const i = this.graph.nodeIndexOf.get(node);
291
- if (i === undefined) return;
292
- const idx = this.compiled.nodes[i]!.attachments[slot];
293
- if (idx === undefined || this.attachmentSrcOf(idx) === null) return;
294
- this.recordSrcEdit(`att:${i}:${slot}:src`, this.attachmentTarget(i, slot, this.compiled.attachments[idx]!.tag), src);
295
- }
296
-
297
- private recordSrcEdit(key: string, target: EditTarget, src: string): void {
298
- const prev = this.srcEdits.get(key);
299
- this.undo.run({
300
- label: `src ${key}`,
301
- coalesceKey: `src:${key}`,
302
- apply: () => void this.srcEdits.set(key, { target, attr: 'src', value: src }),
303
- invert: () => {
304
- if (prev) this.srcEdits.set(key, prev);
305
- else this.srcEdits.delete(key);
306
- },
307
- });
345
+ /** Add a component to a node — a `<Type />` inside its `<Components>`. */
346
+ addComponent(node: Object3D, type: string): void {
347
+ const at = this.mine(node);
348
+ if (!at) return;
349
+ const components = this.componentsBlockOf(node);
350
+ if (components) {
351
+ this.log.push({ op: 'insertChild', at: components.at, xml: `<${type} />` }, `add <${type}>`);
352
+ return;
353
+ }
354
+ // No `<Components>` yet, so the block comes with it.
355
+ this.log.push(
356
+ { op: 'insertChild', at, xml: `<Components>\n <${type} />\n</Components>` },
357
+ `add <${type}>`,
358
+ );
308
359
  }
309
360
 
310
- // ── persistence ───────────────────────────────────────────────────────────────
311
-
312
- /** The minimal set of changed attributes vs. the loaded source, for surgical save. */
313
- netEdits(): EditOp[] {
314
- const out: EditOp[] = [];
315
- this.compiled.nodes.forEach((node, i) => {
316
- const orig = this.original.nodes[i]!;
317
- const target = (): EditTarget => ({ kind: 'node', nodeIndex: i, ...(node.id ? { id: node.id } : {}) });
318
- if (!numbersEqual(node.transform.position, orig.transform.position))
319
- out.push({ target: target(), attr: 'position', value: node.transform.position.slice() });
320
- if (!numbersEqual(node.transform.rotation, orig.transform.rotation))
321
- out.push({ target: target(), attr: 'rotation', value: node.transform.rotation.slice() });
322
- if (!numbersEqual(node.transform.scale, orig.transform.scale))
323
- out.push({ target: target(), attr: 'scale', value: node.transform.scale.slice() });
324
- if (node.transform.visible !== orig.transform.visible)
325
- out.push({ target: target(), attr: 'visible', value: node.transform.visible });
326
- });
327
- this.compiled.components.forEach((comp, ci) => {
328
- const orig = this.original.components[ci]!;
329
- for (const [name, value] of Object.entries(comp.props)) {
330
- if (!isSavableScalar(value) || valuesEqual(value, orig.props[name])) continue;
331
- out.push({ target: this.componentTarget(ci), attr: name, value: value as number | boolean | string | number[] });
332
- }
333
- });
334
- // Attachment attrs: a slot whose pool index changed (copy-on-write) vs. its original asset.
335
- this.compiled.nodes.forEach((node, i) => {
336
- const orig = this.original.nodes[i]!;
337
- node.attachments.forEach((attIdx, slot) => {
338
- const origIdx = orig.attachments[slot];
339
- if (origIdx === undefined || attIdx === origIdx) return;
340
- const cur = this.compiled.attachments[attIdx]!;
341
- const base = this.original.attachments[origIdx]!;
342
- for (const [attr, val] of Object.entries(cur.attrs)) {
343
- if (!isSavableScalar(val) || valuesEqual(val, base.attrs[attr])) continue;
344
- out.push({ target: this.attachmentTarget(i, slot, cur.tag), attr, value: val as number | boolean | string | number[] });
345
- }
346
- });
347
- });
348
- // `src` edits (prefab instance / material) have no compiled home — tracked explicitly.
349
- out.push(...this.srcEdits.values());
350
- return out;
361
+ removeComponent(component: Component): void {
362
+ const element = this.componentElement(component);
363
+ if (!element) return;
364
+ this.log.push({ op: 'removeElement', at: element.at }, `remove <${element.tag}>`);
351
365
  }
352
366
 
353
- /** Mark the current state as the saved baseline (keeps the undo stack). */
354
- markSaved(): void {
355
- this.original = structuredClone(this.compiled);
356
- this.srcEdits.clear();
357
- this.emit();
367
+ private componentsBlockOf(node: Object3D): ElementView | undefined {
368
+ const owner = this.elementOf(node);
369
+ if (!owner) return undefined;
370
+ const start = this.elements.indexOf(owner);
371
+ const next = this.elements[start + 1];
372
+ return next?.tag === 'Components' ? next : undefined;
358
373
  }
359
374
 
375
+ // --- the edit log ------------------------------------------------------------------------
376
+ //
377
+ // Kept by `EditLog`; the document supplies the pair of closures that make each step and takes
378
+ // it back. Everything below is the document's side of that contract.
379
+
360
380
  /**
361
- * Re-apply a set of edits onto this (freshly loaded) document — used to preserve
362
- * the user's unsaved changes after a `src=` dependency reload. Resolution is by
363
- * id/tag/ordinal (best-effort): edits whose target no longer exists are dropped.
381
+ * Make the live scene agree with what the document now says about one attribute.
382
+ *
383
+ * The document is the model and the scene is the view, and until this existed nothing carried
384
+ * the model back to the view: typing a position in the inspector rewrote the XML and left the
385
+ * object where it was, and undoing a gizmo drag rewound the file while the object stayed
386
+ * dragged. Every write goes through here, so an edit, an undo and a redo all land the same
387
+ * way — which is the only reason undo can be trusted.
388
+ *
389
+ * `fallback` is the value the object had before this edit, used when the document has nothing
390
+ * left to say (the attribute was cleared, or was never authored). The class's own default is
391
+ * the more principled answer and is not knowable here: it lives in the compiled scene, and
392
+ * reading it back would mean rebuilding the node.
364
393
  */
365
- applyEdits(ops: EditOp[]): void {
366
- for (const op of ops) this.applyEdit(op);
367
- }
368
-
369
- private applyEdit(op: EditOp): void {
370
- const t = op.target;
371
- if (t.kind === 'node') {
372
- const node = this.nodeById(t.id);
373
- if (!node) return;
374
- if (op.attr === 'src') this.setNodeSrc(node, op.value as string);
375
- else if (op.attr === 'visible') this.setVisible(node, op.value as boolean);
376
- else if (op.attr === 'position' || op.attr === 'rotation' || op.attr === 'scale')
377
- this.setTransform(node, op.attr, op.value as number[]);
378
- } else if (t.kind === 'component') {
379
- const comp = this.componentByTarget(t.nodeId, t.type, t.ordinal);
380
- if (comp) this.setComponentProp(comp, op.attr, op.value);
381
- } else {
382
- const node = t.root ? this.graph.nodeByIndex[this.compiled.root] : this.nodeById(t.nodeId);
383
- if (!node) return;
384
- const slot = this.attachmentSlot(node, t.tag, t.ordinal);
385
- if (slot === undefined) return;
386
- if (op.attr === 'src') this.setAttachmentSrc(node, slot, op.value as string);
387
- else this.setAttachmentProp(node, slot, op.attr, op.value);
388
- }
394
+ private applyLive(
395
+ target: Object3D | Component,
396
+ name: string,
397
+ text: string | null,
398
+ shape: ValueShape | undefined,
399
+ fallback: unknown,
400
+ ): void {
401
+ if (text === null || shape === undefined) return restore(target, name, fallback);
402
+ const value = parseValue(shape, text);
403
+ if (value === undefined) return restore(target, name, fallback);
404
+ write(target, name, value);
389
405
  }
390
406
 
391
- private nodeById(id?: string): Object3D | undefined {
392
- if (!id) return undefined;
393
- const i = this.compiled.nodes.findIndex((n) => n.id === id);
394
- return i >= 0 ? this.graph.nodeByIndex[i] : undefined;
395
- }
396
- private componentByTarget(nodeId: string | undefined, type: string, ordinal: number): Component | undefined {
397
- if (!nodeId) return undefined;
398
- const ni = this.compiled.nodes.findIndex((n) => n.id === nodeId);
399
- if (ni < 0) return undefined;
400
- const ci = this.compiled.nodes[ni]!.components.filter((x) => this.compiled.components[x]!.type === type)[ordinal];
401
- if (ci === undefined) return undefined;
402
- for (const [inst, idx] of this.graph.componentIndexOf) if (idx === ci) return inst;
403
- return undefined;
404
- }
405
- private attachmentSlot(node: Object3D, tag: string, ordinal: number): number | undefined {
406
- const ni = this.graph.nodeIndexOf.get(node);
407
- if (ni === undefined) return undefined;
408
- const atts = this.compiled.nodes[ni]!.attachments;
409
- let count = 0;
410
- for (let slot = 0; slot < atts.length; slot++) {
411
- if (this.compiled.attachments[atts[slot]!]!.tag !== tag) continue;
412
- if (count === ordinal) return slot;
413
- count++;
414
- }
415
- return undefined;
416
- }
417
-
418
- private attachmentTarget(i: number, slot: number, tag: string): EditTarget {
419
- const node = this.compiled.nodes[i]!;
420
- const ordinal = node.attachments.slice(0, slot + 1).filter((idx) => this.compiled.attachments[idx]!.tag === tag).length - 1;
421
- return {
422
- kind: 'attachment',
423
- nodeIndex: i,
424
- ...(node.id ? { nodeId: node.id } : {}),
425
- ...(i === this.compiled.root ? { root: true } : {}),
426
- tag,
427
- ordinal,
428
- };
429
- }
430
-
431
- private componentTarget(ci: number): EditTarget {
432
- const cc = this.compiled.components[ci]!;
433
- const node = this.compiled.nodes[cc.node]!;
434
- const sameType = node.components.filter((x) => this.compiled.components[x]!.type === cc.type);
435
- return {
436
- kind: 'component',
437
- componentIndex: ci,
438
- nodeIndex: cc.node,
439
- ...(node.id ? { nodeId: node.id } : {}),
440
- type: cc.type,
441
- ordinal: sameType.indexOf(ci),
442
- };
407
+ /** Queue the splice, and keep this document's own view of the element in step with it. */
408
+ private writeAttribute(at: ElementAt, name: string, value: string | null): void {
409
+ this.log.replaceAttribute({ op: 'setAttribute', at, name, value });
410
+
411
+ const element = this.elements.find((e) => e.at.line === at.line && e.at.column === at.column);
412
+ if (!element) return;
413
+ if (value === null) element.attributes.delete(name);
414
+ else element.attributes.set(name, value);
415
+ }
416
+
417
+ get canUndo(): boolean {
418
+ return this.log.canUndo;
419
+ }
420
+
421
+ get canRedo(): boolean {
422
+ return this.log.canRedo;
443
423
  }
444
- }
445
424
 
446
- // ── value helpers ────────────────────────────────────────────────────────────────
425
+ get isDirty(): boolean {
426
+ return this.log.isDirty;
427
+ }
428
+
429
+ /** Everything not yet written to disk. */
430
+ edits(): readonly Edit[] {
431
+ return this.log.edits;
432
+ }
447
433
 
448
- function applyTransform(node: Object3D, channel: TransformChannel, v: number[]): void {
449
- if (channel === 'rotation') node.rotation.set(v[0]!, v[1]!, v[2]!);
450
- else node[channel].fromArray(v);
434
+ undoLast(): void {
435
+ this.log.undo();
436
+ }
437
+
438
+ redoLast(): void {
439
+ this.log.redo();
440
+ }
441
+
442
+ /** Called once the server has written the file. */
443
+ markSaved(text: string, elements: readonly RawElement[]): void {
444
+ this.text = text;
445
+ this.log.clear();
446
+ this.adopt(elements);
447
+ this.log.emit();
448
+ }
449
+
450
+ onChange(listener: () => void): () => void {
451
+ return this.log.onChange(listener);
452
+ }
453
+
454
+ /**
455
+ * Take the element index the server built.
456
+ *
457
+ * Not parsed here on purpose. The positions have to be **the same numbers** the compiler
458
+ * stamped into the live objects, and the only way to guarantee that is for one parser to
459
+ * produce both. A second implementation in the browser drifted on the first long open tag it
460
+ * met, and the symptom — some nodes selectable and some not — pointed nowhere near the cause.
461
+ */
462
+ private adopt(elements: readonly RawElement[]): void {
463
+ this.elements = elements.map((e) => ({
464
+ tag: e.tag,
465
+ at: { line: e.line, column: e.column },
466
+ start: e.start,
467
+ end: e.end,
468
+ attributes: new Map(Object.entries(e.attributes)),
469
+ }));
470
+ }
451
471
  }
452
472
 
453
- /** Compiled scalar → the Three.js-typed value a component field holds (mirrors instantiate). */
454
- function toRuntime(type: PropertyType, value: number | boolean | string | number[]): unknown {
455
- const elem = elementType(type);
456
- if (isArrayType(type)) return (value as number[]).map((v) => toRuntimeScalar(elem, v));
457
- return toRuntimeScalar(elem, value as number | boolean | string | number[]);
473
+ /** One element, as the server's index reports it. */
474
+ export interface RawElement {
475
+ tag: string;
476
+ line: number;
477
+ column: number;
478
+ start: number;
479
+ end: number;
480
+ attributes: Record<string, string>;
458
481
  }
459
- function toRuntimeScalar(elem: PropType | { new (): unknown } | EnumDef, value: unknown): unknown {
460
- switch (elem) {
461
- case 'vec2':
462
- return new Vector2().fromArray(value as number[]);
463
- case 'vec3':
464
- return new Vector3().fromArray(value as number[]);
465
- case 'euler': {
466
- const e = value as number[];
467
- return new Euler(e[0]!, e[1]!, e[2]!);
468
- }
469
- case 'color':
470
- return new Color(value as number);
471
- default:
472
- return value;
473
- }
482
+
483
+ /** The default a document leaves unwritten, as the compiler read it from the source. */
484
+ export function defaultOf(prop: PropInfo): string | undefined {
485
+ return prop.default;
474
486
  }
475
487
 
476
- /** Clone a runtime value so undo snapshots don't alias live Three.js objects. */
477
- function cloneRuntime(v: unknown): unknown {
478
- if (v == null) return v;
479
- const o = v as { isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; clone?: () => unknown };
480
- if (o.isVector2 || o.isVector3 || o.isColor) return o.clone!();
481
- if (o.isEuler) {
482
- const e = v as Euler;
483
- return new Euler(e.x, e.y, e.z, e.order);
484
- }
485
- return Array.isArray(v) ? v.map(cloneRuntime) : v;
488
+ /** `x: 1; y: 2; z: 3` — the DSL's only vector spelling. */
489
+ export function formatVector(x: number, y: number, z: number): string {
490
+ const round = (n: number): string => String(Math.round(n * 1000) / 1000);
491
+ return `x: ${round(x)}; y: ${round(y)}; z: ${round(z)}`;
486
492
  }
487
493
 
488
- const isSavableScalar = (v: unknown): boolean =>
489
- typeof v === 'number' ||
490
- typeof v === 'boolean' ||
491
- typeof v === 'string' ||
492
- (Array.isArray(v) && v.every((x) => typeof x === 'number'));
493
494
 
494
- function numbersEqual(a: number[], b: number[]): boolean {
495
- return a.length === b.length && a.every((x, i) => x === b[i]);
495
+ // --- the live half of an edit -----------------------------------------------------------------
496
+
497
+ /**
498
+ * `eulerAngles` hands back a cached vector that is recomputed from `rotation` on every read, so
499
+ * writing *into* it sets a field the next read discards. Assigning the whole vector goes through
500
+ * the setter, which is the documented way to use it — and the reason this needs naming at all.
501
+ */
502
+ const ASSIGN_WHOLE = new Set(['eulerAngles']);
503
+
504
+ /** The live value of one attribute, detached so a later mutation cannot change it. */
505
+ function snapshot(target: object, name: string): unknown {
506
+ const value = (target as Record<string, unknown>)[name];
507
+ const cloneable = value as { clone?: () => unknown } | null;
508
+ return cloneable && typeof cloneable.clone === 'function' ? cloneable.clone() : value;
509
+ }
510
+
511
+ /** Put a snapshot back where it came from. */
512
+ function restore(target: object, name: string, snap: unknown): void {
513
+ const current = (target as Record<string, unknown>)[name] as { copy?: (v: unknown) => void } | null;
514
+ if (!ASSIGN_WHOLE.has(name) && current && snap && typeof current.copy === 'function') {
515
+ current.copy(snap);
516
+ return;
517
+ }
518
+ (target as Record<string, unknown>)[name] = snap;
496
519
  }
497
- function valuesEqual(a: unknown, b: unknown): boolean {
498
- if (Array.isArray(a) && Array.isArray(b)) return numbersEqual(a as number[], b as number[]);
499
- return a === b;
520
+
521
+ /** Write a parsed attribute value onto the live object, in whatever form the field takes. */
522
+ function write(target: object, name: string, value: Scalar): void {
523
+ const current = (target as Record<string, unknown>)[name] as
524
+ | { set?: (...args: number[]) => void; copy?: unknown }
525
+ | null;
526
+
527
+ if (Array.isArray(value)) {
528
+ // A vector or an Euler. Assign a fresh one where the field aliases, otherwise write in place
529
+ // so anything holding a reference to it — three does, everywhere — sees the change.
530
+ if (ASSIGN_WHOLE.has(name) && current && typeof current.set === 'function') {
531
+ const fresh = (current as { clone(): { set(...args: number[]): unknown } }).clone();
532
+ fresh.set(...value);
533
+ (target as Record<string, unknown>)[name] = fresh;
534
+ return;
535
+ }
536
+ if (current && typeof current.set === 'function') {
537
+ current.set(...value);
538
+ return;
539
+ }
540
+ }
541
+ // A colour is a number here, and `Color.set` takes one; everything else is a plain field.
542
+ if (typeof value === 'number' && current && typeof current.set === 'function') {
543
+ current.set(value);
544
+ return;
545
+ }
546
+ (target as Record<string, unknown>)[name] = value;
500
547
  }