@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.
- package/dist/index.cjs +1379 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1935 -72
- package/dist/index.d.ts +1935 -72
- package/dist/index.js +1299 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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 +6 -6
- package/src/index.ts +6 -0
- package/src/scene3d-camera-track.ts +369 -0
- package/src/scene3d-edit.ts +10 -10
- 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 +39 -13
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
SCENE3D_ASSET_ROLE_KINDS,
|
|
4
|
+
SCENE3D_GLB_EXTRAS_ALLOWLIST,
|
|
5
|
+
SCENE3D_GLB_EXTRAS_ENTITY_ID,
|
|
6
|
+
SCENE3D_RENDERER_ASSET_KINDS,
|
|
7
|
+
SCENE3D_SCHEMA_VERSION_V2,
|
|
8
|
+
SCENE3D_SUPPORTED_SCHEMA_VERSIONS,
|
|
9
|
+
SCENE3D_V2_LIMITS,
|
|
10
|
+
SCENE3D_V2_PRIMITIVES,
|
|
11
|
+
type Scene3DEntityV2,
|
|
12
|
+
type Scene3DOverride,
|
|
13
|
+
type Scene3DPlanV2,
|
|
14
|
+
} from "../scene3d-v2.js"
|
|
15
|
+
import {
|
|
16
|
+
isKnownScene3DEngine,
|
|
17
|
+
isScene3DPlan,
|
|
18
|
+
isScene3DPlanV2,
|
|
19
|
+
isScene3DSchemaVersionSupported,
|
|
20
|
+
scene3DAcceptedSchemaVersionsSchema,
|
|
21
|
+
scene3DAnyPlanSchema,
|
|
22
|
+
scene3DEntityAcceptsOverlay,
|
|
23
|
+
scene3DPlanSchemaVersion,
|
|
24
|
+
scene3DPlanV2Issues,
|
|
25
|
+
scene3DPlanV2Schema,
|
|
26
|
+
scene3DShotForFrame,
|
|
27
|
+
scene3DShotIndexForFrame,
|
|
28
|
+
} from "../scene3d-v2-plan.js"
|
|
29
|
+
import {
|
|
30
|
+
SCENE3D_LIMITS,
|
|
31
|
+
SCENE3D_SCHEMA_VERSION,
|
|
32
|
+
isScene3DPlanV1,
|
|
33
|
+
scene3DPlanSchema,
|
|
34
|
+
scene3DPlanV1Schema,
|
|
35
|
+
type Scene3DPlanV1,
|
|
36
|
+
} from "../scene3d.js"
|
|
37
|
+
import { FIXTURE_CUTS, FIXTURE_FRAMES, planV2 } from "./scene3d-v2-fixtures.js"
|
|
38
|
+
|
|
39
|
+
function messages(plan: Scene3DPlanV2): string[] {
|
|
40
|
+
return scene3DPlanV2Issues(plan).map((issue) => issue.message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expectRejects(plan: Scene3DPlanV2, fragment: string): void {
|
|
44
|
+
const result = scene3DPlanV2Schema.safeParse(plan)
|
|
45
|
+
expect(result.success).toBe(false)
|
|
46
|
+
const all = result.success ? [] : result.error.issues.map((issue) => issue.message).join(" | ")
|
|
47
|
+
expect(all).toContain(fragment)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A v1 plan, built the way the existing v1 fixtures do. */
|
|
51
|
+
function planV1(over: Partial<Scene3DPlanV1> = {}): Scene3DPlanV1 {
|
|
52
|
+
return {
|
|
53
|
+
planType: "3d-scene",
|
|
54
|
+
schemaVersion: SCENE3D_SCHEMA_VERSION,
|
|
55
|
+
revisionId: "11111111-2222-4333-8444-555555555555",
|
|
56
|
+
width: 1280,
|
|
57
|
+
height: 720,
|
|
58
|
+
fps: 24,
|
|
59
|
+
durationInFrames: 96,
|
|
60
|
+
backgroundColor: "#101014",
|
|
61
|
+
camera: { position: [0, 2, 6], target: [0, 1, 0], focalLengthMm: 35, sensorWidthMm: 36 },
|
|
62
|
+
objects: [
|
|
63
|
+
{
|
|
64
|
+
id: "box",
|
|
65
|
+
name: "Box",
|
|
66
|
+
primitive: "box",
|
|
67
|
+
dimensions: [1, 1, 1],
|
|
68
|
+
position: [0, 0.5, 0],
|
|
69
|
+
rotation: [0, 0, 0],
|
|
70
|
+
scale: [1, 1, 1],
|
|
71
|
+
color: "#4f8ef7",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
lighting: { ambientIntensity: 0.5, keyIntensity: 2, keyPosition: [4, 6, 3] },
|
|
75
|
+
...over,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("scene3d v2 — the happy path", () => {
|
|
80
|
+
it("accepts the fixture manifest", () => {
|
|
81
|
+
const result = scene3DPlanV2Schema.safeParse(planV2())
|
|
82
|
+
expect(result.success).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("parse() returns the input unchanged — no defaults may be injected", () => {
|
|
86
|
+
// A `.default()` anywhere would make a producer hashing raw JSON and a
|
|
87
|
+
// consumer hashing parsed output disagree about the same revision.
|
|
88
|
+
const fixture = planV2()
|
|
89
|
+
expect(scene3DPlanV2Schema.parse(fixture)).toEqual(fixture)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("narrows through the version predicates", () => {
|
|
93
|
+
expect(isScene3DPlanV2(planV2())).toBe(true)
|
|
94
|
+
expect(isScene3DPlanV1(planV2())).toBe(false)
|
|
95
|
+
expect(isScene3DPlan(planV2())).toBe(true)
|
|
96
|
+
expect(isScene3DPlan(planV1())).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("scene3d v2 — v1 is untouched", () => {
|
|
101
|
+
it("still validates v1 plans, and scene3DPlanSchema is still v1-only", () => {
|
|
102
|
+
expect(scene3DPlanV1Schema.safeParse(planV1()).success).toBe(true)
|
|
103
|
+
expect(scene3DPlanSchema.safeParse(planV1()).success).toBe(true)
|
|
104
|
+
expect(scene3DPlanSchema.safeParse(planV2()).success).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("keeps every v1 limit at its frozen value", () => {
|
|
108
|
+
expect(SCENE3D_LIMITS.maxObjects).toBe(100)
|
|
109
|
+
expect(SCENE3D_LIMITS.maxKeyframes).toBe(240)
|
|
110
|
+
expect(SCENE3D_LIMITS.maxHierarchyDepth).toBe(8)
|
|
111
|
+
expect(SCENE3D_LIMITS.maxDimensionPx).toBe(1920)
|
|
112
|
+
expect(SCENE3D_LIMITS.maxReferences).toBe(8)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("does not let v2's raised ceilings leak into v1", () => {
|
|
116
|
+
// v2 nests 16 deep; a v1 plan 9 deep is still refused.
|
|
117
|
+
const chained = Array.from({ length: 9 }, (_unused, index) => ({
|
|
118
|
+
id: `n${index}`,
|
|
119
|
+
name: `n${index}`,
|
|
120
|
+
primitive: "box" as const,
|
|
121
|
+
parentId: index === 0 ? undefined : `n${index - 1}`,
|
|
122
|
+
dimensions: [1, 1, 1] as [number, number, number],
|
|
123
|
+
position: [0, 0, 0] as [number, number, number],
|
|
124
|
+
rotation: [0, 0, 0] as [number, number, number],
|
|
125
|
+
scale: [1, 1, 1] as [number, number, number],
|
|
126
|
+
color: "#ffffff",
|
|
127
|
+
}))
|
|
128
|
+
const result = scene3DPlanV1Schema.safeParse(planV1({ objects: chained }))
|
|
129
|
+
expect(result.success).toBe(false)
|
|
130
|
+
expect(result.success ? "" : result.error.issues.map((i) => i.message).join(" ")).toContain(
|
|
131
|
+
"hierarchy deeper than 8 levels",
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe("scene3d v2 — the discriminator", () => {
|
|
137
|
+
it("routes each version to its own branch", () => {
|
|
138
|
+
expect(scene3DAnyPlanSchema.safeParse(planV1()).success).toBe(true)
|
|
139
|
+
expect(scene3DAnyPlanSchema.safeParse(planV2()).success).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("reports an unknown version as a discriminator failure, not a pile of key errors", () => {
|
|
143
|
+
const result = scene3DAnyPlanSchema.safeParse({ ...planV2(), schemaVersion: 3 })
|
|
144
|
+
expect(result.success).toBe(false)
|
|
145
|
+
const issues = result.success ? [] : result.error.issues
|
|
146
|
+
expect(issues).toHaveLength(1)
|
|
147
|
+
expect(issues[0].message).toContain("Invalid discriminator value")
|
|
148
|
+
expect(issues[0].path).toEqual(["schemaVersion"])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("runs the RIGHT semantic pass per branch", () => {
|
|
152
|
+
// A v2 shot-coverage failure must surface through the union too.
|
|
153
|
+
const broken = planV2({ shots: [{ id: "only", startFrame: 0, endFrameExclusive: 100 }] })
|
|
154
|
+
const result = scene3DAnyPlanSchema.safeParse(broken)
|
|
155
|
+
expect(result.success).toBe(false)
|
|
156
|
+
expect(result.success ? "" : result.error.issues.map((i) => i.message).join(" ")).toContain(
|
|
157
|
+
"must be covered completely",
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("identifies the claimed version without validating the rest", () => {
|
|
162
|
+
expect(scene3DPlanSchemaVersion(planV1())).toBe(1)
|
|
163
|
+
expect(scene3DPlanSchemaVersion(planV2())).toBe(2)
|
|
164
|
+
// A future version reports its number so an SDK can say "upgrade to render".
|
|
165
|
+
expect(scene3DPlanSchemaVersion({ planType: "3d-scene", schemaVersion: 7 })).toBe(7)
|
|
166
|
+
expect(isScene3DSchemaVersionSupported(7)).toBe(false)
|
|
167
|
+
expect(SCENE3D_SUPPORTED_SCHEMA_VERSIONS).toEqual([1, 2])
|
|
168
|
+
// Not a scene plan at all.
|
|
169
|
+
expect(scene3DPlanSchemaVersion({ planType: "video", schemaVersion: 1 })).toBeNull()
|
|
170
|
+
expect(scene3DPlanSchemaVersion(null)).toBeNull()
|
|
171
|
+
expect(scene3DPlanSchemaVersion("3d-scene")).toBeNull()
|
|
172
|
+
expect(scene3DPlanSchemaVersion({ planType: "3d-scene", schemaVersion: 1.5 })).toBeNull()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("validates a client's accepted-version list", () => {
|
|
176
|
+
expect(scene3DAcceptedSchemaVersionsSchema.safeParse([1, 2]).success).toBe(true)
|
|
177
|
+
expect(scene3DAcceptedSchemaVersionsSchema.safeParse([2]).success).toBe(true)
|
|
178
|
+
expect(scene3DAcceptedSchemaVersionsSchema.safeParse([]).success).toBe(false)
|
|
179
|
+
expect(scene3DAcceptedSchemaVersionsSchema.safeParse([3]).success).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe("scene3d v2 — hostile and malformed input", () => {
|
|
184
|
+
it("rejects a prototype-pollution key through .strict()", () => {
|
|
185
|
+
for (const key of ["__proto__", "constructor", "toString", "objectsExtra"]) {
|
|
186
|
+
const hostile = JSON.parse(JSON.stringify(planV2())) as Record<string, unknown>
|
|
187
|
+
hostile[key] = { polluted: true }
|
|
188
|
+
expect(scene3DPlanV2Schema.safeParse(hostile).success).toBe(false)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("rejects unknown keys on nested objects too", () => {
|
|
193
|
+
const plan = planV2()
|
|
194
|
+
;(plan.objects[0] as unknown as Record<string, unknown>).script = "alert(1)"
|
|
195
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("rejects non-finite numbers everywhere they could reach the renderer", () => {
|
|
199
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
|
200
|
+
const plan = planV2()
|
|
201
|
+
plan.objects[2].position = [bad, 0, 0]
|
|
202
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("rejects a path or a URL smuggled into an asset id", () => {
|
|
207
|
+
for (const bad of ["../secrets", "a/b", "https://example.com/x.glb", "/etc/passwd", ""]) {
|
|
208
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ cameraTrackAssetId: bad })).success).toBe(false)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("rejects a native path or prose in a provenance version", () => {
|
|
213
|
+
const plan = planV2()
|
|
214
|
+
for (const bad of [
|
|
215
|
+
"/home/example/project.blend",
|
|
216
|
+
"C:\\\\Program Files\\\\Blender",
|
|
217
|
+
"built by the engine",
|
|
218
|
+
"1.0.0:secret",
|
|
219
|
+
"a".repeat(65),
|
|
220
|
+
]) {
|
|
221
|
+
expect(
|
|
222
|
+
scene3DPlanV2Schema.safeParse({ ...plan, provenance: { ...plan.provenance, exporterVersion: bad } }).success,
|
|
223
|
+
).toBe(false)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("rejects a malformed digest", () => {
|
|
228
|
+
const plan = planV2()
|
|
229
|
+
for (const bad of ["A".repeat(64), "abc", `sha256:${"a".repeat(64)}`, "a".repeat(63)]) {
|
|
230
|
+
plan.assets[0].sha256 = bad
|
|
231
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("rejects a control character in a material name", () => {
|
|
236
|
+
const plan = planV2()
|
|
237
|
+
const entity = plan.objects[1]
|
|
238
|
+
if (entity.visual.kind !== "asset") throw new Error("fixture changed")
|
|
239
|
+
entity.materialBindings = [{ role: "bodyPaint", materialName: "Body\nPaint", color: "#ffffff" }]
|
|
240
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("rejects a completely foreign object", () => {
|
|
244
|
+
for (const bad of [null, 42, "plan", [], { hello: "world" }]) {
|
|
245
|
+
expect(scene3DPlanV2Schema.safeParse(bad).success).toBe(false)
|
|
246
|
+
expect(isScene3DPlan(bad)).toBe(false)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe("scene3d v2 — world conventions and output shape", () => {
|
|
252
|
+
it("requires the coordinate literals", () => {
|
|
253
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ upAxis: "Z" as never })).success).toBe(false)
|
|
254
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ handedness: "left" as never })).success).toBe(false)
|
|
255
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ units: "centimeters" as never })).success).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("requires even output dimensions", () => {
|
|
259
|
+
expectRejects(planV2({ width: 1681 }), "width must be an even number of pixels")
|
|
260
|
+
expectRejects(planV2({ height: 721 }), "height must be an even number of pixels")
|
|
261
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ width: 1680, height: 720 })).success).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("applies BOTH the frame ceiling and the seconds ceiling", () => {
|
|
265
|
+
// 3600 frames at 15 fps is 240s — inside the frame cap, past the time cap.
|
|
266
|
+
expectRejects(planV2({ fps: 15, durationInFrames: 3600 }), "the limit is 60s")
|
|
267
|
+
expect(
|
|
268
|
+
scene3DPlanV2Schema.safeParse(planV2({ durationInFrames: 3601 })).success,
|
|
269
|
+
).toBe(false)
|
|
270
|
+
expect(SCENE3D_V2_LIMITS.maxDurationInFrames).toBe(3600)
|
|
271
|
+
expect(SCENE3D_V2_LIMITS.maxDurationSeconds).toBe(60)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe("scene3d v2 — entities", () => {
|
|
276
|
+
it("rejects duplicate ids", () => {
|
|
277
|
+
const plan = planV2()
|
|
278
|
+
plan.objects[2] = { ...plan.objects[2], id: "e1" }
|
|
279
|
+
expectRejects(plan, 'duplicate entity id "e1"')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("requires a base transform on group and primitive entities", () => {
|
|
283
|
+
const plan = planV2()
|
|
284
|
+
delete plan.objects[2].position
|
|
285
|
+
expectRejects(plan, 'entity "e3" is a primitive and must declare position')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("lets an asset entity omit its transform — the GLB node owns it", () => {
|
|
289
|
+
const plan = planV2()
|
|
290
|
+
delete plan.objects[1].position
|
|
291
|
+
delete plan.objects[1].rotation
|
|
292
|
+
delete plan.objects[1].scale
|
|
293
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(true)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("rejects a self-parent, an unknown parent and a cycle", () => {
|
|
297
|
+
const selfParent = planV2()
|
|
298
|
+
selfParent.objects[2].parentId = "e3"
|
|
299
|
+
expectRejects(selfParent, 'entity "e3" cannot parent itself')
|
|
300
|
+
|
|
301
|
+
const unknown = planV2()
|
|
302
|
+
unknown.objects[2].parentId = "nope"
|
|
303
|
+
expectRejects(unknown, 'references unknown parent "nope"')
|
|
304
|
+
|
|
305
|
+
const cycle = planV2()
|
|
306
|
+
cycle.objects[0].parentId = "e2"
|
|
307
|
+
expect(messages(cycle).some((message) => message.startsWith("parent cycle"))).toBe(true)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it("bounds the hierarchy at 16, not v1's 8", () => {
|
|
311
|
+
const chain = (length: number): Scene3DEntityV2[] =>
|
|
312
|
+
Array.from({ length }, (_unused, index) => ({
|
|
313
|
+
id: `n${index}`,
|
|
314
|
+
name: `n${index}`,
|
|
315
|
+
parentId: index === 0 ? undefined : `n${index - 1}`,
|
|
316
|
+
position: [0, 0, 0] as [number, number, number],
|
|
317
|
+
rotation: [0, 0, 0] as [number, number, number],
|
|
318
|
+
scale: [1, 1, 1] as [number, number, number],
|
|
319
|
+
visual: { kind: "group" as const },
|
|
320
|
+
}))
|
|
321
|
+
const shallowShots = [{ id: "shot-1", startFrame: 0, endFrameExclusive: FIXTURE_FRAMES }]
|
|
322
|
+
const okPlan = planV2({ objects: chain(16), shots: shallowShots })
|
|
323
|
+
expect(messages(okPlan).some((message) => message.includes("hierarchy deeper"))).toBe(false)
|
|
324
|
+
const deepPlan = planV2({ objects: chain(17), shots: shallowShots })
|
|
325
|
+
expect(messages(deepPlan).some((message) => message.includes("hierarchy deeper than 16 levels"))).toBe(true)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it("gives each exported root node exactly one owner", () => {
|
|
329
|
+
const plan = planV2()
|
|
330
|
+
plan.objects.push({
|
|
331
|
+
id: "e4",
|
|
332
|
+
name: "Twin",
|
|
333
|
+
visual: { kind: "asset", assetId: "asset-vehicle", rootNodeId: "vehicle_root" },
|
|
334
|
+
})
|
|
335
|
+
expectRejects(plan, 'is already the root of entity "e2"')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("keeps `group` out of the primitive vocabulary", () => {
|
|
339
|
+
expect(SCENE3D_V2_PRIMITIVES).not.toContain("group")
|
|
340
|
+
const plan = planV2()
|
|
341
|
+
plan.objects[2].visual = { kind: "primitive", primitive: "group" as never, dimensions: [1, 1, 1], color: "#fff" }
|
|
342
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("keeps material bindings to asset entities and to unique roles", () => {
|
|
346
|
+
const onPrimitive = planV2()
|
|
347
|
+
onPrimitive.objects[2].materialBindings = [{ role: "identity", materialName: "Clay" }]
|
|
348
|
+
expectRejects(onPrimitive, "material bindings name materials in an asset root")
|
|
349
|
+
|
|
350
|
+
const duplicated = planV2()
|
|
351
|
+
duplicated.objects[1].materialBindings = [
|
|
352
|
+
{ role: "bodyPaint", materialName: "A" },
|
|
353
|
+
{ role: "bodyPaint", materialName: "B" },
|
|
354
|
+
]
|
|
355
|
+
expectRejects(duplicated, 'binds material role "bodyPaint" twice')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it("rejects duplicate anchor names", () => {
|
|
359
|
+
const plan = planV2()
|
|
360
|
+
plan.objects[1].anchors = [
|
|
361
|
+
{ name: "roof", position: [0, 1, 0] },
|
|
362
|
+
{ name: "roof", position: [0, 2, 0] },
|
|
363
|
+
]
|
|
364
|
+
expectRejects(plan, 'declares anchor "roof" twice')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it("bounds an animation binding to the timeline", () => {
|
|
368
|
+
const past = planV2()
|
|
369
|
+
past.objects[1].visual = {
|
|
370
|
+
kind: "asset",
|
|
371
|
+
assetId: "asset-vehicle",
|
|
372
|
+
rootNodeId: "vehicle_root",
|
|
373
|
+
animation: { clipName: "drive", startFrame: 0, endFrameExclusive: 721 },
|
|
374
|
+
}
|
|
375
|
+
expectRejects(past, "animation runs past the scene (720 frames)")
|
|
376
|
+
|
|
377
|
+
const inverted = planV2()
|
|
378
|
+
inverted.objects[1].visual = {
|
|
379
|
+
kind: "asset",
|
|
380
|
+
assetId: "asset-vehicle",
|
|
381
|
+
rootNodeId: "vehicle_root",
|
|
382
|
+
animation: { clipName: "drive", startFrame: 300, endFrameExclusive: 300 },
|
|
383
|
+
}
|
|
384
|
+
expect(scene3DPlanV2Schema.safeParse(inverted).success).toBe(false)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it("exports the GLB extras allowlist both ends must agree on", () => {
|
|
388
|
+
expect(SCENE3D_GLB_EXTRAS_ENTITY_ID).toBe("nodaroEntityId")
|
|
389
|
+
expect(SCENE3D_GLB_EXTRAS_ALLOWLIST).toContain(SCENE3D_GLB_EXTRAS_ENTITY_ID)
|
|
390
|
+
expect(SCENE3D_GLB_EXTRAS_ALLOWLIST).toHaveLength(3)
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
describe("scene3d v2 — assets", () => {
|
|
395
|
+
it("rejects duplicate asset ids", () => {
|
|
396
|
+
const plan = planV2()
|
|
397
|
+
plan.assets[2] = { ...plan.assets[2], assetId: "asset-track" }
|
|
398
|
+
expectRejects(plan, 'duplicate asset id "asset-track"')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it("enforces the kind↔role matrix", () => {
|
|
402
|
+
const plan = planV2()
|
|
403
|
+
plan.assets[0] = { ...plan.assets[0], role: "camera-track" }
|
|
404
|
+
expectRejects(plan, 'requires kind "camera-track-json"')
|
|
405
|
+
for (const [role, kind] of Object.entries(SCENE3D_ASSET_ROLE_KINDS)) {
|
|
406
|
+
expect(typeof kind).toBe("string")
|
|
407
|
+
expect(typeof role).toBe("string")
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it("requires cameraTrackAssetId to resolve to a camera track", () => {
|
|
412
|
+
expectRejects(planV2({ cameraTrackAssetId: "asset-missing" }), "is not in assets")
|
|
413
|
+
const wrongKind = planV2({ cameraTrackAssetId: "asset-poster" })
|
|
414
|
+
expectRejects(wrongKind, "a camera track must be camera-track-json")
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it("rejects an entity pointing at a non-GLB or missing asset", () => {
|
|
418
|
+
const missing = planV2()
|
|
419
|
+
missing.objects[1].visual = { kind: "asset", assetId: "asset-nope", rootNodeId: "vehicle_root" }
|
|
420
|
+
expectRejects(missing, 'references unknown asset "asset-nope"')
|
|
421
|
+
|
|
422
|
+
const wrongKind = planV2()
|
|
423
|
+
wrongKind.objects[1].visual = { kind: "asset", assetId: "asset-poster", rootNodeId: "vehicle_root" }
|
|
424
|
+
expectRejects(wrongKind, "geometry must be glb")
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it("rejects a GLB no entity uses", () => {
|
|
428
|
+
const plan = planV2()
|
|
429
|
+
plan.assets.push({
|
|
430
|
+
assetId: "asset-orphan",
|
|
431
|
+
kind: "glb",
|
|
432
|
+
role: "scene-geometry",
|
|
433
|
+
byteLength: 1000,
|
|
434
|
+
sha256: "e".repeat(64),
|
|
435
|
+
})
|
|
436
|
+
expectRejects(plan, 'glb asset "asset-orphan" is not referenced by any entity')
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it("counts renderer-visible bytes against 64 MiB and excludes the native source", () => {
|
|
440
|
+
expect(SCENE3D_RENDERER_ASSET_KINDS).not.toContain("blend-source")
|
|
441
|
+
|
|
442
|
+
// A 200 MiB source alone is fine — the browser never downloads it.
|
|
443
|
+
const bigSource = planV2()
|
|
444
|
+
bigSource.assets[3] = { ...bigSource.assets[3], byteLength: 200 * 1024 * 1024 }
|
|
445
|
+
expect(scene3DPlanV2Schema.safeParse(bigSource).success).toBe(true)
|
|
446
|
+
|
|
447
|
+
// Two GLBs that together cross the budget are not.
|
|
448
|
+
const overBudget = planV2()
|
|
449
|
+
overBudget.assets[0] = { ...overBudget.assets[0], byteLength: 40 * 1024 * 1024 }
|
|
450
|
+
overBudget.assets[1] = { ...overBudget.assets[1], byteLength: 8 * 1024 * 1024 }
|
|
451
|
+
overBudget.assets[2] = { ...overBudget.assets[2], byteLength: 20 * 1024 * 1024 }
|
|
452
|
+
expectRejects(overBudget, "downloaded scene assets total")
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it("caps the camera track at 8 MiB", () => {
|
|
456
|
+
const plan = planV2()
|
|
457
|
+
plan.assets[1] = { ...plan.assets[1], byteLength: 8 * 1024 * 1024 + 1 }
|
|
458
|
+
expectRejects(plan, "camera track")
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it("retains at most one native source, and requires sourceArtifactId to be one", () => {
|
|
462
|
+
const twoSources = planV2()
|
|
463
|
+
twoSources.assets.push({
|
|
464
|
+
assetId: "asset-source-2",
|
|
465
|
+
kind: "blend-source",
|
|
466
|
+
role: "source",
|
|
467
|
+
byteLength: 10,
|
|
468
|
+
sha256: "f".repeat(64),
|
|
469
|
+
})
|
|
470
|
+
expectRejects(twoSources, "at most one blend-source asset")
|
|
471
|
+
|
|
472
|
+
const wrongSource = planV2()
|
|
473
|
+
wrongSource.provenance = { ...wrongSource.provenance, sourceArtifactId: "asset-poster" }
|
|
474
|
+
expectRejects(wrongSource, "a retained source must be blend-source")
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
describe("scene3d v2 — shots", () => {
|
|
479
|
+
it("accepts the exact fixture cut ranges", () => {
|
|
480
|
+
const plan = planV2()
|
|
481
|
+
expect(plan.shots.map((shot) => [shot.startFrame, shot.endFrameExclusive])).toEqual(
|
|
482
|
+
FIXTURE_CUTS.map((cut) => [...cut]),
|
|
483
|
+
)
|
|
484
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(true)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it("assigns every frame to exactly one shot", () => {
|
|
488
|
+
const shots = planV2().shots
|
|
489
|
+
for (const frame of [0, 359, 360, 431, 432, 491, 492, 719]) {
|
|
490
|
+
const owning = shots.filter((shot) => frame >= shot.startFrame && frame < shot.endFrameExclusive)
|
|
491
|
+
expect(owning).toHaveLength(1)
|
|
492
|
+
}
|
|
493
|
+
expect(scene3DShotForFrame(shots, 0)?.id).toBe("shot-1")
|
|
494
|
+
expect(scene3DShotForFrame(shots, 359)?.id).toBe("shot-1")
|
|
495
|
+
expect(scene3DShotForFrame(shots, 360)?.id).toBe("shot-2")
|
|
496
|
+
expect(scene3DShotForFrame(shots, 431)?.id).toBe("shot-2")
|
|
497
|
+
expect(scene3DShotForFrame(shots, 432)?.id).toBe("shot-3")
|
|
498
|
+
expect(scene3DShotForFrame(shots, 492)?.id).toBe("shot-4")
|
|
499
|
+
expect(scene3DShotForFrame(shots, 719)?.id).toBe("shot-4")
|
|
500
|
+
expect(scene3DShotForFrame(shots, 720)).toBeUndefined()
|
|
501
|
+
expect(scene3DShotIndexForFrame(shots, -1)).toBe(-1)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it("rejects a one-frame gap", () => {
|
|
505
|
+
const plan = planV2()
|
|
506
|
+
plan.shots[1] = { ...plan.shots[1], startFrame: 361 }
|
|
507
|
+
expectRejects(plan, "every frame belongs to exactly one shot")
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it("rejects an overlap", () => {
|
|
511
|
+
const plan = planV2()
|
|
512
|
+
plan.shots[1] = { ...plan.shots[1], startFrame: 359 }
|
|
513
|
+
expectRejects(plan, "every frame belongs to exactly one shot")
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it("rejects shots that do not start at 0", () => {
|
|
517
|
+
const plan = planV2()
|
|
518
|
+
plan.shots[0] = { ...plan.shots[0], startFrame: 1 }
|
|
519
|
+
expectRejects(plan, "shots must start at frame 0")
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it("rejects shots that stop short of the timeline", () => {
|
|
523
|
+
const plan = planV2()
|
|
524
|
+
plan.shots[3] = { ...plan.shots[3], endFrameExclusive: 700 }
|
|
525
|
+
expectRejects(plan, "must be covered completely")
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it("rejects an unsorted shot list", () => {
|
|
529
|
+
const plan = planV2()
|
|
530
|
+
plan.shots = [plan.shots[1], plan.shots[0], plan.shots[2], plan.shots[3]]
|
|
531
|
+
expect(scene3DPlanV2Schema.safeParse(plan).success).toBe(false)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it("rejects an empty or inverted range", () => {
|
|
535
|
+
const plan = planV2()
|
|
536
|
+
plan.shots[1] = { ...plan.shots[1], endFrameExclusive: 360 }
|
|
537
|
+
expectRejects(plan, "ends at or before it starts")
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it("rejects duplicate shot ids and unknown subject entities", () => {
|
|
541
|
+
const duplicate = planV2()
|
|
542
|
+
duplicate.shots[1] = { ...duplicate.shots[1], id: "shot-1" }
|
|
543
|
+
expectRejects(duplicate, 'duplicate shot id "shot-1"')
|
|
544
|
+
|
|
545
|
+
const unknown = planV2()
|
|
546
|
+
unknown.shots[0] = { ...unknown.shots[0], subjectEntityIds: ["ghost"] }
|
|
547
|
+
expectRejects(unknown, 'references unknown entity "ghost"')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it("caps the shot count", () => {
|
|
551
|
+
expect(SCENE3D_V2_LIMITS.maxShots).toBe(32)
|
|
552
|
+
const many = Array.from({ length: 33 }, (_unused, index) => ({
|
|
553
|
+
id: `s${index}`,
|
|
554
|
+
startFrame: index * 20,
|
|
555
|
+
endFrameExclusive: index === 32 ? FIXTURE_FRAMES : (index + 1) * 20,
|
|
556
|
+
}))
|
|
557
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ shots: many })).success).toBe(false)
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
describe("scene3d v2 — overlays", () => {
|
|
562
|
+
const provenanceFields = {
|
|
563
|
+
sourceRevisionId: "6d5b0b64-2b6c-4d4a-9e4f-2f4b7f6a1c00",
|
|
564
|
+
sourceContentHash: "a".repeat(64),
|
|
565
|
+
operationVersion: 1,
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
it("requires each override to name its source revision, hash and operation version", () => {
|
|
569
|
+
const plan = planV2()
|
|
570
|
+
const stripped = { ...plan.overrides![0] } as Record<string, unknown>
|
|
571
|
+
delete stripped.sourceContentHash
|
|
572
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ overrides: [stripped as never] })).success).toBe(false)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it("rejects an override written by a newer operation vocabulary", () => {
|
|
576
|
+
const plan = planV2()
|
|
577
|
+
plan.overrides = [{ ...plan.overrides![0], operationVersion: 2 }]
|
|
578
|
+
expectRejects(plan, "this reader understands up to 1")
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it("allows one owner per channel", () => {
|
|
582
|
+
const twoTransforms = planV2()
|
|
583
|
+
twoTransforms.overrides = [
|
|
584
|
+
{ ...provenanceFields, id: "a", kind: "entity-transform", entityId: "e2", space: "local", position: [1, 0, 0] },
|
|
585
|
+
{ ...provenanceFields, id: "b", kind: "entity-transform", entityId: "e2", space: "local", position: [2, 0, 0] },
|
|
586
|
+
]
|
|
587
|
+
expectRejects(twoTransforms, "already has a transform override")
|
|
588
|
+
|
|
589
|
+
const twoOffsets = planV2()
|
|
590
|
+
twoOffsets.overrides = [
|
|
591
|
+
{ ...provenanceFields, id: "a", kind: "camera-shot-offset", shotId: "shot-1", positionOffset: [0, 1, 0] },
|
|
592
|
+
{ ...provenanceFields, id: "b", kind: "camera-shot-offset", shotId: "shot-1", positionOffset: [0, 2, 0] },
|
|
593
|
+
]
|
|
594
|
+
expectRejects(twoOffsets, "already has a camera offset")
|
|
595
|
+
|
|
596
|
+
const twoColors = planV2()
|
|
597
|
+
twoColors.overrides = [
|
|
598
|
+
{ ...provenanceFields, id: "a", kind: "entity-color", entityId: "e2", materialRole: "bodyPaint", color: "#111111" },
|
|
599
|
+
{ ...provenanceFields, id: "b", kind: "entity-color", entityId: "e2", materialRole: "bodyPaint", color: "#222222" },
|
|
600
|
+
]
|
|
601
|
+
expectRejects(twoColors, 'already recolours material role "bodyPaint"')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it("rejects duplicate override ids", () => {
|
|
605
|
+
const plan = planV2()
|
|
606
|
+
plan.overrides = [
|
|
607
|
+
{ ...provenanceFields, id: "same", kind: "entity-visibility", entityId: "e2", visible: false },
|
|
608
|
+
{ ...provenanceFields, id: "same", kind: "entity-visibility", entityId: "e3", visible: false },
|
|
609
|
+
]
|
|
610
|
+
expectRejects(plan, 'duplicate override id "same"')
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it("requires a declared material role, so recolouring one entity cannot reach another", () => {
|
|
614
|
+
const foreign = planV2()
|
|
615
|
+
foreign.overrides = [
|
|
616
|
+
{ ...provenanceFields, id: "a", kind: "entity-color", entityId: "e2", materialRole: "seatFabric", color: "#111111" },
|
|
617
|
+
]
|
|
618
|
+
expectRejects(foreign, 'declares no material role "seatFabric"')
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it("gives a primitive exactly one role and a group none", () => {
|
|
622
|
+
const primitiveWrongRole = planV2()
|
|
623
|
+
primitiveWrongRole.overrides = [
|
|
624
|
+
{ ...provenanceFields, id: "a", kind: "entity-color", entityId: "e3", materialRole: "bodyPaint", color: "#111111" },
|
|
625
|
+
]
|
|
626
|
+
expectRejects(primitiveWrongRole, 'its only material role is "identity"')
|
|
627
|
+
|
|
628
|
+
const primitiveOk = planV2()
|
|
629
|
+
primitiveOk.overrides = [
|
|
630
|
+
{ ...provenanceFields, id: "a", kind: "entity-color", entityId: "e3", materialRole: "identity", color: "#111111" },
|
|
631
|
+
]
|
|
632
|
+
expect(scene3DPlanV2Schema.safeParse(primitiveOk).success).toBe(true)
|
|
633
|
+
|
|
634
|
+
const group = planV2()
|
|
635
|
+
group.overrides = [
|
|
636
|
+
{ ...provenanceFields, id: "a", kind: "entity-color", entityId: "e1", materialRole: "identity", color: "#111111" },
|
|
637
|
+
]
|
|
638
|
+
expectRejects(group, "is a group and has no geometry to recolour")
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it("honours locks and undeclared capabilities", () => {
|
|
642
|
+
const locked = planV2()
|
|
643
|
+
locked.objects[1] = { ...locked.objects[1], locks: ["transform"] }
|
|
644
|
+
expectRejects(locked, "does not accept a transform overlay")
|
|
645
|
+
|
|
646
|
+
const notAdvertised = planV2()
|
|
647
|
+
notAdvertised.objects[1] = { ...notAdvertised.objects[1], capabilities: ["visibility"] }
|
|
648
|
+
expectRejects(notAdvertised, "does not accept a transform overlay")
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it("defaults an entity with no declared capabilities to accepting all three", () => {
|
|
652
|
+
const entity: Scene3DEntityV2 = {
|
|
653
|
+
id: "x",
|
|
654
|
+
name: "x",
|
|
655
|
+
position: [0, 0, 0],
|
|
656
|
+
rotation: [0, 0, 0],
|
|
657
|
+
scale: [1, 1, 1],
|
|
658
|
+
visual: { kind: "group" },
|
|
659
|
+
}
|
|
660
|
+
expect(scene3DEntityAcceptsOverlay(entity, "transform")).toBe(true)
|
|
661
|
+
expect(scene3DEntityAcceptsOverlay({ ...entity, locks: ["color"] }, "color")).toBe(false)
|
|
662
|
+
expect(scene3DEntityAcceptsOverlay({ ...entity, capabilities: [] }, "visibility")).toBe(false)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it("rejects an override that changes nothing and one that targets a ghost", () => {
|
|
666
|
+
const empty = planV2()
|
|
667
|
+
empty.overrides = [{ ...provenanceFields, id: "a", kind: "entity-transform", entityId: "e2", space: "local" }]
|
|
668
|
+
expectRejects(empty, "changes nothing")
|
|
669
|
+
|
|
670
|
+
const ghost = planV2()
|
|
671
|
+
ghost.overrides = [
|
|
672
|
+
{ ...provenanceFields, id: "a", kind: "entity-visibility", entityId: "nobody", visible: false },
|
|
673
|
+
]
|
|
674
|
+
expectRejects(ghost, 'targets unknown entity "nobody"')
|
|
675
|
+
|
|
676
|
+
const ghostShot = planV2()
|
|
677
|
+
ghostShot.overrides = [
|
|
678
|
+
{ ...provenanceFields, id: "a", kind: "camera-shot-offset", shotId: "nope", positionOffset: [0, 1, 0] },
|
|
679
|
+
]
|
|
680
|
+
expectRejects(ghostShot, 'targets unknown shot "nope"')
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("has no camera-track-replacement kind — a replaced track is a new revision", () => {
|
|
684
|
+
const replacement = {
|
|
685
|
+
...provenanceFields,
|
|
686
|
+
id: "a",
|
|
687
|
+
kind: "camera-track-replacement",
|
|
688
|
+
cameraTrackAssetId: "asset-track",
|
|
689
|
+
} as unknown as Scene3DOverride
|
|
690
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ overrides: [replacement] })).success).toBe(false)
|
|
691
|
+
})
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
describe("scene3d v2 — references and engines", () => {
|
|
695
|
+
it("resolves a reference's entity id against v2 entities", () => {
|
|
696
|
+
const ok = planV2({
|
|
697
|
+
references: [{ id: "r1", url: "https://example.com/a.png", kind: "image", role: "appearance", objectId: "e2" }],
|
|
698
|
+
})
|
|
699
|
+
expect(scene3DPlanV2Schema.safeParse(ok).success).toBe(true)
|
|
700
|
+
|
|
701
|
+
const bad = planV2({
|
|
702
|
+
references: [{ id: "r1", url: "https://example.com/a.png", kind: "image", role: "appearance", objectId: "e9" }],
|
|
703
|
+
})
|
|
704
|
+
expectRejects(bad, 'points at unknown entity "e9"')
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it("keeps v1's reference limit", () => {
|
|
708
|
+
expect(SCENE3D_V2_LIMITS.maxReferences).toBe(SCENE3D_LIMITS.maxReferences)
|
|
709
|
+
const nine = Array.from({ length: 9 }, (_unused, index) => ({
|
|
710
|
+
id: `r${index}`,
|
|
711
|
+
url: "https://example.com/a.png",
|
|
712
|
+
kind: "image" as const,
|
|
713
|
+
role: "appearance" as const,
|
|
714
|
+
}))
|
|
715
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ references: nine })).success).toBe(false)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it("names the shipped engines without closing the field", () => {
|
|
719
|
+
expect(isKnownScene3DEngine("blender-cloud")).toBe(true)
|
|
720
|
+
expect(isKnownScene3DEngine("blender-local")).toBe(true)
|
|
721
|
+
expect(isKnownScene3DEngine("something-else")).toBe(false)
|
|
722
|
+
// ...but an unknown slug still parses, so a new engine needs no release.
|
|
723
|
+
const plan = planV2()
|
|
724
|
+
expect(
|
|
725
|
+
scene3DPlanV2Schema.safeParse({ ...plan, provenance: { ...plan.provenance, engine: "future-engine" } }).success,
|
|
726
|
+
).toBe(true)
|
|
727
|
+
expect(
|
|
728
|
+
scene3DPlanV2Schema.safeParse({ ...plan, provenance: { ...plan.provenance, engine: "Blender Cloud" } }).success,
|
|
729
|
+
).toBe(false)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it("pins the clay lighting preset", () => {
|
|
733
|
+
expect(scene3DPlanV2Schema.safeParse(planV2({ lighting: { ...planV2().lighting, preset: "neon" as never } })).success).toBe(
|
|
734
|
+
false,
|
|
735
|
+
)
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it("exposes the v2 schema version as 2", () => {
|
|
739
|
+
expect(SCENE3D_SCHEMA_VERSION_V2).toBe(2)
|
|
740
|
+
expect(SCENE3D_SCHEMA_VERSION).toBe(1)
|
|
741
|
+
})
|
|
742
|
+
})
|