@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,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
|
+
})
|