@nodaro/shared 2.27.0 → 3.1.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 +1776 -159
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2921 -73
- package/dist/index.d.ts +2921 -73
- package/dist/index.js +1665 -160
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/presentation-utils.test.ts +97 -0
- package/src/__tests__/scene3d-authoring-engine.test.ts +104 -0
- package/src/__tests__/scene3d-camera-track.test.ts +235 -0
- package/src/__tests__/scene3d-v2-edit.test.ts +83 -0
- package/src/__tests__/scene3d-v2-fixtures.ts +232 -0
- package/src/__tests__/scene3d-v2-resources.test.ts +239 -0
- package/src/__tests__/scene3d-v2.test.ts +742 -0
- package/src/__tests__/scene3d.test.ts +6 -6
- package/src/index.ts +9 -0
- package/src/model-constants.ts +10 -0
- package/src/node-mappable-fields.ts +1 -0
- package/src/presentation-utils.ts +54 -2
- package/src/pro-3d-render.ts +466 -0
- package/src/producer-types.ts +5 -0
- package/src/scene3d-authoring-engine.ts +215 -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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared v2 fixtures. Deliberately generic (`e1`, `shot-1`, one car-ish
|
|
3
|
+
* assembly) — the contract has no opinion about what a scene contains, and a
|
|
4
|
+
* fixture that encodes one would quietly become a spec.
|
|
5
|
+
*
|
|
6
|
+
* The one non-arbitrary thing here is the SHAPE of the timeline: 720 frames at
|
|
7
|
+
* 24 fps cut into `[0,360) [360,432) [432,492) [492,720)`. That is the exact
|
|
8
|
+
* multi-shot arrangement the format has to carry, so every coverage test uses
|
|
9
|
+
* it rather than a two-shot toy.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
SCENE3D_CAMERA_TRACK_FORMAT,
|
|
13
|
+
SCENE3D_CAMERA_TRACK_VERSION,
|
|
14
|
+
type Scene3DCameraSample,
|
|
15
|
+
type Scene3DCameraTrackV1,
|
|
16
|
+
} from "../scene3d-camera-track.js"
|
|
17
|
+
import { SCENE3D_SCHEMA_VERSION_V2, type Scene3DPlanV2, type Scene3DShot } from "../scene3d-v2.js"
|
|
18
|
+
import { SCENE3D_PLAN_TYPE } from "../scene3d.js"
|
|
19
|
+
|
|
20
|
+
export const FIXTURE_WIDTH = 1680
|
|
21
|
+
export const FIXTURE_HEIGHT = 720
|
|
22
|
+
export const FIXTURE_FPS = 24
|
|
23
|
+
export const FIXTURE_FRAMES = 720
|
|
24
|
+
|
|
25
|
+
/** The cut plan the format must be able to express exactly. */
|
|
26
|
+
export const FIXTURE_CUTS: ReadonlyArray<readonly [number, number]> = [
|
|
27
|
+
[0, 360],
|
|
28
|
+
[360, 432],
|
|
29
|
+
[432, 492],
|
|
30
|
+
[492, 720],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const REVISION_ID = "6d5b0b64-2b6c-4d4a-9e4f-2f4b7f6a1c01"
|
|
34
|
+
const PARENT_REVISION_ID = "6d5b0b64-2b6c-4d4a-9e4f-2f4b7f6a1c00"
|
|
35
|
+
const HASH_A = "a".repeat(64)
|
|
36
|
+
const HASH_B = "b".repeat(64)
|
|
37
|
+
const HASH_C = "c".repeat(64)
|
|
38
|
+
const HASH_D = "d".repeat(64)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A symmetric column-major perspective matrix, exactly as Three.js and every
|
|
42
|
+
* glTF camera build one. `m[0]/m[5]` is `height/width`, which is what the
|
|
43
|
+
* manifest cross-check reads.
|
|
44
|
+
*/
|
|
45
|
+
export function perspectiveMatrix(
|
|
46
|
+
fovYDegrees: number,
|
|
47
|
+
aspect: number,
|
|
48
|
+
near: number,
|
|
49
|
+
far: number,
|
|
50
|
+
): number[] {
|
|
51
|
+
const top = near * Math.tan((fovYDegrees * Math.PI) / 360)
|
|
52
|
+
const height = 2 * top
|
|
53
|
+
const width = height * aspect
|
|
54
|
+
const matrix = new Array<number>(16).fill(0)
|
|
55
|
+
matrix[0] = (2 * near) / width
|
|
56
|
+
matrix[5] = (2 * near) / height
|
|
57
|
+
matrix[10] = -(far + near) / (far - near)
|
|
58
|
+
matrix[11] = -1
|
|
59
|
+
matrix[14] = (-2 * far * near) / (far - near)
|
|
60
|
+
return matrix
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An orthographic matrix — the thing the projection validator must NAME rather
|
|
64
|
+
* than mis-read as a broken perspective. */
|
|
65
|
+
export function orthographicMatrix(near: number, far: number): number[] {
|
|
66
|
+
const matrix = new Array<number>(16).fill(0)
|
|
67
|
+
matrix[0] = 1
|
|
68
|
+
matrix[5] = 1
|
|
69
|
+
matrix[10] = -2 / (far - near)
|
|
70
|
+
matrix[14] = -(far + near) / (far - near)
|
|
71
|
+
matrix[15] = 1
|
|
72
|
+
return matrix
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function cameraSample(overrides: Partial<Scene3DCameraSample> = {}): Scene3DCameraSample {
|
|
76
|
+
return {
|
|
77
|
+
position: [0, 1.5, 4],
|
|
78
|
+
quaternion: [0, 0, 0, 1],
|
|
79
|
+
projectionMatrix: perspectiveMatrix(35, FIXTURE_WIDTH / FIXTURE_HEIGHT, 0.1, 200),
|
|
80
|
+
near: 0.1,
|
|
81
|
+
far: 200,
|
|
82
|
+
...overrides,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function cameraTrack(
|
|
87
|
+
frameCount = FIXTURE_FRAMES,
|
|
88
|
+
overrides: Partial<Scene3DCameraTrackV1> = {},
|
|
89
|
+
): Scene3DCameraTrackV1 {
|
|
90
|
+
return {
|
|
91
|
+
format: SCENE3D_CAMERA_TRACK_FORMAT,
|
|
92
|
+
version: SCENE3D_CAMERA_TRACK_VERSION,
|
|
93
|
+
frameStart: 0,
|
|
94
|
+
frameCount,
|
|
95
|
+
fps: FIXTURE_FPS,
|
|
96
|
+
samples: Array.from({ length: frameCount }, (_unused, frame) =>
|
|
97
|
+
cameraSample({ position: [frame / 100, 1.5, 4] }),
|
|
98
|
+
),
|
|
99
|
+
...overrides,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function fixtureShots(): Scene3DShot[] {
|
|
104
|
+
return FIXTURE_CUTS.map(([startFrame, endFrameExclusive], index) => ({
|
|
105
|
+
id: `shot-${index + 1}`,
|
|
106
|
+
startFrame,
|
|
107
|
+
endFrameExclusive,
|
|
108
|
+
subjectEntityIds: ["e2"],
|
|
109
|
+
foregroundEntityIds: ["e3"],
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A complete, valid v2 manifest: one group assembly with an asset child and a
|
|
115
|
+
* primitive sibling, three renderer-visible assets plus a retained source, four
|
|
116
|
+
* contiguous shots, one of each override kind.
|
|
117
|
+
*/
|
|
118
|
+
export function planV2(overrides: Partial<Scene3DPlanV2> = {}): Scene3DPlanV2 {
|
|
119
|
+
return {
|
|
120
|
+
planType: SCENE3D_PLAN_TYPE,
|
|
121
|
+
schemaVersion: SCENE3D_SCHEMA_VERSION_V2,
|
|
122
|
+
revisionId: REVISION_ID,
|
|
123
|
+
parentRevisionId: PARENT_REVISION_ID,
|
|
124
|
+
width: FIXTURE_WIDTH,
|
|
125
|
+
height: FIXTURE_HEIGHT,
|
|
126
|
+
fps: FIXTURE_FPS,
|
|
127
|
+
durationInFrames: FIXTURE_FRAMES,
|
|
128
|
+
units: "meters",
|
|
129
|
+
upAxis: "Y",
|
|
130
|
+
handedness: "right",
|
|
131
|
+
objects: [
|
|
132
|
+
{
|
|
133
|
+
id: "e1",
|
|
134
|
+
name: "Assembly",
|
|
135
|
+
role: "other",
|
|
136
|
+
position: [0, 0, 0],
|
|
137
|
+
rotation: [0, 0, 0],
|
|
138
|
+
scale: [1, 1, 1],
|
|
139
|
+
visual: { kind: "group" },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "e2",
|
|
143
|
+
name: "Vehicle",
|
|
144
|
+
parentId: "e1",
|
|
145
|
+
role: "vehicle",
|
|
146
|
+
identityColor: "#3355ff",
|
|
147
|
+
anchors: [
|
|
148
|
+
{ name: "roof", position: [0, 1.4, 0] },
|
|
149
|
+
{ name: "wheel.frontLeft", position: [0.8, 0.3, 1.2] },
|
|
150
|
+
],
|
|
151
|
+
capabilities: ["transform", "color", "visibility"],
|
|
152
|
+
materialBindings: [
|
|
153
|
+
{ role: "bodyPaint", materialName: "Body Paint", color: "#3355ff" },
|
|
154
|
+
{ role: "tires", materialName: "Rubber", roughness: 0.9 },
|
|
155
|
+
],
|
|
156
|
+
visual: {
|
|
157
|
+
kind: "asset",
|
|
158
|
+
assetId: "asset-vehicle",
|
|
159
|
+
rootNodeId: "vehicle_root",
|
|
160
|
+
animation: { clipName: "drive", startFrame: 0, endFrameExclusive: 720 },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "e3",
|
|
165
|
+
name: "Marker",
|
|
166
|
+
role: "prop",
|
|
167
|
+
position: [2, 0, 0],
|
|
168
|
+
rotation: [0, 0, 0],
|
|
169
|
+
scale: [1, 1, 1],
|
|
170
|
+
visual: { kind: "primitive", primitive: "box", dimensions: [1, 1, 1], color: "#22cc88" },
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
assets: [
|
|
174
|
+
{ assetId: "asset-vehicle", kind: "glb", role: "entity-geometry", byteLength: 1_200_000, sha256: HASH_A },
|
|
175
|
+
{ assetId: "asset-track", kind: "camera-track-json", role: "camera-track", byteLength: 640_000, sha256: HASH_B },
|
|
176
|
+
{ assetId: "asset-poster", kind: "poster", role: "poster", byteLength: 90_000, sha256: HASH_C },
|
|
177
|
+
{ assetId: "asset-source", kind: "blend-source", role: "source", byteLength: 40_000_000, sha256: HASH_D },
|
|
178
|
+
],
|
|
179
|
+
cameraTrackAssetId: "asset-track",
|
|
180
|
+
shots: fixtureShots(),
|
|
181
|
+
lighting: {
|
|
182
|
+
preset: "clay-studio-v1",
|
|
183
|
+
ambientIntensity: 0.6,
|
|
184
|
+
keyIntensity: 2.4,
|
|
185
|
+
keyPosition: [4, 6, 3],
|
|
186
|
+
},
|
|
187
|
+
backgroundColor: "#101014",
|
|
188
|
+
overrides: [
|
|
189
|
+
{
|
|
190
|
+
id: "ov-1",
|
|
191
|
+
sourceRevisionId: PARENT_REVISION_ID,
|
|
192
|
+
sourceContentHash: HASH_A,
|
|
193
|
+
operationVersion: 1,
|
|
194
|
+
kind: "entity-transform",
|
|
195
|
+
entityId: "e2",
|
|
196
|
+
space: "world",
|
|
197
|
+
position: [1, 0, 0],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "ov-2",
|
|
201
|
+
sourceRevisionId: PARENT_REVISION_ID,
|
|
202
|
+
sourceContentHash: HASH_A,
|
|
203
|
+
operationVersion: 1,
|
|
204
|
+
kind: "entity-color",
|
|
205
|
+
entityId: "e2",
|
|
206
|
+
materialRole: "bodyPaint",
|
|
207
|
+
color: "#ff2200",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "ov-3",
|
|
211
|
+
sourceRevisionId: PARENT_REVISION_ID,
|
|
212
|
+
sourceContentHash: HASH_A,
|
|
213
|
+
operationVersion: 1,
|
|
214
|
+
kind: "camera-shot-offset",
|
|
215
|
+
shotId: "shot-2",
|
|
216
|
+
positionOffset: [0, 0.1, 0],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
provenance: {
|
|
220
|
+
engine: "blender-cloud",
|
|
221
|
+
engineVersion: "1.4.0",
|
|
222
|
+
recipeVersion: "1.0.0",
|
|
223
|
+
compilerVersion: "0.9.2",
|
|
224
|
+
exporterVersion: "0.9.2",
|
|
225
|
+
rendererVersion: "3.1.0",
|
|
226
|
+
sourceRevisionId: PARENT_REVISION_ID,
|
|
227
|
+
sourceArtifactId: "asset-source",
|
|
228
|
+
contentHash: "0".repeat(64),
|
|
229
|
+
},
|
|
230
|
+
...overrides,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { SCENE3D_V2_LIMITS, type Scene3DEntityV2 } from "../scene3d-v2.js"
|
|
3
|
+
import {
|
|
4
|
+
SCENE3D_V2_CONTENT_HASH_EXCLUDED,
|
|
5
|
+
canonicalScene3DPlanV2Json,
|
|
6
|
+
computeScene3DPlanV2ContentHash,
|
|
7
|
+
parseScene3DPlanV2Json,
|
|
8
|
+
scene3DV2AdmissionIssues,
|
|
9
|
+
scene3DV2HierarchyDepth,
|
|
10
|
+
scene3DV2NormalizationIssues,
|
|
11
|
+
scene3DV2ResourceUsage,
|
|
12
|
+
verifyScene3DPlanV2ContentHash,
|
|
13
|
+
type Scene3DNormalizedAssetStats,
|
|
14
|
+
} from "../scene3d-v2-resources.js"
|
|
15
|
+
import { FIXTURE_FRAMES, planV2 } from "./scene3d-v2-fixtures.js"
|
|
16
|
+
|
|
17
|
+
function stats(overrides: Partial<Scene3DNormalizedAssetStats>[] = []): Scene3DNormalizedAssetStats[] {
|
|
18
|
+
const plan = planV2()
|
|
19
|
+
const base: Scene3DNormalizedAssetStats[] = plan.assets.map((asset) => ({
|
|
20
|
+
assetId: asset.assetId,
|
|
21
|
+
kind: asset.kind,
|
|
22
|
+
byteLength: asset.byteLength,
|
|
23
|
+
sha256: asset.sha256,
|
|
24
|
+
...(asset.kind === "glb" ? { meshNodes: 40, triangles: 12_000, maxNodeDepth: 4 } : {}),
|
|
25
|
+
...(asset.kind === "poster" ? { imageWidth: 1680, imageHeight: 720 } : {}),
|
|
26
|
+
}))
|
|
27
|
+
overrides.forEach((patch, index) => {
|
|
28
|
+
if (base[index]) base[index] = { ...base[index], ...patch }
|
|
29
|
+
})
|
|
30
|
+
return base
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("scene3d v2 resources — declared usage", () => {
|
|
34
|
+
it("reports what the manifest claims", () => {
|
|
35
|
+
const usage = scene3DV2ResourceUsage(planV2())
|
|
36
|
+
expect(usage.entities).toBe(3)
|
|
37
|
+
expect(usage.assets).toBe(4)
|
|
38
|
+
expect(usage.shots).toBe(4)
|
|
39
|
+
expect(usage.overrides).toBe(3)
|
|
40
|
+
expect(usage.frames).toBe(FIXTURE_FRAMES)
|
|
41
|
+
expect(usage.durationSeconds).toBe(30)
|
|
42
|
+
expect(usage.hierarchyDepth).toBe(2)
|
|
43
|
+
// The native source is excluded from the renderer budget.
|
|
44
|
+
expect(usage.rendererAssetBytes).toBe(1_200_000 + 640_000 + 90_000)
|
|
45
|
+
expect(usage.blendSourceBytes).toBe(40_000_000)
|
|
46
|
+
expect(usage.cameraTrackBytes).toBe(640_000)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("measures hierarchy depth without looping on a cyclic plan", () => {
|
|
50
|
+
const cyclic: Scene3DEntityV2[] = [
|
|
51
|
+
{ id: "a", name: "a", parentId: "b", visual: { kind: "group" }, position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
|
|
52
|
+
{ id: "b", name: "b", parentId: "a", visual: { kind: "group" }, position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] },
|
|
53
|
+
]
|
|
54
|
+
expect(scene3DV2HierarchyDepth(cyclic)).toBe(2)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("scene3d v2 resources — pre-allocation gate", () => {
|
|
59
|
+
it("passes the fixture", () => {
|
|
60
|
+
expect(scene3DV2AdmissionIssues(planV2())).toEqual([])
|
|
61
|
+
expect(scene3DV2AdmissionIssues(planV2(), 40_000)).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("refuses an oversized manifest", () => {
|
|
65
|
+
const issues = scene3DV2AdmissionIssues(planV2(), SCENE3D_V2_LIMITS.maxManifestBytes + 1)
|
|
66
|
+
expect(issues.map((issue) => issue.message).join(" ")).toContain("manifest is")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("refuses a scene past either duration ceiling", () => {
|
|
70
|
+
expect(
|
|
71
|
+
scene3DV2AdmissionIssues(planV2({ durationInFrames: 3601 })).map((issue) => issue.message).join(" "),
|
|
72
|
+
).toContain("3601 frames")
|
|
73
|
+
expect(
|
|
74
|
+
scene3DV2AdmissionIssues(planV2({ fps: 15, durationInFrames: 3000 })).map((issue) => issue.message).join(" "),
|
|
75
|
+
).toContain("the limit is 60s")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("refuses declared asset bytes past the download budget", () => {
|
|
79
|
+
const plan = planV2()
|
|
80
|
+
plan.assets[0] = { ...plan.assets[0], byteLength: SCENE3D_V2_LIMITS.maxRendererAssetBytes }
|
|
81
|
+
expect(scene3DV2AdmissionIssues(plan).map((issue) => issue.message).join(" ")).toContain(
|
|
82
|
+
"downloaded scene assets total",
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("refuses too many entities or shots", () => {
|
|
87
|
+
const many = Array.from({ length: 101 }, (_unused, index) => ({
|
|
88
|
+
id: `n${index}`,
|
|
89
|
+
name: `n${index}`,
|
|
90
|
+
position: [0, 0, 0] as [number, number, number],
|
|
91
|
+
rotation: [0, 0, 0] as [number, number, number],
|
|
92
|
+
scale: [1, 1, 1] as [number, number, number],
|
|
93
|
+
visual: { kind: "group" as const },
|
|
94
|
+
}))
|
|
95
|
+
expect(scene3DV2AdmissionIssues(planV2({ objects: many })).map((i) => i.message).join(" ")).toContain(
|
|
96
|
+
"101 entities",
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("scene3d v2 resources — post-decode gate", () => {
|
|
102
|
+
it("passes matching stats", () => {
|
|
103
|
+
expect(scene3DV2NormalizationIssues(planV2(), stats())).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("catches bytes that do not match the manifest", () => {
|
|
107
|
+
const issues = scene3DV2NormalizationIssues(planV2(), stats([{ byteLength: 999 }]))
|
|
108
|
+
expect(issues.map((issue) => issue.message).join(" ")).toContain("is 999 bytes; the manifest declares")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("catches a digest that does not match", () => {
|
|
112
|
+
const issues = scene3DV2NormalizationIssues(planV2(), stats([{ sha256: "9".repeat(64) }]))
|
|
113
|
+
expect(issues.map((issue) => issue.message).join(" ")).toContain("digest does not match")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("catches an undeclared asset and a kind that changed after decode", () => {
|
|
117
|
+
const foreign = scene3DV2NormalizationIssues(planV2(), [
|
|
118
|
+
{ assetId: "asset-ghost", kind: "glb", byteLength: 10 },
|
|
119
|
+
])
|
|
120
|
+
expect(foreign.map((issue) => issue.message).join(" ")).toContain("is not declared in the manifest")
|
|
121
|
+
|
|
122
|
+
const wrongKind = scene3DV2NormalizationIssues(planV2(), stats([{ kind: "poster" }]))
|
|
123
|
+
expect(wrongKind.map((issue) => issue.message).join(" ")).toContain("decoded as")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("sums triangles and mesh nodes across assets — compression waives nothing", () => {
|
|
127
|
+
// A small file that decodes past the geometry budget is still refused.
|
|
128
|
+
const overTriangles = scene3DV2NormalizationIssues(
|
|
129
|
+
planV2(),
|
|
130
|
+
stats([{ triangles: SCENE3D_V2_LIMITS.maxTriangles + 1 }]),
|
|
131
|
+
)
|
|
132
|
+
expect(overTriangles.map((issue) => issue.message).join(" ")).toContain("triangles; the limit is 200000")
|
|
133
|
+
|
|
134
|
+
const overNodes = scene3DV2NormalizationIssues(planV2(), stats([{ meshNodes: 2001 }]))
|
|
135
|
+
expect(overNodes.map((issue) => issue.message).join(" ")).toContain("2001 mesh nodes")
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("bounds node depth and image dimensions", () => {
|
|
139
|
+
const deep = scene3DV2NormalizationIssues(planV2(), stats([{ maxNodeDepth: 17 }]))
|
|
140
|
+
expect(deep.map((issue) => issue.message).join(" ")).toContain("nests 17 levels")
|
|
141
|
+
|
|
142
|
+
const huge = scene3DV2NormalizationIssues(planV2(), stats([{}, {}, { imageWidth: 9000 }]))
|
|
143
|
+
expect(huge.map((issue) => issue.message).join(" ")).toContain("imageWidth is 9000")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("bounds decoded renderer bytes even when the manifest lied consistently", () => {
|
|
147
|
+
const plan = planV2()
|
|
148
|
+
plan.assets[0] = { ...plan.assets[0], byteLength: 70 * 1024 * 1024 }
|
|
149
|
+
const issues = scene3DV2NormalizationIssues(plan, stats([{ byteLength: 70 * 1024 * 1024 }]))
|
|
150
|
+
expect(issues.map((issue) => issue.message).join(" ")).toContain("decoded to")
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe("scene3d v2 resources — manifest admission", () => {
|
|
155
|
+
it("accepts a valid manifest payload", () => {
|
|
156
|
+
const result = parseScene3DPlanV2Json(JSON.stringify(planV2()))
|
|
157
|
+
expect(result.ok).toBe(true)
|
|
158
|
+
expect(result.ok && result.value.schemaVersion).toBe(2)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("refuses an oversized manifest before parsing", () => {
|
|
162
|
+
const result = parseScene3DPlanV2Json("x".repeat(SCENE3D_V2_LIMITS.maxManifestBytes + 1))
|
|
163
|
+
expect(result.ok).toBe(false)
|
|
164
|
+
expect(result.ok ? "" : result.issues[0].message).toContain("manifest is")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("reports malformed JSON and schema failures as issues", () => {
|
|
168
|
+
expect(parseScene3DPlanV2Json("{{").ok).toBe(false)
|
|
169
|
+
const broken = parseScene3DPlanV2Json(JSON.stringify(planV2({ shots: [] })))
|
|
170
|
+
expect(broken.ok).toBe(false)
|
|
171
|
+
expect(broken.ok ? [] : broken.issues.length).toBeGreaterThan(0)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("scene3d v2 resources — content hash", () => {
|
|
176
|
+
it("excludes identity, includes content", () => {
|
|
177
|
+
expect([...SCENE3D_V2_CONTENT_HASH_EXCLUDED]).toEqual(["revisionId", "parentRevisionId"])
|
|
178
|
+
const canonical = canonicalScene3DPlanV2Json(planV2())
|
|
179
|
+
expect(canonical).not.toContain("revisionId")
|
|
180
|
+
expect(canonical).not.toContain("contentHash")
|
|
181
|
+
expect(canonical).toContain("bodyPaint")
|
|
182
|
+
expect(canonical).toContain("shot-2")
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("is stable under key reordering and identity changes", async () => {
|
|
186
|
+
const a = planV2()
|
|
187
|
+
const reordered = JSON.parse(JSON.stringify({ ...a })) as typeof a
|
|
188
|
+
// Rebuild the top level in reverse key order.
|
|
189
|
+
const shuffled = Object.fromEntries(
|
|
190
|
+
Object.entries(reordered).reverse(),
|
|
191
|
+
) as unknown as typeof a
|
|
192
|
+
expect(canonicalScene3DPlanV2Json(shuffled)).toBe(canonicalScene3DPlanV2Json(a))
|
|
193
|
+
|
|
194
|
+
const otherRevision = planV2({ revisionId: "7d5b0b64-2b6c-4d4a-9e4f-2f4b7f6a1c99" })
|
|
195
|
+
expect(await computeScene3DPlanV2ContentHash(otherRevision)).toBe(
|
|
196
|
+
await computeScene3DPlanV2ContentHash(a),
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("changes when any content changes", async () => {
|
|
201
|
+
const base = await computeScene3DPlanV2ContentHash(planV2())
|
|
202
|
+
const recoloured = planV2()
|
|
203
|
+
recoloured.objects[1].materialBindings = [
|
|
204
|
+
{ role: "bodyPaint", materialName: "Body Paint", color: "#00ff00" },
|
|
205
|
+
{ role: "tires", materialName: "Rubber", roughness: 0.9 },
|
|
206
|
+
]
|
|
207
|
+
expect(await computeScene3DPlanV2ContentHash(recoloured)).not.toBe(base)
|
|
208
|
+
|
|
209
|
+
const withoutOverride = planV2({ overrides: [] })
|
|
210
|
+
expect(await computeScene3DPlanV2ContentHash(withoutOverride)).not.toBe(base)
|
|
211
|
+
|
|
212
|
+
const differentDigest = planV2()
|
|
213
|
+
differentDigest.assets[0] = { ...differentDigest.assets[0], sha256: "1".repeat(64) }
|
|
214
|
+
expect(await computeScene3DPlanV2ContentHash(differentDigest)).not.toBe(base)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("normalizes -0 so it cannot fork the hash", () => {
|
|
218
|
+
const zero = planV2()
|
|
219
|
+
zero.objects[2].position = [0, 0, 0]
|
|
220
|
+
const negativeZero = planV2()
|
|
221
|
+
negativeZero.objects[2].position = [-0, 0, 0]
|
|
222
|
+
expect(canonicalScene3DPlanV2Json(negativeZero)).toBe(canonicalScene3DPlanV2Json(zero))
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("produces 64 lowercase hex characters, and verifies a declared hash", async () => {
|
|
226
|
+
const plan = planV2()
|
|
227
|
+
const hash = await computeScene3DPlanV2ContentHash(plan)
|
|
228
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
|
229
|
+
expect(await verifyScene3DPlanV2ContentHash(plan)).toBe(false)
|
|
230
|
+
expect(
|
|
231
|
+
await verifyScene3DPlanV2ContentHash({ ...plan, provenance: { ...plan.provenance, contentHash: hash } }),
|
|
232
|
+
).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("omits undefined-valued keys instead of emitting them", () => {
|
|
236
|
+
const withUndefined = { ...planV2(), parentRevisionId: undefined }
|
|
237
|
+
expect(canonicalScene3DPlanV2Json(withUndefined)).toBe(canonicalScene3DPlanV2Json(planV2()))
|
|
238
|
+
})
|
|
239
|
+
})
|