@deepblend/dsh-blender-contracts 0.5.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 +2 -1
- package/lib/recipe-parameters.js +107 -0
- package/lib/recipe.js +47 -10
- package/lib/sdk.d.ts +17 -0
- package/lib/sdk.js +2 -1
- package/lib/ui-api.js +3 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -877,4 +877,5 @@ export {
|
|
|
877
877
|
managedBlenderCandidates,
|
|
878
878
|
} from './deployment-paths.js'
|
|
879
879
|
|
|
880
|
-
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
|
@@ -231,20 +231,14 @@ function bindingsAgainstScene(manifest, spec) {
|
|
|
231
231
|
return errors
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
/**
|
|
235
|
-
|
|
236
|
-
const checked = validateRecipeManifest(bundle?.manifest, options)
|
|
237
|
-
if (!checked.ok) return checked
|
|
238
|
-
const { manifest } = bundle
|
|
234
|
+
/** Shared input validation; locked recipes do not retain original preview pixels. */
|
|
235
|
+
function validateRecipeInput(manifest, input) {
|
|
239
236
|
const errors = []
|
|
240
237
|
let sceneSpec
|
|
241
238
|
try {
|
|
242
|
-
const sceneBytes = asBytes(
|
|
243
|
-
const previewBytes = asBytes(bundle.previewBytes, RECIPE_LIMITS.previewBytes, 'preview')
|
|
239
|
+
const sceneBytes = asBytes(input, RECIPE_LIMITS.sceneBytes, 'input')
|
|
244
240
|
if (sha256(sceneBytes) !== manifest.input.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'input.sha256', 'Loaded SceneSpec bytes differ from the declared hash'))
|
|
245
|
-
if (sha256(previewBytes) !== manifest.preview.sha256) errors.push(issue('RECIPE_HASH_MISMATCH', 'preview.sha256', 'Loaded preview bytes differ from the declared hash'))
|
|
246
241
|
if (errors.length) return result(errors)
|
|
247
|
-
try { validatePreview(previewBytes) } catch (error) { return result([issue('RECIPE_PREVIEW_INVALID', 'preview', error.message)]) }
|
|
248
242
|
try { sceneSpec = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(sceneBytes)) }
|
|
249
243
|
catch { return result([issue('RECIPE_INPUT_INVALID', 'input', 'SceneSpec must be valid UTF-8 JSON')]) }
|
|
250
244
|
const scene = validateSceneSpec(sceneSpec)
|
|
@@ -269,12 +263,55 @@ export function validateRecipePackage(bundle, options = {}) {
|
|
|
269
263
|
} catch (error) { return result([issue(error.code ?? 'RECIPE_INPUT_INVALID', 'input', error.message)]) }
|
|
270
264
|
}
|
|
271
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
|
+
|
|
272
283
|
/** Return a fresh resolved SceneSpec; only explicit scalar/color slots can change. */
|
|
273
284
|
export function instantiateRecipe(bundle, values = {}, options = {}) {
|
|
274
285
|
const checked = validateRecipePackage(bundle, options)
|
|
275
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) {
|
|
276
314
|
if (!plain(values)) throw new RecipeError('RECIPE_PARAMETER_INVALID', 'Parameter values must be a plain object')
|
|
277
|
-
const { manifest } = bundle
|
|
278
315
|
const known = new Set(manifest.parameters.map(parameter => parameter.id))
|
|
279
316
|
const errors = Object.keys(values).filter(key => !known.has(key) || RESERVED.has(key))
|
|
280
317
|
.map(key => issue('RECIPE_PARAMETER_UNKNOWN', `values.${key}`, 'Unknown parameter'))
|
package/lib/sdk.d.ts
CHANGED
|
@@ -94,6 +94,19 @@ export type RecipeValues = Record<string, number | [number, number, number]>;
|
|
|
94
94
|
export interface RecipeBundle { manifest: RecipeManifest; sceneBytes: string | Uint8Array; previewBytes: string | Uint8Array }
|
|
95
95
|
export interface RecipeOptions { supportedCapabilities?: Iterable<RecipeCapability> }
|
|
96
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
|
+
}
|
|
97
110
|
export function recipeCapabilitiesForScene(spec: SceneSpec): RecipeCapability[];
|
|
98
111
|
export function validateRecipeManifest(manifest: unknown, options?: RecipeOptions): RecipeValidation;
|
|
99
112
|
export function parseRecipeManifest(manifest: unknown, options?: RecipeOptions): RecipeManifest;
|
|
@@ -102,3 +115,7 @@ export function instantiateRecipe(bundle: RecipeBundle, values?: RecipeValues, o
|
|
|
102
115
|
spec: SceneSpec; values: RecipeValues; notices: SceneSpecIssue[];
|
|
103
116
|
recipe: { id: string; version: string; manifestSha256: string; inputSha256: string; previewSha256: string; valuesSha256: string };
|
|
104
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
|
@@ -17,8 +17,9 @@ export {
|
|
|
17
17
|
export { SCENE_OPERATION_NAMES, validateScenePatch, applyPatchToSpec, buildOperationManifest } from './scene-patch.js'
|
|
18
18
|
export {
|
|
19
19
|
RECIPE_SCHEMA_VERSION, RECIPE_CAPABILITIES, RECIPE_LIMITS, RecipeError,
|
|
20
|
-
recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe,
|
|
20
|
+
recipeCapabilitiesForScene, validateRecipeManifest, validateRecipePackage, instantiateRecipe, instantiateRecipeLock,
|
|
21
21
|
} from './recipe.js'
|
|
22
|
+
export { planRecipeGeometryEdit, recipeGeometryParameters } from './recipe-parameters.js'
|
|
22
23
|
|
|
23
24
|
/** Validate untrusted JSON and return a separate document; never assert a type without checking it. */
|
|
24
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
|
])
|