@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
package/package.json
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
SCENE3D_CAMERA_TRACK_LIMITS,
|
|
4
|
+
isScene3DCameraTrack,
|
|
5
|
+
parseScene3DCameraTrackJson,
|
|
6
|
+
scene3DCameraTrackIssues,
|
|
7
|
+
scene3DCameraTrackPlanIssues,
|
|
8
|
+
scene3DCameraTrackSchema,
|
|
9
|
+
scene3DProjectionIssues,
|
|
10
|
+
scene3DSampleForFrame,
|
|
11
|
+
} from "../scene3d-camera-track.js"
|
|
12
|
+
import {
|
|
13
|
+
FIXTURE_FPS,
|
|
14
|
+
FIXTURE_FRAMES,
|
|
15
|
+
FIXTURE_HEIGHT,
|
|
16
|
+
FIXTURE_WIDTH,
|
|
17
|
+
cameraSample,
|
|
18
|
+
cameraTrack,
|
|
19
|
+
orthographicMatrix,
|
|
20
|
+
perspectiveMatrix,
|
|
21
|
+
planV2,
|
|
22
|
+
} from "./scene3d-v2-fixtures.js"
|
|
23
|
+
|
|
24
|
+
function joined(track: unknown): string {
|
|
25
|
+
const result = scene3DCameraTrackSchema.safeParse(track)
|
|
26
|
+
return result.success ? "" : result.error.issues.map((issue) => issue.message).join(" | ")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("scene3d camera track — structure", () => {
|
|
30
|
+
it("accepts a full 720-sample track", () => {
|
|
31
|
+
const track = cameraTrack()
|
|
32
|
+
expect(scene3DCameraTrackSchema.safeParse(track).success).toBe(true)
|
|
33
|
+
expect(isScene3DCameraTrack(track)).toBe(true)
|
|
34
|
+
expect(scene3DCameraTrackIssues(track)).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("accepts the maximum frame count and rejects one more", () => {
|
|
38
|
+
expect(SCENE3D_CAMERA_TRACK_LIMITS.maxFrameCount).toBe(3600)
|
|
39
|
+
expect(scene3DCameraTrackSchema.safeParse(cameraTrack(3600)).success).toBe(true)
|
|
40
|
+
expect(scene3DCameraTrackSchema.safeParse(cameraTrack(3601)).success).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("requires exactly one sample per frame", () => {
|
|
44
|
+
const short = cameraTrack(10)
|
|
45
|
+
short.samples = short.samples.slice(0, 9)
|
|
46
|
+
expect(joined(short)).toContain("exactly one sample per frame is required")
|
|
47
|
+
|
|
48
|
+
const long = cameraTrack(10)
|
|
49
|
+
long.samples.push(cameraSample())
|
|
50
|
+
expect(joined(long)).toContain("exactly one sample per frame is required")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("pins the format, version and zero-based frame start", () => {
|
|
54
|
+
expect(joined({ ...cameraTrack(2), format: "something-else" })).not.toBe("")
|
|
55
|
+
expect(joined({ ...cameraTrack(2), version: 2 })).not.toBe("")
|
|
56
|
+
expect(joined({ ...cameraTrack(2), frameStart: 1 })).not.toBe("")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("rejects unknown keys on the track and on a sample", () => {
|
|
60
|
+
expect(joined({ ...cameraTrack(2), extra: true })).not.toBe("")
|
|
61
|
+
const track = cameraTrack(2)
|
|
62
|
+
;(track.samples[0] as unknown as Record<string, unknown>).lookAtOverride = [0, 0, 0]
|
|
63
|
+
expect(joined(track)).not.toBe("")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("rejects a foreign value outright", () => {
|
|
67
|
+
for (const bad of [null, 7, "track", [], {}]) {
|
|
68
|
+
expect(isScene3DCameraTrack(bad)).toBe(false)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("scene3d camera track — quaternions", () => {
|
|
74
|
+
it("rejects an unnormalized quaternion", () => {
|
|
75
|
+
const track = cameraTrack(3)
|
|
76
|
+
track.samples[1] = cameraSample({ quaternion: [0, 0, 0, 2] })
|
|
77
|
+
expect(joined(track)).toContain("it must be normalized")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("accepts float noise inside the tolerance", () => {
|
|
81
|
+
const track = cameraTrack(3)
|
|
82
|
+
track.samples[1] = cameraSample({ quaternion: [0, 0, 0, 1 + 1e-6] })
|
|
83
|
+
expect(scene3DCameraTrackSchema.safeParse(track).success).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("rejects a non-finite quaternion component", () => {
|
|
87
|
+
const track = cameraTrack(2)
|
|
88
|
+
track.samples[0] = cameraSample({ quaternion: [0, 0, 0, Number.NaN] })
|
|
89
|
+
expect(scene3DCameraTrackSchema.safeParse(track).success).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("scene3d camera track — projection", () => {
|
|
94
|
+
const aspect = FIXTURE_WIDTH / FIXTURE_HEIGHT
|
|
95
|
+
|
|
96
|
+
it("accepts a real perspective matrix", () => {
|
|
97
|
+
expect(scene3DProjectionIssues(perspectiveMatrix(35, aspect, 0.1, 200), 0.1, 200, ["m"])).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("names orthographic instead of mis-reading it as a broken perspective", () => {
|
|
101
|
+
const issues = scene3DProjectionIssues(orthographicMatrix(0.1, 200), 0.1, 200, ["m"])
|
|
102
|
+
expect(issues).toHaveLength(1)
|
|
103
|
+
expect(issues[0].message).toContain("orthographic")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("rejects the wrong number of entries and non-finite entries", () => {
|
|
107
|
+
expect(scene3DProjectionIssues([1, 2, 3], 0.1, 200, ["m"])[0].message).toContain("exactly 16 entries")
|
|
108
|
+
const broken = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
109
|
+
broken[0] = Number.POSITIVE_INFINITY
|
|
110
|
+
expect(scene3DProjectionIssues(broken, 0.1, 200, ["m"])[0].message).toContain("non-finite")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("rejects a matrix whose fixed entries are not fixed", () => {
|
|
114
|
+
for (const index of [1, 2, 3, 4, 6, 7, 12, 13, 15]) {
|
|
115
|
+
const matrix = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
116
|
+
matrix[index] = 0.5
|
|
117
|
+
const issues = scene3DProjectionIssues(matrix, 0.1, 200, ["m"])
|
|
118
|
+
expect(issues.length).toBeGreaterThan(0)
|
|
119
|
+
}
|
|
120
|
+
const notPerspective = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
121
|
+
notPerspective[11] = -0.5
|
|
122
|
+
expect(scene3DProjectionIssues(notPerspective, 0.1, 200, ["m"])[0].message).toContain("must be -1")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("rejects a matrix that disagrees with its own declared near/far", () => {
|
|
126
|
+
const matrix = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
127
|
+
const nearIssues = scene3DProjectionIssues(matrix, 0.5, 200, ["m"])
|
|
128
|
+
expect(nearIssues.map((issue) => issue.message).join(" ")).toContain("implies near")
|
|
129
|
+
|
|
130
|
+
const farIssues = scene3DProjectionIssues(matrix, 0.1, 500, ["m"])
|
|
131
|
+
expect(farIssues.map((issue) => issue.message).join(" ")).toContain("implies far")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("rejects an infinite-far matrix that declares a finite far plane", () => {
|
|
135
|
+
const matrix = perspectiveMatrix(35, aspect, 0.1, 200)
|
|
136
|
+
matrix[10] = -1
|
|
137
|
+
matrix[14] = -0.2
|
|
138
|
+
expect(scene3DProjectionIssues(matrix, 0.1, 200, ["m"]).map((i) => i.message).join(" ")).toContain(
|
|
139
|
+
"infinite far plane",
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("rejects near/far that are not strictly ordered", () => {
|
|
144
|
+
const track = cameraTrack(2)
|
|
145
|
+
track.samples[0] = cameraSample({ near: 200, far: 200 })
|
|
146
|
+
expect(joined(track)).toContain("must be greater than near")
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe("scene3d camera track — agreement with the manifest", () => {
|
|
151
|
+
const plan = planV2()
|
|
152
|
+
|
|
153
|
+
it("accepts a matching track", () => {
|
|
154
|
+
expect(scene3DCameraTrackPlanIssues(cameraTrack(), plan)).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("rejects a different fps — retiming needs a new revision", () => {
|
|
158
|
+
const track = cameraTrack(FIXTURE_FRAMES, { fps: 30 })
|
|
159
|
+
const messages = scene3DCameraTrackPlanIssues(track, plan).map((issue) => issue.message)
|
|
160
|
+
expect(messages.join(" ")).toContain("changing fps requires an explicit resample")
|
|
161
|
+
expect(FIXTURE_FPS).toBe(24)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("rejects a different length", () => {
|
|
165
|
+
const messages = scene3DCameraTrackPlanIssues(cameraTrack(600), plan).map((issue) => issue.message)
|
|
166
|
+
expect(messages.join(" ")).toContain("covers 600 frames but the scene is 720 frames")
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("rejects a projection baked for another aspect ratio", () => {
|
|
170
|
+
const track = cameraTrack(4)
|
|
171
|
+
track.samples[2] = cameraSample({ projectionMatrix: perspectiveMatrix(35, 16 / 9, 0.1, 200) })
|
|
172
|
+
const messages = scene3DCameraTrackPlanIssues(track, {
|
|
173
|
+
fps: FIXTURE_FPS,
|
|
174
|
+
durationInFrames: 4,
|
|
175
|
+
width: FIXTURE_WIDTH,
|
|
176
|
+
height: FIXTURE_HEIGHT,
|
|
177
|
+
}).map((issue) => issue.message)
|
|
178
|
+
expect(messages.join(" ")).toContain("reprojection requires a new revision")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe("scene3d camera track — deterministic sampling", () => {
|
|
183
|
+
it("maps frame f to samples[f], and refuses to clamp", () => {
|
|
184
|
+
const track = cameraTrack(10)
|
|
185
|
+
for (const frame of [0, 4, 9]) {
|
|
186
|
+
expect(scene3DSampleForFrame(track, frame)).toBe(track.samples[frame])
|
|
187
|
+
}
|
|
188
|
+
expect(scene3DSampleForFrame(track, 10)).toBeUndefined()
|
|
189
|
+
expect(scene3DSampleForFrame(track, -1)).toBeUndefined()
|
|
190
|
+
expect(scene3DSampleForFrame(track, 2.5)).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("is order-independent: backwards and shuffled reads give identical samples", () => {
|
|
194
|
+
const track = cameraTrack(24)
|
|
195
|
+
const forward = Array.from({ length: 24 }, (_unused, frame) => scene3DSampleForFrame(track, frame))
|
|
196
|
+
const backward = Array.from({ length: 24 }, (_unused, index) => scene3DSampleForFrame(track, 23 - index)).reverse()
|
|
197
|
+
const shuffled = [7, 0, 23, 12, 3].map((frame) => scene3DSampleForFrame(track, frame))
|
|
198
|
+
expect(backward).toEqual(forward)
|
|
199
|
+
expect(shuffled).toEqual([7, 0, 23, 12, 3].map((frame) => forward[frame]))
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe("scene3d camera track — JSON admission", () => {
|
|
204
|
+
it("accepts a well-formed payload", () => {
|
|
205
|
+
const result = parseScene3DCameraTrackJson(JSON.stringify(cameraTrack(12)))
|
|
206
|
+
expect(result.ok).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("refuses an oversized payload before parsing it", () => {
|
|
210
|
+
const padding = "x".repeat(SCENE3D_CAMERA_TRACK_LIMITS.maxJsonBytes + 1)
|
|
211
|
+
const result = parseScene3DCameraTrackJson(padding)
|
|
212
|
+
expect(result.ok).toBe(false)
|
|
213
|
+
expect(result.ok ? "" : result.issues[0].message).toContain("the limit is")
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("reports malformed JSON as an issue rather than throwing", () => {
|
|
217
|
+
const result = parseScene3DCameraTrackJson("{ not json")
|
|
218
|
+
expect(result.ok).toBe(false)
|
|
219
|
+
expect(result.ok ? "" : result.issues[0].message).toContain("not valid JSON")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it("reports schema failures with paths", () => {
|
|
223
|
+
const track = cameraTrack(3)
|
|
224
|
+
track.samples[1] = cameraSample({ quaternion: [1, 1, 1, 1] })
|
|
225
|
+
const result = parseScene3DCameraTrackJson(JSON.stringify(track))
|
|
226
|
+
expect(result.ok).toBe(false)
|
|
227
|
+
expect(result.ok ? [] : result.issues.map((issue) => issue.path.join("."))).toContain("samples.1.quaternion")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("counts bytes, not characters", () => {
|
|
231
|
+
// A multi-byte name must not slip past a length check that counted chars.
|
|
232
|
+
const text = JSON.stringify({ note: "é".repeat(10) })
|
|
233
|
+
expect(text.length).toBeLessThan(new TextEncoder().encode(text).length)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { applyScene3DV2EditOperations } from "../scene3d-v2-edit.js"
|
|
3
|
+
import { computeScene3DPlanV2ContentHash, verifyScene3DPlanV2ContentHash } from "../scene3d-v2-resources.js"
|
|
4
|
+
import { planV2 } from "./scene3d-v2-fixtures.js"
|
|
5
|
+
|
|
6
|
+
async function fixture() {
|
|
7
|
+
const p = planV2()
|
|
8
|
+
return { ...p, provenance: { ...p.provenance, contentHash: await computeScene3DPlanV2ContentHash(p) } }
|
|
9
|
+
}
|
|
10
|
+
const move = { op: "set-override" as const, override: { kind: "entity-transform" as const, entityId: "e2", space: "world" as const, position: [4, 0, 0] as [number, number, number] } }
|
|
11
|
+
const revision = "c94b9f2f-4f10-48a6-a0bf-3ca4c2653b5a"
|
|
12
|
+
|
|
13
|
+
describe("baked scene edits", () => {
|
|
14
|
+
it("preserves unrelated state, stamps an immutable revision, and withdraws a stale native download", async () => {
|
|
15
|
+
const p = await fixture()
|
|
16
|
+
const before = JSON.stringify(p)
|
|
17
|
+
const result = await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: p.revisionId, newRevisionId: revision })
|
|
18
|
+
expect(result.ok).toBe(true)
|
|
19
|
+
if (!result.ok) return
|
|
20
|
+
expect(result.plan.revisionId).toBe(revision)
|
|
21
|
+
expect(result.plan.parentRevisionId).toBe(p.revisionId)
|
|
22
|
+
expect(result.plan.objects).toEqual(p.objects)
|
|
23
|
+
expect(result.plan.assets).toEqual(p.assets.filter((a) => a.kind === "glb" || a.kind === "camera-track-json").map((a) => ({ ...a, originRevisionId: a.originRevisionId ?? p.revisionId })))
|
|
24
|
+
expect(result.plan.provenance.sourceArtifactId).toBeUndefined()
|
|
25
|
+
expect(result.plan.overrides?.find((o) => o.kind === "entity-color")).toEqual(p.overrides?.find((o) => o.kind === "entity-color"))
|
|
26
|
+
expect(result.plan.overrides?.find((o) => o.kind === "entity-transform")).toMatchObject({ position: [4, 0, 0], sourceRevisionId: p.revisionId, sourceContentHash: p.provenance.contentHash })
|
|
27
|
+
expect(await verifyScene3DPlanV2ContentHash(result.plan)).toBe(true)
|
|
28
|
+
expect(JSON.stringify(p)).toBe(before)
|
|
29
|
+
})
|
|
30
|
+
it("replays identically with an admission-allocated revision", async () => {
|
|
31
|
+
const p = await fixture()
|
|
32
|
+
const options = { expectedRevisionId: p.revisionId, newRevisionId: revision }
|
|
33
|
+
expect(await applyScene3DV2EditOperations(p, [move], options)).toEqual(await applyScene3DV2EditOperations(p, [move], options))
|
|
34
|
+
})
|
|
35
|
+
it("withdraws images and validation that describe the previous revision", async () => {
|
|
36
|
+
const p = await fixture()
|
|
37
|
+
p.assets.push(
|
|
38
|
+
{ assetId: "12b3a7d6-3aef-4e5f-b388-e6b96dcaa0f1", kind: "poster", role: "poster", sha256: "a".repeat(64), byteLength: 100 },
|
|
39
|
+
{ assetId: "12b3a7d6-3aef-4e5f-b388-e6b96dcaa0f2", kind: "validation-report", role: "validation-report", sha256: "b".repeat(64), byteLength: 100 },
|
|
40
|
+
)
|
|
41
|
+
p.provenance.contentHash = await computeScene3DPlanV2ContentHash(p)
|
|
42
|
+
const result = await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: p.revisionId })
|
|
43
|
+
expect(result.ok).toBe(true)
|
|
44
|
+
if (result.ok) expect(result.plan.assets.every((a) => a.kind === "glb" || a.kind === "camera-track-json")).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
it("rejects stale identity and content, and tampered manifests", async () => {
|
|
47
|
+
const p = await fixture()
|
|
48
|
+
expect(await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: revision })).toMatchObject({ ok: false, code: "stale_revision" })
|
|
49
|
+
expect(await applyScene3DV2EditOperations(p, [move], { expectedRevisionId: p.revisionId, expectedContentHash: "a".repeat(64) })).toMatchObject({ ok: false, code: "stale_revision" })
|
|
50
|
+
expect(await applyScene3DV2EditOperations({ ...p, backgroundColor: "#ffffff" }, [move], { expectedRevisionId: p.revisionId })).toMatchObject({ ok: false, code: "invalid_plan" })
|
|
51
|
+
})
|
|
52
|
+
it("blocks changes and removal of changes on locked entities", async () => {
|
|
53
|
+
const p = await fixture()
|
|
54
|
+
const options = { expectedRevisionId: p.revisionId, lockedObjectIds: ["e2"] }
|
|
55
|
+
expect(await applyScene3DV2EditOperations(p, [move], options)).toMatchObject({ ok: false, code: "locked" })
|
|
56
|
+
expect(await applyScene3DV2EditOperations(p, [{ op: "remove-override", overrideId: "ov-1" }], options)).toMatchObject({ ok: false, code: "locked" })
|
|
57
|
+
})
|
|
58
|
+
it("cannot evade a descendant lock by moving or hiding its parent", async () => {
|
|
59
|
+
const p = await fixture()
|
|
60
|
+
const options = { expectedRevisionId: p.revisionId, lockedObjectIds: ["e2"] }
|
|
61
|
+
expect(await applyScene3DV2EditOperations(p, [{ ...move, override: { ...move.override, entityId: "e1" } }], options)).toMatchObject({ ok: false, code: "locked" })
|
|
62
|
+
expect(await applyScene3DV2EditOperations(p, [{ op: "set-override", override: { kind: "entity-visibility", entityId: "e1", visible: false } }], options)).toMatchObject({ ok: false, code: "locked" })
|
|
63
|
+
})
|
|
64
|
+
it("preserves edited pose components when another component changes", async () => {
|
|
65
|
+
const p = await fixture()
|
|
66
|
+
const result = await applyScene3DV2EditOperations(p, [move, { ...move, override: { kind: "entity-transform", entityId: "e2", space: "world", rotation: [0, 1, 0] } }], { expectedRevisionId: p.revisionId })
|
|
67
|
+
expect(result.ok).toBe(true)
|
|
68
|
+
if (result.ok) expect(result.plan.overrides?.find((o) => o.kind === "entity-transform")).toMatchObject({ position: [4, 0, 0], rotation: [0, 1, 0] })
|
|
69
|
+
})
|
|
70
|
+
it("does not carry coordinates across a space change", async () => {
|
|
71
|
+
const p = await fixture()
|
|
72
|
+
const result = await applyScene3DV2EditOperations(p, [{ ...move, override: { kind: "entity-transform", entityId: "e2", space: "local", rotation: [0, 1, 0] } }], { expectedRevisionId: p.revisionId })
|
|
73
|
+
expect(result.ok).toBe(true)
|
|
74
|
+
if (result.ok) expect(result.plan.overrides?.find((o) => o.kind === "entity-transform")).not.toHaveProperty("position")
|
|
75
|
+
})
|
|
76
|
+
it("rejects missing targets, unknown material roles, and invalid later operations atomically", async () => {
|
|
77
|
+
const p = await fixture()
|
|
78
|
+
const before = JSON.stringify(p)
|
|
79
|
+
expect(await applyScene3DV2EditOperations(p, [move, { op: "set-override", override: { kind: "entity-color", entityId: "e2", materialRole: "chair", color: "#ffffff" } }], { expectedRevisionId: p.revisionId })).toMatchObject({ ok: false, code: "invalid_operations" })
|
|
80
|
+
expect(await applyScene3DV2EditOperations(p, [{ op: "remove-override", overrideId: "missing" }], { expectedRevisionId: p.revisionId })).toMatchObject({ ok: false, code: "invalid_operations" })
|
|
81
|
+
expect(JSON.stringify(p)).toBe(before)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -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
|
+
}
|