@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.
@@ -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
+ }