@operato/scene-ops 10.14.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/README.md +30 -0
- package/dist/cjs/apply-model.js +328 -0
- package/dist/cjs/apply-model.js.map +1 -0
- package/dist/cjs/apply-scene.js +359 -0
- package/dist/cjs/apply-scene.js.map +1 -0
- package/dist/cjs/index.js +19 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/model.js +11 -0
- package/dist/cjs/model.js.map +1 -0
- package/dist/cjs/ops.js +3 -0
- package/dist/cjs/ops.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/src/apply-model.d.ts +47 -0
- package/dist/src/apply-model.js +321 -0
- package/dist/src/apply-model.js.map +1 -0
- package/dist/src/apply-scene.d.ts +94 -0
- package/dist/src/apply-scene.js +351 -0
- package/dist/src/apply-scene.js.map +1 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +16 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/model.d.ts +44 -0
- package/dist/src/model.js +10 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/ops.d.ts +135 -0
- package/dist/src/ops.js +2 -0
- package/dist/src/ops.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +50 -0
- package/src/apply-model.ts +370 -0
- package/src/apply-scene.ts +397 -0
- package/src/index.ts +15 -0
- package/src/model.ts +46 -0
- package/src/ops.ts +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @operato/scene-ops
|
|
2
|
+
|
|
3
|
+
A vocabulary for describing a change to a things-scene model, and the two appliers that carry
|
|
4
|
+
it out.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { dispatchSceneEditOp, applyScenePatch, type SceneEditOp } from '@operato/scene-ops'
|
|
8
|
+
|
|
9
|
+
const op: SceneEditOp = { op: 'modify', refid: 12, patch: { left: 100 } }
|
|
10
|
+
|
|
11
|
+
dispatchSceneEditOp(scene, op) // a scene open on screen — undo and dirty are preserved
|
|
12
|
+
applyScenePatch(model, patch) // a stored model — pure, returns a new one
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why both appliers live here
|
|
16
|
+
|
|
17
|
+
A scene gets changed in two situations, and they need different code. A scene that is open has
|
|
18
|
+
a commander behind it, so every change must go through things-scene's own API or the user
|
|
19
|
+
loses undo. A scene that is only stored is plain JSON and is transformed by returning a new
|
|
20
|
+
object.
|
|
21
|
+
|
|
22
|
+
What they must agree on is *what a change is*. When the vocabulary and the two appliers sit in
|
|
23
|
+
three packages, nothing keeps them in step — and they did drift apart before this package
|
|
24
|
+
existed: the operations were declared twice and the model applier existed twice, in a server
|
|
25
|
+
package and in a chat package, kept identical by hand.
|
|
26
|
+
|
|
27
|
+
## What is not here
|
|
28
|
+
|
|
29
|
+
Nothing about who proposed the change. A language model, an importer, a template and a person
|
|
30
|
+
dragging a box all produce the same operations, and this package cannot tell them apart.
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyScenePatch = applyScenePatch;
|
|
4
|
+
exports.applyScenePatchVerbose = applyScenePatchVerbose;
|
|
5
|
+
exports.computeInverseOp = computeInverseOp;
|
|
6
|
+
exports.applyOp = applyOp;
|
|
7
|
+
exports.mergeComponent = mergeComponent;
|
|
8
|
+
const EMPTY_SCENE = {
|
|
9
|
+
width: 1000,
|
|
10
|
+
height: 600,
|
|
11
|
+
components: []
|
|
12
|
+
};
|
|
13
|
+
function applyScenePatch(model, patch) {
|
|
14
|
+
return applyScenePatchVerbose(model, patch).model;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The same, but reporting which ops landed.
|
|
18
|
+
*
|
|
19
|
+
* `modify` and `remove` do nothing at all when the refid is not in the scene. Without this
|
|
20
|
+
* report the host answers "done" to a change that never happened — which is exactly what a
|
|
21
|
+
* language model producing a wrong refid looks like from the user's seat.
|
|
22
|
+
*/
|
|
23
|
+
function applyScenePatchVerbose(model, patch) {
|
|
24
|
+
let result = model ?? EMPTY_SCENE;
|
|
25
|
+
const applied = [];
|
|
26
|
+
const missed = [];
|
|
27
|
+
for (const op of patch.ops) {
|
|
28
|
+
if (SCENE_ONLY_OPS.has(op.op)) {
|
|
29
|
+
/* A no-op here, but the scene applier really does carry it out — count it as applied. */
|
|
30
|
+
applied.push(op);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const next = applyOp(result, op);
|
|
34
|
+
/*
|
|
35
|
+
* `componentsUnchanged` looks at the component list and at width/height/fillStyle only.
|
|
36
|
+
* It is here to catch a `modify`/`remove` that silently hit nothing, so it must not run
|
|
37
|
+
* for `modifyScene`, which legitimately changes other root keys such as `sky`.
|
|
38
|
+
*/
|
|
39
|
+
const componentMutating = op.op === 'modify' || op.op === 'remove';
|
|
40
|
+
if (next === result || (componentMutating && componentsUnchanged(result, next))) {
|
|
41
|
+
missed.push(op);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
applied.push(op);
|
|
45
|
+
result = next;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { model: result, applied, missed };
|
|
49
|
+
}
|
|
50
|
+
function componentsUnchanged(prev, next) {
|
|
51
|
+
/*
|
|
52
|
+
* `applyOp` always builds a new object, so comparing references tells us nothing and we
|
|
53
|
+
* compare contents. The component arrays come out of map/filter, so we compare element by
|
|
54
|
+
* element rather than by array identity.
|
|
55
|
+
*/
|
|
56
|
+
const a = prev.components ?? [];
|
|
57
|
+
const b = next.components ?? [];
|
|
58
|
+
if (a.length !== b.length)
|
|
59
|
+
return false;
|
|
60
|
+
for (let i = 0; i < a.length; i++) {
|
|
61
|
+
if (a[i] !== b[i])
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The inverse of one operation, against the scene as it was before that operation ran.
|
|
68
|
+
*
|
|
69
|
+
* This is what makes undo possible for a proposed change: a host that applies ops one at a
|
|
70
|
+
* time and keeps the inverses can restore the scene by running them backwards.
|
|
71
|
+
*
|
|
72
|
+
* Returns null when the inverse cannot be known from the model alone. `add` is the clear
|
|
73
|
+
* case — its inverse needs the refid that things-scene issues on insert, so the host captures
|
|
74
|
+
* that itself. The scene-only operations are the same story: their result depends on
|
|
75
|
+
* coordinates and parenting that only the live scene knows.
|
|
76
|
+
*/
|
|
77
|
+
function computeInverseOp(model, op) {
|
|
78
|
+
if (!model)
|
|
79
|
+
return null;
|
|
80
|
+
const components = model.components ?? [];
|
|
81
|
+
switch (op.op) {
|
|
82
|
+
case 'add':
|
|
83
|
+
return null;
|
|
84
|
+
case 'remove': {
|
|
85
|
+
const target = findComponentDeep(components, op.refid);
|
|
86
|
+
if (!target)
|
|
87
|
+
return null; /* nothing was removed, so there is nothing to put back */
|
|
88
|
+
return { op: 'add', component: JSON.parse(JSON.stringify(target)) };
|
|
89
|
+
}
|
|
90
|
+
case 'modify': {
|
|
91
|
+
const target = findComponentDeep(components, op.refid);
|
|
92
|
+
if (!target)
|
|
93
|
+
return null;
|
|
94
|
+
/* Keep only the keys the patch touches; nested values are kept whole. */
|
|
95
|
+
const oldValues = {};
|
|
96
|
+
for (const k of Object.keys(op.patch || {})) {
|
|
97
|
+
const v = target[k];
|
|
98
|
+
oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
|
|
99
|
+
}
|
|
100
|
+
return { op: 'modify', refid: op.refid, patch: oldValues };
|
|
101
|
+
}
|
|
102
|
+
case 'modifyScene': {
|
|
103
|
+
const oldValues = {};
|
|
104
|
+
const patch = op.patch || {};
|
|
105
|
+
for (const k of Object.keys(patch)) {
|
|
106
|
+
if (k === 'components')
|
|
107
|
+
continue;
|
|
108
|
+
const v = model[k];
|
|
109
|
+
oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
|
|
110
|
+
}
|
|
111
|
+
return { op: 'modifyScene', patch: oldValues };
|
|
112
|
+
}
|
|
113
|
+
case 'replace':
|
|
114
|
+
return { op: 'replace', model: JSON.parse(JSON.stringify(model)) };
|
|
115
|
+
case 'zorder': {
|
|
116
|
+
/*
|
|
117
|
+
* forward ↔ backward and front ↔ back. front/back is best effort — sending something
|
|
118
|
+
* to the front and then to the back does not put it where it started. A host that needs
|
|
119
|
+
* an exact inverse captures the index beforehand and writes explicit modifies.
|
|
120
|
+
*/
|
|
121
|
+
const opp = {
|
|
122
|
+
forward: 'backward',
|
|
123
|
+
backward: 'forward',
|
|
124
|
+
front: 'back',
|
|
125
|
+
back: 'front'
|
|
126
|
+
};
|
|
127
|
+
const dir = opp[op.direction];
|
|
128
|
+
if (!dir)
|
|
129
|
+
return null;
|
|
130
|
+
return { op: 'zorder', refid: op.refid, direction: dir };
|
|
131
|
+
}
|
|
132
|
+
case 'align':
|
|
133
|
+
case 'distribute':
|
|
134
|
+
case 'group':
|
|
135
|
+
case 'ungroup':
|
|
136
|
+
case 'arrange':
|
|
137
|
+
return null;
|
|
138
|
+
default:
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The operations whose result only the live scene can produce — exact coordinates, refid
|
|
144
|
+
* issuing, reparenting. Simulating them here would give an answer that disagrees with what
|
|
145
|
+
* the user sees, so they do nothing and `apply-scene.ts` calls things-scene's own API.
|
|
146
|
+
*/
|
|
147
|
+
const SCENE_ONLY_OPS = new Set([
|
|
148
|
+
'align',
|
|
149
|
+
'distribute',
|
|
150
|
+
'group',
|
|
151
|
+
'ungroup',
|
|
152
|
+
'zorder',
|
|
153
|
+
'arrange'
|
|
154
|
+
]);
|
|
155
|
+
function applyOp(model, op) {
|
|
156
|
+
const components = model.components ?? [];
|
|
157
|
+
switch (op.op) {
|
|
158
|
+
case 'replace':
|
|
159
|
+
return op.model;
|
|
160
|
+
case 'add':
|
|
161
|
+
/* Top level only — adding into a container is a separate operation we do not have yet. */
|
|
162
|
+
return { ...model, components: [...components, op.component] };
|
|
163
|
+
case 'remove':
|
|
164
|
+
return { ...model, components: removeComponentDeep(components, op.refid) };
|
|
165
|
+
case 'modify':
|
|
166
|
+
return { ...model, components: modifyComponentDeep(components, op.refid, op.patch) };
|
|
167
|
+
case 'modifyScene': {
|
|
168
|
+
/* Root properties only. `components` is ignored — children move by add/remove/modify. */
|
|
169
|
+
const patch = { ...op.patch };
|
|
170
|
+
delete patch.components;
|
|
171
|
+
return mergeSceneRoot(model, patch);
|
|
172
|
+
}
|
|
173
|
+
case 'align':
|
|
174
|
+
case 'distribute':
|
|
175
|
+
case 'group':
|
|
176
|
+
case 'ungroup':
|
|
177
|
+
case 'zorder':
|
|
178
|
+
case 'arrange':
|
|
179
|
+
return model;
|
|
180
|
+
default:
|
|
181
|
+
return model;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* `null` in a patch means **remove this key**, not "set it to null".
|
|
186
|
+
*
|
|
187
|
+
* Three reasons this is the right reading:
|
|
188
|
+
* 1. Fields such as fillStyle and strokeStyle are `string | object | undefined`. Setting
|
|
189
|
+
* one to null walks straight into `typeof null === 'object'` downstream.
|
|
190
|
+
* 2. `computeInverseOp` emits null for a key the original did not have, which is exactly
|
|
191
|
+
* "remove it again" — so undo comes out right without a special case.
|
|
192
|
+
* 3. Clearing a field that a mode switch left behind is then just another key in the patch.
|
|
193
|
+
*
|
|
194
|
+
* A caller that genuinely wants to store null would need a new operation. Nothing in the
|
|
195
|
+
* scene model means anything by a stored null today.
|
|
196
|
+
*/
|
|
197
|
+
function mergeSceneRoot(model, patch) {
|
|
198
|
+
const out = { ...model };
|
|
199
|
+
for (const key of Object.keys(patch)) {
|
|
200
|
+
const bv = model[key];
|
|
201
|
+
const pv = patch[key];
|
|
202
|
+
if (pv === null) {
|
|
203
|
+
delete out[key];
|
|
204
|
+
}
|
|
205
|
+
else if (isPlainObject(bv) && isPlainObject(pv)) {
|
|
206
|
+
out[key] = deepMerge(bv, pv);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
out[key] = pv;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Apply a partial patch to one component.
|
|
216
|
+
*
|
|
217
|
+
* Nested objects such as `threeD` are merged rather than replaced, so that changing a colour
|
|
218
|
+
* does not take the geometry with it. A null value removes the key, as above.
|
|
219
|
+
*/
|
|
220
|
+
function mergeComponent(base, patch) {
|
|
221
|
+
const out = { ...base };
|
|
222
|
+
for (const key of Object.keys(patch)) {
|
|
223
|
+
const baseVal = base[key];
|
|
224
|
+
const patchVal = patch[key];
|
|
225
|
+
if (patchVal === null) {
|
|
226
|
+
delete out[key];
|
|
227
|
+
}
|
|
228
|
+
else if (isPlainObject(baseVal) && isPlainObject(patchVal)) {
|
|
229
|
+
out[key] = deepMerge(baseVal, patchVal);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
out[key] = patchVal;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
function deepMerge(a, b) {
|
|
238
|
+
const out = { ...a };
|
|
239
|
+
for (const key of Object.keys(b)) {
|
|
240
|
+
const av = a[key];
|
|
241
|
+
const bv = b[key];
|
|
242
|
+
if (bv === null) {
|
|
243
|
+
delete out[key];
|
|
244
|
+
}
|
|
245
|
+
else if (isPlainObject(av) && isPlainObject(bv)) {
|
|
246
|
+
out[key] = deepMerge(av, bv);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
out[key] = bv;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
function isPlainObject(v) {
|
|
255
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
256
|
+
}
|
|
257
|
+
/* ── Reaching into containers ─────────────────────────────────────────────────────────── */
|
|
258
|
+
/** Find a component by refid anywhere in the tree, not only at the top level. */
|
|
259
|
+
function findComponentDeep(components, refid) {
|
|
260
|
+
for (const c of components) {
|
|
261
|
+
if (!c)
|
|
262
|
+
continue;
|
|
263
|
+
if (c.refid === refid)
|
|
264
|
+
return c;
|
|
265
|
+
const children = c.components;
|
|
266
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
267
|
+
const sub = findComponentDeep(children, refid);
|
|
268
|
+
if (sub)
|
|
269
|
+
return sub;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* `modify`, walking into containers. Branches that did not change come back as the same
|
|
276
|
+
* reference, so a caller can tell what moved by identity.
|
|
277
|
+
*/
|
|
278
|
+
function modifyComponentDeep(components, refid, patch) {
|
|
279
|
+
let changed = false;
|
|
280
|
+
const out = components.map(c => {
|
|
281
|
+
if (!c)
|
|
282
|
+
return c;
|
|
283
|
+
if (c.refid === refid) {
|
|
284
|
+
changed = true;
|
|
285
|
+
return mergeComponent(c, patch);
|
|
286
|
+
}
|
|
287
|
+
const children = c.components;
|
|
288
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
289
|
+
const updated = modifyComponentDeep(children, refid, patch);
|
|
290
|
+
if (updated !== children) {
|
|
291
|
+
changed = true;
|
|
292
|
+
return { ...c, components: updated };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return c;
|
|
296
|
+
});
|
|
297
|
+
return changed ? out : components;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* `remove`, walking into containers. The parent stays, and a group left empty stays too —
|
|
301
|
+
* the user made that container on purpose and removing one child is not a reason to take it.
|
|
302
|
+
*/
|
|
303
|
+
function removeComponentDeep(components, refid) {
|
|
304
|
+
let changed = false;
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const c of components) {
|
|
307
|
+
if (!c) {
|
|
308
|
+
out.push(c);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (c.refid === refid) {
|
|
312
|
+
changed = true;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const children = c.components;
|
|
316
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
317
|
+
const updated = removeComponentDeep(children, refid);
|
|
318
|
+
if (updated !== children) {
|
|
319
|
+
changed = true;
|
|
320
|
+
out.push({ ...c, components: updated });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
out.push(c);
|
|
325
|
+
}
|
|
326
|
+
return changed ? out : components;
|
|
327
|
+
}
|
|
328
|
+
//# sourceMappingURL=apply-model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply-model.js","sourceRoot":"","sources":["../../src/apply-model.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Applying edit operations to a stored scene model.\n *\n * Pure: nothing here mutates its input. This is the applier for a scene that is not open —\n * a file being transformed, a template being instantiated, a proposal being previewed. For a\n * scene that is open on screen, use `apply-scene.ts`; things-scene's own API is the authority\n * there and several operations are deliberately no-ops here.\n */\nimport type { SceneComponentModel, SceneModel } from './model.js'\nimport type { SceneEditOp, SceneEditPatch } from './ops.js'\n\nconst EMPTY_SCENE: SceneModel = {\n width: 1000,\n height: 600,\n components: []\n}\n\nexport interface PatchApplyReport {\n /** The scene after the patch. Unchanged input is returned as-is when every op was a no-op. */\n model: SceneModel\n /** The ops that actually changed something. */\n applied: SceneEditOp[]\n /** The ops that did nothing — a refid that is not there, most often. */\n missed: SceneEditOp[]\n}\n\nexport function applyScenePatch(model: SceneModel | undefined, patch: SceneEditPatch): SceneModel {\n return applyScenePatchVerbose(model, patch).model\n}\n\n/**\n * The same, but reporting which ops landed.\n *\n * `modify` and `remove` do nothing at all when the refid is not in the scene. Without this\n * report the host answers \"done\" to a change that never happened — which is exactly what a\n * language model producing a wrong refid looks like from the user's seat.\n */\nexport function applyScenePatchVerbose(\n model: SceneModel | undefined,\n patch: SceneEditPatch\n): PatchApplyReport {\n let result: SceneModel = model ?? EMPTY_SCENE\n const applied: SceneEditOp[] = []\n const missed: SceneEditOp[] = []\n\n for (const op of patch.ops) {\n if (SCENE_ONLY_OPS.has(op.op)) {\n /* A no-op here, but the scene applier really does carry it out — count it as applied. */\n applied.push(op)\n continue\n }\n const next = applyOp(result, op)\n /*\n * `componentsUnchanged` looks at the component list and at width/height/fillStyle only.\n * It is here to catch a `modify`/`remove` that silently hit nothing, so it must not run\n * for `modifyScene`, which legitimately changes other root keys such as `sky`.\n */\n const componentMutating = op.op === 'modify' || op.op === 'remove'\n if (next === result || (componentMutating && componentsUnchanged(result, next))) {\n missed.push(op)\n } else {\n applied.push(op)\n result = next\n }\n }\n\n return { model: result, applied, missed }\n}\n\nfunction componentsUnchanged(prev: SceneModel, next: SceneModel): boolean {\n /*\n * `applyOp` always builds a new object, so comparing references tells us nothing and we\n * compare contents. The component arrays come out of map/filter, so we compare element by\n * element rather than by array identity.\n */\n const a = prev.components ?? []\n const b = next.components ?? []\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i++) {\n if (a[i] !== b[i]) return false\n }\n return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle\n}\n\n/**\n * The inverse of one operation, against the scene as it was before that operation ran.\n *\n * This is what makes undo possible for a proposed change: a host that applies ops one at a\n * time and keeps the inverses can restore the scene by running them backwards.\n *\n * Returns null when the inverse cannot be known from the model alone. `add` is the clear\n * case — its inverse needs the refid that things-scene issues on insert, so the host captures\n * that itself. The scene-only operations are the same story: their result depends on\n * coordinates and parenting that only the live scene knows.\n */\nexport function computeInverseOp(model: SceneModel | undefined, op: SceneEditOp): SceneEditOp | null {\n if (!model) return null\n const components = model.components ?? []\n\n switch (op.op) {\n case 'add':\n return null\n\n case 'remove': {\n const target = findComponentDeep(components, op.refid)\n if (!target) return null /* nothing was removed, so there is nothing to put back */\n return { op: 'add', component: JSON.parse(JSON.stringify(target)) }\n }\n\n case 'modify': {\n const target = findComponentDeep(components, op.refid)\n if (!target) return null\n /* Keep only the keys the patch touches; nested values are kept whole. */\n const oldValues: any = {}\n for (const k of Object.keys(op.patch || {})) {\n const v = (target as any)[k]\n oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))\n }\n return { op: 'modify', refid: op.refid, patch: oldValues }\n }\n\n case 'modifyScene': {\n const oldValues: any = {}\n const patch = op.patch || {}\n for (const k of Object.keys(patch)) {\n if (k === 'components') continue\n const v = (model as any)[k]\n oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))\n }\n return { op: 'modifyScene', patch: oldValues }\n }\n\n case 'replace':\n return { op: 'replace', model: JSON.parse(JSON.stringify(model)) }\n\n case 'zorder': {\n /*\n * forward ↔ backward and front ↔ back. front/back is best effort — sending something\n * to the front and then to the back does not put it where it started. A host that needs\n * an exact inverse captures the index beforehand and writes explicit modifies.\n */\n const opp: Record<string, ZorderInverse> = {\n forward: 'backward',\n backward: 'forward',\n front: 'back',\n back: 'front'\n }\n const dir = opp[op.direction]\n if (!dir) return null\n return { op: 'zorder', refid: op.refid, direction: dir }\n }\n\n case 'align':\n case 'distribute':\n case 'group':\n case 'ungroup':\n case 'arrange':\n return null\n\n default:\n return null\n }\n}\n\ntype ZorderInverse = 'front' | 'back' | 'forward' | 'backward'\n\n/**\n * The operations whose result only the live scene can produce — exact coordinates, refid\n * issuing, reparenting. Simulating them here would give an answer that disagrees with what\n * the user sees, so they do nothing and `apply-scene.ts` calls things-scene's own API.\n */\nconst SCENE_ONLY_OPS = new Set<SceneEditOp['op']>([\n 'align',\n 'distribute',\n 'group',\n 'ungroup',\n 'zorder',\n 'arrange'\n])\n\nexport function applyOp(model: SceneModel, op: SceneEditOp): SceneModel {\n const components = model.components ?? []\n switch (op.op) {\n case 'replace':\n return op.model\n\n case 'add':\n /* Top level only — adding into a container is a separate operation we do not have yet. */\n return { ...model, components: [...components, op.component] }\n\n case 'remove':\n return { ...model, components: removeComponentDeep(components, op.refid) }\n\n case 'modify':\n return { ...model, components: modifyComponentDeep(components, op.refid, op.patch) }\n\n case 'modifyScene': {\n /* Root properties only. `components` is ignored — children move by add/remove/modify. */\n const patch = { ...op.patch } as any\n delete patch.components\n return mergeSceneRoot(model, patch)\n }\n\n case 'align':\n case 'distribute':\n case 'group':\n case 'ungroup':\n case 'zorder':\n case 'arrange':\n return model\n\n default:\n return model\n }\n}\n\n/**\n * `null` in a patch means **remove this key**, not \"set it to null\".\n *\n * Three reasons this is the right reading:\n * 1. Fields such as fillStyle and strokeStyle are `string | object | undefined`. Setting\n * one to null walks straight into `typeof null === 'object'` downstream.\n * 2. `computeInverseOp` emits null for a key the original did not have, which is exactly\n * \"remove it again\" — so undo comes out right without a special case.\n * 3. Clearing a field that a mode switch left behind is then just another key in the patch.\n *\n * A caller that genuinely wants to store null would need a new operation. Nothing in the\n * scene model means anything by a stored null today.\n */\nfunction mergeSceneRoot(model: SceneModel, patch: any): SceneModel {\n const out: any = { ...model }\n for (const key of Object.keys(patch)) {\n const bv = (model as any)[key]\n const pv = patch[key]\n if (pv === null) {\n delete out[key]\n } else if (isPlainObject(bv) && isPlainObject(pv)) {\n out[key] = deepMerge(bv, pv)\n } else {\n out[key] = pv\n }\n }\n return out\n}\n\n/**\n * Apply a partial patch to one component.\n *\n * Nested objects such as `threeD` are merged rather than replaced, so that changing a colour\n * does not take the geometry with it. A null value removes the key, as above.\n */\nexport function mergeComponent(\n base: SceneComponentModel,\n patch: Partial<SceneComponentModel>\n): SceneComponentModel {\n const out: any = { ...base }\n for (const key of Object.keys(patch)) {\n const baseVal = (base as any)[key]\n const patchVal = (patch as any)[key]\n if (patchVal === null) {\n delete out[key]\n } else if (isPlainObject(baseVal) && isPlainObject(patchVal)) {\n out[key] = deepMerge(baseVal, patchVal)\n } else {\n out[key] = patchVal\n }\n }\n return out as SceneComponentModel\n}\n\nfunction deepMerge(a: any, b: any): any {\n const out: any = { ...a }\n for (const key of Object.keys(b)) {\n const av = a[key]\n const bv = b[key]\n if (bv === null) {\n delete out[key]\n } else if (isPlainObject(av) && isPlainObject(bv)) {\n out[key] = deepMerge(av, bv)\n } else {\n out[key] = bv\n }\n }\n return out\n}\n\nfunction isPlainObject(v: any): boolean {\n return v !== null && typeof v === 'object' && !Array.isArray(v)\n}\n\n/* ── Reaching into containers ─────────────────────────────────────────────────────────── */\n\n/** Find a component by refid anywhere in the tree, not only at the top level. */\nfunction findComponentDeep(\n components: SceneComponentModel[],\n refid: number\n): SceneComponentModel | undefined {\n for (const c of components) {\n if (!c) continue\n if ((c as any).refid === refid) return c\n const children = (c as any).components\n if (Array.isArray(children) && children.length > 0) {\n const sub = findComponentDeep(children, refid)\n if (sub) return sub\n }\n }\n return undefined\n}\n\n/**\n * `modify`, walking into containers. Branches that did not change come back as the same\n * reference, so a caller can tell what moved by identity.\n */\nfunction modifyComponentDeep(\n components: SceneComponentModel[],\n refid: number,\n patch: any\n): SceneComponentModel[] {\n let changed = false\n const out = components.map(c => {\n if (!c) return c\n if ((c as any).refid === refid) {\n changed = true\n return mergeComponent(c, patch)\n }\n const children = (c as any).components\n if (Array.isArray(children) && children.length > 0) {\n const updated = modifyComponentDeep(children, refid, patch)\n if (updated !== children) {\n changed = true\n return { ...c, components: updated }\n }\n }\n return c\n })\n return changed ? out : components\n}\n\n/**\n * `remove`, walking into containers. The parent stays, and a group left empty stays too —\n * the user made that container on purpose and removing one child is not a reason to take it.\n */\nfunction removeComponentDeep(\n components: SceneComponentModel[],\n refid: number\n): SceneComponentModel[] {\n let changed = false\n const out: SceneComponentModel[] = []\n for (const c of components) {\n if (!c) {\n out.push(c)\n continue\n }\n if ((c as any).refid === refid) {\n changed = true\n continue\n }\n const children = (c as any).components\n if (Array.isArray(children) && children.length > 0) {\n const updated = removeComponentDeep(children, refid)\n if (updated !== children) {\n changed = true\n out.push({ ...c, components: updated })\n continue\n }\n }\n out.push(c)\n }\n return changed ? out : components\n}\n"]}
|