@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 +5 -1
- package/lib/recipe-parameters.js +107 -0
- package/lib/recipe.js +115 -17
- package/lib/runtime-types.d.ts +2 -0
- package/lib/scene-spec.js +19 -4
- package/lib/schema-types.d.ts +8 -7
- package/lib/schemas/recipe.schema.json +87 -3
- package/lib/schemas/scene-patch.schema.json +9 -0
- package/lib/schemas/scene-spec.schema.json +9 -0
- package/lib/sdk.d.ts +19 -0
- package/lib/sdk.js +3 -1
- package/lib/ui-api.js +3 -0
- package/package.json +1 -1
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
|
|
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}`
|
|
95
|
-
|
|
96
|
-
targets.
|
|
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
|
-
/**
|
|
182
|
-
|
|
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(
|
|
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)
|
package/lib/runtime-types.d.ts
CHANGED
|
@@ -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':
|
|
866
|
-
case 'rounded_box':
|
|
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
|
package/lib/schema-types.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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":
|
|
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":
|
|
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
|
])
|