@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.
@@ -0,0 +1,666 @@
1
+ /**
2
+ * Scene3D v2 — the PUBLIC wire contract for exported (GLB-backed) scenes.
3
+ *
4
+ * One Scene3D family, two schema versions. `Scene3DPlan` is the discriminated
5
+ * union `Scene3DPlanV1 | Scene3DPlanV2`, keyed on `schemaVersion`; v1 (see
6
+ * `scene3d.ts`) is untouched — same fields, same bounds, same messages — and a
7
+ * default v1 authoring request must never come back as v2.
8
+ *
9
+ * What v2 adds over v1's inline primitives:
10
+ *
11
+ * - **Assets.** Geometry lives in GLB files referenced by opaque id + SHA-256
12
+ * digest, never by an expiring URL. The camera lives in a sidecar
13
+ * (`scene3d-camera-track.ts`), one sample per frame, so a 720-frame baked
14
+ * move is not squeezed through v1's 240-keyframe budget.
15
+ * - **Semantic entities.** A user-selectable object or assembly — a person, a
16
+ * car, a prop, an environment — not every mesh in the export. Entities carry
17
+ * stable ids, anchors, identity colour and material-role bindings, so
18
+ * recolouring a person cannot recolour its chair.
19
+ * - **Shots.** Explicit contiguous integer ranges covering the whole timeline,
20
+ * with hard cuts. Interpolation never spans a cut.
21
+ * - **Overlays.** Deterministic entity/camera overrides applied over immutable
22
+ * baked bytes, in a fixed order, so a rebuild cannot silently drop a manual
23
+ * edit.
24
+ * - **Provenance.** Which engine/compiler/exporter/renderer produced this, and
25
+ * the canonical content hash of the revision.
26
+ *
27
+ * This file is STRUCTURE ONLY: shapes, bounds, cross-references. How a scene is
28
+ * authored, how a camera move is solved and what any of it costs are not part
29
+ * of the published contract and are not here.
30
+ *
31
+ * It owns the VOCABULARY — constants, types and per-component schemas. The
32
+ * whole-plan schema and every cross-field rule live in `scene3d-v2-plan.ts`,
33
+ * which builds on this; the dense camera sidecar lives in
34
+ * `scene3d-camera-track.ts`. Consumers import all three from the package root.
35
+ *
36
+ * ## World conventions (identical to v1, and now stated on the wire)
37
+ *
38
+ * Meters, Y up, right-handed, zero-based frames. `units`/`upAxis`/`handedness`
39
+ * are required literals so a reader can refuse a manifest that assumes anything
40
+ * else instead of quietly rendering a Z-up scene on its side. Conversion from
41
+ * the authoring package's basis happens exactly once, at export: a GLB that is
42
+ * already Y-up must not be rotated again, and the renderer must not replace an
43
+ * exported camera quaternion with a `lookAt()`.
44
+ */
45
+ import {
46
+ SCENE3D_LIMITS,
47
+ SCENE3D_PLAN_TYPE,
48
+ rotationVec3Schema,
49
+ scaleVec3Schema,
50
+ scene3DColorSchema,
51
+ scene3DIdSchema,
52
+ scene3DReferenceSchema,
53
+ sizeVec3Schema,
54
+ vec3Schema,
55
+ type Scene3DPrimitive,
56
+ type Scene3DReference,
57
+ type Scene3DSemanticIssue,
58
+ type Vec3,
59
+ } from "./scene3d.js"
60
+ import { z } from "zod"
61
+
62
+ /** The v2 discriminator value. v1's `SCENE3D_SCHEMA_VERSION` is unchanged. */
63
+ export const SCENE3D_SCHEMA_VERSION_V2 = 2
64
+
65
+ /** Every version this package can parse AND validate. An SDK consumer checks a
66
+ * plan against this BEFORE trying to render one it cannot understand. */
67
+ export const SCENE3D_SUPPORTED_SCHEMA_VERSIONS = [1, 2] as const
68
+ export type Scene3DSupportedSchemaVersion = (typeof SCENE3D_SUPPORTED_SCHEMA_VERSIONS)[number]
69
+
70
+ /** Authoring engines a v2 manifest may name. Open-ended on the wire (the field
71
+ * is a bounded slug) so a new engine does not need a package release; this
72
+ * list is what the current platform ships. */
73
+ export const SCENE3D_V2_ENGINES = ["blender-cloud", "blender-local"] as const
74
+ export type Scene3DKnownEngine = (typeof SCENE3D_V2_ENGINES)[number]
75
+
76
+ /**
77
+ * Admission bounds for v2. Server-configured ceilings are returned in
78
+ * capabilities; these are the contract's hard maxima, quoted by the route Zod,
79
+ * the builder, the renderer and the docs so they cannot drift apart.
80
+ *
81
+ * v1's `SCENE3D_LIMITS` is NOT changed by any of this — "raise maxObjects" was
82
+ * never the v2 design.
83
+ */
84
+ export const SCENE3D_V2_LIMITS = {
85
+ /** Both the seconds and the frame ceiling apply; neither waives the other. */
86
+ maxDurationSeconds: 60,
87
+ minDurationInFrames: 1,
88
+ maxDurationInFrames: 3600,
89
+ minFps: 15,
90
+ maxFps: 60,
91
+ defaultFps: 24,
92
+ /** Even integers only — an odd axis breaks H.264 chroma subsampling. */
93
+ minDimensionPx: 100,
94
+ maxDimensionPx: 1920,
95
+ minEntities: 1,
96
+ /** SEMANTIC entities, not exported mesh nodes. */
97
+ maxEntities: 100,
98
+ /** Enforced during asset normalization, after decode — see
99
+ * `scene3DV2NormalizationIssues` in `scene3d-v2-resources.ts`. */
100
+ maxMeshNodes: 2000,
101
+ maxTriangles: 200_000,
102
+ maxHierarchyDepth: 16,
103
+ /** Decoded manifest JSON. Measured on the bytes, before `JSON.parse`. */
104
+ maxManifestBytes: 512 * 1024,
105
+ /** Decoded camera-track JSON. */
106
+ maxCameraTrackBytes: 8 * 1024 * 1024,
107
+ /** Total DECLARED bytes of the assets the renderer downloads. Compression
108
+ * does not waive the decoded geometry limits above. */
109
+ maxRendererAssetBytes: 64 * 1024 * 1024,
110
+ /** A `blend-source` is a separately authorized download, never handed to the
111
+ * browser renderer, and therefore not part of the renderer budget. */
112
+ maxBlendSourceBytes: 512 * 1024 * 1024,
113
+ maxAssets: 64,
114
+ maxShots: 32,
115
+ maxShotEntityIds: 16,
116
+ /** v1's reference limit, unchanged until deliberately expanded. */
117
+ maxReferences: SCENE3D_LIMITS.maxReferences,
118
+ maxAnchorsPerEntity: 32,
119
+ maxMaterialBindingsPerEntity: 16,
120
+ maxOverrides: 200,
121
+ minPosterDimensionPx: 16,
122
+ maxPosterDimensionPx: 4096,
123
+ maxIdLength: SCENE3D_LIMITS.maxIdLength,
124
+ maxAssetIdLength: 128,
125
+ maxNodeIdLength: 128,
126
+ maxNameLength: SCENE3D_LIMITS.maxNameLength,
127
+ maxLabelLength: SCENE3D_LIMITS.maxNameLength,
128
+ maxMaterialNameLength: 120,
129
+ maxVersionLength: 64,
130
+ maxCoordinate: SCENE3D_LIMITS.maxCoordinate,
131
+ minSize: SCENE3D_LIMITS.minSize,
132
+ maxSize: SCENE3D_LIMITS.maxSize,
133
+ maxIntensity: SCENE3D_LIMITS.maxIntensity,
134
+ } as const
135
+
136
+ /** The overlay operation vocabulary this package understands. An override
137
+ * written by a NEWER writer is rejected with an explicit message rather than
138
+ * silently skipped — a dropped edit is worse than a refused manifest. */
139
+ export const SCENE3D_V2_OVERRIDE_OPERATION_VERSION = 1
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // GLB metadata allowlist
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * The GLB `extras` keys the exporter writes and the importer reads. Blender
147
+ * display names and array indices are NOT durable identifiers: a re-export
148
+ * renames `Cube.003` and reorders children, and a hit-test would then select a
149
+ * different entity. Both ends import these constants — never the literals.
150
+ */
151
+ export const SCENE3D_GLB_EXTRAS_ENTITY_ID = "nodaroEntityId"
152
+ export const SCENE3D_GLB_EXTRAS_SUBPART_ID = "nodaroSubpartId"
153
+ export const SCENE3D_GLB_EXTRAS_MATERIAL_ROLE = "nodaroMaterialRole"
154
+
155
+ /** Nothing outside this set is read from `extras`; an importer ignores the rest
156
+ * rather than trusting arbitrary exporter metadata. */
157
+ export const SCENE3D_GLB_EXTRAS_ALLOWLIST = [
158
+ SCENE3D_GLB_EXTRAS_ENTITY_ID,
159
+ SCENE3D_GLB_EXTRAS_SUBPART_ID,
160
+ SCENE3D_GLB_EXTRAS_MATERIAL_ROLE,
161
+ ] as const
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Vocabularies
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** What an entity IS, for selection, grouping and validation reporting. It is
168
+ * advisory: no rule anywhere requires a head on a car or a wheel on a person. */
169
+ export type Scene3DEntityRole = "person" | "vehicle" | "prop" | "environment" | "other"
170
+ export const SCENE3D_ENTITY_ROLES: readonly Scene3DEntityRole[] = [
171
+ "person",
172
+ "vehicle",
173
+ "prop",
174
+ "environment",
175
+ "other",
176
+ ]
177
+
178
+ /** The v1 primitive vocabulary MINUS `group` — grouping is `visual.kind:"group"`
179
+ * in v2, so there is exactly one way to say "no geometry". */
180
+ export type Scene3DV2Primitive = Exclude<Scene3DPrimitive, "group">
181
+ export const SCENE3D_V2_PRIMITIVES: readonly Scene3DV2Primitive[] = [
182
+ "box",
183
+ "sphere",
184
+ "cylinder",
185
+ "cone",
186
+ "plane",
187
+ "capsule",
188
+ ]
189
+
190
+ /** Deterministic overlays an entity accepts. Geometry and pose are deliberately
191
+ * absent: those rebuild through the authoring engine, they are not overlays. */
192
+ export type Scene3DEntityCapability = "transform" | "color" | "visibility"
193
+ export const SCENE3D_ENTITY_CAPABILITIES: readonly Scene3DEntityCapability[] = [
194
+ "transform",
195
+ "color",
196
+ "visibility",
197
+ ]
198
+
199
+ /** What an entity accepts when it does not say. */
200
+ export const SCENE3D_DEFAULT_ENTITY_CAPABILITIES: readonly Scene3DEntityCapability[] =
201
+ SCENE3D_ENTITY_CAPABILITIES
202
+
203
+ export type Scene3DAssetKind = "glb" | "camera-track-json" | "poster" | "validation-report" | "blend-source"
204
+ export const SCENE3D_ASSET_KINDS: readonly Scene3DAssetKind[] = [
205
+ "glb",
206
+ "camera-track-json",
207
+ "poster",
208
+ "validation-report",
209
+ "blend-source",
210
+ ]
211
+
212
+ export type Scene3DAssetRole =
213
+ | "scene-geometry"
214
+ | "entity-geometry"
215
+ | "camera-track"
216
+ | "poster"
217
+ | "validation-report"
218
+ | "source"
219
+ export const SCENE3D_ASSET_ROLES: readonly Scene3DAssetRole[] = [
220
+ "scene-geometry",
221
+ "entity-geometry",
222
+ "camera-track",
223
+ "poster",
224
+ "validation-report",
225
+ "source",
226
+ ]
227
+
228
+ /** Which kinds may carry which role. A role is not decoration — it is what lets
229
+ * a resolver decide whether bytes go to the renderer, the UI or an authorized
230
+ * download, without sniffing the file. */
231
+ export const SCENE3D_ASSET_ROLE_KINDS: Readonly<Record<Scene3DAssetRole, Scene3DAssetKind>> = {
232
+ "scene-geometry": "glb",
233
+ "entity-geometry": "glb",
234
+ "camera-track": "camera-track-json",
235
+ poster: "poster",
236
+ "validation-report": "validation-report",
237
+ source: "blend-source",
238
+ }
239
+
240
+ /** The kinds the BROWSER downloads. `blend-source` is never in this set: it is
241
+ * a separately authorized download and it does not spend the renderer budget. */
242
+ export const SCENE3D_RENDERER_ASSET_KINDS: readonly Scene3DAssetKind[] = [
243
+ "glb",
244
+ "camera-track-json",
245
+ "poster",
246
+ ]
247
+
248
+ /** The one material role a `primitive` entity has: its own `color`. */
249
+ export const SCENE3D_PRIMITIVE_MATERIAL_ROLE = "identity"
250
+
251
+ /** The standardized clay look, pinned by id. Browser preview, critic stills and
252
+ * the final export must implement a given preset identically. */
253
+ export const SCENE3D_CLAY_LIGHTING_PRESETS = ["clay-studio-v1"] as const
254
+ export type Scene3DClayLightingPreset = (typeof SCENE3D_CLAY_LIGHTING_PRESETS)[number]
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Types
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** A stable contact/selection location in entity-local space. Names are free
261
+ * structural labels (`face`, `seat`, `wheel.frontLeft`, `roof`, `lookAt`) —
262
+ * human anatomy is never required. */
263
+ export interface Scene3DAnchor {
264
+ name: string
265
+ position: Vec3
266
+ /** Euler XYZ radians. Absent = identity orientation. */
267
+ rotation?: Vec3
268
+ }
269
+
270
+ /** Binds an editable colour ROLE to a real material inside the entity's own
271
+ * asset root. Recolouring `bodyPaint` on a car must not touch its tires, and
272
+ * cannot reach a material that belongs to a different entity. */
273
+ export interface Scene3DMaterialBinding {
274
+ role: string
275
+ materialName: string
276
+ /** Baked colour for the role, sRGB opaque hex. */
277
+ color?: string
278
+ roughness?: number
279
+ }
280
+
281
+ /** Maps a clip inside the referenced GLB onto public frames. Sampling is
282
+ * `time = (frame - startFrame) / fps` — never a wall-clock mixer delta, or
283
+ * scrubbing backwards would not reproduce the rendered frame. */
284
+ export interface Scene3DAssetAnimation {
285
+ clipName: string
286
+ startFrame: number
287
+ endFrameExclusive: number
288
+ /** Absent/false = hold the last sample after the window. */
289
+ loop?: boolean
290
+ }
291
+
292
+ export type Scene3DEntityVisual =
293
+ /** Organizational identity: a transform and a name, no geometry of its own. */
294
+ | { kind: "group" }
295
+ /** The v1 primitive vocabulary and validated dimensions. */
296
+ | { kind: "primitive"; primitive: Scene3DV2Primitive; dimensions: Vec3; color: string }
297
+ /** An authorized GLB plus the exported node that roots this entity. */
298
+ | { kind: "asset"; assetId: string; rootNodeId: string; animation?: Scene3DAssetAnimation }
299
+
300
+ export interface Scene3DEntityV2 {
301
+ id: string
302
+ name: string
303
+ /** Transform parent, another entity. Cycles are rejected. */
304
+ parentId?: string
305
+ role?: Scene3DEntityRole
306
+ /**
307
+ * Local base transform. REQUIRED for `group` and `primitive`.
308
+ *
309
+ * OPTIONAL for `asset`, where the GLB node's own transform is authoritative:
310
+ * a value here is an informational frame-0 snapshot and the renderer must NOT
311
+ * apply it on top of the node transform. Applying both is the
312
+ * double-transform bug that puts a car at twice its offset.
313
+ */
314
+ position?: Vec3
315
+ rotation?: Vec3
316
+ scale?: Vec3
317
+ /** The selection/identity chip colour. Opaque hex, sRGB. */
318
+ identityColor?: string
319
+ anchors?: Scene3DAnchor[]
320
+ /** Deterministic overlays this entity accepts. Absent = all of them. */
321
+ capabilities?: Scene3DEntityCapability[]
322
+ /** Currently frozen subset. An overlay is accepted iff its capability is
323
+ * advertised AND not locked. */
324
+ locks?: Scene3DEntityCapability[]
325
+ /** `asset` entities only. */
326
+ materialBindings?: Scene3DMaterialBinding[]
327
+ visual: Scene3DEntityVisual
328
+ }
329
+
330
+ /** A reference to immutable bytes. IDs and digests are persisted; short-lived
331
+ * transport URLs are issued by the authenticated resolver and never stored. */
332
+ export interface Scene3DAssetRef {
333
+ assetId: string
334
+ kind: Scene3DAssetKind
335
+ role: Scene3DAssetRole
336
+ byteLength: number
337
+ /** Lowercase hex SHA-256 of the bytes. */
338
+ sha256: string
339
+ /** Set when this revision reuses an earlier revision's immutable bytes. */
340
+ originRevisionId?: string
341
+ }
342
+
343
+ /** A contiguous half-open frame range `[startFrame, endFrameExclusive)`. */
344
+ export interface Scene3DShot {
345
+ id: string
346
+ startFrame: number
347
+ endFrameExclusive: number
348
+ label?: string
349
+ /** Who the shot is ABOUT — used by validation reporting and the UI. */
350
+ subjectEntityIds?: string[]
351
+ /** Who is deliberately in front of the lens (an over-the-shoulder anchor). */
352
+ foregroundEntityIds?: string[]
353
+ }
354
+
355
+ export interface Scene3DClayLighting {
356
+ preset: Scene3DClayLightingPreset
357
+ ambientIntensity: number
358
+ keyIntensity: number
359
+ keyPosition: Vec3
360
+ }
361
+
362
+ /** Which space a constant transform override is expressed in. Declared, so the
363
+ * renderer never multiplies the same parent transform in twice. */
364
+ export type Scene3DOverrideSpace = "local" | "world"
365
+
366
+ interface Scene3DOverrideProvenance {
367
+ id: string
368
+ /** The revision this override was authored against. */
369
+ sourceRevisionId: string
370
+ /** That revision's canonical content hash when the override was authored. */
371
+ sourceContentHash: string
372
+ operationVersion: number
373
+ }
374
+
375
+ export type Scene3DOverride = Scene3DOverrideProvenance &
376
+ (
377
+ | { kind: "entity-transform"; entityId: string; space: Scene3DOverrideSpace; position?: Vec3; rotation?: Vec3; scale?: Vec3 }
378
+ | { kind: "entity-color"; entityId: string; materialRole: string; color: string }
379
+ | { kind: "entity-visibility"; entityId: string; visible: boolean }
380
+ | { kind: "camera-shot-offset"; shotId: string; positionOffset?: Vec3; targetOffset?: Vec3 }
381
+ )
382
+
383
+ /**
384
+ * Who built this revision and from what. Source versioning and renderer
385
+ * versioning are independent — a renderer upgrade does not invalidate a scene.
386
+ *
387
+ * Every string here is a bounded slug, which is a structural guarantee that no
388
+ * native path or block of prose fits in one. Scrubbing credentials
389
+ * out of the values it does accept remains the producer's duty.
390
+ */
391
+ export interface Scene3DProvenance {
392
+ engine: string
393
+ engineVersion: string
394
+ recipeVersion: string
395
+ compilerVersion: string
396
+ exporterVersion: string
397
+ rendererVersion: string
398
+ sourceRevisionId?: string
399
+ /** The retained `blend-source` asset, when one was kept. */
400
+ sourceArtifactId?: string
401
+ /** Canonical content hash of this revision — see `scene3d-v2-resources.ts`. */
402
+ contentHash: string
403
+ }
404
+
405
+ export interface Scene3DPlanV2 {
406
+ planType: typeof SCENE3D_PLAN_TYPE
407
+ schemaVersion: typeof SCENE3D_SCHEMA_VERSION_V2
408
+ revisionId: string
409
+ parentRevisionId?: string
410
+ width: number
411
+ height: number
412
+ fps: number
413
+ durationInFrames: number
414
+ units: "meters"
415
+ upAxis: "Y"
416
+ handedness: "right"
417
+ objects: Scene3DEntityV2[]
418
+ assets: Scene3DAssetRef[]
419
+ cameraTrackAssetId: string
420
+ shots: Scene3DShot[]
421
+ lighting: Scene3DClayLighting
422
+ backgroundColor: string
423
+ references?: Scene3DReference[]
424
+ overrides?: Scene3DOverride[]
425
+ provenance: Scene3DProvenance
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Primitive schemas
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /** Opaque storage id. Deliberately slash-free: an asset id is an ID, resolved
433
+ * server-side against ownership — never a path and never a URL. */
434
+ export const scene3DAssetIdSchema = z
435
+ .string()
436
+ .min(1)
437
+ .max(SCENE3D_V2_LIMITS.maxAssetIdLength)
438
+ .regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/, "assetId must be an opaque id (letters, digits, '_', '-', '.', ':')")
439
+ .refine((value) => !value.includes(".."), "assetId must not contain '..'")
440
+
441
+ /** A node name inside an exported GLB. */
442
+ export const scene3DNodeIdSchema = z
443
+ .string()
444
+ .min(1)
445
+ .max(SCENE3D_V2_LIMITS.maxNodeIdLength)
446
+ .regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]*$/, "node id must be an exporter-generated stable id")
447
+
448
+ export const scene3DSha256Schema = z
449
+ .string()
450
+ .regex(/^[0-9a-f]{64}$/, "sha256 must be 64 lowercase hex characters")
451
+
452
+ /**
453
+ * A version/engine token. The charset excludes `/`, `\`, `:` and whitespace, so
454
+ * a native filesystem path cannot be spelled as one; the 64-character cap
455
+ * excludes prose.
456
+ */
457
+ export const scene3DVersionTokenSchema = z
458
+ .string()
459
+ .min(1)
460
+ .max(SCENE3D_V2_LIMITS.maxVersionLength)
461
+ .regex(
462
+ /^[A-Za-z0-9][A-Za-z0-9_.+-]*$/,
463
+ "version must be a bounded token (letters, digits, '_', '-', '.', '+') — never a path or prose",
464
+ )
465
+
466
+ export const scene3DEngineIdSchema = z
467
+ .string()
468
+ .min(1)
469
+ .max(SCENE3D_V2_LIMITS.maxVersionLength)
470
+ .regex(/^[a-z0-9][a-z0-9-]*$/, "engine must be a lowercase slug such as blender-cloud")
471
+
472
+ /** Anchor names and material roles share the id charset (dots allowed, so
473
+ * `wheel.frontLeft` is one name and not a path). */
474
+ export const scene3DAnchorNameSchema = scene3DIdSchema
475
+ export const scene3DMaterialRoleSchema = scene3DIdSchema
476
+
477
+ export const scene3DMaterialNameSchema = z
478
+ .string()
479
+ .min(1)
480
+ .max(SCENE3D_V2_LIMITS.maxMaterialNameLength)
481
+ // A GLB material name is authored text, so the charset is wide — but control
482
+ // characters are never legitimate and are how a log line gets forged.
483
+ .regex(/^[^\u0000-\u001f\u007f]+$/, "material name must not contain control characters")
484
+
485
+ const v2FrameSchema = z.number().int().min(0).max(SCENE3D_V2_LIMITS.maxDurationInFrames)
486
+
487
+ export const scene3DEntityCapabilitySchema = z.enum(["transform", "color", "visibility"])
488
+
489
+ export const scene3DAnchorSchema = z
490
+ .object({
491
+ name: scene3DAnchorNameSchema,
492
+ position: vec3Schema,
493
+ rotation: rotationVec3Schema.optional(),
494
+ })
495
+ .strict()
496
+
497
+ export const scene3DMaterialBindingSchema = z
498
+ .object({
499
+ role: scene3DMaterialRoleSchema,
500
+ materialName: scene3DMaterialNameSchema,
501
+ color: scene3DColorSchema.optional(),
502
+ roughness: z.number().min(0).max(1).optional(),
503
+ })
504
+ .strict()
505
+
506
+ export const scene3DAssetAnimationSchema = z
507
+ .object({
508
+ clipName: z.string().min(1).max(SCENE3D_V2_LIMITS.maxNameLength),
509
+ startFrame: v2FrameSchema,
510
+ endFrameExclusive: z.number().int().min(1).max(SCENE3D_V2_LIMITS.maxDurationInFrames),
511
+ loop: z.boolean().optional(),
512
+ })
513
+ .strict()
514
+
515
+ export const scene3DEntityVisualSchema = z.discriminatedUnion("kind", [
516
+ z.object({ kind: z.literal("group") }).strict(),
517
+ z
518
+ .object({
519
+ kind: z.literal("primitive"),
520
+ primitive: z.enum(["box", "sphere", "cylinder", "cone", "plane", "capsule"]),
521
+ dimensions: sizeVec3Schema,
522
+ color: scene3DColorSchema,
523
+ })
524
+ .strict(),
525
+ z
526
+ .object({
527
+ kind: z.literal("asset"),
528
+ assetId: scene3DAssetIdSchema,
529
+ rootNodeId: scene3DNodeIdSchema,
530
+ animation: scene3DAssetAnimationSchema.optional(),
531
+ })
532
+ .strict(),
533
+ ])
534
+
535
+ export const scene3DEntityV2Schema = z
536
+ .object({
537
+ id: scene3DIdSchema,
538
+ name: z.string().min(1).max(SCENE3D_V2_LIMITS.maxNameLength),
539
+ parentId: scene3DIdSchema.optional(),
540
+ role: z.enum(["person", "vehicle", "prop", "environment", "other"]).optional(),
541
+ position: vec3Schema.optional(),
542
+ rotation: rotationVec3Schema.optional(),
543
+ scale: scaleVec3Schema.optional(),
544
+ identityColor: scene3DColorSchema.optional(),
545
+ anchors: z.array(scene3DAnchorSchema).max(SCENE3D_V2_LIMITS.maxAnchorsPerEntity).optional(),
546
+ capabilities: z.array(scene3DEntityCapabilitySchema).max(SCENE3D_ENTITY_CAPABILITIES.length).optional(),
547
+ locks: z.array(scene3DEntityCapabilitySchema).max(SCENE3D_ENTITY_CAPABILITIES.length).optional(),
548
+ materialBindings: z
549
+ .array(scene3DMaterialBindingSchema)
550
+ .max(SCENE3D_V2_LIMITS.maxMaterialBindingsPerEntity)
551
+ .optional(),
552
+ visual: scene3DEntityVisualSchema,
553
+ })
554
+ .strict()
555
+
556
+ export const scene3DAssetRefSchema = z
557
+ .object({
558
+ assetId: scene3DAssetIdSchema,
559
+ kind: z.enum(["glb", "camera-track-json", "poster", "validation-report", "blend-source"]),
560
+ role: z.enum(["scene-geometry", "entity-geometry", "camera-track", "poster", "validation-report", "source"]),
561
+ byteLength: z.number().int().min(1).max(SCENE3D_V2_LIMITS.maxBlendSourceBytes),
562
+ sha256: scene3DSha256Schema,
563
+ originRevisionId: z.uuid().optional(),
564
+ })
565
+ .strict()
566
+
567
+ export const scene3DShotSchema = z
568
+ .object({
569
+ id: scene3DIdSchema,
570
+ startFrame: v2FrameSchema,
571
+ endFrameExclusive: z.number().int().min(1).max(SCENE3D_V2_LIMITS.maxDurationInFrames),
572
+ label: z.string().min(1).max(SCENE3D_V2_LIMITS.maxLabelLength).optional(),
573
+ subjectEntityIds: z.array(scene3DIdSchema).max(SCENE3D_V2_LIMITS.maxShotEntityIds).optional(),
574
+ foregroundEntityIds: z.array(scene3DIdSchema).max(SCENE3D_V2_LIMITS.maxShotEntityIds).optional(),
575
+ })
576
+ .strict()
577
+
578
+ export const scene3DClayLightingSchema = z
579
+ .object({
580
+ preset: z.enum(SCENE3D_CLAY_LIGHTING_PRESETS),
581
+ ambientIntensity: z.number().min(0).max(SCENE3D_V2_LIMITS.maxIntensity),
582
+ keyIntensity: z.number().min(0).max(SCENE3D_V2_LIMITS.maxIntensity),
583
+ keyPosition: vec3Schema,
584
+ })
585
+ .strict()
586
+
587
+ const overrideProvenanceShape = {
588
+ id: scene3DIdSchema,
589
+ sourceRevisionId: z.uuid(),
590
+ sourceContentHash: scene3DSha256Schema,
591
+ operationVersion: z.number().int().min(1).max(255),
592
+ }
593
+
594
+ export const scene3DOverrideSchema = z.discriminatedUnion("kind", [
595
+ z
596
+ .object({
597
+ ...overrideProvenanceShape,
598
+ kind: z.literal("entity-transform"),
599
+ entityId: scene3DIdSchema,
600
+ space: z.enum(["local", "world"]),
601
+ position: vec3Schema.optional(),
602
+ rotation: rotationVec3Schema.optional(),
603
+ scale: scaleVec3Schema.optional(),
604
+ })
605
+ .strict(),
606
+ z
607
+ .object({
608
+ ...overrideProvenanceShape,
609
+ kind: z.literal("entity-color"),
610
+ entityId: scene3DIdSchema,
611
+ materialRole: scene3DMaterialRoleSchema,
612
+ color: scene3DColorSchema,
613
+ })
614
+ .strict(),
615
+ z
616
+ .object({
617
+ ...overrideProvenanceShape,
618
+ kind: z.literal("entity-visibility"),
619
+ entityId: scene3DIdSchema,
620
+ visible: z.boolean(),
621
+ })
622
+ .strict(),
623
+ z
624
+ .object({
625
+ ...overrideProvenanceShape,
626
+ kind: z.literal("camera-shot-offset"),
627
+ shotId: scene3DIdSchema,
628
+ positionOffset: vec3Schema.optional(),
629
+ targetOffset: vec3Schema.optional(),
630
+ })
631
+ .strict(),
632
+ ])
633
+
634
+ export const scene3DProvenanceSchema = z
635
+ .object({
636
+ engine: scene3DEngineIdSchema,
637
+ engineVersion: scene3DVersionTokenSchema,
638
+ recipeVersion: scene3DVersionTokenSchema,
639
+ compilerVersion: scene3DVersionTokenSchema,
640
+ exporterVersion: scene3DVersionTokenSchema,
641
+ rendererVersion: scene3DVersionTokenSchema,
642
+ sourceRevisionId: z.uuid().optional(),
643
+ sourceArtifactId: scene3DAssetIdSchema.optional(),
644
+ contentHash: scene3DSha256Schema,
645
+ })
646
+ .strict()
647
+ // ---------------------------------------------------------------------------
648
+ // JSON admission helpers
649
+ // ---------------------------------------------------------------------------
650
+
651
+ type Issue = Scene3DSemanticIssue
652
+
653
+ /** Decoded byte length of a JSON payload, browser and Node alike. Size is
654
+ * checked on the BYTES, before `JSON.parse` allocates anything. */
655
+ export function scene3DJsonByteLength(text: string): number {
656
+ return new TextEncoder().encode(text).length
657
+ }
658
+
659
+ /** What every `parse…Json` admission helper returns: the value, or the issues
660
+ * that stopped it — never a throw, so a route can map issues to a 400. */
661
+ export type Scene3DParseResult<T> = { ok: true; value: T } | { ok: false; issues: Issue[] }
662
+
663
+ /** Flattens a zod failure into the same issue shape the semantic validators use. */
664
+ export function scene3DZodIssues(error: z.ZodError): Issue[] {
665
+ return error.issues.map((issue) => ({ path: [...issue.path] as (string | number)[], message: issue.message }))
666
+ }