@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,397 @@
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
+ import { mergeComponent } from './apply-model.js'
15
+
16
+ export interface DispatchContext {
17
+ /**
18
+ * Fill an `add` component out with the defaults of its type before it goes in.
19
+ *
20
+ * The host does this because the defaults live in its template registry — an editor knows
21
+ * what a fresh component of each type looks like, and this module does not. Left out, the
22
+ * component goes to the scene exactly as given.
23
+ */
24
+ normalize?: (c: any) => any
25
+ }
26
+
27
+ export interface DispatchResult {
28
+ /** False when nothing happened — an unknown refid, too few targets, an op we do not take. */
29
+ applied: boolean
30
+ /**
31
+ * How to undo this one operation, computed from the scene as it was. One operation can
32
+ * need several: an `align` of five components inverts to five `modify`s.
33
+ */
34
+ inverseOps: SceneEditOp[]
35
+ }
36
+
37
+ const NOOP_RESULT: DispatchResult = { applied: false, inverseOps: [] }
38
+
39
+ /**
40
+ * Find a component by refid, or by id when that is all the caller has.
41
+ *
42
+ * refid is issued by things-scene to everything in the scene; id is an optional string the
43
+ * author may have set. refid is tried first because it is the one that is always there.
44
+ */
45
+ export function findSceneComponent(scene: any, target: { id?: string; refid?: number }): any {
46
+ if (!scene) return null
47
+ if (typeof target.refid === 'number') {
48
+ const byRefid = scene.rootContainer?.refidIndexMap?.get(target.refid)
49
+ if (byRefid) return byRefid
50
+ }
51
+ if (typeof target.id === 'string' && target.id.length > 0) {
52
+ return scene.findById?.(target.id) ?? null
53
+ }
54
+ return null
55
+ }
56
+
57
+ /**
58
+ * Carry out one edit operation on a live scene.
59
+ *
60
+ * `replace` is not taken here — swapping the whole model is the host's own path, because it
61
+ * has to decide what happens to the selection and the undo stack.
62
+ */
63
+ export function dispatchSceneEditOp(
64
+ scene: any,
65
+ op: SceneEditOp,
66
+ ctx: DispatchContext = {}
67
+ ): DispatchResult {
68
+ if (!scene || !op) return NOOP_RESULT
69
+ const normalize = ctx.normalize ?? ((c: any) => c)
70
+
71
+ switch (op.op) {
72
+ case 'add': {
73
+ const normalized = normalize(op.component)
74
+ /* The inverse needs the refid the scene is about to issue, so we diff around the add. */
75
+ const prevRefids = new Set<number>(collectAllRefids(scene))
76
+ scene.add(normalized, {})
77
+ const newRefids = collectAllRefids(scene).filter(r => !prevRefids.has(r))
78
+ const inverseOps: SceneEditOp[] = newRefids.map(refid => ({ op: 'remove', refid }))
79
+ return { applied: true, inverseOps }
80
+ }
81
+
82
+ case 'remove': {
83
+ const target = findSceneComponent(scene, { refid: op.refid })
84
+ if (!target || !target.parent) return NOOP_RESULT
85
+ const savedModel = JSON.parse(JSON.stringify(target.model))
86
+ const prevSelected = scene.selected ?? []
87
+ scene.selected = [target]
88
+ scene.remove()
89
+ scene.selected = prevSelected.filter((c: any) => c !== target)
90
+ return { applied: true, inverseOps: [{ op: 'add', component: savedModel }] }
91
+ }
92
+
93
+ case 'modify': {
94
+ const target = findSceneComponent(scene, { refid: op.refid })
95
+ if (!target) return NOOP_RESULT
96
+ const oldValues = captureOldKeys(target.model, op.patch as any)
97
+ const merged = mergeComponent(target.model, op.patch as any)
98
+ target.set(merged)
99
+ /* `set` does not push a snapshot by itself — ask the commander for one. */
100
+ scene.commander?.execute(null, false)
101
+ return {
102
+ applied: true,
103
+ inverseOps: [{ op: 'modify', refid: op.refid, patch: oldValues }]
104
+ }
105
+ }
106
+
107
+ case 'modifyScene': {
108
+ const root = scene.root
109
+ if (!root || typeof root.set !== 'function') return NOOP_RESULT
110
+ const cleanPatch = { ...((op.patch as any) || {}) }
111
+ delete cleanPatch.components /* children move by add/remove/modify */
112
+ const oldValues = captureOldKeys(root.model, cleanPatch)
113
+ const merged = mergeComponent(root.model, cleanPatch)
114
+ root.set(merged)
115
+ scene.commander?.execute(null, false)
116
+ return { applied: true, inverseOps: [{ op: 'modifyScene', patch: oldValues }] }
117
+ }
118
+
119
+ case 'align': {
120
+ const targets = op.refids.map(r => findSceneComponent(scene, { refid: r })).filter((c: any) => c)
121
+ if (targets.length < 2) return NOOP_RESULT
122
+ const beforeBounds = targets.map((c: any) => ({
123
+ refid: c.get('refid'),
124
+ left: c.get('left'),
125
+ top: c.get('top'),
126
+ width: c.get('width'),
127
+ height: c.get('height')
128
+ }))
129
+ const prevSelected = scene.selected ?? []
130
+ scene.selected = targets
131
+ scene.align(op.direction)
132
+ scene.selected = prevSelected
133
+ /* Undo restores the coordinates we read, rather than trying to invert the alignment. */
134
+ const inverseOps: SceneEditOp[] = beforeBounds.map(b => ({
135
+ op: 'modify',
136
+ refid: b.refid,
137
+ patch: { left: b.left, top: b.top, width: b.width, height: b.height } as any
138
+ }))
139
+ return { applied: true, inverseOps }
140
+ }
141
+
142
+ case 'distribute': {
143
+ const targets = op.refids.map(r => findSceneComponent(scene, { refid: r })).filter((c: any) => c)
144
+ if (targets.length < 2) return NOOP_RESULT
145
+ const beforeBounds = targets.map((c: any) => ({
146
+ refid: c.get('refid'),
147
+ left: c.get('left'),
148
+ top: c.get('top')
149
+ }))
150
+ const prevSelected = scene.selected ?? []
151
+ scene.selected = targets
152
+ /* things-scene spells these in capitals. */
153
+ scene.distribute(op.axis === 'horizontal' ? 'HORIZONTAL' : 'VERTICAL')
154
+ scene.selected = prevSelected
155
+ const inverseOps: SceneEditOp[] = beforeBounds.map(b => ({
156
+ op: 'modify',
157
+ refid: b.refid,
158
+ patch: { left: b.left, top: b.top } as any
159
+ }))
160
+ return { applied: true, inverseOps }
161
+ }
162
+
163
+ case 'group': {
164
+ const targets = op.refids.map(r => findSceneComponent(scene, { refid: r })).filter((c: any) => c)
165
+ if (targets.length < 2) return NOOP_RESULT
166
+ const prevRefids = new Set<number>(collectAllRefids(scene))
167
+ const prevSelected = scene.selected ?? []
168
+ scene.selected = targets
169
+ scene.group()
170
+ scene.selected = prevSelected
171
+ const newRefids = collectAllRefids(scene).filter(r => !prevRefids.has(r))
172
+ const inverseOps: SceneEditOp[] = newRefids.map(refid => ({ op: 'ungroup', refid }))
173
+ return { applied: true, inverseOps }
174
+ }
175
+
176
+ case 'ungroup': {
177
+ const target = findSceneComponent(scene, { refid: op.refid })
178
+ if (!target) return NOOP_RESULT
179
+ const childRefids: number[] = []
180
+ const children = (target as any).components ?? []
181
+ for (const child of children) {
182
+ const r = child.get?.('refid')
183
+ if (typeof r === 'number') childRefids.push(r)
184
+ }
185
+ const prevSelected = scene.selected ?? []
186
+ scene.selected = [target]
187
+ scene.ungroup()
188
+ scene.selected = prevSelected.filter((c: any) => c !== target)
189
+ const inverseOps: SceneEditOp[] =
190
+ childRefids.length >= 2 ? [{ op: 'group', refids: childRefids }] : []
191
+ return { applied: true, inverseOps }
192
+ }
193
+
194
+ case 'zorder': {
195
+ const target = findSceneComponent(scene, { refid: op.refid })
196
+ if (!target) return NOOP_RESULT
197
+ const prevSelected = scene.selected ?? []
198
+ scene.selected = [target]
199
+ scene.zorder(op.direction)
200
+ scene.selected = prevSelected
201
+ /*
202
+ * Best effort. forward/backward invert exactly; front/back do not — sending something
203
+ * to the front and then to the back does not put it back where it was.
204
+ */
205
+ const opp: Record<string, 'front' | 'back' | 'forward' | 'backward'> = {
206
+ forward: 'backward',
207
+ backward: 'forward',
208
+ front: 'back',
209
+ back: 'front'
210
+ }
211
+ const dir = opp[op.direction]
212
+ const inverseOps: SceneEditOp[] = dir ? [{ op: 'zorder', refid: op.refid, direction: dir }] : []
213
+ return { applied: true, inverseOps }
214
+ }
215
+
216
+ case 'arrange': {
217
+ /*
218
+ * things-scene has no native call for this, so the positions are computed here and
219
+ * written with `set`. Only left/top move; width and height are the author's.
220
+ */
221
+ const targets = op.refids.map(r => findSceneComponent(scene, { refid: r })).filter((c: any) => c)
222
+ if (targets.length < 2) return NOOP_RESULT
223
+
224
+ const beforePositions = targets.map((c: any) => ({
225
+ refid: c.get('refid'),
226
+ left: c.get('left'),
227
+ top: c.get('top')
228
+ }))
229
+ const sizes = targets.map((c: any) => ({
230
+ width: typeof c.get('width') === 'number' ? c.get('width') : 0,
231
+ height: typeof c.get('height') === 'number' ? c.get('height') : 0
232
+ }))
233
+
234
+ const positions = computeArrangePositions(op.layout, beforePositions, sizes)
235
+ for (let i = 0; i < targets.length; i++) {
236
+ const t = targets[i]
237
+ const pos = positions[i]
238
+ const merged = mergeComponent(t.model, { left: pos.left, top: pos.top } as any)
239
+ t.set(merged)
240
+ }
241
+ /* One snapshot for the whole arrangement — moving twelve things is one undo. */
242
+ scene.commander?.execute(null, false)
243
+
244
+ const inverseOps: SceneEditOp[] = beforePositions.map(b => ({
245
+ op: 'modify',
246
+ refid: b.refid,
247
+ patch: { left: b.left, top: b.top } as any
248
+ }))
249
+ return { applied: true, inverseOps }
250
+ }
251
+
252
+ case 'replace':
253
+ return NOOP_RESULT
254
+
255
+ default:
256
+ return NOOP_RESULT
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Carry out one view action on a live scene.
262
+ *
263
+ * Returns false when it could not — an unknown action, a missing component. Nothing here
264
+ * touches the model, so nothing here enters the undo history.
265
+ *
266
+ * `setSceneMode` changes `scene.mode`; a host holding its own reactive copy re-reads it.
267
+ * things-scene spells the modes 1 for edit and 0 for view.
268
+ */
269
+ export function dispatchSceneAction(scene: any, action: SceneActionOp): boolean {
270
+ if (!scene || !action) return false
271
+ switch (action.action) {
272
+ case 'selectComponents': {
273
+ const refids = Array.isArray(action.refids) ? action.refids : []
274
+ scene.selected = refids.map(r => findSceneComponent(scene, { refid: r })).filter((c: any) => c)
275
+ return true
276
+ }
277
+ case 'centerToComponent': {
278
+ const target = findSceneComponent(scene, { refid: action.refid })
279
+ if (!target) return false
280
+ scene.centerTo(target, action.animated !== false)
281
+ return true
282
+ }
283
+ case 'fitToView': {
284
+ scene.fit(action.mode ?? 'fit')
285
+ return true
286
+ }
287
+ case 'setSceneMode': {
288
+ scene.mode = action.mode === 'edit' ? 1 : 0
289
+ return true
290
+ }
291
+ case 'highlightComponents': {
292
+ /* things-scene's own call — it outlines in 2D and in 3D. */
293
+ const refids = Array.isArray(action.refids) ? action.refids : []
294
+ const targets = refids.map(r => findSceneComponent(scene, { refid: r })).filter((c: any) => c)
295
+ if (typeof scene.highlightSearchResults === 'function') {
296
+ scene.highlightSearchResults(targets)
297
+ }
298
+ if (typeof scene.invalidate === 'function') scene.invalidate()
299
+ return true
300
+ }
301
+ default:
302
+ return false
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Where each component goes for a grid, row or column arrangement.
308
+ *
309
+ * - Only left/top are produced; sizes are the author's and are left alone.
310
+ * - grid cells are as wide and as tall as the largest component, so components of
311
+ * different sizes do not overlap. Filled row by row.
312
+ * - row and column walk each component's own size plus the gap, and `align` decides the
313
+ * cross axis.
314
+ * - Without an anchor, the first component's current position is the origin, so the result
315
+ * starts where the user is already looking.
316
+ */
317
+ export function computeArrangePositions(
318
+ layout: ArrangeLayout,
319
+ current: Array<{ left: number; top: number }>,
320
+ sizes: Array<{ width: number; height: number }>
321
+ ): Array<{ left: number; top: number }> {
322
+ if (current.length === 0) return []
323
+ const anchor = layout.anchor ?? { left: current[0].left, top: current[0].top }
324
+ const gap = typeof layout.gap === 'number' ? layout.gap : 10
325
+
326
+ if (layout.type === 'grid') {
327
+ const cols = Math.max(1, Math.floor(layout.cols))
328
+ const cellW = sizes.reduce((m, s) => Math.max(m, s.width), 0)
329
+ const cellH = sizes.reduce((m, s) => Math.max(m, s.height), 0)
330
+ return current.map((_, i) => {
331
+ const row = Math.floor(i / cols)
332
+ const col = i % cols
333
+ return {
334
+ left: anchor.left + col * (cellW + gap),
335
+ top: anchor.top + row * (cellH + gap)
336
+ }
337
+ })
338
+ }
339
+
340
+ if (layout.type === 'row') {
341
+ const align = layout.align ?? 'start'
342
+ const maxH = sizes.reduce((m, s) => Math.max(m, s.height), 0)
343
+ const out: Array<{ left: number; top: number }> = []
344
+ let cursor = anchor.left
345
+ for (const s of sizes) {
346
+ let top = anchor.top
347
+ if (align === 'center') top = anchor.top + (maxH - s.height) / 2
348
+ else if (align === 'end') top = anchor.top + (maxH - s.height)
349
+ out.push({ left: cursor, top })
350
+ cursor += s.width + gap
351
+ }
352
+ return out
353
+ }
354
+
355
+ const align = layout.align ?? 'start'
356
+ const maxW = sizes.reduce((m, s) => Math.max(m, s.width), 0)
357
+ const out: Array<{ left: number; top: number }> = []
358
+ let cursor = anchor.top
359
+ for (const s of sizes) {
360
+ let left = anchor.left
361
+ if (align === 'center') left = anchor.left + (maxW - s.width) / 2
362
+ else if (align === 'end') left = anchor.left + (maxW - s.width)
363
+ out.push({ left, top: cursor })
364
+ cursor += s.height + gap
365
+ }
366
+ return out
367
+ }
368
+
369
+ /**
370
+ * Every refid currently in the scene.
371
+ *
372
+ * Called on both sides of an `add` or a `group`, so that the difference tells us which refids
373
+ * the scene just issued — which is the only way to write their inverse.
374
+ */
375
+ export function collectAllRefids(scene: any): number[] {
376
+ const refids: number[] = []
377
+ const map = scene?.rootContainer?.refidIndexMap
378
+ if (map && typeof map.forEach === 'function') {
379
+ map.forEach((_: any, refid: number) => refids.push(refid))
380
+ }
381
+ return refids
382
+ }
383
+
384
+ /**
385
+ * The current values of exactly the keys a patch is about to change, deep-cloned.
386
+ *
387
+ * This is the patch of the inverse `modify`. A key the model did not have is kept as null,
388
+ * which the mergers read as "remove it" — so undoing an added key removes it again.
389
+ */
390
+ export function captureOldKeys(model: any, patch: any): any {
391
+ const out: any = {}
392
+ for (const k of Object.keys(patch || {})) {
393
+ const v = model?.[k]
394
+ out[k] = v === undefined ? null : JSON.parse(JSON.stringify(v))
395
+ }
396
+ return out
397
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @operato/scene-ops — describing a change to a scene, and carrying it out.
3
+ *
4
+ * A scene can be changed while it is stored (a JSON model in a file or a row) or while it is
5
+ * open (a things-scene Scene with a commander behind it). Those are two appliers, but they
6
+ * have to agree about what a change *is*, so the vocabulary and both appliers live here
7
+ * together.
8
+ *
9
+ * Nothing here knows who proposed the change. A language model, a template, an importer and
10
+ * a person dragging a box all produce the same operations.
11
+ */
12
+ export * from './model.js'
13
+ export * from './ops.js'
14
+ export * from './apply-model.js'
15
+ export * from './apply-scene.js'
package/src/model.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The scene model — the JSON a things-scene board/scene is stored and loaded as.
3
+ *
4
+ * This shape used to be declared in `@things-factory/board-import` as `BoardModel` /
5
+ * `BoardComponent`, inside a CAD-import pipeline stage. It is not an import concept and it
6
+ * is not a board concept: it is what a scene *is* on disk. Anything that reads or writes a
7
+ * scene needs it, so it lives here with the operations that change it.
8
+ */
9
+
10
+ /** One component in a scene, and its children if it is a container. */
11
+ export interface SceneComponentModel {
12
+ /** Domain type, e.g. 'rect', 'twin-resource-card'. */
13
+ type: string
14
+ left: number
15
+ top: number
16
+ width: number
17
+ height: number
18
+ rotation?: number
19
+ /**
20
+ * Assigned by things-scene when the component joins a scene, and stable while it stays
21
+ * there. Every operation below targets components by this, never by `id` — `id` is
22
+ * optional metadata that most components do not carry.
23
+ */
24
+ refid?: number
25
+ id?: string
26
+ /** Children, when this component is a group or container. */
27
+ components?: SceneComponentModel[]
28
+ /** Components carry their own properties; we do not enumerate them. */
29
+ [k: string]: any
30
+ }
31
+
32
+ /**
33
+ * A whole scene. The root is a component too — it has its own fillStyle, camera, lights and
34
+ * so on — but it is reached by a separate operation (`modifyScene`) because it has no refid.
35
+ */
36
+ export interface SceneModel {
37
+ width?: number
38
+ height?: number
39
+ fillStyle?: string
40
+ /**
41
+ * Optional: an empty scene, a legacy file, or a model that is itself the root container
42
+ * may not have it. Always read it as `model.components ?? []`.
43
+ */
44
+ components?: SceneComponentModel[]
45
+ [k: string]: any
46
+ }
package/src/ops.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Scene edit operations — the vocabulary for describing a change to a scene.
3
+ *
4
+ * One vocabulary, two appliers: `apply-model.ts` changes the stored JSON, `apply-scene.ts`
5
+ * changes a Scene that is open on screen. They live together so they cannot drift.
6
+ *
7
+ * ── Targeting ──
8
+ * Existing components are addressed by `refid` only. things-scene issues one to every
9
+ * component; `model.id` is optional metadata and is absent more often than not, so it cannot
10
+ * be a target. There is one channel, not two.
11
+ *
12
+ * ── The root ──
13
+ * The scene root is the top-level parent (things-scene's model-layer) and carries its own
14
+ * properties: fillStyle, width, height, fitMode, translate, scale, sky, skyColor, exposure,
15
+ * the hemi/dir light fields, the camera fields. It has no refid, so `modifyScene` reaches it
16
+ * and `modify` reaches everything else.
17
+ *
18
+ * Note that a board's *name* is a column on the board row in the database, not a field of
19
+ * the scene model. Sending it through `modifyScene` writes a dead key into the JSON and
20
+ * changes nothing on screen.
21
+ */
22
+ import type { SceneComponentModel, SceneModel } from './model.js'
23
+
24
+ export type AlignDirection = 'left' | 'right' | 'center' | 'top' | 'middle' | 'bottom'
25
+
26
+ export type DistributeAxis = 'horizontal' | 'vertical'
27
+
28
+ export type ZorderDirection = 'front' | 'back' | 'forward' | 'backward'
29
+
30
+ /**
31
+ * The layout an `arrange` operation asks for.
32
+ *
33
+ * This sits above align/distribute so that "in a 3x2 grid", "in one row", "in a column"
34
+ * is one operation rather than a list of coordinates.
35
+ *
36
+ * Only left/top change — width and height are kept. Changing size is a separate `modify`.
37
+ *
38
+ * Positions are computed by the scene applier, because they need each component's current
39
+ * width and height. The model applier treats `arrange` as a no-op.
40
+ */
41
+ export type ArrangeLayout =
42
+ | { type: 'grid'; cols: number; gap?: number; anchor?: { left: number; top: number } }
43
+ | {
44
+ type: 'row'
45
+ gap?: number
46
+ anchor?: { left: number; top: number }
47
+ align?: 'start' | 'center' | 'end'
48
+ }
49
+ | {
50
+ type: 'column'
51
+ gap?: number
52
+ anchor?: { left: number; top: number }
53
+ align?: 'start' | 'center' | 'end'
54
+ }
55
+
56
+ /** An operation that changes the scene. */
57
+ export type SceneEditOp =
58
+ | { op: 'add'; component: SceneComponentModel }
59
+ | { op: 'remove'; refid: number }
60
+ | { op: 'modify'; refid: number; patch: Partial<SceneComponentModel> }
61
+ | { op: 'modifyScene'; patch: Partial<SceneModel> }
62
+ | { op: 'replace'; model: SceneModel }
63
+ | { op: 'align'; refids: number[]; direction: AlignDirection }
64
+ | { op: 'distribute'; refids: number[]; axis: DistributeAxis }
65
+ | { op: 'group'; refids: number[] }
66
+ | { op: 'ungroup'; refid: number }
67
+ | { op: 'zorder'; refid: number; direction: ZorderDirection }
68
+ | { op: 'arrange'; refids: number[]; layout: ArrangeLayout }
69
+
70
+ /**
71
+ * An operation that changes what the viewer sees but not what the scene is.
72
+ *
73
+ * Separate from `SceneEditOp` on purpose: these leave the model alone, so they do not enter
74
+ * the undo history and do not make the document dirty. A host that mixes the two ends up
75
+ * asking the user to save because the AI scrolled the view.
76
+ */
77
+ export type SceneActionOp =
78
+ | { action: 'selectComponents'; refids: number[] }
79
+ | { action: 'centerToComponent'; refid: number; animated?: boolean }
80
+ | { action: 'fitToView'; mode?: 'fit' | 'ratio' | 'width' | 'height' }
81
+ | { action: 'setSceneMode'; mode: 'edit' | 'view' }
82
+ /** Outline several components at once — the "these are all the matches" of a search. */
83
+ | { action: 'highlightComponents'; refids: number[] }
84
+
85
+ /** A batch of edit operations, with whatever the proposer wants to say about them. */
86
+ export interface SceneEditPatch {
87
+ ops: SceneEditOp[]
88
+ /** One or two sentences, for the person who has to approve it. */
89
+ summary: string
90
+ /** 0..1 */
91
+ confidence: number
92
+ }