@deepblend/dsh-blender-contracts 0.4.0 → 0.5.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,
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,10 +190,44 @@ 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
 
@@ -240,6 +293,14 @@ export function instantiateRecipe(bundle, values = {}, options = {}) {
240
293
  const material = spec.materials.find(item => item.id === binding.materialId)
241
294
  material.parameters[binding.property] = parameter.type === 'color'
242
295
  ? [...value, material.parameters.baseColor[3] ?? 1] : value
296
+ } else if (['entity', 'camera'].includes(binding.kind)) {
297
+ const vector = bindingVector(bindingTarget(spec, binding), binding)
298
+ // Preserve the input's exact default bytes and avoid cumulative edits.
299
+ if (value !== parameter.default) {
300
+ const delta = (value - parameter.default) * binding.multiplier
301
+ vector[bindingAxis(binding)] += delta
302
+ if (binding.offsetAnimation === true) for (const track of bindingTracks(spec, binding)) for (const keyframe of track.keyframes) keyframe.value += delta
303
+ }
243
304
  } else spec.renderProfiles[binding.profile].colorManagement.exposure = value
244
305
  }
245
306
  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 }>;
package/lib/sdk.js CHANGED
@@ -12,6 +12,7 @@ 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 {
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.5.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
6
6
  "main": "lib/index.js",