@deepblend/dsh-blender-contracts 0.4.0 → 0.6.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/index.js CHANGED
@@ -73,6 +73,7 @@ export const BlenderErrorCode = Object.freeze({
73
73
  PROTOCOL_VERSION_MISMATCH: 'BLENDER_PROTOCOL_VERSION_MISMATCH',
74
74
  /** bootstrap.py reported an action it does not implement. */
75
75
  UNSUPPORTED_ACTION: 'BLENDER_UNSUPPORTED_ACTION',
76
+ SCENE_FEATURE_UNSUPPORTED: 'BLENDER_SCENE_FEATURE_UNSUPPORTED',
76
77
  /** The collection of capabilities threw unexpectedly inside Blender. */
77
78
  CAPABILITY_PROBE_FAILED: 'BLENDER_CAPABILITY_PROBE_FAILED',
78
79
  /** The configured bootstrap script is missing or unreadable. */
@@ -696,6 +697,8 @@ export {
696
697
  ENVIRONMENT_ASSET_TYPES,
697
698
  validateSceneSpec,
698
699
  compileSceneSpec,
700
+ BOX_DIMENSIONS_FEATURE,
701
+ requiredSceneFeatures,
699
702
  entityBoundingRadius,
700
703
  sceneProjection,
701
704
  sceneSpecDigest,
@@ -874,4 +877,5 @@ export {
874
877
  managedBlenderCandidates,
875
878
  } from './deployment-paths.js'
876
879
 
877
- export { RECIPE_SCHEMA_VERSION, RECIPE_CAPABILITIES, RECIPE_LIMITS, RecipeError, recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe } from './recipe.js'
880
+ export { RECIPE_SCHEMA_VERSION, RECIPE_CAPABILITIES, RECIPE_LIMITS, RecipeError, recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe, instantiateRecipeLock } from './recipe.js'
881
+ export { planRecipeGeometryEdit, recipeGeometryParameters } from './recipe-parameters.js'
@@ -0,0 +1,107 @@
1
+ /** Plan existing-assembly edits as ordinary, bounded ScenePatch operations. */
2
+ import { canonicalStringify, sha256Canonical } from './canonical.js'
3
+ import { instantiateRecipeLock, RecipeError } from './recipe.js'
4
+ import { validateSceneSpec } from './scene-spec.js'
5
+
6
+ const axisOf = binding => ['x', 'y', 'z'].indexOf(binding.axis)
7
+ const structural = parameter => parameter.bindings.some(binding => binding.kind === 'entity')
8
+ && parameter.bindings.every(binding => ['entity', 'camera'].includes(binding.kind))
9
+ const entity = (spec, id) => spec.entities.find(item => item.id === id)
10
+ const vector = (target, property) => property === 'dimensions' ? target?.generator?.dimensions
11
+ : property === 'targetPoint' ? target?.targetPoint : target?.transform?.location
12
+ const close = (a, b) => Number.isFinite(a) && Number.isFinite(b)
13
+ && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(b))
14
+
15
+ export function recipeGeometryParameters(manifest) {
16
+ return manifest.parameters.filter(structural)
17
+ }
18
+
19
+ export function planRecipeGeometryEdit(lock, currentSpec, parameterState, changes) {
20
+ const origin = instantiateRecipeLock(lock)
21
+ const scene = validateSceneSpec(currentSpec)
22
+ if (!scene.ok) throw new RecipeError('RECIPE_SCENE_INVALID', scene.summary, { errors: scene.errors })
23
+ let currentLock = lock
24
+ if (parameterState !== undefined) {
25
+ if (parameterState?.schemaVersion !== 'deepblend.recipe-parameters/v1'
26
+ || parameterState.manifestSha256 !== lock.manifestSha256) {
27
+ throw new RecipeError('RECIPE_LOCK_INVALID', 'Parameter state belongs to a different recipe')
28
+ }
29
+ currentLock = { ...lock, values: parameterState.values, valuesSha256: parameterState.valuesSha256 }
30
+ }
31
+ const before = parameterState === undefined ? origin : instantiateRecipeLock(currentLock)
32
+ const after = instantiateRecipeLock(currentLock, changes)
33
+ const parameters = recipeGeometryParameters(lock.manifest)
34
+ for (const id of Object.keys(changes)) {
35
+ if (!parameters.some(parameter => parameter.id === id)) {
36
+ throw new RecipeError('RECIPE_PARAMETER_INVALID', `Parameter ${id} is not an assembly geometry control`)
37
+ }
38
+ }
39
+ const conflicts = []
40
+ for (const parameter of parameters) for (const binding of parameter.bindings) {
41
+ if (binding.kind !== 'entity') continue
42
+ const expected = vector(entity(before.spec, binding.entityId), binding.property)?.[axisOf(binding)]
43
+ const actual = vector(entity(currentSpec, binding.entityId), binding.property)?.[axisOf(binding)]
44
+ if (!close(actual, expected)) conflicts.push({ parameterId: parameter.id, entityId: binding.entityId,
45
+ property: binding.property, axis: binding.axis, expected, actual })
46
+ if (binding.property === 'dimensions') {
47
+ const oldScale = entity(before.spec, binding.entityId).transform.scale
48
+ const scale = entity(currentSpec, binding.entityId)?.transform.scale
49
+ if (!scale?.every((value, axis) => close(value, oldScale[axis]))) {
50
+ conflicts.push({ parameterId: parameter.id, entityId: binding.entityId,
51
+ property: 'scale', expected: oldScale, actual: scale })
52
+ }
53
+ }
54
+ }
55
+ if (conflicts.length) throw new RecipeError('RECIPE_PARAMETER_CONFLICT',
56
+ 'Recipe-controlled geometry was independently edited; resolve these fields before resizing', { conflicts })
57
+
58
+ const changed = parameters.filter(parameter => before.values[parameter.id] !== after.values[parameter.id])
59
+ const generators = new Map(), locations = new Map(), cameras = new Map(), tracks = new Map(), animationOffsets = new Map()
60
+ const protectedCameras = new Set()
61
+ for (const parameter of changed) for (const binding of parameter.bindings) {
62
+ const axis = axisOf(binding)
63
+ if (binding.kind === 'camera') {
64
+ const actual = currentSpec.cameras?.find(item => item.id === binding.cameraId)
65
+ const original = before.spec.cameras?.find(item => item.id === binding.cameraId)
66
+ // Independently authored photography remains intact, including deleted cameras.
67
+ if (!actual || canonicalStringify(actual) !== canonicalStringify(original)) {
68
+ protectedCameras.add(binding.cameraId); continue
69
+ }
70
+ if (!cameras.has(binding.cameraId)) cameras.set(binding.cameraId, { op: 'camera.update', cameraId: binding.cameraId })
71
+ const operation = cameras.get(binding.cameraId)
72
+ const target = after.spec.cameras.find(item => item.id === binding.cameraId)
73
+ if (binding.property === 'targetPoint') operation.targetPoint = structuredClone(target.targetPoint)
74
+ else operation.transform = { location: structuredClone(target.transform.location) }
75
+ continue
76
+ }
77
+ const actual = entity(currentSpec, binding.entityId), target = entity(after.spec, binding.entityId)
78
+ if (binding.property === 'dimensions') {
79
+ if (!generators.has(actual.id)) generators.set(actual.id, structuredClone(actual.generator))
80
+ generators.get(actual.id).dimensions[axis] = target.generator.dimensions[axis]
81
+ } else {
82
+ if (!locations.has(actual.id)) locations.set(actual.id, structuredClone(actual.transform.location))
83
+ const delta = target.transform.location[axis] - entity(before.spec, actual.id).transform.location[axis]
84
+ locations.get(actual.id)[axis] = target.transform.location[axis]
85
+ if (binding.offsetAnimation === true) animationOffsets.set(`${actual.id}:${binding.axis}`, {
86
+ entityId: actual.id, property: `location.${binding.axis}`, delta,
87
+ })
88
+ }
89
+ }
90
+ for (const offset of animationOffsets.values()) for (const track of currentSpec.animationTracks ?? []) {
91
+ if ((track.targetKind ?? 'entity') !== 'entity' || track.targetEntityId !== offset.entityId
92
+ || track.property !== offset.property) continue
93
+ if (!tracks.has(track.id)) tracks.set(track.id, structuredClone(track))
94
+ for (const keyframe of tracks.get(track.id).keyframes) keyframe.value += offset.delta
95
+ }
96
+ const operations = [
97
+ ...[...generators].map(([entityId, generator]) => ({ op: 'entity.generator.set', entityId, generator })),
98
+ ...[...locations].map(([entityId, location]) => ({ op: 'entity.transform.update', entityId, location })),
99
+ ...cameras.values(),
100
+ ...[...tracks.values()].map(track => ({ op: 'animation.track.set', track })),
101
+ ]
102
+ return { operations, changedParameters: changed.map(parameter => parameter.id),
103
+ preservedCameraIds: [...protectedCameras], parameterState: {
104
+ schemaVersion: 'deepblend.recipe-parameters/v1', manifestSha256: lock.manifestSha256,
105
+ values: after.values, valuesSha256: sha256Canonical(after.values),
106
+ } }
107
+ }
package/lib/recipe.js CHANGED
@@ -3,14 +3,15 @@ import { inflateSync } from 'node:zlib'
3
3
  import { sha256, sha256Canonical } from './canonical.js'
4
4
  import { compileSchema } from './json-schema.js'
5
5
  import { decodePng } from './png.js'
6
- import { validateSceneSpec, compileSceneSpec } from './scene-spec.js'
6
+ import { validateSceneSpec, compileSceneSpec, BOX_DIMENSIONS_FEATURE, requiredSceneFeatures } from './scene-spec.js'
7
7
  import recipeSchema from './schemas/recipe.schema.json' with { type: 'json' }
8
8
 
9
9
  export const RECIPE_SCHEMA_VERSION = 'deepblend.recipe/v1'
10
10
  export const RECIPE_CAPABILITIES = Object.freeze([
11
- 'geometry.primitive', 'geometry.lathe', 'geometry.curve', 'geometry.handled_cup', 'geometry.handled_cup.tension', 'geometry.handled_cup.tension.extended', 'geometry.modifiers',
11
+ 'geometry.primitive', BOX_DIMENSIONS_FEATURE, 'geometry.lathe', 'geometry.curve', 'geometry.handled_cup', 'geometry.handled_cup.tension', 'geometry.handled_cup.tension.extended', 'geometry.modifiers',
12
12
  'material.principled', 'material.glass', 'material.emission', 'material.procedural', 'material.procedural.uv', 'material.anisotropy',
13
13
  'animation.transform', 'animation.material',
14
+ 'recipe.geometry-bindings',
14
15
  ])
15
16
  export const RECIPE_LIMITS = Object.freeze({ sceneBytes: 2 * 1024 * 1024, previewBytes: 4 * 1024 * 1024, previewPixels: 4 * 1024 * 1024 })
16
17
  const structural = compileSchema(recipeSchema, { id: 'recipe.schema.json' })
@@ -20,6 +21,12 @@ const plain = value => value !== null && typeof value === 'object' &&
20
21
  const issue = (code, path, message) => ({ code, path, message })
21
22
  const result = (errors, extra = {}) => ({ ok: errors.length === 0, errors, notices: [],
22
23
  summary: errors.length ? errors.map(error => `${error.path}: ${error.message}`).join('; ') : 'Recipe valid', ...extra })
24
+ const bindingAxis = binding => ['x', 'y', 'z'].indexOf(binding.axis)
25
+ const bindingTarget = (spec, binding) => binding.kind === 'camera' ? spec.cameras?.find(item => item.id === binding.cameraId) : spec.entities.find(item => item.id === binding.entityId)
26
+ const bindingVector = (target, binding) => binding.property === 'dimensions' ? target?.generator?.dimensions
27
+ : binding.property === 'targetPoint' ? target?.targetPoint : target?.transform?.location
28
+ const bindingTracks = (spec, binding) => (spec.animationTracks ?? []).filter(track => (track.targetKind ?? 'entity') === binding.kind
29
+ && track.targetEntityId === (binding.entityId ?? binding.cameraId) && track.property === `location.${binding.axis}`)
23
30
 
24
31
  export class RecipeError extends Error {
25
32
  constructor(code, message, details = {}) {
@@ -47,6 +54,7 @@ export function recipeCapabilitiesForScene(spec) {
47
54
  used.add(track.targetKind === 'material' ? 'animation.material' : 'animation.transform')
48
55
  if (track.targetKind === 'material' && ['anisotropic', 'anisotropicRotation'].includes(track.property)) used.add('material.anisotropy')
49
56
  }
57
+ for (const feature of requiredSceneFeatures(spec)) used.add(feature)
50
58
  return [...used].sort()
51
59
  }
52
60
 
@@ -82,7 +90,7 @@ export function validateRecipeManifest(manifest, options = {}) {
82
90
  declared.add(capability)
83
91
  if (!supported.has(capability)) errors.push(issue('RECIPE_INCOMPATIBLE', 'compatibility.capabilities', `Unsupported capability ${capability}`))
84
92
  }
85
- const ids = new Set(), targets = new Set()
93
+ const ids = new Set(), targets = new Map()
86
94
  for (const parameter of manifest.parameters) {
87
95
  const path = `parameters.${parameter.id}`
88
96
  if (ids.has(parameter.id) || RESERVED.has(parameter.id)) errors.push(issue('RECIPE_PARAMETER_INVALID', path, 'Parameter IDs must be unique and must not be reserved object keys'))
@@ -91,12 +99,23 @@ export function validateRecipeManifest(manifest, options = {}) {
91
99
  const valueIssue = parameterValueIssue(parameter, parameter.default, `${path}.default`)
92
100
  if (valueIssue) errors.push(valueIssue)
93
101
  for (const binding of parameter.bindings) {
94
- const target = binding.kind === 'material' ? `material:${binding.materialId}:${binding.property}` : `render-profile:${binding.profile}:exposure`
95
- if (targets.has(target)) errors.push(issue('RECIPE_BINDING_INVALID', path, `Target ${target} is bound more than once`))
96
- targets.add(target)
102
+ const target = binding.kind === 'material' ? `material:${binding.materialId}:${binding.property}`
103
+ : ['entity', 'camera'].includes(binding.kind) ? `${binding.kind}:${binding.entityId ?? binding.cameraId}:${binding.property}.${binding.axis}` : `render-profile:${binding.profile}:exposure`
104
+ const prior = targets.get(target)
105
+ // Camera framing may add independent width/depth/height deltas. Repeating a
106
+ // slot inside one parameter, or overwriting any other target, remains invalid.
107
+ if (prior && (binding.kind !== 'camera' || prior.has(parameter.id))) errors.push(issue('RECIPE_BINDING_INVALID', path, `Target ${target} is bound more than once`))
108
+ if (!prior) targets.set(target, new Set([parameter.id]))
109
+ else prior.add(parameter.id)
97
110
  const color = binding.kind === 'material' && binding.property === 'baseColor'
98
111
  if ((parameter.type === 'color') !== color) errors.push(issue('RECIPE_BINDING_INVALID', path, 'Parameter type does not match the allowlisted property'))
99
112
  if (parameter.type === 'number') {
113
+ if (['entity', 'camera'].includes(binding.kind)) {
114
+ if (!Number.isFinite(binding.multiplier) || binding.multiplier === 0) errors.push(issue('RECIPE_BINDING_INVALID', path, 'Entity binding multiplier must be finite and nonzero'))
115
+ if (!declared.has('recipe.geometry-bindings')) errors.push(issue('RECIPE_CAPABILITY_UNDECLARED', path, 'Entity bindings require recipe.geometry-bindings'))
116
+ if (binding.property !== 'location' && binding.offsetAnimation !== undefined) errors.push(issue('RECIPE_BINDING_INVALID', path, 'Animation offsets are only valid on location bindings'))
117
+ continue
118
+ }
100
119
  const range = binding.kind === 'material' ? [0, 1] : [-10, 10]
101
120
  if (parameter.minimum < range[0] || parameter.maximum > range[1]) errors.push(issue('RECIPE_BINDING_INVALID', path, `Binding range must stay within [${range}]`))
102
121
  }
@@ -156,7 +175,7 @@ function validatePreview(bytes) {
156
175
  }
157
176
 
158
177
  function bindingsAgainstScene(manifest, spec) {
159
- const errors = []
178
+ const errors = [], cameraRanges = new Map()
160
179
  for (const parameter of manifest.parameters) for (const binding of parameter.bindings) {
161
180
  const path = `parameters.${parameter.id}.bindings`
162
181
  if (binding.kind === 'material') {
@@ -171,27 +190,55 @@ function bindingsAgainstScene(manifest, spec) {
171
190
  (track.property === binding.property || track.property.startsWith(`${binding.property}.`)))) {
172
191
  errors.push(issue('RECIPE_BINDING_INVALID', path, 'A parameter must not compete with animation on the same material property'))
173
192
  }
193
+ } else if (['entity', 'camera'].includes(binding.kind)) {
194
+ const entity = bindingTarget(spec, binding)
195
+ const vector = bindingVector(entity, binding), axis = bindingAxis(binding)
196
+ if (!entity || (binding.property === 'dimensions' && (!['cube', 'rounded_box'].includes(entity.generator?.shape) || entity.generator.size !== undefined))
197
+ || !Array.isArray(vector) || vector.length !== 3 || !Number.isFinite(vector[axis])) {
198
+ errors.push(issue('RECIPE_BINDING_INVALID', path, 'Structural binding requires an explicit supported dimension, position or aim point'))
199
+ continue
200
+ }
201
+ const ends = [parameter.minimum, parameter.maximum].map(value => vector[axis] + (value - parameter.default) * binding.multiplier)
202
+ if (ends.some(value => !Number.isFinite(value) || (binding.property === 'dimensions' && value <= 0))) {
203
+ errors.push(issue('RECIPE_BINDING_INVALID', path, 'The entire parameter range must produce finite values and positive dimensions'))
204
+ }
205
+ if (binding.kind === 'camera') {
206
+ const key = `${binding.cameraId}:${binding.property}.${binding.axis}`
207
+ const range = cameraRanges.get(key) ?? [vector[axis], vector[axis]]
208
+ const deltas = [parameter.minimum, parameter.maximum].map(value => (value - parameter.default) * binding.multiplier)
209
+ range[0] += Math.min(...deltas); range[1] += Math.max(...deltas)
210
+ cameraRanges.set(key, range)
211
+ }
212
+ if (binding.property === 'location') {
213
+ const tracks = bindingTracks(spec, binding)
214
+ if (tracks.length && binding.offsetAnimation !== true) errors.push(issue('RECIPE_BINDING_INVALID', path, 'An animated location requires an explicit animation offset'))
215
+ if (binding.offsetAnimation === true && tracks.some(track => track.keyframes.some(keyframe => [parameter.minimum, parameter.maximum]
216
+ .some(value => !Number.isFinite(keyframe.value + (value - parameter.default) * binding.multiplier))))) {
217
+ errors.push(issue('RECIPE_BINDING_INVALID', path, 'Animation offsets must remain finite throughout the parameter range'))
218
+ }
219
+ }
220
+ if (binding.kind === 'camera' && binding.property === 'targetPoint' && (entity.targetEntityId !== undefined
221
+ || spec.animationTracks?.some(track => track.targetKind === 'camera' && track.targetEntityId === entity.id && track.property.startsWith('rotationEuler.')))) {
222
+ errors.push(issue('RECIPE_BINDING_INVALID', path, 'A bound camera aim point must not compete with an entity target or animated rotation'))
223
+ }
174
224
  } else if (spec.renderProfiles?.[binding.profile]?.colorManagement?.exposure !== parameter.default) {
175
225
  errors.push(issue('RECIPE_BINDING_INVALID', path, `Profile ${binding.profile} must explicitly declare exposure equal to the parameter default`))
176
226
  }
177
227
  }
228
+ for (const [key, range] of cameraRanges) if (range.some(value => !Number.isFinite(value))) {
229
+ errors.push(issue('RECIPE_BINDING_INVALID', 'parameters', `Combined camera binding range must remain finite: ${key}`))
230
+ }
178
231
  return errors
179
232
  }
180
233
 
181
- /** Hash actual loaded bytes; the manifest is a claim, never a verification receipt. */
182
- export function validateRecipePackage(bundle, options = {}) {
183
- const checked = validateRecipeManifest(bundle?.manifest, options)
184
- if (!checked.ok) return checked
185
- const { manifest } = bundle
234
+ /** Shared input validation; locked recipes do not retain original preview pixels. */
235
+ function validateRecipeInput(manifest, input) {
186
236
  const errors = []
187
237
  let sceneSpec
188
238
  try {
189
- const sceneBytes = asBytes(bundle.sceneBytes, RECIPE_LIMITS.sceneBytes, 'input')
190
- const previewBytes = asBytes(bundle.previewBytes, RECIPE_LIMITS.previewBytes, 'preview')
239
+ const sceneBytes = asBytes(input, RECIPE_LIMITS.sceneBytes, 'input')
191
240
  if (sha256(sceneBytes) !== manifest.input.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'input.sha256', 'Loaded SceneSpec bytes differ from the declared hash'))
192
- if (sha256(previewBytes) !== manifest.preview.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'preview.sha256', 'Loaded preview bytes differ from the declared hash'))
193
241
  if (errors.length) return result(errors)
194
- try { validatePreview(previewBytes) } catch (error) { return result([issue('RECIPE_PREVIEW_INVALID', 'preview', error.message)]) }
195
242
  try { sceneSpec = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(sceneBytes)) }
196
243
  catch { return result([issue('RECIPE_INPUT_INVALID', 'input', 'SceneSpec must be valid UTF-8 JSON')]) }
197
244
  const scene = validateSceneSpec(sceneSpec)
@@ -216,12 +263,55 @@ export function validateRecipePackage(bundle, options = {}) {
216
263
  } catch (error) { return result([issue(error.code ?? 'RECIPE_INPUT_INVALID', 'input', error.message)]) }
217
264
  }
218
265
 
266
+ /** Hash actual loaded bytes; the manifest is a claim, never a verification receipt. */
267
+ export function validateRecipePackage(bundle, options = {}) {
268
+ const checked = validateRecipeManifest(bundle?.manifest, options)
269
+ if (!checked.ok) return checked
270
+ const { manifest } = bundle
271
+ try {
272
+ const sceneBytes = asBytes(bundle.sceneBytes, RECIPE_LIMITS.sceneBytes, 'input')
273
+ const previewBytes = asBytes(bundle.previewBytes, RECIPE_LIMITS.previewBytes, 'preview')
274
+ const errors = []
275
+ if (sha256(sceneBytes) !== manifest.input.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'input.sha256', 'Loaded SceneSpec bytes differ from the declared hash'))
276
+ if (sha256(previewBytes) !== manifest.preview.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'preview.sha256', 'Loaded preview bytes differ from the declared hash'))
277
+ if (errors.length) return result(errors)
278
+ try { validatePreview(previewBytes) } catch (error) { return result([issue('RECIPE_PREVIEW_INVALID', 'preview', error.message)]) }
279
+ return validateRecipeInput(manifest, sceneBytes)
280
+ } catch (error) { return result([issue(error.code ?? 'RECIPE_INPUT_INVALID', 'input', error.message)]) }
281
+ }
282
+
219
283
  /** Return a fresh resolved SceneSpec; only explicit scalar/color slots can change. */
220
284
  export function instantiateRecipe(bundle, values = {}, options = {}) {
221
285
  const checked = validateRecipePackage(bundle, options)
222
286
  if (!checked.ok) throw new RecipeError(checked.errors[0].code, checked.summary, { errors: checked.errors })
287
+ return resolveRecipe(bundle.manifest, checked, values)
288
+ }
289
+
290
+ /** Resolve a saved origin without inventing or accepting replacement preview bytes. */
291
+ export function instantiateRecipeLock(lock, changes = {}, options = {}) {
292
+ const fail = message => { throw new RecipeError('RECIPE_LOCK_INVALID', message) }
293
+ if (!plain(lock) || lock.schemaVersion !== 'deepblend.recipe-lock/v1') fail('Unsupported recipe lock')
294
+ const manifestCheck = validateRecipeManifest(lock.manifest, options)
295
+ if (!manifestCheck.ok) throw new RecipeError(manifestCheck.errors[0].code, manifestCheck.summary, { errors: manifestCheck.errors })
296
+ const { manifest } = lock
297
+ if (lock.id !== manifest.id || lock.version !== manifest.version
298
+ || lock.manifestSha256 !== sha256Canonical(manifest) || lock.packageDigest !== lock.manifestSha256
299
+ || lock.inputSha256 !== manifest.input.sha256 || lock.previewSha256 !== manifest.preview.sha256) {
300
+ fail('Recipe lock identity or manifest hashes differ')
301
+ }
302
+ if (typeof lock.sceneSource !== 'string' || lock.sceneSource.length > RECIPE_LIMITS.sceneBytes
303
+ || !plain(lock.values)) fail('Recipe lock must retain bounded exact input and resolved values')
304
+ const checked = validateRecipeInput(manifest, new TextEncoder().encode(lock.sceneSource))
305
+ if (!checked.ok) throw new RecipeError(checked.errors[0].code, checked.summary, { errors: checked.errors })
306
+ const original = resolveRecipe(manifest, checked, lock.values)
307
+ if (sha256Canonical(original.values) !== lock.valuesSha256
308
+ || sha256Canonical(lock.values) !== lock.valuesSha256) fail('Recipe lock values differ from their resolved hash')
309
+ if (!plain(changes)) throw new RecipeError('RECIPE_PARAMETER_INVALID', 'Parameter values must be a plain object')
310
+ return resolveRecipe(manifest, checked, { ...original.values, ...changes })
311
+ }
312
+
313
+ function resolveRecipe(manifest, checked, values) {
223
314
  if (!plain(values)) throw new RecipeError('RECIPE_PARAMETER_INVALID', 'Parameter values must be a plain object')
224
- const { manifest } = bundle
225
315
  const known = new Set(manifest.parameters.map(parameter => parameter.id))
226
316
  const errors = Object.keys(values).filter(key => !known.has(key) || RESERVED.has(key))
227
317
  .map(key => issue('RECIPE_PARAMETER_UNKNOWN', `values.${key}`, 'Unknown parameter'))
@@ -240,6 +330,14 @@ export function instantiateRecipe(bundle, values = {}, options = {}) {
240
330
  const material = spec.materials.find(item => item.id === binding.materialId)
241
331
  material.parameters[binding.property] = parameter.type === 'color'
242
332
  ? [...value, material.parameters.baseColor[3] ?? 1] : value
333
+ } else if (['entity', 'camera'].includes(binding.kind)) {
334
+ const vector = bindingVector(bindingTarget(spec, binding), binding)
335
+ // Preserve the input's exact default bytes and avoid cumulative edits.
336
+ if (value !== parameter.default) {
337
+ const delta = (value - parameter.default) * binding.multiplier
338
+ vector[bindingAxis(binding)] += delta
339
+ if (binding.offsetAnimation === true) for (const track of bindingTracks(spec, binding)) for (const keyframe of track.keyframes) keyframe.value += delta
340
+ }
243
341
  } else spec.renderProfiles[binding.profile].colorManagement.exposure = value
244
342
  }
245
343
  const validation = validateSceneSpec(spec)
@@ -21,6 +21,8 @@ export interface BlenderCapabilities {
21
21
  exportFormats: string[]; importFormats: string[]; unavailableFormats: string[];
22
22
  renderSmokeTest: BlenderRenderSmokeTest | null; cyclesSmokeTest: BlenderRenderSmokeTest | null;
23
23
  textBlockApi: boolean; frameApi: boolean; hostPlatform: string | null; warnings: RuntimeWarning[];
24
+ /** Compiler vocabulary extensions; absent means unsupported. */
25
+ sceneFeatures?: string[];
24
26
  probedAt: number; durationMs: number; commandLine: string | null;
25
27
  }
26
28
  export interface BlenderBootstrapRequest { action: string; payload?: Record<string, unknown>; jobId?: string }
package/lib/scene-spec.js CHANGED
@@ -29,6 +29,14 @@ import sceneSpecSchema from './schemas/scene-spec.schema.json' with { type: 'jso
29
29
  /** The one schema version this module understands. */
30
30
  export const SCENE_SCHEMA_VERSION = 'deepblend.scene/v1'
31
31
 
32
+ export const BOX_DIMENSIONS_FEATURE = 'geometry.primitive.dimensions'
33
+
34
+ /** Features whose absence must refuse compilation rather than reinterpret the scene. */
35
+ export function requiredSceneFeatures(spec) {
36
+ return spec.entities?.some(entity => entity.type === 'generator' && entity.generator?.dimensions !== undefined)
37
+ ? [BOX_DIMENSIONS_FEATURE] : []
38
+ }
39
+
32
40
  /**
33
41
  * What a scene with no `world` block is lit by.
34
42
  *
@@ -360,6 +368,11 @@ export function validateSceneSpec(spec) {
360
368
  selectors.add(key)
361
369
  }
362
370
  }
371
+ if (entity.generator?.dimensions !== undefined &&
372
+ (!['cube', 'rounded_box'].includes(entity.generator.shape) || entity.generator.size !== undefined)) {
373
+ errors.push({ severity: 'error', code: 'SCENE_GENERATOR_PARAMETERS_INVALID', path: `${at}.generator`,
374
+ message: 'axis dimensions require cube or rounded_box and cannot be combined with scalar size' })
375
+ }
363
376
  if (entity.generator?.shape === 'handled_cup') {
364
377
  for (const message of handledCupIssues(entity.generator)) errors.push({ severity: 'error',
365
378
  code: 'SCENE_GENERATOR_PARAMETERS_INVALID', path: `${at}.generator`, message })
@@ -780,12 +793,12 @@ function resolveGenerator(generator) {
780
793
  const base = { shape }
781
794
  switch (shape) {
782
795
  case 'cube':
783
- return { ...base, ...generator, size: generator.size ?? 2 }
796
+ return { ...base, ...generator, ...(generator.dimensions === undefined ? { size: generator.size ?? 2 } : {}) }
784
797
  case 'rounded_box':
785
798
  return {
786
799
  ...base,
787
800
  ...generator,
788
- size: generator.size ?? 2,
801
+ ...(generator.dimensions === undefined ? { size: generator.size ?? 2 } : {}),
789
802
  bevel: { width: generator.bevel?.width ?? 0.08, segments: generator.bevel?.segments ?? 4 },
790
803
  }
791
804
  case 'uv_sphere':
@@ -862,8 +875,10 @@ export function entityBoundingRadius(entity) {
862
875
  const generator = entity?.generator
863
876
  if (generator !== undefined) {
864
877
  switch (generator.shape) {
865
- case 'cube': radius = (generator.size / 2) * Math.sqrt(3); break
866
- case 'rounded_box': radius = (generator.size / 2) * Math.sqrt(3); break
878
+ case 'cube':
879
+ case 'rounded_box':
880
+ radius = generator.dimensions === undefined ? (generator.size / 2) * Math.sqrt(3) : Math.hypot(...generator.dimensions) / 2
881
+ break
867
882
  case 'uv_sphere': radius = generator.radius; break
868
883
  case 'cylinder': radius = Math.hypot(generator.radius, generator.depth / 2); break
869
884
  case 'cone': radius = Math.hypot(generator.radius, generator.depth / 2); break
@@ -1,7 +1,7 @@
1
1
  /** Generated by deepblend/tools/generate-sdk-types.mjs. Do not edit.
2
2
  * Types describe JSON shape. Numeric bounds, ID grammar and references still require validation. */
3
3
 
4
- /** Source SHA-256: e1d0fab58ba2263925efdee9e2489f205fde30e957b62b128c085f4706d6c11d */
4
+ /** Source SHA-256: 2fe4251bf80fa6f10a5c8b6d877250776c4b305a172c28224821d0082252c3a6 */
5
5
  export namespace SceneSpecSchema {
6
6
  export type Id = string;
7
7
  export type World = { "color"?: SceneSpecSchema.UnitColor; "strength"?: number; "environment"?: { "assetId": SceneSpecSchema.Id; "rotation"?: number; }; };
@@ -15,7 +15,7 @@ export namespace SceneSpecSchema {
15
15
  export type ProceduralTexture = ({ "type": "noise" | "wave" | "voronoi"; "scale": number; "detail"?: number; "distortion"?: number; "stretch"?: [number, number, number]; "bump"?: number; "roughnessVariation"?: number; "colorVariation"?: number; "coordinates"?: "object"; }) | ({ "type": "noise" | "wave" | "voronoi"; "scale": number; "detail"?: number; "distortion"?: number; "stretch"?: [number, number, number]; "bump"?: number; "roughnessVariation"?: number; "colorVariation"?: number; "coordinates": "uv"; "uvMap"?: string; });
16
16
  export type Material = { "id": SceneSpecSchema.Id; "shader": "principled" | "emission" | "glass"; "parameters"?: { "baseColor"?: SceneSpecSchema.UnitColor; "metallic"?: number; "roughness"?: number; "ior"?: number; "alpha"?: number; "emissionColor"?: SceneSpecSchema.UnitColor; "emissionStrength"?: SceneSpecSchema.NonNegativeNumber; "coatWeight"?: number; "transmissionWeight"?: number; "anisotropic"?: number; "anisotropicRotation"?: number; }; "texture"?: SceneSpecSchema.ProceduralTexture; "images"?: SceneSpecSchema.MaterialImages; "tangent"?: SceneSpecSchema.MaterialTangent; };
17
17
  export type Transform = { "location"?: SceneSpecSchema.Vec3; "rotationEuler"?: SceneSpecSchema.Vec3; "scale"?: SceneSpecSchema.Vec3; };
18
- export type Generator = { "shape": "cube" | "uv_sphere" | "cylinder" | "cone" | "plane" | "torus" | "rounded_box" | "lathe" | "curve" | "handled_cup"; "size"?: SceneSpecSchema.PositiveNumber; "radius"?: SceneSpecSchema.PositiveNumber; "depth"?: SceneSpecSchema.PositiveNumber; "segments"?: number; "ringCount"?: number; "majorRadius"?: SceneSpecSchema.PositiveNumber; "minorRadius"?: SceneSpecSchema.PositiveNumber; "bevel"?: { "width"?: SceneSpecSchema.PositiveNumber; "segments"?: number; }; "profile"?: Array<[number, number]>; "closedProfile"?: boolean; "capEnds"?: boolean; "path"?: Array<SceneSpecSchema.Vec3>; "pathClosed"?: boolean; "pathInterpolation"?: "poly" | "bezier"; "curveResolution"?: number; "bevelResolution"?: number; "height"?: SceneSpecSchema.PositiveNumber; "wallThickness"?: SceneSpecSchema.PositiveNumber; "baseThickness"?: SceneSpecSchema.PositiveNumber; "footRound"?: SceneSpecSchema.PositiveNumber; "handleRadius"?: SceneSpecSchema.PositiveNumber; "handleLower"?: SceneSpecSchema.PositiveNumber; "handleUpper"?: SceneSpecSchema.PositiveNumber; "rootRadius"?: SceneSpecSchema.PositiveNumber; "rootLength"?: SceneSpecSchema.PositiveNumber; "sectionSegments"?: number; "handleSegments"?: number; "rootSegments"?: number; "wallRows"?: number; "rootTension"?: number; };
18
+ export type Generator = { "shape": "cube" | "uv_sphere" | "cylinder" | "cone" | "plane" | "torus" | "rounded_box" | "lathe" | "curve" | "handled_cup"; "size"?: SceneSpecSchema.PositiveNumber; "radius"?: SceneSpecSchema.PositiveNumber; "depth"?: SceneSpecSchema.PositiveNumber; "segments"?: number; "ringCount"?: number; "majorRadius"?: SceneSpecSchema.PositiveNumber; "minorRadius"?: SceneSpecSchema.PositiveNumber; "bevel"?: { "width"?: SceneSpecSchema.PositiveNumber; "segments"?: number; }; "profile"?: Array<[number, number]>; "closedProfile"?: boolean; "capEnds"?: boolean; "path"?: Array<SceneSpecSchema.Vec3>; "pathClosed"?: boolean; "pathInterpolation"?: "poly" | "bezier"; "curveResolution"?: number; "bevelResolution"?: number; "height"?: SceneSpecSchema.PositiveNumber; "wallThickness"?: SceneSpecSchema.PositiveNumber; "baseThickness"?: SceneSpecSchema.PositiveNumber; "footRound"?: SceneSpecSchema.PositiveNumber; "handleRadius"?: SceneSpecSchema.PositiveNumber; "handleLower"?: SceneSpecSchema.PositiveNumber; "handleUpper"?: SceneSpecSchema.PositiveNumber; "rootRadius"?: SceneSpecSchema.PositiveNumber; "rootLength"?: SceneSpecSchema.PositiveNumber; "sectionSegments"?: number; "handleSegments"?: number; "rootSegments"?: number; "wallRows"?: number; "rootTension"?: number; "dimensions"?: [SceneSpecSchema.PositiveNumber, SceneSpecSchema.PositiveNumber, SceneSpecSchema.PositiveNumber]; };
19
19
  export type Entity = { "id": SceneSpecSchema.Id; "type": "asset-instance" | "generator" | "empty"; "assetId"?: string; "armatureId"?: string; "rigidBody"?: SceneSpecSchema.RigidBody; "cloth"?: SceneSpecSchema.Cloth; "softBody"?: SceneSpecSchema.SoftBody; "fluid"?: SceneSpecSchema.Fluid; "generator"?: SceneSpecSchema.Generator; "materialId"?: string; "transform"?: SceneSpecSchema.Transform; "tags"?: Array<string>; "locked"?: boolean; "visible"?: boolean; "modifiers"?: Array<SceneSpecSchema.ModelModifier>; "materialBindings"?: SceneSpecSchema.MaterialBindings; };
20
20
  export type Light = { "id": SceneSpecSchema.Id; "type": "area" | "point" | "spot" | "sun"; "transform"?: SceneSpecSchema.Transform; "energy"?: SceneSpecSchema.NonNegativeNumber; "color"?: SceneSpecSchema.UnitColor; "size"?: SceneSpecSchema.PositiveNumber; "spotSize"?: number; "spotBlend"?: number; "angle"?: number; };
21
21
  export type Camera = { "id": SceneSpecSchema.Id; "role"?: "active-camera" | "three-quarter" | "top" | "detail"; "lens"?: SceneSpecSchema.PositiveNumber; "sensorWidth"?: SceneSpecSchema.PositiveNumber; "clipping"?: [SceneSpecSchema.PositiveNumber, SceneSpecSchema.PositiveNumber]; "transform"?: SceneSpecSchema.Transform; "targetEntityId"?: string; "targetPoint"?: SceneSpecSchema.Vec3; "fStop"?: SceneSpecSchema.PositiveNumber; };
@@ -42,7 +42,7 @@ export namespace SceneSpecSchema {
42
42
  }
43
43
  export type SceneSpec = SceneSpecSchema.Document;
44
44
 
45
- /** Source SHA-256: bb1e7ee67ba552fff1a14e9eb87fae015ccd40d3c13cd21982e8a2b1674a019e */
45
+ /** Source SHA-256: b34ca16667f6771bdcc5ec7c503fe3ded71acadf2175e5e52e9f52ea7d532230 */
46
46
  export namespace ScenePatchSchema {
47
47
  export type Id = string;
48
48
  export type License = { "source"?: string; "commercialUse"?: boolean; "attribution"?: string; };
@@ -54,7 +54,7 @@ export namespace ScenePatchSchema {
54
54
  export type TransformPatch = { "location"?: ScenePatchSchema.Vec3; "rotationEuler"?: ScenePatchSchema.Vec3; "scale"?: ScenePatchSchema.Vec3; };
55
55
  export type MaterialParameters = { "baseColor"?: ScenePatchSchema.UnitColor; "metallic"?: number; "roughness"?: number; "ior"?: number; "alpha"?: number; "emissionColor"?: ScenePatchSchema.UnitColor; "emissionStrength"?: ScenePatchSchema.NonNegativeNumber; "coatWeight"?: number; "transmissionWeight"?: number; "anisotropic"?: number; "anisotropicRotation"?: number; };
56
56
  export type ProceduralTexture = ({ "type": "noise" | "wave" | "voronoi"; "scale": number; "detail"?: number; "distortion"?: number; "stretch"?: [number, number, number]; "bump"?: number; "roughnessVariation"?: number; "colorVariation"?: number; "coordinates"?: "object"; }) | ({ "type": "noise" | "wave" | "voronoi"; "scale": number; "detail"?: number; "distortion"?: number; "stretch"?: [number, number, number]; "bump"?: number; "roughnessVariation"?: number; "colorVariation"?: number; "coordinates": "uv"; "uvMap"?: string; });
57
- export type Generator = { "shape": "cube" | "uv_sphere" | "cylinder" | "cone" | "plane" | "torus" | "rounded_box" | "lathe" | "curve" | "handled_cup"; "size"?: ScenePatchSchema.PositiveNumber; "radius"?: ScenePatchSchema.PositiveNumber; "depth"?: ScenePatchSchema.PositiveNumber; "segments"?: number; "ringCount"?: number; "majorRadius"?: ScenePatchSchema.PositiveNumber; "minorRadius"?: ScenePatchSchema.PositiveNumber; "bevel"?: { "width"?: ScenePatchSchema.PositiveNumber; "segments"?: number; }; "profile"?: Array<[number, number]>; "closedProfile"?: boolean; "capEnds"?: boolean; "path"?: Array<ScenePatchSchema.Vec3>; "pathClosed"?: boolean; "pathInterpolation"?: "poly" | "bezier"; "curveResolution"?: number; "bevelResolution"?: number; "height"?: ScenePatchSchema.PositiveNumber; "wallThickness"?: ScenePatchSchema.PositiveNumber; "baseThickness"?: ScenePatchSchema.PositiveNumber; "footRound"?: ScenePatchSchema.PositiveNumber; "handleRadius"?: ScenePatchSchema.PositiveNumber; "handleLower"?: ScenePatchSchema.PositiveNumber; "handleUpper"?: ScenePatchSchema.PositiveNumber; "rootRadius"?: ScenePatchSchema.PositiveNumber; "rootLength"?: ScenePatchSchema.PositiveNumber; "sectionSegments"?: number; "handleSegments"?: number; "rootSegments"?: number; "wallRows"?: number; "rootTension"?: number; };
57
+ export type Generator = { "shape": "cube" | "uv_sphere" | "cylinder" | "cone" | "plane" | "torus" | "rounded_box" | "lathe" | "curve" | "handled_cup"; "size"?: ScenePatchSchema.PositiveNumber; "radius"?: ScenePatchSchema.PositiveNumber; "depth"?: ScenePatchSchema.PositiveNumber; "segments"?: number; "ringCount"?: number; "majorRadius"?: ScenePatchSchema.PositiveNumber; "minorRadius"?: ScenePatchSchema.PositiveNumber; "bevel"?: { "width"?: ScenePatchSchema.PositiveNumber; "segments"?: number; }; "profile"?: Array<[number, number]>; "closedProfile"?: boolean; "capEnds"?: boolean; "path"?: Array<ScenePatchSchema.Vec3>; "pathClosed"?: boolean; "pathInterpolation"?: "poly" | "bezier"; "curveResolution"?: number; "bevelResolution"?: number; "height"?: ScenePatchSchema.PositiveNumber; "wallThickness"?: ScenePatchSchema.PositiveNumber; "baseThickness"?: ScenePatchSchema.PositiveNumber; "footRound"?: ScenePatchSchema.PositiveNumber; "handleRadius"?: ScenePatchSchema.PositiveNumber; "handleLower"?: ScenePatchSchema.PositiveNumber; "handleUpper"?: ScenePatchSchema.PositiveNumber; "rootRadius"?: ScenePatchSchema.PositiveNumber; "rootLength"?: ScenePatchSchema.PositiveNumber; "sectionSegments"?: number; "handleSegments"?: number; "rootSegments"?: number; "wallRows"?: number; "rootTension"?: number; "dimensions"?: [ScenePatchSchema.PositiveNumber, ScenePatchSchema.PositiveNumber, ScenePatchSchema.PositiveNumber]; };
58
58
  export type Entity = { "id": ScenePatchSchema.Id; "type": "asset-instance" | "generator" | "empty"; "assetId"?: string; "generator"?: ScenePatchSchema.Generator; "materialId"?: string; "transform"?: { "location"?: ScenePatchSchema.Vec3; "rotationEuler"?: ScenePatchSchema.Vec3; "scale"?: ScenePatchSchema.Vec3; }; "tags"?: Array<string>; "locked"?: boolean; "visible"?: boolean; "modifiers"?: Array<ScenePatchSchema.ModelModifier>; "materialBindings"?: ScenePatchSchema.MaterialBindings; };
59
59
  export type Light = { "id": ScenePatchSchema.Id; "type": "area" | "point" | "spot" | "sun"; "transform"?: { "location"?: ScenePatchSchema.Vec3; "rotationEuler"?: ScenePatchSchema.Vec3; "scale"?: ScenePatchSchema.Vec3; }; "energy"?: ScenePatchSchema.NonNegativeNumber; "color"?: ScenePatchSchema.UnitColor; "size"?: ScenePatchSchema.PositiveNumber; "spotSize"?: number; "spotBlend"?: number; "angle"?: number; };
60
60
  export type Camera = { "id": ScenePatchSchema.Id; "role"?: "active-camera" | "three-quarter" | "top" | "detail"; "lens"?: ScenePatchSchema.PositiveNumber; "sensorWidth"?: ScenePatchSchema.PositiveNumber; "clipping"?: [ScenePatchSchema.PositiveNumber, ScenePatchSchema.PositiveNumber]; "transform"?: { "location"?: ScenePatchSchema.Vec3; "rotationEuler"?: ScenePatchSchema.Vec3; "scale"?: ScenePatchSchema.Vec3; }; "targetEntityId"?: string; "targetPoint"?: ScenePatchSchema.Vec3; "fStop"?: ScenePatchSchema.PositiveNumber; };
@@ -73,12 +73,12 @@ export namespace ScenePatchSchema {
73
73
  }
74
74
  export type ScenePatch = ScenePatchSchema.Document;
75
75
 
76
- /** Source SHA-256: d1f5b189bbf0e5cab018be87ce52757acfd208c24b1fa07d087947ed98e20ad5 */
76
+ /** Source SHA-256: c166cbaef86b7bd59f85536c9f04acd645b69e9cd614a6ea9de93f7b6a698917 */
77
77
  export namespace RecipeSchema {
78
- export type Binding = ({ "kind": "material"; "materialId": string; "property": "baseColor" | "roughness"; }) | ({ "kind": "render-profile"; "profile": "preview" | "final"; "property": "exposure"; });
78
+ export type Binding = ({ "kind": "material"; "materialId": string; "property": "baseColor" | "roughness"; }) | ({ "kind": "render-profile"; "profile": "preview" | "final"; "property": "exposure"; }) | ({ "kind": "entity"; "entityId": string; "property": "dimensions" | "location"; "axis": "x" | "y" | "z"; "multiplier": number; "offsetAnimation"?: boolean; }) | ({ "kind": "camera"; "cameraId": string; "property": "location" | "targetPoint"; "axis": "x" | "y" | "z"; "multiplier": number; });
79
79
  export type ColorParameter = { "id": string; "title": string; "description"?: string; "bindings": Array<RecipeSchema.Binding>; "type": "color"; "default": [number, number, number]; };
80
80
  export type NumberParameter = { "id": string; "title": string; "description"?: string; "bindings": Array<RecipeSchema.Binding>; "type": "number"; "default": number; "minimum": number; "maximum": number; };
81
- export type Document = { "schemaVersion": "deepblend.recipe/v1"; "id": string; "version": string; "title": string; "description": string; "author": { "name": string; "url"?: string; }; "license": string; "source": { "url": string; "note"?: string; }; "compatibility": { "sceneSchemaVersion": "deepblend.scene/v1"; "capabilities": Array<"geometry.primitive" | "geometry.lathe" | "geometry.curve" | "geometry.handled_cup" | "geometry.handled_cup.tension" | "geometry.handled_cup.tension.extended" | "geometry.modifiers" | "material.principled" | "material.glass" | "material.emission" | "material.procedural" | "material.procedural.uv" | "material.anisotropy" | "animation.transform" | "animation.material">; }; "input": { "path": "scene-spec.json"; "sha256": string; }; "preview": { "path": "preview.png"; "sha256": string; "mediaType": "image/png"; "alt": string; }; "parameters": Array<(RecipeSchema.ColorParameter) | (RecipeSchema.NumberParameter)>; };
81
+ export type Document = { "schemaVersion": "deepblend.recipe/v1"; "id": string; "version": string; "title": string; "description": string; "author": { "name": string; "url"?: string; }; "license": string; "source": { "url": string; "note"?: string; }; "compatibility": { "sceneSchemaVersion": "deepblend.scene/v1"; "capabilities": Array<"geometry.primitive" | "geometry.primitive.dimensions" | "geometry.lathe" | "geometry.curve" | "geometry.handled_cup" | "geometry.handled_cup.tension" | "geometry.handled_cup.tension.extended" | "geometry.modifiers" | "material.principled" | "material.glass" | "material.emission" | "material.procedural" | "material.procedural.uv" | "material.anisotropy" | "animation.transform" | "animation.material" | "recipe.geometry-bindings">; }; "input": { "path": "scene-spec.json"; "sha256": string; }; "preview": { "path": "preview.png"; "sha256": string; "mediaType": "image/png"; "alt": string; }; "parameters": Array<(RecipeSchema.ColorParameter) | (RecipeSchema.NumberParameter)>; };
82
82
  }
83
83
  export type RecipeManifest = RecipeSchema.Document;
84
84
  export type Entity = SceneSpecSchema.Entity;
@@ -110,6 +110,7 @@ export interface BlenderErrorCodeMap {
110
110
  readonly SCRIPT_ERROR: "BLENDER_SCRIPT_ERROR";
111
111
  readonly PROTOCOL_VERSION_MISMATCH: "BLENDER_PROTOCOL_VERSION_MISMATCH";
112
112
  readonly UNSUPPORTED_ACTION: "BLENDER_UNSUPPORTED_ACTION";
113
+ readonly SCENE_FEATURE_UNSUPPORTED: "BLENDER_SCENE_FEATURE_UNSUPPORTED";
113
114
  readonly CAPABILITY_PROBE_FAILED: "BLENDER_CAPABILITY_PROBE_FAILED";
114
115
  readonly BOOTSTRAP_MISSING: "BLENDER_BOOTSTRAP_MISSING";
115
116
  readonly ENGINE_UNAVAILABLE: "BLENDER_ENGINE_UNAVAILABLE";
@@ -97,10 +97,11 @@
97
97
  "capabilities": {
98
98
  "type": "array",
99
99
  "minItems": 1,
100
- "maxItems": 15,
100
+ "maxItems": 17,
101
101
  "items": {
102
102
  "enum": [
103
103
  "geometry.primitive",
104
+ "geometry.primitive.dimensions",
104
105
  "geometry.lathe",
105
106
  "geometry.curve",
106
107
  "geometry.handled_cup",
@@ -114,7 +115,8 @@
114
115
  "material.procedural.uv",
115
116
  "material.anisotropy",
116
117
  "animation.transform",
117
- "animation.material"
118
+ "animation.material",
119
+ "recipe.geometry-bindings"
118
120
  ]
119
121
  }
120
122
  }
@@ -228,6 +230,88 @@
228
230
  "const": "exposure"
229
231
  }
230
232
  }
233
+ },
234
+ {
235
+ "type": "object",
236
+ "additionalProperties": false,
237
+ "required": [
238
+ "kind",
239
+ "entityId",
240
+ "property",
241
+ "axis",
242
+ "multiplier"
243
+ ],
244
+ "description": "Apply a bounded parameter delta to an explicit local box dimension or entity location. No expressions or arbitrary paths.",
245
+ "properties": {
246
+ "kind": {
247
+ "const": "entity"
248
+ },
249
+ "entityId": {
250
+ "type": "string",
251
+ "pattern": "^[a-zA-Z][a-zA-Z0-9._-]{0,63}$"
252
+ },
253
+ "property": {
254
+ "enum": [
255
+ "dimensions",
256
+ "location"
257
+ ]
258
+ },
259
+ "axis": {
260
+ "enum": [
261
+ "x",
262
+ "y",
263
+ "z"
264
+ ]
265
+ },
266
+ "multiplier": {
267
+ "type": "number",
268
+ "minimum": -1000,
269
+ "maximum": 1000
270
+ },
271
+ "offsetAnimation": {
272
+ "type": "boolean",
273
+ "description": "Explicitly translate keyframe values on the bound location axis by the same delta, preserving the motion. Only valid for location bindings."
274
+ }
275
+ }
276
+ },
277
+ {
278
+ "type": "object",
279
+ "additionalProperties": false,
280
+ "required": [
281
+ "kind",
282
+ "cameraId",
283
+ "property",
284
+ "axis",
285
+ "multiplier"
286
+ ],
287
+ "properties": {
288
+ "kind": {
289
+ "const": "camera"
290
+ },
291
+ "cameraId": {
292
+ "type": "string",
293
+ "pattern": "^[a-zA-Z][a-zA-Z0-9._-]{0,63}$"
294
+ },
295
+ "property": {
296
+ "enum": [
297
+ "location",
298
+ "targetPoint"
299
+ ]
300
+ },
301
+ "axis": {
302
+ "enum": [
303
+ "x",
304
+ "y",
305
+ "z"
306
+ ]
307
+ },
308
+ "multiplier": {
309
+ "type": "number",
310
+ "minimum": -1000,
311
+ "maximum": 1000
312
+ }
313
+ },
314
+ "description": "Apply a bounded parameter delta to an explicit camera position or aim point; animated camera channels are refused."
231
315
  }
232
316
  ]
233
317
  },
@@ -309,7 +393,7 @@
309
393
  "bindings": {
310
394
  "type": "array",
311
395
  "minItems": 1,
312
- "maxItems": 8,
396
+ "maxItems": 128,
313
397
  "items": {
314
398
  "$ref": "#/$defs/binding"
315
399
  }
@@ -519,6 +519,15 @@
519
519
  "minimum": 1,
520
520
  "maximum": 2.5,
521
521
  "description": "Dimensionless scale of both endpoint tangent speeds for the quintic attachment. Missing or 1 preserves the original surface. Values above 1.5 require the extended recipe capability. Native output validation still applies."
522
+ },
523
+ "dimensions": {
524
+ "type": "array",
525
+ "minItems": 3,
526
+ "maxItems": 3,
527
+ "items": {
528
+ "$ref": "#/$defs/positiveNumber"
529
+ },
530
+ "description": "Explicit local width, depth and height for cube or rounded_box, in scene units, before object transform and bevel. Mutually exclusive with scalar size."
522
531
  }
523
532
  },
524
533
  "description": "Deterministic procedural geometry. Lathe revolves a radius/height profile; handled_cup constructs a closed cup with shared-boundary flared handle roots. Coupled parameter constraints are checked before generation and the discrete output is validated."
@@ -702,6 +702,15 @@
702
702
  "minimum": 1,
703
703
  "maximum": 2.5,
704
704
  "description": "Dimensionless scale of both endpoint tangent speeds for the quintic attachment. Missing or 1 preserves the original surface. Values above 1.5 require the extended recipe capability. Native output validation still applies."
705
+ },
706
+ "dimensions": {
707
+ "type": "array",
708
+ "minItems": 3,
709
+ "maxItems": 3,
710
+ "items": {
711
+ "$ref": "#/$defs/positiveNumber"
712
+ },
713
+ "description": "Explicit local width, depth and height for cube or rounded_box, in scene units, before object transform and bevel. Mutually exclusive with scalar size."
705
714
  }
706
715
  }
707
716
  },
package/lib/sdk.d.ts CHANGED
@@ -32,6 +32,8 @@ export interface SceneSpecValidation extends ValidationResult<SceneSpecIssue> {
32
32
  export function validateSceneSpec(input: unknown): SceneSpecValidation;
33
33
  export function parseSceneSpec(input: unknown): SceneSpec;
34
34
  /** Does not validate first. Call parseSceneSpec or check validateSceneSpec before compiling. */
35
+ export const BOX_DIMENSIONS_FEATURE: 'geometry.primitive.dimensions';
36
+ export function requiredSceneFeatures(spec: SceneSpec): string[];
35
37
  export function compileSceneSpec(spec: SceneSpec): {
36
38
  spec: SceneSpec; notices: SceneSpecIssue[];
37
39
  entityBounds: Record<string, { location: number[]; radius: number }>;
@@ -92,6 +94,19 @@ export type RecipeValues = Record<string, number | [number, number, number]>;
92
94
  export interface RecipeBundle { manifest: RecipeManifest; sceneBytes: string | Uint8Array; previewBytes: string | Uint8Array }
93
95
  export interface RecipeOptions { supportedCapabilities?: Iterable<RecipeCapability> }
94
96
  export interface RecipeValidation extends ValidationResult { notices: SceneSpecIssue[]; sceneSpec?: SceneSpec }
97
+ export interface RecipeLock {
98
+ schemaVersion: 'deepblend.recipe-lock/v1'; id: string; version: string;
99
+ manifestSha256: string; packageDigest: string; inputSha256: string; previewSha256: string;
100
+ valuesSha256: string; manifest: RecipeManifest; values: RecipeValues; sceneSource: string;
101
+ }
102
+ export interface RecipeParameterState {
103
+ schemaVersion: 'deepblend.recipe-parameters/v1'; manifestSha256: string;
104
+ values: RecipeValues; valuesSha256: string; originRevision?: string;
105
+ }
106
+ export interface RecipeGeometryPlan {
107
+ operations: ScenePatchOperation[]; changedParameters: string[]; preservedCameraIds: string[];
108
+ parameterState: RecipeParameterState;
109
+ }
95
110
  export function recipeCapabilitiesForScene(spec: SceneSpec): RecipeCapability[];
96
111
  export function validateRecipeManifest(manifest: unknown, options?: RecipeOptions): RecipeValidation;
97
112
  export function parseRecipeManifest(manifest: unknown, options?: RecipeOptions): RecipeManifest;
@@ -100,3 +115,7 @@ export function instantiateRecipe(bundle: RecipeBundle, values?: RecipeValues, o
100
115
  spec: SceneSpec; values: RecipeValues; notices: SceneSpecIssue[];
101
116
  recipe: { id: string; version: string; manifestSha256: string; inputSha256: string; previewSha256: string; valuesSha256: string };
102
117
  };
118
+ export function instantiateRecipeLock(lock: RecipeLock, changes?: RecipeValues, options?: RecipeOptions): ReturnType<typeof instantiateRecipe>;
119
+ export function recipeGeometryParameters(manifest: RecipeManifest): RecipeManifest['parameters'];
120
+ export function planRecipeGeometryEdit(lock: RecipeLock, currentSpec: SceneSpec,
121
+ parameterState: RecipeParameterState | undefined, changes: Record<string, number>): RecipeGeometryPlan;
package/lib/sdk.js CHANGED
@@ -12,12 +12,14 @@ export { canonicalStringify, canonicalPretty, sha256, sha256Canonical } from './
12
12
  export {
13
13
  validateSceneSpec, compileSceneSpec, sceneSpecCanonicalText, sceneSpecDigest, specHash,
14
14
  reviewInputsDigest, summarizeSceneSpec,
15
+ BOX_DIMENSIONS_FEATURE, requiredSceneFeatures,
15
16
  } from './scene-spec.js'
16
17
  export { SCENE_OPERATION_NAMES, validateScenePatch, applyPatchToSpec, buildOperationManifest } from './scene-patch.js'
17
18
  export {
18
19
  RECIPE_SCHEMA_VERSION, RECIPE_CAPABILITIES, RECIPE_LIMITS, RecipeError,
19
- recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe,
20
+ recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe, instantiateRecipeLock,
20
21
  } from './recipe.js'
22
+ export { planRecipeGeometryEdit, recipeGeometryParameters } from './recipe-parameters.js'
21
23
 
22
24
  /** Validate untrusted JSON and return a separate document; never assert a type without checking it. */
23
25
  export function parseSceneSpec(input) {
package/lib/ui-api.js CHANGED
@@ -83,6 +83,8 @@ export const UI_ROUTES = Object.freeze([
83
83
  { id: 'project.review', method: 'POST', path: '/deepblend/projects/:projectId/review', write: true, summary: 'Explicitly render and consult the visual reviewer for one revision.' },
84
84
  { id: 'project.autofix', method: 'POST', path: '/deepblend/projects/:projectId/autofix', write: true, summary: 'Explicitly run a bounded visual correction loop for one revision.' },
85
85
  { id: 'project.patch', method: 'POST', path: '/deepblend/projects/:projectId/patch', write: true, summary: 'Apply a ScenePatch as one atomic revision.' },
86
+ { id: 'project.recipeParameters', method: 'GET', path: '/deepblend/projects/:projectId/recipe-parameters', write: false, summary: 'Read saved assembly controls at one revision.' },
87
+ { id: 'project.recipeParameters.update', method: 'POST', path: '/deepblend/projects/:projectId/recipe-parameters', write: true, summary: 'Save bounded assembly dimensions as an atomic revision.' },
86
88
  { id: 'project.restore', method: 'POST', path: '/deepblend/projects/:projectId/restore', write: true, summary: 'Move the current revision pointer to a saved revision, preserving history without creating a new revision.' },
87
89
  { id: 'project.jobs', method: 'GET', path: '/deepblend/projects/:projectId/jobs', write: false, summary: 'Render/export jobs of a project.' },
88
90
  { id: 'project.job', method: 'GET', path: '/deepblend/projects/:projectId/jobs/:jobId', write: false, summary: 'One job with its live progress.' },
@@ -930,4 +932,5 @@ export const UI_TOOL_CARD_KEYS = Object.freeze([
930
932
  'blender_export',
931
933
  'blender_job_status',
932
934
  'blender_job_cancel',
935
+ 'blender_recipe_parameters',
933
936
  ])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-contracts",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
6
6
  "main": "lib/index.js",