@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
package/app/document.ts DELETED
@@ -1,547 +0,0 @@
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 {
19
- tag: string;
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>;
25
- }
26
-
27
- /**
28
- * The open document, and every change made to it since it was opened.
29
- *
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.
44
- */
45
- export class SceneDocument {
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
-
82
- canEditTransform(node: Object3D): boolean {
83
- return this.mine(node) !== null;
84
- }
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
- */
94
- canEditComponents(node: Object3D): boolean {
95
- return this.mine(node) !== null && this.tagNameOf(node) !== 'Prefab';
96
- }
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;
113
- }
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;
119
- }
120
-
121
- // --- reading -----------------------------------------------------------------------------
122
-
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);
128
- }
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);
133
- }
134
-
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;
138
- }
139
-
140
- /** The element name the document wrote — `Panel`, not `Mesh`. */
141
- tagNameOf(node: Object3D): string | undefined {
142
- return this.elementOf(node)?.tag;
143
- }
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;
149
- }
150
-
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
- }));
157
- }
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,
211
- });
212
- }
213
-
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);
240
- },
241
- () => {
242
- this.writeAttribute(at, attr, before);
243
- this.applyLive(node, attr, before, opts?.shape, live);
244
- },
245
- opts?.coalesce === true,
246
- );
247
- }
248
-
249
- clearNodeAttr(node: Object3D, attr: string): void {
250
- this.setNodeAttr(node, attr, null);
251
- }
252
-
253
- setComponentProp(
254
- component: Component,
255
- name: string,
256
- value: string | null,
257
- opts?: { coalesce?: boolean; shape?: ValueShape },
258
- ): void {
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);
268
- },
269
- () => {
270
- this.writeAttribute(element.at, name, before);
271
- this.applyLive(component, name, before, opts?.shape, live);
272
- },
273
- opts?.coalesce === true,
274
- );
275
- }
276
-
277
- clearComponentProp(component: Component, name: string): void {
278
- this.setComponentProp(component, name, null);
279
- }
280
-
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() } : {}),
300
- });
301
- }
302
-
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
- );
324
- }
325
-
326
- /**
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.
332
- */
333
- sourceOf(node: Object3D): string | null {
334
- const element = this.elementOf(node);
335
- return element ? this.text.slice(element.start, element.end) : null;
336
- }
337
-
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');
343
- }
344
-
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
- );
359
- }
360
-
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}>`);
365
- }
366
-
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;
373
- }
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
-
380
- /**
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.
393
- */
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);
405
- }
406
-
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;
423
- }
424
-
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
- }
433
-
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
- }
471
- }
472
-
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>;
481
- }
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;
486
- }
487
-
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)}`;
492
- }
493
-
494
-
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;
519
- }
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;
547
- }
package/app/editLog.ts DELETED
@@ -1,127 +0,0 @@
1
- import type { Edit } from './document.js';
2
-
3
- /** One reversible step. `redo` is replaced rather than appended while a drag coalesces. */
4
- interface Step {
5
- label: string;
6
- undo: () => void;
7
- redo: () => void;
8
- }
9
-
10
- /**
11
- * The undo stack, the pending splices, and who to tell when either changes.
12
- *
13
- * Split out of `SceneDocument` because they are two objects: one answers *what the document
14
- * says* — which element built this node, what may be edited, what an attribute reads as — and
15
- * this one answers *what has changed since it was opened*. They were one class of forty-eight
16
- * methods, and the half you were reading was never the half you wanted.
17
- *
18
- * Every step is a pair of closures the document supplies, so this knows nothing about XML,
19
- * elements or the live scene: it decides only when a change is recorded, folded into the
20
- * previous one, or reversed.
21
- */
22
- export class EditLog {
23
- private readonly pending: Edit[] = [];
24
- private readonly undoStack: Step[] = [];
25
- private redoStack: Step[] = [];
26
- private readonly listeners = new Set<() => void>();
27
- private saved = true;
28
-
29
- /** Everything not yet written to disk. */
30
- get edits(): readonly Edit[] {
31
- return this.pending;
32
- }
33
-
34
- get canUndo(): boolean {
35
- return this.undoStack.length > 0;
36
- }
37
-
38
- get canRedo(): boolean {
39
- return this.redoStack.length > 0;
40
- }
41
-
42
- get isDirty(): boolean {
43
- return !this.saved;
44
- }
45
-
46
- /**
47
- * Record one change.
48
- *
49
- * `coalesce` folds it into the previous step when it touches the same thing, so a gizmo drag
50
- * that produced two hundred edits undoes in one.
51
- */
52
- commit(label: string, apply: () => void, revert: () => void, coalesce: boolean): void {
53
- const previous = this.undoStack[this.undoStack.length - 1];
54
- apply();
55
- if (coalesce && previous?.label === label) {
56
- previous.redo = apply;
57
- } else {
58
- this.undoStack.push({ label, undo: revert, redo: apply });
59
- }
60
- this.redoStack = [];
61
- this.saved = false;
62
- this.emit();
63
- }
64
-
65
- /** Record a splice with nothing to apply to the live scene — a structural edit. */
66
- push(edit: Edit, label: string): void {
67
- this.commit(
68
- label,
69
- () => this.pending.push(edit),
70
- () => this.pending.pop(),
71
- false,
72
- );
73
- }
74
-
75
- /**
76
- * Queue an attribute write, replacing any pending write to the same attribute.
77
- *
78
- * The **last** write to one attribute wins, so a scrubbed number leaves one edit rather than
79
- * one per pixel — the file is written once, and the diff shows the value that survived.
80
- */
81
- replaceAttribute(edit: Extract<Edit, { op: 'setAttribute' }>): void {
82
- const index = this.pending.findIndex(
83
- (e) =>
84
- e.op === 'setAttribute' &&
85
- e.at.line === edit.at.line &&
86
- e.at.column === edit.at.column &&
87
- e.name === edit.name,
88
- );
89
- if (index !== -1) this.pending.splice(index, 1);
90
- this.pending.push(edit);
91
- }
92
-
93
- undo(): void {
94
- const step = this.undoStack.pop();
95
- if (!step) return;
96
- step.undo();
97
- this.redoStack.push(step);
98
- this.saved = false;
99
- this.emit();
100
- }
101
-
102
- redo(): void {
103
- const step = this.redoStack.pop();
104
- if (!step) return;
105
- step.redo();
106
- this.undoStack.push(step);
107
- this.saved = false;
108
- this.emit();
109
- }
110
-
111
- /** The file has been written: nothing is pending and nothing is undoable past this point. */
112
- clear(): void {
113
- this.pending.length = 0;
114
- this.undoStack.length = 0;
115
- this.redoStack = [];
116
- this.saved = true;
117
- }
118
-
119
- onChange(listener: () => void): () => void {
120
- this.listeners.add(listener);
121
- return () => this.listeners.delete(listener);
122
- }
123
-
124
- emit(): void {
125
- for (const listener of this.listeners) listener();
126
- }
127
- }