@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.
- package/dist/index.cjs +549 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +731 -2
- package/dist/index.d.ts +731 -2
- package/dist/index.js +513 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +4 -2
- package/src/__tests__/node-preset-extract.test.ts +11 -0
- package/src/__tests__/scene3d.test.ts +482 -0
- package/src/index.ts +6 -0
- package/src/llm-models.ts +10 -0
- package/src/model-constants.ts +5 -0
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-preset-extract.ts +8 -2
- package/src/scene3d-edit.ts +311 -0
- package/src/scene3d.ts +615 -0
|
@@ -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
|
+
}
|