@deepblend/dsh-blender-contracts 0.5.0 → 0.6.1

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
@@ -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
- /** Hash actual loaded bytes; the manifest is a claim, never a verification receipt. */
235
- export function validateRecipePackage(bundle, options = {}) {
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(bundle.sceneBytes, RECIPE_LIMITS.sceneBytes, 'input')
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
  ])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-contracts",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
6
6
  "main": "lib/index.js",