@scenoco-three/editor 0.4.0 → 0.5.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.
- package/LICENSE +21 -0
- package/README.md +23 -17
- package/app/cameraRig.ts +73 -12
- package/app/commands.ts +5 -1
- package/app/desktopControls.ts +13 -10
- package/app/document.ts +474 -427
- package/app/editLog.ts +127 -0
- package/app/editor.css +51 -0
- package/app/gizmo.ts +100 -6
- package/app/index.html +6 -122
- package/app/main.ts +488 -304
- package/app/meta.ts +24 -0
- package/app/picking.ts +47 -0
- package/app/session.ts +476 -311
- package/app/touchControls.ts +85 -62
- package/app/ui-react/AddMemberPopup.tsx +114 -0
- package/app/ui-react/BottomLauncher.tsx +15 -0
- package/app/ui-react/EditorUI.tsx +22 -0
- package/app/ui-react/FilePicker.tsx +80 -0
- package/app/ui-react/HeadBtn.tsx +21 -0
- package/app/ui-react/InspectorSheet.tsx +475 -0
- package/app/ui-react/NodeListSheet.tsx +56 -0
- package/app/ui-react/TopBar.tsx +137 -0
- package/app/ui-react/Tree.tsx +106 -0
- package/app/ui-react/cx.ts +4 -0
- package/app/ui-react/fields.tsx +407 -0
- package/app/ui-react/format.ts +30 -0
- package/app/ui-react/mount.tsx +31 -0
- package/app/ui-react/store.ts +80 -0
- package/app/ui-react/types.ts +88 -0
- package/app/ui-react/ui.module.css +250 -0
- package/app/ui-react/useSize.ts +20 -0
- package/app/values.ts +137 -0
- package/dist/cli/index.js +93 -70
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -5
- package/dist/mcp.d.ts +28 -12
- package/dist/mcp.js +172 -131
- package/dist/protocol.d.ts +74 -65
- package/dist/protocol.js +17 -4
- package/dist/server.d.ts +106 -39
- package/dist/server.js +386 -361
- package/package.json +25 -10
- package/app/filePicker.ts +0 -64
- package/app/panels.ts +0 -553
- package/app/toolbar.ts +0 -131
- package/app/wsClient.ts +0 -92
- package/dist/args.d.ts +0 -8
- package/dist/args.js +0 -28
package/app/document.ts
CHANGED
|
@@ -1,500 +1,547 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
28
|
+
* The open document, and every change made to it since it was opened.
|
|
35
29
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
private readonly
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
return k === 'scene' || k === 'prefab-root';
|
|
83
|
+
return this.mine(node) !== null;
|
|
73
84
|
}
|
|
74
|
-
|
|
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.
|
|
95
|
+
return this.mine(node) !== null && this.tagNameOf(node) !== 'Prefab';
|
|
77
96
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
121
|
+
// --- reading -----------------------------------------------------------------------------
|
|
88
122
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
node
|
|
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
|
-
|
|
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:
|
|
167
|
-
opts?: { coalesce?: boolean },
|
|
256
|
+
value: string | null,
|
|
257
|
+
opts?: { coalesce?: boolean; shape?: ValueShape },
|
|
168
258
|
): void {
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
273
|
+
opts?.coalesce === true,
|
|
274
|
+
);
|
|
197
275
|
}
|
|
198
276
|
|
|
199
|
-
|
|
277
|
+
clearComponentProp(component: Component, name: string): void {
|
|
278
|
+
this.setComponentProp(component, name, null);
|
|
279
|
+
}
|
|
200
280
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
/**
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
if (
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
this.
|
|
357
|
-
this.
|
|
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
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
/**
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
/**
|
|
477
|
-
function
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
}
|