@nodaro/shared 2.26.0 → 2.27.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,311 @@
1
+ /**
2
+ * Scene3D edit operations — the ONLY way a Scene3D plan changes.
3
+ *
4
+ * Split out of `scene3d.ts` (which owns the shape) because this file owns the
5
+ * TRANSITION: given a plan, a list of operations and the caller's locks, it
6
+ * produces the next immutable revision or an explained refusal. Both edit
7
+ * lanes go through it — the deterministic one (the caller sent operations) and
8
+ * the instruction one (an LLM authored the operations from a sentence) — so
9
+ * locks, staleness and whole-scene validation cannot be enforced twice and
10
+ * differently. The model never writes a plan and never writes code; it writes
11
+ * operations that this function is free to refuse.
12
+ */
13
+ import { z } from "zod"
14
+ import {
15
+ SCENE3D_LIMITS,
16
+ newScene3DRevisionId,
17
+ rotationVec3Schema,
18
+ scaleVec3Schema,
19
+ scene3DCameraKeyframeSchema,
20
+ scene3DColorSchema,
21
+ scene3DDeepEqual,
22
+ scene3DIdSchema,
23
+ scene3DObjectKeyframeSchema,
24
+ scene3DObjectSchema,
25
+ scene3DPlanSchema,
26
+ scene3DPrimitiveSchema,
27
+ sizeVec3Schema,
28
+ vec3Schema,
29
+ type Scene3DObject,
30
+ type Scene3DPlan,
31
+ } from "./scene3d.js"
32
+
33
+
34
+ /** Everything about an object EXCEPT its identity. `id` is deliberately absent
35
+ * (and the schema is strict) so no operation can rename an object out from
36
+ * under a lock, a parent link or a reference. */
37
+ export const scene3DObjectChangesSchema = z
38
+ .object({
39
+ name: z.string().min(1).max(SCENE3D_LIMITS.maxNameLength).optional(),
40
+ primitive: scene3DPrimitiveSchema.optional(),
41
+ /** `null` detaches from the parent; omitted leaves it as-is. */
42
+ parentId: scene3DIdSchema.nullable().optional(),
43
+ dimensions: sizeVec3Schema.optional(),
44
+ position: vec3Schema.optional(),
45
+ rotation: rotationVec3Schema.optional(),
46
+ scale: scaleVec3Schema.optional(),
47
+ color: scene3DColorSchema.optional(),
48
+ keyframes: z.array(scene3DObjectKeyframeSchema).max(SCENE3D_LIMITS.maxKeyframes).optional(),
49
+ })
50
+ .strict()
51
+
52
+ export const scene3DCameraChangesSchema = z
53
+ .object({
54
+ position: vec3Schema.optional(),
55
+ target: vec3Schema.optional(),
56
+ focalLengthMm: z
57
+ .number()
58
+ .min(SCENE3D_LIMITS.minFocalLengthMm)
59
+ .max(SCENE3D_LIMITS.maxFocalLengthMm)
60
+ .optional(),
61
+ sensorWidthMm: z
62
+ .number()
63
+ .min(SCENE3D_LIMITS.minSensorWidthMm)
64
+ .max(SCENE3D_LIMITS.maxSensorWidthMm)
65
+ .optional(),
66
+ keyframes: z.array(scene3DCameraKeyframeSchema).max(SCENE3D_LIMITS.maxKeyframes).optional(),
67
+ })
68
+ .strict()
69
+
70
+ export const scene3DLightingChangesSchema = z
71
+ .object({
72
+ ambientIntensity: z.number().min(0).max(SCENE3D_LIMITS.maxIntensity).optional(),
73
+ keyIntensity: z.number().min(0).max(SCENE3D_LIMITS.maxIntensity).optional(),
74
+ keyPosition: vec3Schema.optional(),
75
+ })
76
+ .strict()
77
+
78
+ export const scene3DEditOperationSchema = z.discriminatedUnion("op", [
79
+ z.object({ op: z.literal("set-object"), objectId: scene3DIdSchema, changes: scene3DObjectChangesSchema }).strict(),
80
+ z.object({ op: z.literal("add-object"), object: scene3DObjectSchema }).strict(),
81
+ z.object({ op: z.literal("remove-object"), objectId: scene3DIdSchema }).strict(),
82
+ z.object({ op: z.literal("set-camera"), changes: scene3DCameraChangesSchema }).strict(),
83
+ z.object({ op: z.literal("set-lighting"), changes: scene3DLightingChangesSchema }).strict(),
84
+ z.object({ op: z.literal("set-background"), color: scene3DColorSchema }).strict(),
85
+ ])
86
+
87
+ export const scene3DEditOperationsSchema = z
88
+ .array(scene3DEditOperationSchema)
89
+ .min(1)
90
+ .max(SCENE3D_LIMITS.maxOperations)
91
+
92
+ export type Scene3DObjectChanges = z.infer<typeof scene3DObjectChangesSchema>
93
+ export type Scene3DCameraChanges = z.infer<typeof scene3DCameraChangesSchema>
94
+ export type Scene3DLightingChanges = z.infer<typeof scene3DLightingChangesSchema>
95
+ export type Scene3DEditOperation = z.infer<typeof scene3DEditOperationSchema>
96
+
97
+ export type Scene3DEditErrorCode =
98
+ /** `expectedRevisionId` did not match the plan handed in. */
99
+ | "stale_revision"
100
+ /** The operation list itself is malformed or over the cap. */
101
+ | "invalid_operations"
102
+ /** An operation targets an object that is not in the scene. */
103
+ | "unknown_object"
104
+ /** `add-object` collided with an existing id. */
105
+ | "duplicate_object"
106
+ /** An operation touched an id the caller declared locked. */
107
+ | "locked_object"
108
+ /** The plan handed in, or the plan the operations produced, is invalid. */
109
+ | "invalid_plan"
110
+
111
+ export interface Scene3DEditOptions {
112
+ /** Optimistic concurrency: reject unless the plan is still this revision. */
113
+ expectedRevisionId?: string
114
+ /** Object ids the caller declared untouchable. Enforced as a POST-condition
115
+ * (see `applyScene3DEditOperations`), which is what makes it total. */
116
+ lockedObjectIds?: readonly string[]
117
+ /** Pin the produced revision id — tests and deterministic replay only. */
118
+ revisionId?: string
119
+ }
120
+
121
+ export type Scene3DEditResult =
122
+ | { ok: true; plan: Scene3DPlan; changedObjectIds: string[]; changeSummary: string }
123
+ | { ok: false; code: Scene3DEditErrorCode; message: string; operationIndex?: number }
124
+
125
+ /** Structural clone that cannot share a reference with its input. `structured-
126
+ * Clone` is not available in every consumer runtime we ship to, and a plan is
127
+ * pure JSON by construction. */
128
+ function clonePlan(plan: Scene3DPlan): Scene3DPlan {
129
+ return JSON.parse(JSON.stringify(plan)) as Scene3DPlan
130
+ }
131
+
132
+ /** One human sentence per operation — the deterministic lane's answer to the
133
+ * LLM lane's `changeSummary`, so both edit paths return the same shape. */
134
+ export function summarizeScene3DOperations(operations: readonly Scene3DEditOperation[]): string {
135
+ const lines = operations.map((operation) => {
136
+ switch (operation.op) {
137
+ case "set-object": {
138
+ const fields = Object.keys(operation.changes)
139
+ return `Updated ${fields.length > 0 ? fields.join(", ") : "nothing"} on "${operation.objectId}"`
140
+ }
141
+ case "add-object":
142
+ return `Added ${operation.object.primitive} "${operation.object.name}" (${operation.object.id})`
143
+ case "remove-object":
144
+ return `Removed "${operation.objectId}"`
145
+ case "set-camera":
146
+ return `Updated camera ${Object.keys(operation.changes).join(", ") || "nothing"}`
147
+ case "set-lighting":
148
+ return `Updated lighting ${Object.keys(operation.changes).join(", ") || "nothing"}`
149
+ case "set-background":
150
+ return `Set background to ${operation.color}`
151
+ }
152
+ })
153
+ return lines.join("; ").slice(0, SCENE3D_LIMITS.maxChangeSummaryLength)
154
+ }
155
+
156
+ function firstIssueMessage(error: z.ZodError): string {
157
+ const issue = error.issues[0]
158
+ if (!issue) return "invalid"
159
+ const path = issue.path.join(".")
160
+ return path ? `${path}: ${issue.message}` : issue.message
161
+ }
162
+
163
+ /**
164
+ * Apply an operation list to a plan, producing a NEW revision.
165
+ *
166
+ * Guarantees, in this order — each one is a distinct failure mode that was
167
+ * cheap to get wrong:
168
+ *
169
+ * 1. The input plan is never mutated (deep clone before the first write).
170
+ * 2. A stale `expectedRevisionId` is refused before anything is applied, so a
171
+ * late async completion can never overwrite a newer manual edit.
172
+ * 3. Operations are schema-validated as a list; the failing INDEX is reported.
173
+ * 4. Locks are enforced as a POST-CONDITION — every locked object must still
174
+ * exist and be deep-equal to the original. Reasoning per-operation would
175
+ * have to anticipate remove + re-add, a reparent from a sibling's `set-
176
+ * object`, and whatever the next operation kind turns out to be; the
177
+ * post-condition covers all of them by construction. (`selectedObjectIds`
178
+ * is CONTEXT for the model, never permission — the caller passes locks
179
+ * explicitly and they are checked here, after the model has spoken.)
180
+ * 5. The WHOLE resulting plan is re-validated, which is what makes "no silent
181
+ * orphaning" free: removing a parent leaves a dangling `parentId` and the
182
+ * plan validator rejects it, as does removing an object a reference points
183
+ * at.
184
+ */
185
+ export function applyScene3DEditOperations(
186
+ plan: Scene3DPlan,
187
+ operations: readonly Scene3DEditOperation[] | unknown,
188
+ options: Scene3DEditOptions = {},
189
+ ): Scene3DEditResult {
190
+ const parsedPlan = scene3DPlanSchema.safeParse(plan)
191
+ if (!parsedPlan.success) {
192
+ return { ok: false, code: "invalid_plan", message: `scenePlan is invalid — ${firstIssueMessage(parsedPlan.error)}` }
193
+ }
194
+ const source = parsedPlan.data as Scene3DPlan
195
+
196
+ if (options.expectedRevisionId !== undefined && options.expectedRevisionId !== source.revisionId) {
197
+ return {
198
+ ok: false,
199
+ code: "stale_revision",
200
+ message: `This scene has moved on — expected revision ${options.expectedRevisionId}, the plan is at ${source.revisionId}.`,
201
+ }
202
+ }
203
+
204
+ const parsedOps = scene3DEditOperationsSchema.safeParse(operations)
205
+ if (!parsedOps.success) {
206
+ const issue = parsedOps.error.issues[0]
207
+ const index = typeof issue?.path[0] === "number" ? (issue.path[0] as number) : undefined
208
+ return {
209
+ ok: false,
210
+ code: "invalid_operations",
211
+ message: `operations are invalid — ${firstIssueMessage(parsedOps.error)}`,
212
+ ...(index === undefined ? {} : { operationIndex: index }),
213
+ }
214
+ }
215
+ const ops = parsedOps.data
216
+
217
+ const next = clonePlan(source)
218
+ const changed = new Set<string>()
219
+
220
+ for (let index = 0; index < ops.length; index++) {
221
+ const operation = ops[index]
222
+ switch (operation.op) {
223
+ case "set-object": {
224
+ const target = next.objects.findIndex((o) => o.id === operation.objectId)
225
+ if (target === -1) {
226
+ return {
227
+ ok: false,
228
+ code: "unknown_object",
229
+ message: `no object "${operation.objectId}" in this scene`,
230
+ operationIndex: index,
231
+ }
232
+ }
233
+ const { parentId, ...rest } = operation.changes
234
+ const updated: Scene3DObject = { ...next.objects[target], ...rest }
235
+ if (parentId !== undefined) {
236
+ if (parentId === null) delete updated.parentId
237
+ else updated.parentId = parentId
238
+ }
239
+ next.objects = next.objects.map((o, i) => (i === target ? updated : o))
240
+ changed.add(operation.objectId)
241
+ break
242
+ }
243
+ case "add-object": {
244
+ if (next.objects.some((o) => o.id === operation.object.id)) {
245
+ return {
246
+ ok: false,
247
+ code: "duplicate_object",
248
+ message: `an object with id "${operation.object.id}" already exists`,
249
+ operationIndex: index,
250
+ }
251
+ }
252
+ next.objects = [...next.objects, operation.object as Scene3DObject]
253
+ changed.add(operation.object.id)
254
+ break
255
+ }
256
+ case "remove-object": {
257
+ if (!next.objects.some((o) => o.id === operation.objectId)) {
258
+ return {
259
+ ok: false,
260
+ code: "unknown_object",
261
+ message: `no object "${operation.objectId}" in this scene`,
262
+ operationIndex: index,
263
+ }
264
+ }
265
+ next.objects = next.objects.filter((o) => o.id !== operation.objectId)
266
+ changed.add(operation.objectId)
267
+ break
268
+ }
269
+ case "set-camera":
270
+ next.camera = { ...next.camera, ...operation.changes }
271
+ break
272
+ case "set-lighting":
273
+ next.lighting = { ...next.lighting, ...operation.changes }
274
+ break
275
+ case "set-background":
276
+ next.backgroundColor = operation.color
277
+ break
278
+ }
279
+ }
280
+
281
+ for (const lockedId of options.lockedObjectIds ?? []) {
282
+ const before = source.objects.find((o) => o.id === lockedId)
283
+ const after = next.objects.find((o) => o.id === lockedId)
284
+ if (before === undefined) continue // not in the scene to begin with — nothing to protect
285
+ if (after === undefined) {
286
+ return { ok: false, code: "locked_object", message: `object "${lockedId}" is locked and cannot be removed` }
287
+ }
288
+ if (!scene3DDeepEqual(before, after)) {
289
+ return { ok: false, code: "locked_object", message: `object "${lockedId}" is locked and cannot be modified` }
290
+ }
291
+ }
292
+
293
+ next.parentRevisionId = source.revisionId
294
+ next.revisionId = options.revisionId ?? newScene3DRevisionId()
295
+
296
+ const validated = scene3DPlanSchema.safeParse(next)
297
+ if (!validated.success) {
298
+ return {
299
+ ok: false,
300
+ code: "invalid_plan",
301
+ message: `the edit would leave the scene invalid — ${firstIssueMessage(validated.error)}`,
302
+ }
303
+ }
304
+
305
+ return {
306
+ ok: true,
307
+ plan: validated.data as Scene3DPlan,
308
+ changedObjectIds: [...changed],
309
+ changeSummary: summarizeScene3DOperations(ops),
310
+ }
311
+ }