@nodaro/shared 2.27.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.
@@ -14,14 +14,14 @@ import {
14
14
  SCENE3D_PLAN_TYPE,
15
15
  SCENE3D_SCHEMA_VERSION,
16
16
  applyScene3DEditOperations,
17
- isScene3DPlan,
17
+ isScene3DPlanV1,
18
18
  newScene3DRevisionId,
19
19
  scene3DDeepEqual,
20
20
  scene3DEditOperationSchema,
21
21
  scene3DPlanSchema,
22
22
  summarizeScene3DOperations,
23
23
  type Scene3DObject,
24
- type Scene3DPlan,
24
+ type Scene3DPlanV1,
25
25
  } from "../index.js"
26
26
 
27
27
  const REV_A = "11111111-2222-4333-8444-555555555555"
@@ -41,7 +41,7 @@ function object(id: string, over: Partial<Scene3DObject> = {}): Scene3DObject {
41
41
  }
42
42
  }
43
43
 
44
- function plan(over: Partial<Scene3DPlan> = {}): Scene3DPlan {
44
+ function plan(over: Partial<Scene3DPlanV1> = {}): Scene3DPlanV1 {
45
45
  return {
46
46
  planType: SCENE3D_PLAN_TYPE,
47
47
  schemaVersion: SCENE3D_SCHEMA_VERSION,
@@ -61,7 +61,7 @@ function plan(over: Partial<Scene3DPlan> = {}): Scene3DPlan {
61
61
  describe("scene3DPlanSchema — structure", () => {
62
62
  it("accepts a minimal well-formed plan", () => {
63
63
  expect(scene3DPlanSchema.safeParse(plan()).success).toBe(true)
64
- expect(isScene3DPlan(plan())).toBe(true)
64
+ expect(isScene3DPlanV1(plan())).toBe(true)
65
65
  })
66
66
 
67
67
  it("defaults sensorWidthMm to full frame", () => {
@@ -345,7 +345,7 @@ describe("applyScene3DEditOperations", () => {
345
345
  })
346
346
 
347
347
  it("refuses to remove an object a reference points at", () => {
348
- const referenced: Scene3DPlan = {
348
+ const referenced: Scene3DPlanV1 = {
349
349
  ...plan({ objects: [object("hero"), object("prop")] }),
350
350
  references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "appearance", objectId: "prop" }],
351
351
  }
@@ -378,7 +378,7 @@ describe("applyScene3DEditOperations", () => {
378
378
  })
379
379
 
380
380
  it("rejects an invalid input plan without applying anything", () => {
381
- const result = applyScene3DEditOperations({ ...base, objects: [] } as Scene3DPlan, [
381
+ const result = applyScene3DEditOperations({ ...base, objects: [] } as Scene3DPlanV1, [
382
382
  { op: "set-background", color: "#000000" },
383
383
  ])
384
384
  expect(result.ok).toBe(false)
package/src/index.ts CHANGED
@@ -1057,6 +1057,10 @@ export type { EntityNodeKind } from "./entity-node-fields.js"
1057
1057
  // SDK/MCP surface. Structure only — no prompts, no pricing. ---
1058
1058
  export * from "./scene3d.js"
1059
1059
  export * from "./scene3d-edit.js"
1060
+ export * from "./scene3d-v2.js"
1061
+ export * from "./scene3d-v2-plan.js"
1062
+ export * from "./scene3d-v2-resources.js"
1063
+ export * from "./scene3d-camera-track.js"
1060
1064
 
1061
1065
  // --- transient studio keys — the public share read strips them ---
1062
1066
  export {
@@ -1064,3 +1068,5 @@ export {
1064
1068
  STUDIO_SHOT_TRANSIENT_KEYS,
1065
1069
  stripStudioTransientSettings,
1066
1070
  } from "./studio-transient.js"
1071
+
1072
+ export * from "./scene3d-v2-edit.js"
@@ -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
+ }
@@ -22,12 +22,12 @@ import {
22
22
  scene3DIdSchema,
23
23
  scene3DObjectKeyframeSchema,
24
24
  scene3DObjectSchema,
25
- scene3DPlanSchema,
25
+ scene3DPlanV1Schema,
26
26
  scene3DPrimitiveSchema,
27
27
  sizeVec3Schema,
28
28
  vec3Schema,
29
29
  type Scene3DObject,
30
- type Scene3DPlan,
30
+ type Scene3DPlanV1,
31
31
  } from "./scene3d.js"
32
32
 
33
33
 
@@ -119,14 +119,14 @@ export interface Scene3DEditOptions {
119
119
  }
120
120
 
121
121
  export type Scene3DEditResult =
122
- | { ok: true; plan: Scene3DPlan; changedObjectIds: string[]; changeSummary: string }
122
+ | { ok: true; plan: Scene3DPlanV1; changedObjectIds: string[]; changeSummary: string }
123
123
  | { ok: false; code: Scene3DEditErrorCode; message: string; operationIndex?: number }
124
124
 
125
125
  /** Structural clone that cannot share a reference with its input. `structured-
126
126
  * Clone` is not available in every consumer runtime we ship to, and a plan is
127
127
  * pure JSON by construction. */
128
- function clonePlan(plan: Scene3DPlan): Scene3DPlan {
129
- return JSON.parse(JSON.stringify(plan)) as Scene3DPlan
128
+ function clonePlan(plan: Scene3DPlanV1): Scene3DPlanV1 {
129
+ return JSON.parse(JSON.stringify(plan)) as Scene3DPlanV1
130
130
  }
131
131
 
132
132
  /** One human sentence per operation — the deterministic lane's answer to the
@@ -183,15 +183,15 @@ function firstIssueMessage(error: z.ZodError): string {
183
183
  * at.
184
184
  */
185
185
  export function applyScene3DEditOperations(
186
- plan: Scene3DPlan,
186
+ plan: Scene3DPlanV1,
187
187
  operations: readonly Scene3DEditOperation[] | unknown,
188
188
  options: Scene3DEditOptions = {},
189
189
  ): Scene3DEditResult {
190
- const parsedPlan = scene3DPlanSchema.safeParse(plan)
190
+ const parsedPlan = scene3DPlanV1Schema.safeParse(plan)
191
191
  if (!parsedPlan.success) {
192
192
  return { ok: false, code: "invalid_plan", message: `scenePlan is invalid — ${firstIssueMessage(parsedPlan.error)}` }
193
193
  }
194
- const source = parsedPlan.data as Scene3DPlan
194
+ const source = parsedPlan.data as Scene3DPlanV1
195
195
 
196
196
  if (options.expectedRevisionId !== undefined && options.expectedRevisionId !== source.revisionId) {
197
197
  return {
@@ -293,7 +293,7 @@ export function applyScene3DEditOperations(
293
293
  next.parentRevisionId = source.revisionId
294
294
  next.revisionId = options.revisionId ?? newScene3DRevisionId()
295
295
 
296
- const validated = scene3DPlanSchema.safeParse(next)
296
+ const validated = scene3DPlanV1Schema.safeParse(next)
297
297
  if (!validated.success) {
298
298
  return {
299
299
  ok: false,
@@ -304,7 +304,7 @@ export function applyScene3DEditOperations(
304
304
 
305
305
  return {
306
306
  ok: true,
307
- plan: validated.data as Scene3DPlan,
307
+ plan: validated.data as Scene3DPlanV1,
308
308
  changedObjectIds: [...changed],
309
309
  changeSummary: summarizeScene3DOperations(ops),
310
310
  }
@@ -0,0 +1,156 @@
1
+ /** Immutable, deterministic edits over a baked scene. No asset bytes are mutated. */
2
+ import { z } from "zod"
3
+ import {
4
+ newScene3DRevisionId, rotationVec3Schema, scaleVec3Schema,
5
+ scene3DColorSchema, scene3DIdSchema, vec3Schema,
6
+ } from "./scene3d.js"
7
+ import {
8
+ SCENE3D_V2_OVERRIDE_OPERATION_VERSION, scene3DMaterialRoleSchema,
9
+ type Scene3DEntityCapability, type Scene3DOverride, type Scene3DPlanV2,
10
+ } from "./scene3d-v2.js"
11
+ import { scene3DPlanV2Schema } from "./scene3d-v2-plan.js"
12
+ import { computeScene3DPlanV2ContentHash, verifyScene3DPlanV2ContentHash } from "./scene3d-v2-resources.js"
13
+
14
+ /** Callers describe values. Revision identity and provenance are assigned here. */
15
+ export const scene3DV2OverrideInputSchema = z.discriminatedUnion("kind", [
16
+ z.object({ kind: z.literal("entity-transform"), entityId: scene3DIdSchema,
17
+ space: z.enum(["local", "world"]), position: vec3Schema.optional(),
18
+ rotation: rotationVec3Schema.optional(), scale: scaleVec3Schema.optional(),
19
+ }).strict(),
20
+ z.object({ kind: z.literal("entity-color"), entityId: scene3DIdSchema,
21
+ materialRole: scene3DMaterialRoleSchema, color: scene3DColorSchema,
22
+ }).strict(),
23
+ z.object({ kind: z.literal("entity-visibility"), entityId: scene3DIdSchema, visible: z.boolean() }).strict(),
24
+ z.object({ kind: z.literal("camera-shot-offset"), shotId: scene3DIdSchema,
25
+ positionOffset: vec3Schema.optional(), targetOffset: vec3Schema.optional(),
26
+ }).strict(),
27
+ ])
28
+ export const scene3DV2EditOperationSchema = z.discriminatedUnion("op", [
29
+ z.object({ op: z.literal("set-override"), override: scene3DV2OverrideInputSchema }).strict(),
30
+ z.object({ op: z.literal("remove-override"), overrideId: scene3DIdSchema }).strict(),
31
+ ])
32
+ export const scene3DV2EditOperationsSchema = z.array(scene3DV2EditOperationSchema).min(1).max(100)
33
+ export type Scene3DV2OverrideInput = z.infer<typeof scene3DV2OverrideInputSchema>
34
+ export type Scene3DV2EditOperation = z.infer<typeof scene3DV2EditOperationSchema>
35
+
36
+ export interface Scene3DV2EditOptions {
37
+ expectedRevisionId: string
38
+ expectedContentHash?: string
39
+ lockedObjectIds?: readonly string[]
40
+ /** Hosts may allocate identity at admission for idempotent job replay. */
41
+ newRevisionId?: string
42
+ }
43
+ export type Scene3DV2EditResult =
44
+ | { ok: true; plan: Scene3DPlanV2; changeSummary: string }
45
+ | { ok: false; code: "invalid_plan" | "invalid_operations" | "stale_revision" | "locked"; message: string }
46
+
47
+ function channel(override: Scene3DV2OverrideInput): string {
48
+ switch (override.kind) {
49
+ case "entity-transform": return `transform:${override.entityId}`
50
+ case "entity-color": return `color:${override.entityId}:${override.materialRole}`
51
+ case "entity-visibility": return `visibility:${override.entityId}`
52
+ case "camera-shot-offset": return `camera:${override.shotId}`
53
+ }
54
+ }
55
+ function capability(override: Scene3DV2OverrideInput): Scene3DEntityCapability | null {
56
+ switch (override.kind) {
57
+ case "entity-transform": return "transform"
58
+ case "entity-color": return "color"
59
+ case "entity-visibility": return "visibility"
60
+ case "camera-shot-offset": return null
61
+ }
62
+ }
63
+
64
+ /** A parent's transform or visibility changes its descendants too. */
65
+ function lockIssue(plan: Scene3DPlanV2, override: Scene3DV2OverrideInput, externalLocks: ReadonlySet<string>): string | null {
66
+ if (override.kind === "camera-shot-offset") return null
67
+ const target = plan.objects.find((o) => o.id === override.entityId)
68
+ if (!target) return `Unknown entity: ${override.entityId}`
69
+ const cap = capability(override)!
70
+ if (target.capabilities && !target.capabilities.includes(cap)) return `Entity ${target.id} does not allow ${cap} edits`
71
+ if (externalLocks.has(target.id) || target.locks?.includes(cap)) return `Entity ${target.id} is locked for ${cap}`
72
+ if (cap !== "transform" && cap !== "visibility") return null
73
+ const byId = new Map(plan.objects.map((entity) => [entity.id, entity]))
74
+ for (const entity of plan.objects) {
75
+ if (!externalLocks.has(entity.id) && !entity.locks?.includes(cap)) continue
76
+ let parent = entity.parentId
77
+ while (parent) {
78
+ if (parent === target.id) return `Changing ${target.id} would change locked descendant ${entity.id}`
79
+ parent = byId.get(parent)?.parentId
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Edits are all-or-nothing. The expected revision and content hash are checked
87
+ * before writing, and the result is validated and hashed before acceptance.
88
+ * A source file belongs to its exact base revision: until the host materializes
89
+ * these edits, the new revision must not advertise the old native download.
90
+ */
91
+ export async function applyScene3DV2EditOperations(
92
+ input: Scene3DPlanV2,
93
+ operations: readonly Scene3DV2EditOperation[],
94
+ options: Scene3DV2EditOptions,
95
+ ): Promise<Scene3DV2EditResult> {
96
+ const parsed = scene3DPlanV2Schema.safeParse(input)
97
+ if (!parsed.success) return { ok: false, code: "invalid_plan", message: parsed.error.issues[0]?.message ?? "Invalid scene" }
98
+ const base = parsed.data as Scene3DPlanV2
99
+ if (base.revisionId !== options.expectedRevisionId ||
100
+ (options.expectedContentHash !== undefined && base.provenance.contentHash !== options.expectedContentHash)) {
101
+ return { ok: false, code: "stale_revision", message: "The scene changed since this edit was prepared" }
102
+ }
103
+ if (!await verifyScene3DPlanV2ContentHash(base)) {
104
+ return { ok: false, code: "invalid_plan", message: "The scene content does not match its digest" }
105
+ }
106
+ const ops = scene3DV2EditOperationsSchema.safeParse(operations)
107
+ if (!ops.success) return { ok: false, code: "invalid_operations", message: ops.error.issues[0]?.message ?? "Invalid edit" }
108
+ const revisionId = options.newRevisionId ?? newScene3DRevisionId()
109
+ if (revisionId === base.revisionId) return { ok: false, code: "invalid_operations", message: "An edit requires a new revision identity" }
110
+ const externalLocks = new Set(options.lockedObjectIds ?? [])
111
+ for (const id of externalLocks) {
112
+ if (!base.objects.some((entity) => entity.id === id)) return { ok: false, code: "invalid_operations", message: `Unknown locked entity: ${id}` }
113
+ }
114
+ let overrides = [...(base.overrides ?? [])]
115
+ for (const [index, operation] of ops.data.entries()) {
116
+ if (operation.op === "remove-override") {
117
+ const existing = overrides.find((override) => override.id === operation.overrideId)
118
+ if (!existing) return { ok: false, code: "invalid_operations", message: `Unknown override: ${operation.overrideId}` }
119
+ const issue = lockIssue(base, existing, externalLocks)
120
+ if (issue) return { ok: false, code: "locked", message: issue }
121
+ overrides = overrides.filter((override) => override.id !== existing.id)
122
+ continue
123
+ }
124
+ const issue = lockIssue(base, operation.override, externalLocks)
125
+ if (issue) return { ok: false, code: "locked", message: issue }
126
+ const previous = overrides.find((override) => channel(override) === channel(operation.override))
127
+ const compatible = previous && (previous.kind !== "entity-transform" ||
128
+ (operation.override.kind === "entity-transform" && previous.space === operation.override.space))
129
+ const next: Scene3DOverride = {
130
+ ...(compatible ? previous : {}),
131
+ ...operation.override,
132
+ id: `edit-${revisionId}-${index}`,
133
+ sourceRevisionId: base.revisionId,
134
+ sourceContentHash: base.provenance.contentHash,
135
+ operationVersion: SCENE3D_V2_OVERRIDE_OPERATION_VERSION,
136
+ }
137
+ const key = channel(next)
138
+ overrides = [...overrides.filter((override) => channel(override) !== key), next]
139
+ }
140
+ const { sourceArtifactId: _sourceArtifactId, ...provenance } = base.provenance
141
+ const next: Scene3DPlanV2 = {
142
+ ...base, revisionId, parentRevisionId: base.revisionId,
143
+ // Geometry and cameras are reused; derived images, validation and native
144
+ // exports describe the old revision until regenerated for these overlays.
145
+ assets: base.assets.filter((asset) => asset.kind === "glb" || asset.kind === "camera-track-json").map((asset) => ({
146
+ ...asset, originRevisionId: asset.originRevisionId ?? base.revisionId,
147
+ })),
148
+ overrides,
149
+ provenance: { ...provenance, sourceRevisionId: base.revisionId },
150
+ }
151
+ const validated = scene3DPlanV2Schema.safeParse(next)
152
+ if (!validated.success) return { ok: false, code: "invalid_operations", message: validated.error.issues[0]?.message ?? "Invalid edited scene" }
153
+ const plan = validated.data as Scene3DPlanV2
154
+ const contentHash = await computeScene3DPlanV2ContentHash(plan)
155
+ return { ok: true, plan: { ...plan, provenance: { ...plan.provenance, contentHash } }, changeSummary: `Applied ${ops.data.length} scene edit${ops.data.length === 1 ? "" : "s"}` }
156
+ }