@deepblend/dsh-blender-contracts 0.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/lib/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneSpec v1 — validation, semantic rules, and the deterministic camera solver.
|
|
3
|
+
*
|
|
4
|
+
* Two validation layers, deliberately separated (SPEC §13.2 "JSON Schema 验证"
|
|
5
|
+
* then "技术验证"):
|
|
6
|
+
*
|
|
7
|
+
* 1. **Structural** — the JSON Schema in `deepblend/schemas/scene-spec.schema.json`.
|
|
8
|
+
* Answers "is this a well-formed SceneSpec?" It cannot answer anything that
|
|
9
|
+
* needs two parts of the document at once.
|
|
10
|
+
* 2. **Semantic** — this module. Answers "does this document describe a scene
|
|
11
|
+
* that can actually be compiled?": unique ids, every reference resolving, no
|
|
12
|
+
* cycles, sane numbers, and no path escaping the project root.
|
|
13
|
+
*
|
|
14
|
+
* A failure in either layer is an *error* and refuses the revision. A "notice"
|
|
15
|
+
* is different: it records that compilation had to make a decision the author
|
|
16
|
+
* did not spell out (aiming a camera, applying a default). Notices are carried
|
|
17
|
+
* into the digest so the model can see exactly what the compiler decided on its
|
|
18
|
+
* behalf instead of discovering it in a render.
|
|
19
|
+
*
|
|
20
|
+
* Owner: DeepBlend Studio — M1
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { canonicalStringify, sha256Canonical } from './canonical.js'
|
|
24
|
+
import { compileSchema, formatIssues } from './json-schema.js'
|
|
25
|
+
import sceneSpecSchema from './schemas/scene-spec.schema.json' with { type: 'json' }
|
|
26
|
+
|
|
27
|
+
/** The one schema version this module understands. */
|
|
28
|
+
export const SCENE_SCHEMA_VERSION = 'deepblend.scene/v1'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a scene with no `world` block is lit by.
|
|
32
|
+
*
|
|
33
|
+
* These numbers used to exist ONLY as literals inside the Blender compiler
|
|
34
|
+
* (`deepblend_scene.py`), which made "the background stays grey whatever the brief
|
|
35
|
+
* asks for" unreachable from a SceneSpec. They are still the default, but they are
|
|
36
|
+
* now contract: the schema declares the same values under `default`, and a test
|
|
37
|
+
* asserts the two agree, so the copies cannot drift apart silently.
|
|
38
|
+
*
|
|
39
|
+
* They are deliberately NOT materialised into the compiled spec. The scene digest
|
|
40
|
+
* is taken over the compiled document, so filling in `world: {...}` for a scene that
|
|
41
|
+
* never mentioned it would change the digest of every revision ever recorded and
|
|
42
|
+
* the store would look corrupt against its own manifests.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_WORLD = Object.freeze({
|
|
45
|
+
color: Object.freeze([0.02, 0.021, 0.026, 1]),
|
|
46
|
+
strength: 0.6,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** `id` grammar, mirrored from the schema so semantic messages can name it. */
|
|
50
|
+
const ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What an animation track can be pointed at.
|
|
54
|
+
*
|
|
55
|
+
* `entity` is the default and the only kind that existed before: an animation could
|
|
56
|
+
* only move generated geometry, so "the camera orbits the product" had to be faked
|
|
57
|
+
* by rotating the product instead, and "the dial lights up" had to be faked by
|
|
58
|
+
* scaling emissive geometry instead of ramping a material.
|
|
59
|
+
*/
|
|
60
|
+
/** Transform channels, shared by every target that has a `transform`. */
|
|
61
|
+
export const TRANSFORM_ANIMATION_PROPERTIES = Object.freeze([
|
|
62
|
+
'location.x', 'location.y', 'location.z',
|
|
63
|
+
'rotationEuler.x', 'rotationEuler.y', 'rotationEuler.z',
|
|
64
|
+
'scale.x', 'scale.y', 'scale.z',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Material parameters that can be ramped.
|
|
69
|
+
*
|
|
70
|
+
* These are the same names `material.parameter.update` takes, so a reader has one
|
|
71
|
+
* vocabulary rather than two. Which Blender socket each one addresses is the
|
|
72
|
+
* compiler's business (`PRINCIPLED_SOCKETS`).
|
|
73
|
+
*/
|
|
74
|
+
export const MATERIAL_ANIMATION_PROPERTIES = Object.freeze([
|
|
75
|
+
'emissionStrength', 'roughness', 'metallic', 'ior', 'alpha', 'coatWeight', 'transmissionWeight',
|
|
76
|
+
'baseColor.r', 'baseColor.g', 'baseColor.b',
|
|
77
|
+
'emissionColor.r', 'emissionColor.g', 'emissionColor.b',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/** The property vocabulary, per target kind. */
|
|
81
|
+
export const ANIMATION_PROPERTIES_BY_KIND = Object.freeze({
|
|
82
|
+
entity: TRANSFORM_ANIMATION_PROPERTIES,
|
|
83
|
+
camera: TRANSFORM_ANIMATION_PROPERTIES,
|
|
84
|
+
material: MATERIAL_ANIMATION_PROPERTIES,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which collection an animation track's `targetKind` resolves against.
|
|
89
|
+
*
|
|
90
|
+
* ONE definition of the branch. The patch transaction and `validateSceneSpec` each
|
|
91
|
+
* spelled out `entities` on their own, and they disagreed the moment kinds existed:
|
|
92
|
+
* the schema accepted a legal camera track while the transaction still resolved it
|
|
93
|
+
* against `entities` and refused the patch with a message about a missing entity.
|
|
94
|
+
* The name form exists because the validator indexes collections by key.
|
|
95
|
+
*/
|
|
96
|
+
export function collectionNameForKind(kind) {
|
|
97
|
+
if (kind === 'camera') return 'cameras'
|
|
98
|
+
if (kind === 'material') return 'materials'
|
|
99
|
+
return 'entities'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The entries themselves, for a caller holding the document rather than an index. */
|
|
103
|
+
export function collectionForKind(spec, kind) {
|
|
104
|
+
return spec[collectionNameForKind(kind)] ?? []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Material parameters that must not go negative; a negative emission is a black one. */
|
|
108
|
+
const NON_NEGATIVE_MATERIAL_PROPERTIES = Object.freeze([
|
|
109
|
+
'emissionStrength', 'emissionColor.r', 'emissionColor.g', 'emissionColor.b',
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
/** Material parameters that are only meaningful inside [0, 1]. */
|
|
113
|
+
const UNIT_MATERIAL_PROPERTIES = Object.freeze([
|
|
114
|
+
'roughness', 'metallic', 'alpha', 'coatWeight', 'transmissionWeight',
|
|
115
|
+
'baseColor.r', 'baseColor.g', 'baseColor.b',
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
/** Render engines the spec vocabulary allows. */export const SCENE_ENGINES = Object.freeze(['eevee', 'cycles', 'workbench'])
|
|
119
|
+
|
|
120
|
+
/** Engine identifier used inside Blender, keyed by the spec's vocabulary. */
|
|
121
|
+
export const BLENDER_ENGINE_BY_KEY = Object.freeze({
|
|
122
|
+
eevee: 'BLENDER_EEVEE',
|
|
123
|
+
cycles: 'CYCLES',
|
|
124
|
+
workbench: 'BLENDER_WORKBENCH',
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
/** Import operator per asset type, resolved behaviorally at compile time. */
|
|
128
|
+
export const IMPORT_OPERATOR_BY_ASSET_TYPE = Object.freeze({
|
|
129
|
+
glb: 'import_scene.gltf',
|
|
130
|
+
gltf: 'import_scene.gltf',
|
|
131
|
+
fbx: 'import_scene.fbx',
|
|
132
|
+
obj: 'wm.obj_import',
|
|
133
|
+
usd: 'wm.usd_import',
|
|
134
|
+
blend: 'wm.append',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const validateStructure = compileSchema(sceneSpecSchema, { id: 'scene-spec.schema.json' })
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {object} SceneSpecIssue
|
|
141
|
+
* @property {'error'|'notice'} severity
|
|
142
|
+
* @property {string} code - stable, machine-branchable.
|
|
143
|
+
* @property {string} path - where in the document.
|
|
144
|
+
* @property {string} message
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validate a SceneSpec structurally and semantically.
|
|
149
|
+
*
|
|
150
|
+
* @param {unknown} spec
|
|
151
|
+
* @returns {{ ok: boolean, errors: SceneSpecIssue[], notices: SceneSpecIssue[], summary: string }}
|
|
152
|
+
*/
|
|
153
|
+
export function validateSceneSpec(spec) {
|
|
154
|
+
/** @type {SceneSpecIssue[]} */
|
|
155
|
+
const errors = []
|
|
156
|
+
/** @type {SceneSpecIssue[]} */
|
|
157
|
+
const notices = []
|
|
158
|
+
|
|
159
|
+
const structural = validateStructure(spec)
|
|
160
|
+
for (const issue of structural) {
|
|
161
|
+
errors.push({
|
|
162
|
+
severity: 'error',
|
|
163
|
+
code: 'SCENE_SCHEMA_INVALID',
|
|
164
|
+
path: issue.path,
|
|
165
|
+
message: `${issue.message} [${issue.keyword}]`,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
if (errors.length > 0) {
|
|
169
|
+
// Semantic rules assume a well-formed document; running them against a
|
|
170
|
+
// malformed one produces noise that buries the actual schema violation.
|
|
171
|
+
return { ok: false, errors, notices, summary: formatIssues(structural) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const document = /** @type {any} */ (spec)
|
|
175
|
+
const project = document.project
|
|
176
|
+
|
|
177
|
+
// ---- project invariants -------------------------------------------------
|
|
178
|
+
if (project.frameEnd <= project.frameStart) {
|
|
179
|
+
errors.push({
|
|
180
|
+
severity: 'error', code: 'SCENE_FRAME_RANGE_INVALID', path: 'project.frameEnd',
|
|
181
|
+
message: `frameEnd (${project.frameEnd}) must be greater than frameStart (${project.frameStart})`,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- uniqueness ---------------------------------------------------------
|
|
186
|
+
const collections = [
|
|
187
|
+
['assets', document.assets ?? []],
|
|
188
|
+
['materials', document.materials ?? []],
|
|
189
|
+
['entities', document.entities],
|
|
190
|
+
['lights', document.lights ?? []],
|
|
191
|
+
['cameras', document.cameras],
|
|
192
|
+
['shots', document.shots ?? []],
|
|
193
|
+
['animationTracks', document.animationTracks ?? []],
|
|
194
|
+
]
|
|
195
|
+
/** @type {Map<string, Map<string, number>>} */
|
|
196
|
+
const indexByCollection = new Map()
|
|
197
|
+
for (const [name, entries] of collections) {
|
|
198
|
+
/** @type {Map<string, number>} */
|
|
199
|
+
const seen = new Map()
|
|
200
|
+
entries.forEach((entry, position) => {
|
|
201
|
+
const id = entry?.id
|
|
202
|
+
if (typeof id !== 'string') return // already a structural error
|
|
203
|
+
if (!ID_PATTERN.test(id)) {
|
|
204
|
+
errors.push({
|
|
205
|
+
severity: 'error', code: 'SCENE_ID_INVALID', path: `${name}[${position}].id`,
|
|
206
|
+
message: `"${id}" is not a valid id (must match ${ID_PATTERN.source})`,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
if (seen.has(id)) {
|
|
210
|
+
errors.push({
|
|
211
|
+
severity: 'error', code: 'SCENE_ID_DUPLICATE', path: `${name}[${position}].id`,
|
|
212
|
+
message: `duplicate id "${id}" — first defined at ${name}[${seen.get(id)}]`,
|
|
213
|
+
})
|
|
214
|
+
} else {
|
|
215
|
+
seen.set(id, position)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
indexByCollection.set(name, seen)
|
|
219
|
+
}
|
|
220
|
+
const idsOf = name => indexByCollection.get(name) ?? new Map()
|
|
221
|
+
|
|
222
|
+
// ---- reference integrity ------------------------------------------------
|
|
223
|
+
const requiresId = (collection, id, path, what) => {
|
|
224
|
+
if (id === undefined || id === null) return
|
|
225
|
+
if (!idsOf(collection).has(id)) {
|
|
226
|
+
errors.push({
|
|
227
|
+
severity: 'error', code: 'SCENE_REFERENCE_MISSING', path,
|
|
228
|
+
message: `${what} "${id}" does not exist in ${collection}`,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
;(document.assets ?? []).forEach((asset, position) => {
|
|
234
|
+
// Path containment (SPEC §15.2). The project root is the only legal base.
|
|
235
|
+
const path = asset.path
|
|
236
|
+
if (typeof path === 'string') {
|
|
237
|
+
if (path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path)) {
|
|
238
|
+
errors.push({
|
|
239
|
+
severity: 'error', code: 'SCENE_ASSET_PATH_ABSOLUTE', path: `assets[${position}].path`,
|
|
240
|
+
message: `"${path}" is absolute; asset paths must be relative to the project root`,
|
|
241
|
+
})
|
|
242
|
+
} else if (path.split(/[\\/]/).includes('..')) {
|
|
243
|
+
errors.push({
|
|
244
|
+
severity: 'error', code: 'SCENE_ASSET_PATH_TRAVERSAL', path: `assets[${position}].path`,
|
|
245
|
+
message: `"${path}" contains a ".." segment and would escape the project root`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
document.entities.forEach((entity, position) => {
|
|
252
|
+
const at = `entities[${position}]`
|
|
253
|
+
if (entity.type === 'asset-instance') {
|
|
254
|
+
if (entity.assetId === undefined) {
|
|
255
|
+
errors.push({
|
|
256
|
+
severity: 'error', code: 'SCENE_ENTITY_ASSET_REQUIRED', path: `${at}.assetId`,
|
|
257
|
+
message: 'an asset-instance entity must name the asset it instantiates',
|
|
258
|
+
})
|
|
259
|
+
} else {
|
|
260
|
+
requiresId('assets', entity.assetId, `${at}.assetId`, 'asset')
|
|
261
|
+
}
|
|
262
|
+
if (entity.generator !== undefined) {
|
|
263
|
+
errors.push({
|
|
264
|
+
severity: 'error', code: 'SCENE_ENTITY_GENERATOR_CONFLICT', path: `${at}.generator`,
|
|
265
|
+
message: 'an asset-instance entity must not also declare a procedural generator',
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (entity.type === 'generator' && entity.generator === undefined) {
|
|
270
|
+
errors.push({
|
|
271
|
+
severity: 'error', code: 'SCENE_ENTITY_GENERATOR_REQUIRED', path: `${at}.generator`,
|
|
272
|
+
message: 'a generator entity must declare its `generator` block',
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
if (entity.type !== 'asset-instance' && entity.assetId !== undefined) {
|
|
276
|
+
errors.push({
|
|
277
|
+
severity: 'error', code: 'SCENE_ENTITY_ASSET_UNUSED', path: `${at}.assetId`,
|
|
278
|
+
message: `only an asset-instance entity may declare assetId (this entity is "${entity.type}")`,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
requiresId('materials', entity.materialId, `${at}.materialId`, 'material')
|
|
282
|
+
if (entity.type !== 'empty' && entity.materialId === undefined) {
|
|
283
|
+
notices.push({
|
|
284
|
+
severity: 'notice', code: 'SCENE_ENTITY_MATERIAL_DEFAULTED', path: at,
|
|
285
|
+
message: `entity "${entity.id}" has no materialId; the default principled material will be applied`,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
checkScale(entity.transform?.scale, `${at}.transform.scale`, errors, entity.id)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
;(document.lights ?? []).forEach((light, position) => {
|
|
292
|
+
checkScale(light.transform?.scale, `lights[${position}].transform.scale`, errors, light.id)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
document.cameras.forEach((camera, position) => {
|
|
296
|
+
const at = `cameras[${position}]`
|
|
297
|
+
requiresId('entities', camera.targetEntityId, `${at}.targetEntityId`, 'entity')
|
|
298
|
+
if (camera.targetEntityId !== undefined && camera.targetPoint !== undefined) {
|
|
299
|
+
errors.push({
|
|
300
|
+
severity: 'error', code: 'SCENE_CAMERA_TARGET_AMBIGUOUS', path: at,
|
|
301
|
+
message: 'a camera must declare either targetEntityId or targetPoint, not both',
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(camera.clipping) && camera.clipping[0] >= camera.clipping[1]) {
|
|
305
|
+
errors.push({
|
|
306
|
+
severity: 'error', code: 'SCENE_CAMERA_CLIPPING_INVALID', path: `${at}.clipping`,
|
|
307
|
+
message: `clip start (${camera.clipping[0]}) must be less than clip end (${camera.clipping[1]})`,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
const hasTarget = camera.targetEntityId !== undefined || camera.targetPoint !== undefined
|
|
311
|
+
const hasLocation = camera.transform?.location !== undefined
|
|
312
|
+
if (!hasLocation && !hasTarget) {
|
|
313
|
+
errors.push({
|
|
314
|
+
severity: 'error', code: 'SCENE_CAMERA_UNPLACED', path: at,
|
|
315
|
+
message: 'a camera needs at least a transform.location or a target to be placed',
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
if (hasTarget && camera.transform?.rotationEuler !== undefined) {
|
|
319
|
+
notices.push({
|
|
320
|
+
severity: 'notice', code: 'SCENE_CAMERA_ROTATION_IGNORED', path: `${at}.transform.rotationEuler`,
|
|
321
|
+
message:
|
|
322
|
+
`camera "${camera.id}" declares both a target and an explicit rotationEuler; ` +
|
|
323
|
+
'the derived aim wins because a target is the stronger statement of intent',
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
if (hasTarget && !hasLocation) {
|
|
327
|
+
notices.push({
|
|
328
|
+
severity: 'notice', code: 'SCENE_CAMERA_POSITION_DERIVED', path: `${at}.transform.location`,
|
|
329
|
+
message:
|
|
330
|
+
`camera "${camera.id}" has a target but no position; a deterministic frontal position ` +
|
|
331
|
+
'will be derived from the target',
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
;(document.shots ?? []).forEach((shot, position) => {
|
|
337
|
+
requiresId('cameras', shot.cameraId, `shots[${position}].cameraId`, 'camera')
|
|
338
|
+
if (Array.isArray(shot.frameRange) && shot.frameRange[0] >= shot.frameRange[1]) {
|
|
339
|
+
errors.push({
|
|
340
|
+
severity: 'error', code: 'SCENE_SHOT_FRAME_RANGE_INVALID', path: `shots[${position}].frameRange`,
|
|
341
|
+
message: `shot frame range end (${shot.frameRange[1]}) must exceed its start (${shot.frameRange[0]})`,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
;(document.animationTracks ?? []).forEach((track, position) => {
|
|
347
|
+
const at = `animationTracks[${position}]`
|
|
348
|
+
// The target kind decides WHICH collection the id resolves against — ids are
|
|
349
|
+
// unique per collection, not globally, so "watch-dial" could name an entity and
|
|
350
|
+
// a material at once and only the kind says which. Absent means `entity`, which
|
|
351
|
+
// is what every track written before kinds existed meant.
|
|
352
|
+
const kind = track.targetKind ?? 'entity'
|
|
353
|
+
requiresId(collectionNameForKind(kind), track.targetEntityId, `${at}.targetEntityId`, kind)
|
|
354
|
+
|
|
355
|
+
// A property that does not belong to the target's kind is not a typo the
|
|
356
|
+
// compiler can shrug off: `emissionStrength` on an entity, or `location.x` on a
|
|
357
|
+
// material, addresses nothing, and the track would then animate in the report
|
|
358
|
+
// while the render showed no motion at all.
|
|
359
|
+
const allowed = ANIMATION_PROPERTIES_BY_KIND[kind]
|
|
360
|
+
if (allowed !== undefined && !allowed.includes(track.property)) {
|
|
361
|
+
errors.push({
|
|
362
|
+
severity: 'error', code: 'SCENE_ANIMATION_PROPERTY_INVALID', path: `${at}.property`,
|
|
363
|
+
message:
|
|
364
|
+
`property "${track.property}" cannot be animated on a ${kind}; ` +
|
|
365
|
+
`${kind} tracks support: ${allowed.join(', ')}`,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Ranges the schema cannot express, because they depend on WHICH parameter the
|
|
370
|
+
// property names. A negative emission strength is a black emitter and a
|
|
371
|
+
// metallic of 3 is a typo; both would otherwise compile and render silently.
|
|
372
|
+
for (const [index, keyframe] of (track.keyframes ?? []).entries()) {
|
|
373
|
+
const value = keyframe?.value
|
|
374
|
+
if (typeof value !== 'number') continue
|
|
375
|
+
const path = `${at}.keyframes[${index}].value`
|
|
376
|
+
if (kind === 'material' && NON_NEGATIVE_MATERIAL_PROPERTIES.includes(track.property) && value < 0) {
|
|
377
|
+
errors.push({
|
|
378
|
+
severity: 'error', code: 'SCENE_KEYFRAME_VALUE_OUT_OF_RANGE', path,
|
|
379
|
+
message: `"${track.property}" cannot be negative, but keyframe ${index} is ${value}`,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
if (kind === 'material' && UNIT_MATERIAL_PROPERTIES.includes(track.property) && (value < 0 || value > 1)) {
|
|
383
|
+
errors.push({
|
|
384
|
+
severity: 'error', code: 'SCENE_KEYFRAME_VALUE_OUT_OF_RANGE', path,
|
|
385
|
+
message: `"${track.property}" must be within [0, 1], but keyframe ${index} is ${value}`,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const frames = track.keyframes.map(keyframe => keyframe.frame)
|
|
391
|
+
for (let index = 1; index < frames.length; index += 1) {
|
|
392
|
+
if (frames[index] <= frames[index - 1]) {
|
|
393
|
+
errors.push({
|
|
394
|
+
severity: 'error', code: 'SCENE_KEYFRAMES_UNORDERED', path: `${at}.keyframes[${index}].frame`,
|
|
395
|
+
message: `keyframe frames must strictly increase; ${frames[index]} follows ${frames[index - 1]}`,
|
|
396
|
+
})
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// ---- renderability ------------------------------------------------------
|
|
403
|
+
const hasEmitter = document.entities.some(entity => entity.type !== 'empty')
|
|
404
|
+
if (!hasEmitter) {
|
|
405
|
+
errors.push({
|
|
406
|
+
severity: 'error', code: 'SCENE_HAS_NO_GEOMETRY', path: 'entities',
|
|
407
|
+
message: 'the scene contains no entity that would produce geometry; a render would be empty',
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
const lights = document.lights ?? []
|
|
411
|
+
if (lights.length === 0) {
|
|
412
|
+
notices.push({
|
|
413
|
+
severity: 'notice', code: 'SCENE_NO_LIGHTS', path: 'lights',
|
|
414
|
+
message: 'the scene declares no lights; the render will be lit only by world illumination',
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
ok: errors.length === 0,
|
|
420
|
+
errors,
|
|
421
|
+
notices,
|
|
422
|
+
summary: errors.length === 0 ? 'valid' : formatIssues(errors.map(issue => ({
|
|
423
|
+
path: issue.path, keyword: issue.code, message: issue.message,
|
|
424
|
+
}))),
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Reject a zero or negative scale component, which would produce a degenerate object. */
|
|
429
|
+
function checkScale(scale, path, errors, owner) {
|
|
430
|
+
if (!Array.isArray(scale)) return
|
|
431
|
+
scale.forEach((component, index) => {
|
|
432
|
+
if (!(Math.abs(component) > 0)) {
|
|
433
|
+
errors.push({
|
|
434
|
+
severity: 'error', code: 'SCENE_SCALE_DEGENERATE', path: `${path}[${index}]`,
|
|
435
|
+
message: `scale component for "${owner}" is ${component}; a zero scale collapses the object`,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Deterministic compilation
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @typedef {object} CompileResult
|
|
447
|
+
* @property {object} spec - the resolved spec, safe to compile in Blender.
|
|
448
|
+
* @property {SceneSpecIssue[]} notices - decisions the compiler had to make.
|
|
449
|
+
* @property {Record<string, { location: number[], radius: number }>} entityBounds
|
|
450
|
+
*/
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Resolve everything a Blender compile would otherwise have to guess, so the
|
|
454
|
+
* same SceneSpec always produces the same scene (SPEC §19.5 golden scenes).
|
|
455
|
+
*
|
|
456
|
+
* M1 resolves:
|
|
457
|
+
* - every generator's missing dimensions to the documented defaults;
|
|
458
|
+
* - every entity transform's missing components to identity;
|
|
459
|
+
* - a camera's POSITION when it declares a target but no location.
|
|
460
|
+
*
|
|
461
|
+
* WHAT IS DELIBERATELY *NOT* RESOLVED HERE: the camera's AIM.
|
|
462
|
+
*
|
|
463
|
+
* Aiming a camera at a point is a rotation, and computing it in Node would mean
|
|
464
|
+
* re-implementing `Vector.to_track_quat('-Z','Y').to_euler('XYZ')` — including
|
|
465
|
+
* its degeneracy choices — in a second language. Two implementations of one
|
|
466
|
+
* rotation is one too many: any divergence shows up as a camera that frames the
|
|
467
|
+
* subject slightly differently on one side than the other, which is among the
|
|
468
|
+
* most expensive bugs to notice. `bootstrap.py` therefore owns the aim, using
|
|
469
|
+
* Blender's own operator, and the authored `rotationEuler` is simply ignored
|
|
470
|
+
* when a target is present (validation emits `SCENE_CAMERA_ROTATION_IGNORED`).
|
|
471
|
+
*
|
|
472
|
+
* The resolved spec stays a valid SceneSpec, so a spec read back from a
|
|
473
|
+
* revision recompiles identically.
|
|
474
|
+
*
|
|
475
|
+
* @param {object} spec - an already schema- and semantically-valid SceneSpec.
|
|
476
|
+
* @returns {CompileResult}
|
|
477
|
+
*/
|
|
478
|
+
export function compileSceneSpec(spec) {
|
|
479
|
+
/** @type {SceneSpecIssue[]} */
|
|
480
|
+
const notices = []
|
|
481
|
+
|
|
482
|
+
const entities = spec.entities.map(entity => ({
|
|
483
|
+
visible: true,
|
|
484
|
+
locked: false,
|
|
485
|
+
tags: [],
|
|
486
|
+
...entity,
|
|
487
|
+
transform: identityTransform(entity.transform),
|
|
488
|
+
...entity.type === 'generator' ? { generator: resolveGenerator(entity.generator) } : {},
|
|
489
|
+
}))
|
|
490
|
+
|
|
491
|
+
/** Entity centre and radius from generator footprint — the aiming substrate. */
|
|
492
|
+
const entityBounds = {}
|
|
493
|
+
for (const entity of entities) {
|
|
494
|
+
entityBounds[entity.id] = boundsOf(entity)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const cameras = spec.cameras.map(camera => {
|
|
498
|
+
const targetPoint = camera.targetEntityId !== undefined
|
|
499
|
+
? entityBounds[camera.targetEntityId]?.location ?? [0, 0, 0]
|
|
500
|
+
: camera.targetPoint
|
|
501
|
+
const targetRadius = camera.targetEntityId !== undefined
|
|
502
|
+
? entityBounds[camera.targetEntityId]?.radius ?? 1
|
|
503
|
+
: 0
|
|
504
|
+
|
|
505
|
+
if (targetPoint === undefined) {
|
|
506
|
+
// No aim requested: honour the authored rotation and position verbatim.
|
|
507
|
+
return { ...camera, transform: identityTransform(camera.transform) }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const authored = camera.transform?.location
|
|
511
|
+
const location = authored ?? deriveCameraLocation(targetPoint, targetRadius)
|
|
512
|
+
if (authored === undefined) {
|
|
513
|
+
notices.push({
|
|
514
|
+
severity: 'notice', code: 'SCENE_CAMERA_POSITION_DERIVED', path: `cameras.${camera.id}.transform.location`,
|
|
515
|
+
message:
|
|
516
|
+
`camera "${camera.id}" was placed at [${location.map(round4).join(', ')}], derived from ` +
|
|
517
|
+
`target [${targetPoint.map(round4).join(', ')}] — set transform.location to control framing exactly`,
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
return { ...camera, transform: { ...identityTransform(camera.transform), location } }
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
const resolved = {
|
|
524
|
+
...spec,
|
|
525
|
+
entities,
|
|
526
|
+
cameras,
|
|
527
|
+
// `shader` is required by the schema, so it is never defaulted here — a
|
|
528
|
+
// default that cannot fire would misrepresent the contract as looser than it
|
|
529
|
+
// is. Only `parameters` is materialised, because every consumer wants a
|
|
530
|
+
// parameters object to read from rather than a chain of existence checks.
|
|
531
|
+
materials: (spec.materials ?? []).map(material => ({
|
|
532
|
+
parameters: {},
|
|
533
|
+
...material,
|
|
534
|
+
})),
|
|
535
|
+
lights: (spec.lights ?? []).map(light => ({
|
|
536
|
+
energy: defaultEnergyFor(light.type),
|
|
537
|
+
color: [1, 1, 1],
|
|
538
|
+
transform: identityTransform(light.transform),
|
|
539
|
+
...light,
|
|
540
|
+
})),
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { spec: resolved, notices, entityBounds }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Generator defaults, applied per shape. */
|
|
547
|
+
function resolveGenerator(generator) {
|
|
548
|
+
const shape = generator?.shape
|
|
549
|
+
const base = { shape }
|
|
550
|
+
switch (shape) {
|
|
551
|
+
case 'cube':
|
|
552
|
+
return { ...base, ...generator, size: generator.size ?? 2 }
|
|
553
|
+
case 'rounded_box':
|
|
554
|
+
return {
|
|
555
|
+
...base,
|
|
556
|
+
...generator,
|
|
557
|
+
size: generator.size ?? 2,
|
|
558
|
+
bevel: { width: generator.bevel?.width ?? 0.08, segments: generator.bevel?.segments ?? 4 },
|
|
559
|
+
}
|
|
560
|
+
case 'uv_sphere':
|
|
561
|
+
return { ...base, ...generator, radius: generator.radius ?? 1, segments: generator.segments ?? 32, ringCount: generator.ringCount ?? 16 }
|
|
562
|
+
case 'cylinder':
|
|
563
|
+
return { ...base, ...generator, radius: generator.radius ?? 1, depth: generator.depth ?? 2, segments: generator.segments ?? 32 }
|
|
564
|
+
case 'cone':
|
|
565
|
+
return { ...base, ...generator, radius: generator.radius ?? 1, depth: generator.depth ?? 2, segments: generator.segments ?? 32 }
|
|
566
|
+
case 'plane':
|
|
567
|
+
return { ...base, ...generator, size: generator.size ?? 2 }
|
|
568
|
+
case 'torus':
|
|
569
|
+
return {
|
|
570
|
+
...base,
|
|
571
|
+
...generator,
|
|
572
|
+
majorRadius: generator.majorRadius ?? 1,
|
|
573
|
+
minorRadius: generator.minorRadius ?? 0.25,
|
|
574
|
+
segments: generator.segments ?? 48,
|
|
575
|
+
ringCount: generator.ringCount ?? 12,
|
|
576
|
+
}
|
|
577
|
+
default:
|
|
578
|
+
return { ...generator }
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Map a light type to a defensible default energy in watts. */
|
|
583
|
+
function defaultEnergyFor(type) {
|
|
584
|
+
switch (type) {
|
|
585
|
+
case 'sun': return 3
|
|
586
|
+
case 'point': return 100
|
|
587
|
+
case 'spot': return 200
|
|
588
|
+
default: return 500
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Centre and bounding radius of one entity, from its generator footprint and
|
|
594
|
+
* transform. Deliberately analytic rather than measured: the aim must be
|
|
595
|
+
* available BEFORE Blender runs, and an unmeasured analytic bound is what keeps
|
|
596
|
+
* compilation reproducible.
|
|
597
|
+
*/
|
|
598
|
+
function boundsOf(entity) {
|
|
599
|
+
return { location: [...entity.transform.location], radius: entityBoundingRadius(entity) }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* How big an entity is, as one bounding radius, from its generator footprint.
|
|
604
|
+
*
|
|
605
|
+
* ONE DEFINITION, TWO CONSUMERS. The camera aiming uses it to decide where to stand,
|
|
606
|
+
* and the visual review uses it to rank which entities are big enough to matter. Those
|
|
607
|
+
* need to agree: a scene where the camera frames "the big thing" and the review tracks
|
|
608
|
+
* "the big thing" and the two disagree is a scene with two half-answers.
|
|
609
|
+
*
|
|
610
|
+
* They did disagree, and it cost a real project its subject. The review had its own
|
|
611
|
+
* `entityVolume` that dispatched on which FIELDS were present rather than on `shape`,
|
|
612
|
+
* so a cylinder — which has both `radius` and `depth` — matched the radius branch first
|
|
613
|
+
* and was scored as a SPHERE. A 36 mm watch dial then outranked the 44 mm case it sits
|
|
614
|
+
* in, and the review scored the dial as the subject of the shot.
|
|
615
|
+
*
|
|
616
|
+
* @param {object} entity
|
|
617
|
+
* @returns {number}
|
|
618
|
+
*/
|
|
619
|
+
export function entityBoundingRadius(entity) {
|
|
620
|
+
const scale = entity?.transform?.scale ?? [1, 1, 1]
|
|
621
|
+
let radius = 1
|
|
622
|
+
const generator = entity?.generator
|
|
623
|
+
if (generator !== undefined) {
|
|
624
|
+
switch (generator.shape) {
|
|
625
|
+
case 'cube': radius = (generator.size / 2) * Math.sqrt(3); break
|
|
626
|
+
case 'rounded_box': radius = (generator.size / 2) * Math.sqrt(3); break
|
|
627
|
+
case 'uv_sphere': radius = generator.radius; break
|
|
628
|
+
case 'cylinder': radius = Math.hypot(generator.radius, generator.depth / 2); break
|
|
629
|
+
case 'cone': radius = Math.hypot(generator.radius, generator.depth / 2); break
|
|
630
|
+
case 'plane': radius = (generator.size / 2) * Math.SQRT2; break
|
|
631
|
+
case 'torus': radius = generator.majorRadius + generator.minorRadius; break
|
|
632
|
+
default: radius = 1
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return radius * Math.max(Math.abs(scale[0]), Math.abs(scale[1]), Math.abs(scale[2]))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Fill in an identity transform for every component the author omitted. */
|
|
639
|
+
function identityTransform(transform) {
|
|
640
|
+
return {
|
|
641
|
+
location: transform?.location ?? [0, 0, 0],
|
|
642
|
+
rotationEuler: transform?.rotationEuler ?? [0, 0, 0],
|
|
643
|
+
scale: transform?.scale ?? [1, 1, 1],
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Deterministic camera position for a target with no authored location.
|
|
649
|
+
*
|
|
650
|
+
* Not a "nice" framing heuristic and not trying to be: it is a fixed, documented
|
|
651
|
+
* relationship to the target's analytic radius, chosen so that (a) the subject
|
|
652
|
+
* is inside the frustum at the default 50 mm lens, and (b) the same spec always
|
|
653
|
+
* yields the same camera. A model that wants real framing sets the location.
|
|
654
|
+
*/
|
|
655
|
+
function deriveCameraLocation(targetPoint, targetRadius) {
|
|
656
|
+
const distance = Math.max(2, targetRadius * 2.5)
|
|
657
|
+
return [
|
|
658
|
+
round4(targetPoint[0]),
|
|
659
|
+
round4(targetPoint[1] - distance),
|
|
660
|
+
round4(targetPoint[2] + distance * 0.45),
|
|
661
|
+
]
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Round to 4 decimals so a derived float is stable across platforms. */
|
|
665
|
+
function round4(value) {
|
|
666
|
+
return Math.round(value * 10000) / 10000
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
// Digest
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The scene-relevant projection of a spec.
|
|
675
|
+
*
|
|
676
|
+
* `project.goal` and `project.title` are excluded on purpose: the digest exists
|
|
677
|
+
* to answer "is the SCENE different?", and a re-worded brief over an unchanged
|
|
678
|
+
* scene is not a new scene. Two revisions can therefore share a digest, which is
|
|
679
|
+
* exactly the signal a caller wants when deciding whether a re-render is needed.
|
|
680
|
+
*
|
|
681
|
+
* @param {object} spec
|
|
682
|
+
* @returns {object}
|
|
683
|
+
*/
|
|
684
|
+
export function sceneProjection(spec) {
|
|
685
|
+
return {
|
|
686
|
+
assets: spec.assets ?? [],
|
|
687
|
+
entities: spec.entities ?? [],
|
|
688
|
+
materials: spec.materials ?? [],
|
|
689
|
+
lights: spec.lights ?? [],
|
|
690
|
+
cameras: spec.cameras ?? [],
|
|
691
|
+
shots: spec.shots ?? [],
|
|
692
|
+
animationTracks: spec.animationTracks ?? [],
|
|
693
|
+
renderProfiles: spec.renderProfiles ?? {},
|
|
694
|
+
// ABSENCE IS PRESERVED, for the same reason `applyPatchToSpec` preserves it: a
|
|
695
|
+
// scene that never mentions a world must keep the digest it was recorded with.
|
|
696
|
+
// `world: spec.world ?? null` here would silently re-digest every revision in
|
|
697
|
+
// every store.
|
|
698
|
+
...(spec.world === undefined ? {} : { world: spec.world }),
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Content hash of the compiled scene. Stable across key order and process runs.
|
|
704
|
+
* @param {object} spec
|
|
705
|
+
* @returns {string} lowercase hex sha256.
|
|
706
|
+
*/
|
|
707
|
+
export function sceneSpecDigest(spec) {
|
|
708
|
+
return sha256Canonical(sceneProjection(spec))
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Content hash of the WHOLE document, including `project`.
|
|
713
|
+
*
|
|
714
|
+
* Distinct from {@link sceneSpecDigest}, and the distinction is load-bearing.
|
|
715
|
+
* The scene digest deliberately excludes `project.title` and `project.goal` so
|
|
716
|
+
* that re-wording a brief over an unchanged scene is not treated as a new scene.
|
|
717
|
+
* But it also excludes `project.frameStart`, `project.frameEnd` and `project.fps`
|
|
718
|
+
* — which ARE scene-relevant: changing the frame range changes what a render
|
|
719
|
+
* produces and what "the animation" means.
|
|
720
|
+
*
|
|
721
|
+
* A single field could not serve both readings, so both exist and are used for
|
|
722
|
+
* different questions:
|
|
723
|
+
*
|
|
724
|
+
* `sceneSpecDigest` → "is the geometry, lighting and animation the same?"
|
|
725
|
+
* (drives re-render decisions)
|
|
726
|
+
* `specHash` → "did ANY part of the stored document change?"
|
|
727
|
+
* (drives the revision's changed/unchanged verdict)
|
|
728
|
+
*
|
|
729
|
+
* Before this existed, a frame-range-only revision reported `sceneChanged: false`
|
|
730
|
+
* even though the frame range had moved, which would make any re-render gate skip
|
|
731
|
+
* it. Guarded by a regression test in scene-patch.test.mjs.
|
|
732
|
+
*
|
|
733
|
+
* @param {object} spec
|
|
734
|
+
* @returns {string} lowercase hex sha256.
|
|
735
|
+
*/
|
|
736
|
+
export function specHash(spec) {
|
|
737
|
+
return sha256Canonical(spec)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The compact `SceneDigest` handed to the model (SPEC §7.2 `getScene`, §18
|
|
742
|
+
* "FullSceneSpec 明确需要时才读取"): counts, framing facts and profiles, never
|
|
743
|
+
* the whole document.
|
|
744
|
+
*
|
|
745
|
+
* THE SUMMARY READS THE RESOLVED FORM, ALWAYS
|
|
746
|
+
* -------------------------------------------
|
|
747
|
+
* Compiling first is not belt-and-braces; it is what makes this function total. The
|
|
748
|
+
* fields it reads are the ones compilation MATERIALISES — `entity.transform`, every
|
|
749
|
+
* generator's shape-specific size, a camera's derived position — and all of them are
|
|
750
|
+
* optional in the schema, so a legal document can omit any of them.
|
|
751
|
+
*
|
|
752
|
+
* Two real defects came from reading the raw document here:
|
|
753
|
+
*
|
|
754
|
+
* - a camera added without a `transform` (also legal) crashed the whole call on
|
|
755
|
+
* `camera.transform.location`, so a successful commit returned an error;
|
|
756
|
+
* - a generator that omitted its shape's size field made `boundsOf` compute
|
|
757
|
+
* `undefined * n`, and NaN becomes `null` in JSON — so the summary carried
|
|
758
|
+
* silently wrong bounds and the harness rejected its own result.
|
|
759
|
+
*
|
|
760
|
+
* Revisions are immutable, so unresolved documents written by earlier code stay on
|
|
761
|
+
* disk forever. Repairing them is not an option; reading them correctly is.
|
|
762
|
+
*
|
|
763
|
+
* @param {object} document - a SceneSpec as STORED, resolved or not.
|
|
764
|
+
* @param {{ revision?: string|null, revisionNumber?: number|null, digest?: string }} [context]
|
|
765
|
+
* @returns {Record<string, unknown>}
|
|
766
|
+
*/
|
|
767
|
+
export function summarizeSceneSpec(document, context = {}) {
|
|
768
|
+
// Compilation is idempotent and only fills in documented defaults, so for the
|
|
769
|
+
// resolved documents every writer now produces this is a no-op.
|
|
770
|
+
const spec = compileSceneSpec(document).spec
|
|
771
|
+
|
|
772
|
+
const entities = spec.entities ?? []
|
|
773
|
+
const lights = spec.lights ?? []
|
|
774
|
+
const cameras = spec.cameras ?? []
|
|
775
|
+
const materials = spec.materials ?? []
|
|
776
|
+
const shots = spec.shots ?? []
|
|
777
|
+
const tracks = spec.animationTracks ?? []
|
|
778
|
+
|
|
779
|
+
const bounds = entities.map(entity => ({ entity, ...boundsOf(entity) }))
|
|
780
|
+
const enclosing = boundsBox(bounds)
|
|
781
|
+
// The subject is what a camera is expected to frame. Environment geometry —
|
|
782
|
+
// a 6 m stage plane under a 20 cm product, for instance — would otherwise make
|
|
783
|
+
// the "scene bounds" say 8 m across, which is true and useless for framing.
|
|
784
|
+
const subject = entities.filter(entity => !(entity.tags ?? []).includes('environment'))
|
|
785
|
+
const subjectBounds = subject.length === 0 ? null : boundsBox(subject.map(entity => ({ entity, ...boundsOf(entity) })))
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
revision: context.revision ?? null,
|
|
789
|
+
revisionNumber: context.revisionNumber ?? null,
|
|
790
|
+
// The digest identifies the DOCUMENT — what is on disk and what a manifest
|
|
791
|
+
// records — so it is taken from the document, not from the resolved projection.
|
|
792
|
+
// For every resolved document the two are the same value; for an older
|
|
793
|
+
// unresolved one, reporting the resolved digest would make a reader compare it
|
|
794
|
+
// against the manifest and conclude the revision was corrupt.
|
|
795
|
+
digest: context.digest ?? sceneSpecDigest(document),
|
|
796
|
+
schemaVersion: spec.schemaVersion,
|
|
797
|
+
project: {
|
|
798
|
+
id: spec.project.id,
|
|
799
|
+
title: spec.project.title,
|
|
800
|
+
goal: spec.project.goal ?? null,
|
|
801
|
+
fps: spec.project.fps,
|
|
802
|
+
frameStart: spec.project.frameStart,
|
|
803
|
+
frameEnd: spec.project.frameEnd,
|
|
804
|
+
aspectRatio: spec.project.aspectRatio,
|
|
805
|
+
units: spec.project.units ?? 'metric',
|
|
806
|
+
activeCamera: spec.project.activeCamera ?? null,
|
|
807
|
+
},
|
|
808
|
+
counts: {
|
|
809
|
+
assets: (spec.assets ?? []).length,
|
|
810
|
+
entities: entities.length,
|
|
811
|
+
materials: materials.length,
|
|
812
|
+
lights: lights.length,
|
|
813
|
+
cameras: cameras.length,
|
|
814
|
+
shots: shots.length,
|
|
815
|
+
animationTracks: tracks.length,
|
|
816
|
+
},
|
|
817
|
+
entities: entities.map(entity => ({
|
|
818
|
+
id: entity.id,
|
|
819
|
+
type: entity.type,
|
|
820
|
+
shape: entity.generator?.shape ?? null,
|
|
821
|
+
materialId: entity.materialId ?? null,
|
|
822
|
+
location: entity.transform.location.map(round4),
|
|
823
|
+
visible: entity.visible !== false,
|
|
824
|
+
locked: entity.locked === true,
|
|
825
|
+
tags: entity.tags ?? [],
|
|
826
|
+
})),
|
|
827
|
+
materials: materials.map(material => ({
|
|
828
|
+
id: material.id,
|
|
829
|
+
shader: material.shader,
|
|
830
|
+
parameters: material.parameters ?? {},
|
|
831
|
+
})),
|
|
832
|
+
lights: lights.map(light => ({
|
|
833
|
+
id: light.id,
|
|
834
|
+
type: light.type,
|
|
835
|
+
energy: light.energy,
|
|
836
|
+
location: light.transform.location.map(round4),
|
|
837
|
+
})),
|
|
838
|
+
cameras: cameras.map(camera => ({
|
|
839
|
+
id: camera.id,
|
|
840
|
+
role: camera.role ?? null,
|
|
841
|
+
lens: camera.lens ?? 50,
|
|
842
|
+
location: camera.transform.location.map(round4),
|
|
843
|
+
rotationEuler: camera.transform.rotationEuler.map(round4),
|
|
844
|
+
targetEntityId: camera.targetEntityId ?? null,
|
|
845
|
+
targetPoint: camera.targetPoint ?? null,
|
|
846
|
+
})),
|
|
847
|
+
shots: shots.map(shot => ({
|
|
848
|
+
id: shot.id,
|
|
849
|
+
cameraId: shot.cameraId,
|
|
850
|
+
frameRange: shot.frameRange ?? [spec.project.frameStart, spec.project.frameEnd],
|
|
851
|
+
description: shot.description ?? null,
|
|
852
|
+
})),
|
|
853
|
+
animationTracks: tracks.map(track => ({
|
|
854
|
+
id: track.id,
|
|
855
|
+
// Only when the document states one. Materialising `targetKind: 'entity'` for a
|
|
856
|
+
// track written before kinds existed would make the summary disagree with the
|
|
857
|
+
// bytes on disk, which is the same trap the world block avoids.
|
|
858
|
+
...(track.targetKind === undefined ? {} : { targetKind: track.targetKind }),
|
|
859
|
+
targetEntityId: track.targetEntityId,
|
|
860
|
+
property: track.property,
|
|
861
|
+
keyframeCount: track.keyframes.length,
|
|
862
|
+
frameRange: [
|
|
863
|
+
track.keyframes[0]?.frame ?? null,
|
|
864
|
+
track.keyframes[track.keyframes.length - 1]?.frame ?? null,
|
|
865
|
+
],
|
|
866
|
+
})),
|
|
867
|
+
renderProfiles: spec.renderProfiles,
|
|
868
|
+
// The world the render will actually use, with the schema's defaults filled in
|
|
869
|
+
// for a scene that never stated one. `declared` says whether the author asked
|
|
870
|
+
// for it, because "black because I said so" and "black because that is the
|
|
871
|
+
// default" are different facts about a scene.
|
|
872
|
+
world: {
|
|
873
|
+
declared: spec.world !== undefined,
|
|
874
|
+
color: spec.world?.color ?? DEFAULT_WORLD.color,
|
|
875
|
+
strength: spec.world?.strength ?? DEFAULT_WORLD.strength,
|
|
876
|
+
},
|
|
877
|
+
bounds: enclosing,
|
|
878
|
+
subjectBounds,
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Analytic bounding box over entities that carry a centre and radius.
|
|
884
|
+
* @param {{ location: number[], radius: number }[]} entries
|
|
885
|
+
* @returns {{ min: number[], max: number[] }}
|
|
886
|
+
*/
|
|
887
|
+
function boundsBox(entries) {
|
|
888
|
+
return {
|
|
889
|
+
min: [0, 1, 2].map(axis => round4(Math.min(...entries.map(entry => entry.location[axis] - entry.radius)))),
|
|
890
|
+
max: [0, 1, 2].map(axis => round4(Math.max(...entries.map(entry => entry.location[axis] + entry.radius)))),
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Canonical text form of a spec, for on-disk storage and golden comparison.
|
|
896
|
+
* @param {object} spec
|
|
897
|
+
* @returns {string}
|
|
898
|
+
*/
|
|
899
|
+
export function sceneSpecCanonicalText(spec) {
|
|
900
|
+
return `${JSON.stringify(JSON.parse(canonicalStringify(spec)), null, 2)}\n`
|
|
901
|
+
}
|