@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.
@@ -0,0 +1,321 @@
1
+ const EMPTY_SCENE = {
2
+ width: 1000,
3
+ height: 600,
4
+ components: []
5
+ };
6
+ export function applyScenePatch(model, patch) {
7
+ return applyScenePatchVerbose(model, patch).model;
8
+ }
9
+ /**
10
+ * The same, but reporting which ops landed.
11
+ *
12
+ * `modify` and `remove` do nothing at all when the refid is not in the scene. Without this
13
+ * report the host answers "done" to a change that never happened — which is exactly what a
14
+ * language model producing a wrong refid looks like from the user's seat.
15
+ */
16
+ export function applyScenePatchVerbose(model, patch) {
17
+ let result = model ?? EMPTY_SCENE;
18
+ const applied = [];
19
+ const missed = [];
20
+ for (const op of patch.ops) {
21
+ if (SCENE_ONLY_OPS.has(op.op)) {
22
+ /* A no-op here, but the scene applier really does carry it out — count it as applied. */
23
+ applied.push(op);
24
+ continue;
25
+ }
26
+ const next = applyOp(result, op);
27
+ /*
28
+ * `componentsUnchanged` looks at the component list and at width/height/fillStyle only.
29
+ * It is here to catch a `modify`/`remove` that silently hit nothing, so it must not run
30
+ * for `modifyScene`, which legitimately changes other root keys such as `sky`.
31
+ */
32
+ const componentMutating = op.op === 'modify' || op.op === 'remove';
33
+ if (next === result || (componentMutating && componentsUnchanged(result, next))) {
34
+ missed.push(op);
35
+ }
36
+ else {
37
+ applied.push(op);
38
+ result = next;
39
+ }
40
+ }
41
+ return { model: result, applied, missed };
42
+ }
43
+ function componentsUnchanged(prev, next) {
44
+ /*
45
+ * `applyOp` always builds a new object, so comparing references tells us nothing and we
46
+ * compare contents. The component arrays come out of map/filter, so we compare element by
47
+ * element rather than by array identity.
48
+ */
49
+ const a = prev.components ?? [];
50
+ const b = next.components ?? [];
51
+ if (a.length !== b.length)
52
+ return false;
53
+ for (let i = 0; i < a.length; i++) {
54
+ if (a[i] !== b[i])
55
+ return false;
56
+ }
57
+ return prev.width === next.width && prev.height === next.height && prev.fillStyle === next.fillStyle;
58
+ }
59
+ /**
60
+ * The inverse of one operation, against the scene as it was before that operation ran.
61
+ *
62
+ * This is what makes undo possible for a proposed change: a host that applies ops one at a
63
+ * time and keeps the inverses can restore the scene by running them backwards.
64
+ *
65
+ * Returns null when the inverse cannot be known from the model alone. `add` is the clear
66
+ * case — its inverse needs the refid that things-scene issues on insert, so the host captures
67
+ * that itself. The scene-only operations are the same story: their result depends on
68
+ * coordinates and parenting that only the live scene knows.
69
+ */
70
+ export function computeInverseOp(model, op) {
71
+ if (!model)
72
+ return null;
73
+ const components = model.components ?? [];
74
+ switch (op.op) {
75
+ case 'add':
76
+ return null;
77
+ case 'remove': {
78
+ const target = findComponentDeep(components, op.refid);
79
+ if (!target)
80
+ return null; /* nothing was removed, so there is nothing to put back */
81
+ return { op: 'add', component: JSON.parse(JSON.stringify(target)) };
82
+ }
83
+ case 'modify': {
84
+ const target = findComponentDeep(components, op.refid);
85
+ if (!target)
86
+ return null;
87
+ /* Keep only the keys the patch touches; nested values are kept whole. */
88
+ const oldValues = {};
89
+ for (const k of Object.keys(op.patch || {})) {
90
+ const v = target[k];
91
+ oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
92
+ }
93
+ return { op: 'modify', refid: op.refid, patch: oldValues };
94
+ }
95
+ case 'modifyScene': {
96
+ const oldValues = {};
97
+ const patch = op.patch || {};
98
+ for (const k of Object.keys(patch)) {
99
+ if (k === 'components')
100
+ continue;
101
+ const v = model[k];
102
+ oldValues[k] = v === undefined ? null : JSON.parse(JSON.stringify(v));
103
+ }
104
+ return { op: 'modifyScene', patch: oldValues };
105
+ }
106
+ case 'replace':
107
+ return { op: 'replace', model: JSON.parse(JSON.stringify(model)) };
108
+ case 'zorder': {
109
+ /*
110
+ * forward ↔ backward and front ↔ back. front/back is best effort — sending something
111
+ * to the front and then to the back does not put it where it started. A host that needs
112
+ * an exact inverse captures the index beforehand and writes explicit modifies.
113
+ */
114
+ const opp = {
115
+ forward: 'backward',
116
+ backward: 'forward',
117
+ front: 'back',
118
+ back: 'front'
119
+ };
120
+ const dir = opp[op.direction];
121
+ if (!dir)
122
+ return null;
123
+ return { op: 'zorder', refid: op.refid, direction: dir };
124
+ }
125
+ case 'align':
126
+ case 'distribute':
127
+ case 'group':
128
+ case 'ungroup':
129
+ case 'arrange':
130
+ return null;
131
+ default:
132
+ return null;
133
+ }
134
+ }
135
+ /**
136
+ * The operations whose result only the live scene can produce — exact coordinates, refid
137
+ * issuing, reparenting. Simulating them here would give an answer that disagrees with what
138
+ * the user sees, so they do nothing and `apply-scene.ts` calls things-scene's own API.
139
+ */
140
+ const SCENE_ONLY_OPS = new Set([
141
+ 'align',
142
+ 'distribute',
143
+ 'group',
144
+ 'ungroup',
145
+ 'zorder',
146
+ 'arrange'
147
+ ]);
148
+ export function applyOp(model, op) {
149
+ const components = model.components ?? [];
150
+ switch (op.op) {
151
+ case 'replace':
152
+ return op.model;
153
+ case 'add':
154
+ /* Top level only — adding into a container is a separate operation we do not have yet. */
155
+ return { ...model, components: [...components, op.component] };
156
+ case 'remove':
157
+ return { ...model, components: removeComponentDeep(components, op.refid) };
158
+ case 'modify':
159
+ return { ...model, components: modifyComponentDeep(components, op.refid, op.patch) };
160
+ case 'modifyScene': {
161
+ /* Root properties only. `components` is ignored — children move by add/remove/modify. */
162
+ const patch = { ...op.patch };
163
+ delete patch.components;
164
+ return mergeSceneRoot(model, patch);
165
+ }
166
+ case 'align':
167
+ case 'distribute':
168
+ case 'group':
169
+ case 'ungroup':
170
+ case 'zorder':
171
+ case 'arrange':
172
+ return model;
173
+ default:
174
+ return model;
175
+ }
176
+ }
177
+ /**
178
+ * `null` in a patch means **remove this key**, not "set it to null".
179
+ *
180
+ * Three reasons this is the right reading:
181
+ * 1. Fields such as fillStyle and strokeStyle are `string | object | undefined`. Setting
182
+ * one to null walks straight into `typeof null === 'object'` downstream.
183
+ * 2. `computeInverseOp` emits null for a key the original did not have, which is exactly
184
+ * "remove it again" — so undo comes out right without a special case.
185
+ * 3. Clearing a field that a mode switch left behind is then just another key in the patch.
186
+ *
187
+ * A caller that genuinely wants to store null would need a new operation. Nothing in the
188
+ * scene model means anything by a stored null today.
189
+ */
190
+ function mergeSceneRoot(model, patch) {
191
+ const out = { ...model };
192
+ for (const key of Object.keys(patch)) {
193
+ const bv = model[key];
194
+ const pv = patch[key];
195
+ if (pv === null) {
196
+ delete out[key];
197
+ }
198
+ else if (isPlainObject(bv) && isPlainObject(pv)) {
199
+ out[key] = deepMerge(bv, pv);
200
+ }
201
+ else {
202
+ out[key] = pv;
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * Apply a partial patch to one component.
209
+ *
210
+ * Nested objects such as `threeD` are merged rather than replaced, so that changing a colour
211
+ * does not take the geometry with it. A null value removes the key, as above.
212
+ */
213
+ export function mergeComponent(base, patch) {
214
+ const out = { ...base };
215
+ for (const key of Object.keys(patch)) {
216
+ const baseVal = base[key];
217
+ const patchVal = patch[key];
218
+ if (patchVal === null) {
219
+ delete out[key];
220
+ }
221
+ else if (isPlainObject(baseVal) && isPlainObject(patchVal)) {
222
+ out[key] = deepMerge(baseVal, patchVal);
223
+ }
224
+ else {
225
+ out[key] = patchVal;
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+ function deepMerge(a, b) {
231
+ const out = { ...a };
232
+ for (const key of Object.keys(b)) {
233
+ const av = a[key];
234
+ const bv = b[key];
235
+ if (bv === null) {
236
+ delete out[key];
237
+ }
238
+ else if (isPlainObject(av) && isPlainObject(bv)) {
239
+ out[key] = deepMerge(av, bv);
240
+ }
241
+ else {
242
+ out[key] = bv;
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+ function isPlainObject(v) {
248
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
249
+ }
250
+ /* ── Reaching into containers ─────────────────────────────────────────────────────────── */
251
+ /** Find a component by refid anywhere in the tree, not only at the top level. */
252
+ function findComponentDeep(components, refid) {
253
+ for (const c of components) {
254
+ if (!c)
255
+ continue;
256
+ if (c.refid === refid)
257
+ return c;
258
+ const children = c.components;
259
+ if (Array.isArray(children) && children.length > 0) {
260
+ const sub = findComponentDeep(children, refid);
261
+ if (sub)
262
+ return sub;
263
+ }
264
+ }
265
+ return undefined;
266
+ }
267
+ /**
268
+ * `modify`, walking into containers. Branches that did not change come back as the same
269
+ * reference, so a caller can tell what moved by identity.
270
+ */
271
+ function modifyComponentDeep(components, refid, patch) {
272
+ let changed = false;
273
+ const out = components.map(c => {
274
+ if (!c)
275
+ return c;
276
+ if (c.refid === refid) {
277
+ changed = true;
278
+ return mergeComponent(c, patch);
279
+ }
280
+ const children = c.components;
281
+ if (Array.isArray(children) && children.length > 0) {
282
+ const updated = modifyComponentDeep(children, refid, patch);
283
+ if (updated !== children) {
284
+ changed = true;
285
+ return { ...c, components: updated };
286
+ }
287
+ }
288
+ return c;
289
+ });
290
+ return changed ? out : components;
291
+ }
292
+ /**
293
+ * `remove`, walking into containers. The parent stays, and a group left empty stays too —
294
+ * the user made that container on purpose and removing one child is not a reason to take it.
295
+ */
296
+ function removeComponentDeep(components, refid) {
297
+ let changed = false;
298
+ const out = [];
299
+ for (const c of components) {
300
+ if (!c) {
301
+ out.push(c);
302
+ continue;
303
+ }
304
+ if (c.refid === refid) {
305
+ changed = true;
306
+ continue;
307
+ }
308
+ const children = c.components;
309
+ if (Array.isArray(children) && children.length > 0) {
310
+ const updated = removeComponentDeep(children, refid);
311
+ if (updated !== children) {
312
+ changed = true;
313
+ out.push({ ...c, components: updated });
314
+ continue;
315
+ }
316
+ }
317
+ out.push(c);
318
+ }
319
+ return changed ? out : components;
320
+ }
321
+ //# 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"]}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Applying edit operations to a scene that is open on screen.
3
+ *
4
+ * The difference from `apply-model.ts` is the commander. Everything here goes through
5
+ * things-scene's own mutation API (`scene.add`, `target.set`, `scene.align`, …), so the
6
+ * commander records a snapshot and the user keeps undo, the dirty mark and their selection.
7
+ * Rebuilding the model and handing it back would take all three away.
8
+ *
9
+ * The scene is typed `any` on purpose: things-scene's Scene is a large surface and this
10
+ * module needs eight methods of it. Typing it loosely is what lets the whole file be tested
11
+ * with a plain object, which is how the operations below are actually covered.
12
+ */
13
+ import type { ArrangeLayout, SceneActionOp, SceneEditOp } from './ops.js';
14
+ export interface DispatchContext {
15
+ /**
16
+ * Fill an `add` component out with the defaults of its type before it goes in.
17
+ *
18
+ * The host does this because the defaults live in its template registry — an editor knows
19
+ * what a fresh component of each type looks like, and this module does not. Left out, the
20
+ * component goes to the scene exactly as given.
21
+ */
22
+ normalize?: (c: any) => any;
23
+ }
24
+ export interface DispatchResult {
25
+ /** False when nothing happened — an unknown refid, too few targets, an op we do not take. */
26
+ applied: boolean;
27
+ /**
28
+ * How to undo this one operation, computed from the scene as it was. One operation can
29
+ * need several: an `align` of five components inverts to five `modify`s.
30
+ */
31
+ inverseOps: SceneEditOp[];
32
+ }
33
+ /**
34
+ * Find a component by refid, or by id when that is all the caller has.
35
+ *
36
+ * refid is issued by things-scene to everything in the scene; id is an optional string the
37
+ * author may have set. refid is tried first because it is the one that is always there.
38
+ */
39
+ export declare function findSceneComponent(scene: any, target: {
40
+ id?: string;
41
+ refid?: number;
42
+ }): any;
43
+ /**
44
+ * Carry out one edit operation on a live scene.
45
+ *
46
+ * `replace` is not taken here — swapping the whole model is the host's own path, because it
47
+ * has to decide what happens to the selection and the undo stack.
48
+ */
49
+ export declare function dispatchSceneEditOp(scene: any, op: SceneEditOp, ctx?: DispatchContext): DispatchResult;
50
+ /**
51
+ * Carry out one view action on a live scene.
52
+ *
53
+ * Returns false when it could not — an unknown action, a missing component. Nothing here
54
+ * touches the model, so nothing here enters the undo history.
55
+ *
56
+ * `setSceneMode` changes `scene.mode`; a host holding its own reactive copy re-reads it.
57
+ * things-scene spells the modes 1 for edit and 0 for view.
58
+ */
59
+ export declare function dispatchSceneAction(scene: any, action: SceneActionOp): boolean;
60
+ /**
61
+ * Where each component goes for a grid, row or column arrangement.
62
+ *
63
+ * - Only left/top are produced; sizes are the author's and are left alone.
64
+ * - grid cells are as wide and as tall as the largest component, so components of
65
+ * different sizes do not overlap. Filled row by row.
66
+ * - row and column walk each component's own size plus the gap, and `align` decides the
67
+ * cross axis.
68
+ * - Without an anchor, the first component's current position is the origin, so the result
69
+ * starts where the user is already looking.
70
+ */
71
+ export declare function computeArrangePositions(layout: ArrangeLayout, current: Array<{
72
+ left: number;
73
+ top: number;
74
+ }>, sizes: Array<{
75
+ width: number;
76
+ height: number;
77
+ }>): Array<{
78
+ left: number;
79
+ top: number;
80
+ }>;
81
+ /**
82
+ * Every refid currently in the scene.
83
+ *
84
+ * Called on both sides of an `add` or a `group`, so that the difference tells us which refids
85
+ * the scene just issued — which is the only way to write their inverse.
86
+ */
87
+ export declare function collectAllRefids(scene: any): number[];
88
+ /**
89
+ * The current values of exactly the keys a patch is about to change, deep-cloned.
90
+ *
91
+ * This is the patch of the inverse `modify`. A key the model did not have is kept as null,
92
+ * which the mergers read as "remove it" — so undoing an added key removes it again.
93
+ */
94
+ export declare function captureOldKeys(model: any, patch: any): any;