@nodaro/shared 2.26.0 → 3.0.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 +1920 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2594 -2
- package/dist/index.d.ts +2594 -2
- package/dist/index.js +1803 -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-camera-track.test.ts +235 -0
- package/src/__tests__/scene3d-v2-edit.test.ts +83 -0
- package/src/__tests__/scene3d-v2-fixtures.ts +232 -0
- package/src/__tests__/scene3d-v2-resources.test.ts +239 -0
- package/src/__tests__/scene3d-v2.test.ts +742 -0
- package/src/__tests__/scene3d.test.ts +482 -0
- package/src/index.ts +12 -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-camera-track.ts +369 -0
- package/src/scene3d-edit.ts +311 -0
- package/src/scene3d-v2-edit.ts +156 -0
- package/src/scene3d-v2-plan.ts +694 -0
- package/src/scene3d-v2-resources.ts +382 -0
- package/src/scene3d-v2.ts +666 -0
- package/src/scene3d.ts +641 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Scene3D v2 camera sidecar — one baked sample per frame.
|
|
3
|
+
*
|
|
4
|
+
* A 30-second scene is 720 camera samples. V1's 240-keyframe interpolated track
|
|
5
|
+
* cannot carry that, and interpolating a sparse track across a hard cut blends
|
|
6
|
+
* two shots into a frame that belongs to neither. So v2 moves the camera out of
|
|
7
|
+
* the manifest into this dense JSON asset, and the rule becomes trivial:
|
|
8
|
+
*
|
|
9
|
+
* **at integer frame `f`, use `samples[f]`.**
|
|
10
|
+
*
|
|
11
|
+
* No interpolation, no easing, no "nearest key". Pausing, scrubbing backwards
|
|
12
|
+
* and rendering frames out of order therefore produce identical state, which is
|
|
13
|
+
* the whole reason preview and export can be trusted to agree.
|
|
14
|
+
*
|
|
15
|
+
* Two things this format refuses to guess at:
|
|
16
|
+
*
|
|
17
|
+
* - **Orientation is a quaternion, not a look-at.** A renderer that replaces the
|
|
18
|
+
* exported quaternion with `lookAt(target)` throws away the authored roll and
|
|
19
|
+
* the handheld component. `target` is carried for inspection and intent only.
|
|
20
|
+
* - **Projection is a matrix, not a lens number.** A focal length cannot express
|
|
21
|
+
* sensor fit or lens shift, and re-deriving a projection at a different aspect
|
|
22
|
+
* silently reframes every shot. `focalLengthMm` is metadata; the 16-element
|
|
23
|
+
* column-major matrix is authoritative.
|
|
24
|
+
*
|
|
25
|
+
* Changing fps or aspect ratio is an explicit resample/reprojection producing a
|
|
26
|
+
* NEW revision — never a render-time override. `scene3DCameraTrackPlanIssues`
|
|
27
|
+
* is what makes that non-negotiable.
|
|
28
|
+
*/
|
|
29
|
+
import { z } from "zod"
|
|
30
|
+
import {
|
|
31
|
+
SCENE3D_V2_LIMITS,
|
|
32
|
+
scene3DJsonByteLength,
|
|
33
|
+
scene3DZodIssues,
|
|
34
|
+
type Scene3DParseResult,
|
|
35
|
+
type Scene3DPlanV2,
|
|
36
|
+
} from "./scene3d-v2.js"
|
|
37
|
+
import { SCENE3D_LIMITS, type Scene3DSemanticIssue, type Vec3 } from "./scene3d.js"
|
|
38
|
+
|
|
39
|
+
export const SCENE3D_CAMERA_TRACK_FORMAT = "scene3d-camera-track"
|
|
40
|
+
export const SCENE3D_CAMERA_TRACK_VERSION = 1
|
|
41
|
+
|
|
42
|
+
export const SCENE3D_CAMERA_TRACK_LIMITS = {
|
|
43
|
+
maxJsonBytes: SCENE3D_V2_LIMITS.maxCameraTrackBytes,
|
|
44
|
+
maxFrameCount: SCENE3D_V2_LIMITS.maxDurationInFrames,
|
|
45
|
+
minFps: SCENE3D_V2_LIMITS.minFps,
|
|
46
|
+
maxFps: SCENE3D_V2_LIMITS.maxFps,
|
|
47
|
+
/** A unit quaternion off by more than this is a bug, not float noise. */
|
|
48
|
+
quaternionTolerance: 1e-4,
|
|
49
|
+
/** Absolute tolerance on the projection entries that must be exactly zero
|
|
50
|
+
* (or exactly ∓1) in a perspective matrix. */
|
|
51
|
+
projectionEpsilon: 1e-6,
|
|
52
|
+
/** Relative tolerance when comparing declared near/far against the values the
|
|
53
|
+
* projection matrix implies. */
|
|
54
|
+
nearFarRelativeTolerance: 1e-3,
|
|
55
|
+
/** Relative tolerance on `m[0]/m[5]` vs the manifest's `height/width`. */
|
|
56
|
+
aspectRelativeTolerance: 1e-3,
|
|
57
|
+
minNear: 1e-4,
|
|
58
|
+
maxFar: 1e7,
|
|
59
|
+
} as const
|
|
60
|
+
|
|
61
|
+
export interface Scene3DCameraSample {
|
|
62
|
+
position: Vec3
|
|
63
|
+
/** `[x, y, z, w]` — that order, normalized. */
|
|
64
|
+
quaternion: [number, number, number, number]
|
|
65
|
+
/** Exactly 16 entries, COLUMN-MAJOR (Three.js `Matrix4.elements` order). */
|
|
66
|
+
projectionMatrix: number[]
|
|
67
|
+
near: number
|
|
68
|
+
far: number
|
|
69
|
+
/** Authoring intent, for inspection and validation reporting. A renderer must
|
|
70
|
+
* never feed this back through `lookAt()`. */
|
|
71
|
+
target?: Vec3
|
|
72
|
+
/** Metadata only; the projection matrix wins. */
|
|
73
|
+
focalLengthMm?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Scene3DCameraTrackV1 {
|
|
77
|
+
format: typeof SCENE3D_CAMERA_TRACK_FORMAT
|
|
78
|
+
version: typeof SCENE3D_CAMERA_TRACK_VERSION
|
|
79
|
+
/** Always 0: public frames are zero-based, and the exporter has already
|
|
80
|
+
* subtracted the authoring package's start frame. */
|
|
81
|
+
frameStart: 0
|
|
82
|
+
frameCount: number
|
|
83
|
+
fps: number
|
|
84
|
+
samples: Scene3DCameraSample[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const coordinate = z.number().min(-SCENE3D_LIMITS.maxCoordinate).max(SCENE3D_LIMITS.maxCoordinate)
|
|
88
|
+
const positionSchema = z.tuple([coordinate, coordinate, coordinate])
|
|
89
|
+
|
|
90
|
+
export const scene3DCameraSampleSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
position: positionSchema,
|
|
93
|
+
quaternion: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
|
94
|
+
projectionMatrix: z.array(z.number()).length(16),
|
|
95
|
+
near: z.number().min(SCENE3D_CAMERA_TRACK_LIMITS.minNear).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFar),
|
|
96
|
+
far: z.number().min(SCENE3D_CAMERA_TRACK_LIMITS.minNear).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFar),
|
|
97
|
+
target: positionSchema.optional(),
|
|
98
|
+
focalLengthMm: z
|
|
99
|
+
.number()
|
|
100
|
+
.min(SCENE3D_LIMITS.minFocalLengthMm)
|
|
101
|
+
.max(SCENE3D_LIMITS.maxFocalLengthMm)
|
|
102
|
+
.optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
|
|
106
|
+
/** Structure only; `scene3DCameraTrackIssues` carries the numeric rules. */
|
|
107
|
+
export const scene3DCameraTrackObjectSchema = z
|
|
108
|
+
.object({
|
|
109
|
+
format: z.literal(SCENE3D_CAMERA_TRACK_FORMAT),
|
|
110
|
+
version: z.literal(SCENE3D_CAMERA_TRACK_VERSION),
|
|
111
|
+
frameStart: z.literal(0),
|
|
112
|
+
frameCount: z.number().int().min(1).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFrameCount),
|
|
113
|
+
fps: z.number().int().min(SCENE3D_CAMERA_TRACK_LIMITS.minFps).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFps),
|
|
114
|
+
samples: z.array(scene3DCameraSampleSchema).min(1).max(SCENE3D_CAMERA_TRACK_LIMITS.maxFrameCount),
|
|
115
|
+
})
|
|
116
|
+
.strict()
|
|
117
|
+
|
|
118
|
+
type Issue = Scene3DSemanticIssue
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Is this a real PERSPECTIVE projection, and does it agree with the declared
|
|
122
|
+
* near/far?
|
|
123
|
+
*
|
|
124
|
+
* Column-major layout produced by every Three.js/glTF perspective camera:
|
|
125
|
+
*
|
|
126
|
+
* ```text
|
|
127
|
+
* m0 0 m8 0
|
|
128
|
+
* 0 m5 m9 0
|
|
129
|
+
* 0 0 m10 m14
|
|
130
|
+
* 0 0 -1 0
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* `m8`/`m9` carry lens shift and are free. Everything else is pinned. Inverting
|
|
134
|
+
* the two depth terms recovers `near = m14 / (m10 - 1)` and
|
|
135
|
+
* `far = m14 / (m10 + 1)`, which is how a matrix that quietly disagrees with its
|
|
136
|
+
* own declared clip planes gets caught.
|
|
137
|
+
*
|
|
138
|
+
* Exported because the builder validates its export with the same function the
|
|
139
|
+
* renderer admits it with.
|
|
140
|
+
*/
|
|
141
|
+
export function scene3DProjectionIssues(
|
|
142
|
+
matrix: readonly number[],
|
|
143
|
+
near: number,
|
|
144
|
+
far: number,
|
|
145
|
+
path: (string | number)[],
|
|
146
|
+
): Issue[] {
|
|
147
|
+
const issues: Issue[] = []
|
|
148
|
+
const eps = SCENE3D_CAMERA_TRACK_LIMITS.projectionEpsilon
|
|
149
|
+
|
|
150
|
+
if (matrix.length !== 16) {
|
|
151
|
+
issues.push({ path, message: `projection matrix must have exactly 16 entries (got ${matrix.length})` })
|
|
152
|
+
return issues
|
|
153
|
+
}
|
|
154
|
+
if (matrix.some((value) => !Number.isFinite(value))) {
|
|
155
|
+
issues.push({ path, message: "projection matrix contains a non-finite entry" })
|
|
156
|
+
return issues
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Orthographic is a future explicit capability. Name it, so it is never
|
|
160
|
+
// mis-read as a broken perspective matrix.
|
|
161
|
+
if (Math.abs(matrix[11]) < eps && Math.abs(matrix[15] - 1) < eps) {
|
|
162
|
+
issues.push({
|
|
163
|
+
path,
|
|
164
|
+
message: "projection matrix is orthographic; only perspective cameras are supported by this schema version",
|
|
165
|
+
})
|
|
166
|
+
return issues
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const index of [1, 2, 3, 4, 6, 7, 12, 13, 15]) {
|
|
170
|
+
if (Math.abs(matrix[index]) > eps) {
|
|
171
|
+
issues.push({ path: [...path, index], message: `projection matrix entry ${index} must be 0 (got ${matrix[index]})` })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (Math.abs(matrix[11] + 1) > eps) {
|
|
175
|
+
issues.push({ path: [...path, 11], message: `projection matrix entry 11 must be -1 for a perspective camera (got ${matrix[11]})` })
|
|
176
|
+
}
|
|
177
|
+
if (!(matrix[0] > 0)) {
|
|
178
|
+
issues.push({ path: [...path, 0], message: `projection matrix entry 0 must be positive (got ${matrix[0]})` })
|
|
179
|
+
}
|
|
180
|
+
if (!(matrix[5] > 0)) {
|
|
181
|
+
issues.push({ path: [...path, 5], message: `projection matrix entry 5 must be positive (got ${matrix[5]})` })
|
|
182
|
+
}
|
|
183
|
+
if (!(matrix[10] < 0)) {
|
|
184
|
+
issues.push({ path: [...path, 10], message: `projection matrix entry 10 must be negative (got ${matrix[10]})` })
|
|
185
|
+
}
|
|
186
|
+
if (!(matrix[14] < 0)) {
|
|
187
|
+
issues.push({ path: [...path, 14], message: `projection matrix entry 14 must be negative (got ${matrix[14]})` })
|
|
188
|
+
}
|
|
189
|
+
if (issues.length > 0) return issues
|
|
190
|
+
|
|
191
|
+
if (!(near > 0) || !(far > near)) {
|
|
192
|
+
issues.push({ path, message: `near/far must satisfy 0 < near < far (got near ${near}, far ${far})` })
|
|
193
|
+
return issues
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tolerance = SCENE3D_CAMERA_TRACK_LIMITS.nearFarRelativeTolerance
|
|
197
|
+
const impliedNear = matrix[14] / (matrix[10] - 1)
|
|
198
|
+
if (Math.abs(impliedNear - near) > Math.abs(near) * tolerance) {
|
|
199
|
+
issues.push({
|
|
200
|
+
path,
|
|
201
|
+
message: `projection matrix implies near ${impliedNear.toPrecision(6)}, but the sample declares ${near}`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
const farDenominator = matrix[10] + 1
|
|
205
|
+
if (Math.abs(farDenominator) < eps) {
|
|
206
|
+
issues.push({
|
|
207
|
+
path,
|
|
208
|
+
message: `projection matrix implies an infinite far plane, but the sample declares ${far}`,
|
|
209
|
+
})
|
|
210
|
+
} else {
|
|
211
|
+
const impliedFar = matrix[14] / farDenominator
|
|
212
|
+
if (Math.abs(impliedFar - far) > Math.abs(far) * tolerance) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path,
|
|
215
|
+
message: `projection matrix implies far ${impliedFar.toPrecision(6)}, but the sample declares ${far}`,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return issues
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The numeric rules the schema cannot express: exact sample count, normalized
|
|
225
|
+
* quaternions, and a real perspective projection on every frame.
|
|
226
|
+
*
|
|
227
|
+
* Split out of the schema (as v1 does) so a caller holding a parsed track can
|
|
228
|
+
* re-check it, and so the per-sample walk stays one readable loop over up to
|
|
229
|
+
* 3,600 samples.
|
|
230
|
+
*/
|
|
231
|
+
export function scene3DCameraTrackIssues(track: Scene3DCameraTrackV1): Issue[] {
|
|
232
|
+
const issues: Issue[] = []
|
|
233
|
+
|
|
234
|
+
if (track.samples.length !== track.frameCount) {
|
|
235
|
+
issues.push({
|
|
236
|
+
path: ["samples"],
|
|
237
|
+
message: `track declares ${track.frameCount} frames but carries ${track.samples.length} samples; exactly one sample per frame is required`,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const quaternionTolerance = SCENE3D_CAMERA_TRACK_LIMITS.quaternionTolerance
|
|
242
|
+
track.samples.forEach((sample, index) => {
|
|
243
|
+
const [x, y, z, w] = sample.quaternion
|
|
244
|
+
const norm = Math.sqrt(x * x + y * y + z * z + w * w)
|
|
245
|
+
if (Math.abs(norm - 1) > quaternionTolerance) {
|
|
246
|
+
issues.push({
|
|
247
|
+
path: ["samples", index, "quaternion"],
|
|
248
|
+
message: `quaternion at frame ${index} has length ${norm.toPrecision(6)}; it must be normalized`,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
if (!(sample.far > sample.near)) {
|
|
252
|
+
issues.push({
|
|
253
|
+
path: ["samples", index, "far"],
|
|
254
|
+
message: `frame ${index}: far (${sample.far}) must be greater than near (${sample.near})`,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
for (const issue of scene3DProjectionIssues(
|
|
258
|
+
sample.projectionMatrix,
|
|
259
|
+
sample.near,
|
|
260
|
+
sample.far,
|
|
261
|
+
["samples", index, "projectionMatrix"],
|
|
262
|
+
)) {
|
|
263
|
+
issues.push(issue)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return issues
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** THE camera-track validator: structure, then the numeric rules. */
|
|
271
|
+
export const scene3DCameraTrackSchema = scene3DCameraTrackObjectSchema.superRefine((track, ctx) => {
|
|
272
|
+
for (const issue of scene3DCameraTrackIssues(track as Scene3DCameraTrackV1)) {
|
|
273
|
+
ctx.addIssue({ code: "custom", path: issue.path, message: issue.message })
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
export function isScene3DCameraTrack(value: unknown): value is Scene3DCameraTrackV1 {
|
|
278
|
+
return scene3DCameraTrackSchema.safeParse(value).success
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Track ↔ manifest agreement. A track that is valid on its own can still be the
|
|
283
|
+
* WRONG track for this scene: a different fps, a different length, or a
|
|
284
|
+
* projection baked for another aspect ratio. Each of those silently reframes or
|
|
285
|
+
* retimes every shot, so each is an error here rather than a render-time
|
|
286
|
+
* surprise.
|
|
287
|
+
*/
|
|
288
|
+
export function scene3DCameraTrackPlanIssues(
|
|
289
|
+
track: Scene3DCameraTrackV1,
|
|
290
|
+
plan: Pick<Scene3DPlanV2, "fps" | "durationInFrames" | "width" | "height">,
|
|
291
|
+
): Issue[] {
|
|
292
|
+
const issues: Issue[] = []
|
|
293
|
+
|
|
294
|
+
if (track.fps !== plan.fps) {
|
|
295
|
+
issues.push({
|
|
296
|
+
path: ["fps"],
|
|
297
|
+
message: `camera track is ${track.fps} fps but the scene is ${plan.fps} fps; changing fps requires an explicit resample and a new revision`,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
if (track.frameCount !== plan.durationInFrames) {
|
|
301
|
+
issues.push({
|
|
302
|
+
path: ["frameCount"],
|
|
303
|
+
message: `camera track covers ${track.frameCount} frames but the scene is ${plan.durationInFrames} frames`,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// m[0]/m[5] === (t-b)/(r-l) === height/width for any perspective matrix,
|
|
308
|
+
// including a shifted one (shift moves m[8]/m[9], not the frustum extents).
|
|
309
|
+
const expected = plan.height / plan.width
|
|
310
|
+
const tolerance = SCENE3D_CAMERA_TRACK_LIMITS.aspectRelativeTolerance
|
|
311
|
+
track.samples.forEach((sample, index) => {
|
|
312
|
+
const m0 = sample.projectionMatrix[0]
|
|
313
|
+
const m5 = sample.projectionMatrix[5]
|
|
314
|
+
if (!Number.isFinite(m0) || !Number.isFinite(m5) || m5 === 0) return
|
|
315
|
+
const actual = m0 / m5
|
|
316
|
+
if (Math.abs(actual - expected) > expected * tolerance) {
|
|
317
|
+
issues.push({
|
|
318
|
+
path: ["samples", index, "projectionMatrix"],
|
|
319
|
+
message: `frame ${index}: projection is baked for aspect ${(1 / actual).toPrecision(6)} but the scene renders ${plan.width}×${plan.height}; reprojection requires a new revision`,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
return issues
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The sample for an integer frame. `undefined` outside `[0, frameCount)` — a
|
|
329
|
+
* caller must fail rather than clamp, because a clamped frame is a wrong frame
|
|
330
|
+
* that looks plausible.
|
|
331
|
+
*/
|
|
332
|
+
export function scene3DSampleForFrame(
|
|
333
|
+
track: Scene3DCameraTrackV1,
|
|
334
|
+
frame: number,
|
|
335
|
+
): Scene3DCameraSample | undefined {
|
|
336
|
+
if (!Number.isInteger(frame) || frame < 0 || frame >= track.frameCount) return undefined
|
|
337
|
+
return track.samples[frame]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Size-gate, then parse, then validate. This is the admission path for a
|
|
342
|
+
* downloaded camera track: an 80 MiB "8 MiB" track is refused before
|
|
343
|
+
* `JSON.parse` gets a chance to allocate it.
|
|
344
|
+
*/
|
|
345
|
+
export function parseScene3DCameraTrackJson(text: string): Scene3DParseResult<Scene3DCameraTrackV1> {
|
|
346
|
+
const bytes = scene3DJsonByteLength(text)
|
|
347
|
+
if (bytes > SCENE3D_CAMERA_TRACK_LIMITS.maxJsonBytes) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
issues: [
|
|
351
|
+
{
|
|
352
|
+
path: [],
|
|
353
|
+
message: `camera track is ${bytes} bytes; the limit is ${SCENE3D_CAMERA_TRACK_LIMITS.maxJsonBytes}`,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let decoded: unknown
|
|
359
|
+
try {
|
|
360
|
+
decoded = JSON.parse(text)
|
|
361
|
+
} catch {
|
|
362
|
+
return { ok: false, issues: [{ path: [], message: "camera track is not valid JSON" }] }
|
|
363
|
+
}
|
|
364
|
+
const parsed = scene3DCameraTrackSchema.safeParse(decoded)
|
|
365
|
+
if (!parsed.success) {
|
|
366
|
+
return { ok: false, issues: scene3DZodIssues(parsed.error) }
|
|
367
|
+
}
|
|
368
|
+
return { ok: true, value: parsed.data as Scene3DCameraTrackV1 }
|
|
369
|
+
}
|
|
@@ -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
|
+
scene3DPlanV1Schema,
|
|
26
|
+
scene3DPrimitiveSchema,
|
|
27
|
+
sizeVec3Schema,
|
|
28
|
+
vec3Schema,
|
|
29
|
+
type Scene3DObject,
|
|
30
|
+
type Scene3DPlanV1,
|
|
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: Scene3DPlanV1; 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: Scene3DPlanV1): Scene3DPlanV1 {
|
|
129
|
+
return JSON.parse(JSON.stringify(plan)) as Scene3DPlanV1
|
|
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: Scene3DPlanV1,
|
|
187
|
+
operations: readonly Scene3DEditOperation[] | unknown,
|
|
188
|
+
options: Scene3DEditOptions = {},
|
|
189
|
+
): Scene3DEditResult {
|
|
190
|
+
const parsedPlan = scene3DPlanV1Schema.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 Scene3DPlanV1
|
|
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 = scene3DPlanV1Schema.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 Scene3DPlanV1,
|
|
308
|
+
changedObjectIds: [...changed],
|
|
309
|
+
changeSummary: summarizeScene3DOperations(ops),
|
|
310
|
+
}
|
|
311
|
+
}
|