@deepblend/dsh-blender-tool 0.2.20 → 0.3.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.
@@ -0,0 +1,102 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { BlenderError, BlenderErrorCode, warning } from '@deepblend/dsh-blender-contracts'
3
+ import { canonicalCall, definedFields, persistImage, renderSuccess } from './shared.js'
4
+
5
+ const REQUIRED_HOST_API = 6
6
+ const MAX_IMAGE_BYTES = 32 * 1024 * 1024
7
+
8
+ function aborted(signal) {
9
+ if (signal?.aborted) {
10
+ throw new BlenderError(BlenderErrorCode.ABORTED,
11
+ 'Inspection cancelled. A diagnostic already published by the Host may remain in the revision.')
12
+ }
13
+ }
14
+
15
+ function receiptError(message) {
16
+ throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, `Inspection receipt verification failed: ${message}`)
17
+ }
18
+
19
+ /** Render and attach one revision-bound inspection. All IO happens during execute. */
20
+ export async function renderInspection(resolved, request) {
21
+ const { studio, attachments } = resolved
22
+ const { projectId, revision, mode, cameraId, frame, signal } = request
23
+ aborted(signal)
24
+ if (!['beauty', 'clay'].includes(mode) || typeof revision !== 'string' || !revision.trim() ||
25
+ typeof cameraId !== 'string' || !cameraId.trim() || !Number.isSafeInteger(frame)) {
26
+ throw new BlenderError(BlenderErrorCode.RENDER_RANGE_INVALID,
27
+ 'An isolated inspection requires mode beauty or clay, an explicit revision, cameraId and integer frame.')
28
+ }
29
+ const reported = typeof studio.hostApiVersion === 'function' ? studio.hostApiVersion() : null
30
+ const missingMethods = ['renderViews', 'readArtifact'].filter(name => typeof studio[name] !== 'function')
31
+ if (!Number.isFinite(reported) || reported < REQUIRED_HOST_API || missingMethods.length) {
32
+ throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE,
33
+ 'Isolated inspections require DeepBlend Host API 6 or newer. Upgrade the Host if needed, then restart the profile.',
34
+ { detail: { requiredHostApiVersion: REQUIRED_HOST_API,
35
+ reportedHostApiVersion: Number.isFinite(reported) ? reported : null, missingMethods,
36
+ fix: 'upgrade the DeepBlend Host if needed, then restart the profile' } })
37
+ }
38
+ const { data, canonicalWarnings } = await canonicalCall(studio.renderViews({
39
+ ...definedFields({ projectId, revision, mode, width: request.width, height: request.height, samples: request.samples }),
40
+ views: [{ id: 'inspection', cameraId, frame }], signal,
41
+ }), warning)
42
+ aborted(signal)
43
+ if (data.schemaVersion !== 'deepblend.diagnostic/v1' || data.projectId !== projectId ||
44
+ data.revision !== revision || data.sourceRevision !== revision || data.mode !== mode ||
45
+ typeof data.sourceDigest !== 'string' || !data.sourceDigest ||
46
+ !Array.isArray(data.artifacts) || data.artifacts.length !== 1) receiptError('source or mode does not match the request.')
47
+ const artifact = data.artifacts[0]
48
+ if (artifact?.kind !== 'diagnostic' || artifact.mode !== mode || artifact.sourceRevision !== revision ||
49
+ artifact.sourceDigest !== data.sourceDigest || artifact.viewId !== 'inspection' ||
50
+ artifact.cameraId !== cameraId || artifact.frame !== frame || artifact.mime !== 'image/png' ||
51
+ typeof artifact.path !== 'string' || !artifact.path.startsWith(`revisions/${revision}/diagnostics/`) ||
52
+ artifact.path.split('/').some(part => !part || part === '.' || part === '..') ||
53
+ typeof artifact.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.sha256) ||
54
+ !Number.isSafeInteger(artifact.bytes) || artifact.bytes <= 0 || artifact.bytes > MAX_IMAGE_BYTES ||
55
+ !Number.isSafeInteger(artifact.width) || artifact.width <= 0 || artifact.width > 2048 ||
56
+ !Number.isSafeInteger(artifact.height) || artifact.height <= 0 || artifact.height > 2048) {
57
+ receiptError('artifact does not match the requested view or image limits.')
58
+ }
59
+ const read = await studio.readArtifact({ projectId, path: artifact.path })
60
+ aborted(signal)
61
+ if (read?.path !== artifact.path || read.contentType !== 'image/png' || !Buffer.isBuffer(read.bytes) ||
62
+ read.size !== artifact.bytes || read.bytes.length !== artifact.bytes ||
63
+ createHash('sha256').update(read.bytes).digest('hex') !== artifact.sha256) {
64
+ receiptError('the stored PNG differs from the published artifact.')
65
+ }
66
+ let image = null
67
+ let note = null
68
+ try {
69
+ const store = typeof attachments?.saveImage === 'function' ? {
70
+ async saveImage(input) {
71
+ const ref = await attachments.saveImage(input)
72
+ if (typeof ref?.attachmentId !== 'string' || !ref.attachmentId.trim() || ref.mediaType !== 'image/png' ||
73
+ ref.bytes !== artifact.bytes || ref.width !== artifact.width || ref.height !== artifact.height) {
74
+ throw new Error('attachment reference does not match the verified PNG')
75
+ }
76
+ return ref
77
+ },
78
+ } : attachments
79
+ const attached = await persistImage(store, read.bytes, `${mode}-${revision}-${cameraId}-${frame}.png`)
80
+ aborted(signal)
81
+ image = attached.image
82
+ note = attached.note
83
+ } catch (cause) {
84
+ aborted(signal)
85
+ image = null
86
+ // The render succeeded. Do not prompt an expensive re-render for an attachment failure.
87
+ note = `The inspection PNG was rendered and verified, but could not be attached: ${cause?.message ?? String(cause)}. ` +
88
+ 'The model cannot see it in this result. Its project-relative path is in the payload; read it with a file-read tool.'
89
+ }
90
+ aborted(signal)
91
+ const notes = [
92
+ `Source revision: ${revision}; mode: ${mode}; camera: ${cameraId}; frame: ${frame}.`,
93
+ `Image: ${artifact.path} (${artifact.width}x${artifact.height}, sha256 ${artifact.sha256}).`,
94
+ 'This independent inspection was rebuilt from SceneSpec. The source revision and normal previews are unchanged.',
95
+ 'It does not establish technical or artistic approval.',
96
+ ...(data.limitations ?? []),
97
+ ...(note ? [note] : []),
98
+ ]
99
+ return { ok: true, data, image,
100
+ text: renderSuccess(`Rendered ${mode} inspection of ${revision}.`, data,
101
+ { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }) }
102
+ }
package/lib/shared.js CHANGED
@@ -205,16 +205,21 @@ export const TOOL_OUTPUT_WITH_IMAGE = {
205
205
  text: { type: 'string', required: true },
206
206
  data: { type: 'object', additionalProperties: true, required: true },
207
207
  image: {
208
- type: 'object',
209
- additionalProperties: true,
210
- properties: {
211
- attachmentId: { type: 'string', required: true },
212
- mediaType: { type: 'string', required: true },
213
- bytes: { type: 'number', required: true },
214
- width: { type: 'number', required: true },
215
- height: { type: 'number', required: true },
216
- name: { type: 'string' },
217
- },
208
+ oneOf: [
209
+ { type: 'null' },
210
+ {
211
+ type: 'object',
212
+ additionalProperties: true,
213
+ properties: {
214
+ attachmentId: { type: 'string', required: true },
215
+ mediaType: { type: 'string', required: true },
216
+ bytes: { type: 'number', required: true },
217
+ width: { type: 'number', required: true },
218
+ height: { type: 'number', required: true },
219
+ name: { type: 'string' },
220
+ },
221
+ },
222
+ ],
218
223
  },
219
224
  },
220
225
  },
package/lib/tools.js CHANGED
@@ -27,9 +27,11 @@
27
27
  */
28
28
 
29
29
  import { defineTool } from '@deepseek-ai/dsh-tools'
30
+ import { renderInspection } from './inspection.js'
30
31
 
31
32
  import {
32
33
  SCENE_OPERATION_NAMES,
34
+ BlenderError,
33
35
  BlenderErrorCode,
34
36
  BlenderWarningCode,
35
37
  warning,
@@ -37,6 +39,7 @@ import {
37
39
 
38
40
  import {
39
41
  TOOL_OUTPUT,
42
+ TOOL_OUTPUT_WITH_IMAGE,
40
43
  canonicalCall,
41
44
  definedFields,
42
45
  describeRevision,
@@ -57,15 +60,25 @@ const SCENE_SPEC_PARAMETER = {
57
60
  type: 'object',
58
61
  additionalProperties: true,
59
62
  description:
60
- 'A complete SceneSpec v1 document to seed the project with. Omit it to start from a minimal, ' +
63
+ 'A complete SceneSpec v1 document to seed the project with. Mutually exclusive with recipe. Omit both to start from a minimal, ' +
61
64
  'immediately renderable scaffold (one cube, one camera, one area light, both render profiles), ' +
62
65
  'then build the scene with blender_scene_patch — that route is usually better, because each patch ' +
63
66
  'is a reviewable revision instead of one large opaque document. ' +
64
67
  'Top-level keys: schemaVersion, project, assets[], materials[], entities[], lights[], cameras[], ' +
65
- 'shots[], animationTracks[], renderProfiles{preview,final}. ' +
68
+ 'shots[], animationTracks[], renderProfiles{preview,final}. project.reviewSubjectId optionally selects an existing non-empty main review entity without moving cameras. ' +
66
69
  'A generator entity is `{id,type:"generator",generator:{shape,size|radius|depth|majorRadius|minorRadius,bevel?},' +
67
70
  'materialId,transform:{location,rotationEuler,scale}}` where shape is one of ' +
68
- 'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus. Material parameters use Blender 5 socket ' +
71
+ 'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus, lathe, curve, handled_cup. handled_cup builds one closed cup/handle mesh with flared shared-boundary roots. ' +
72
+ 'Defaults are radius 0.04, height 0.105, wallThickness 0.003, baseThickness 0.005; optional handleRadius, handleLower, handleUpper, rootRadius, rootLength and mesh segments obey coupled constraints. Inspect root highlights; mesh checks do not certify aesthetic quality. Curve sweeps a round section ' +
73
+ 'of radius (default 0.01) along path:[[x,y,z],…]; pathInterpolation is poly or bezier, pathClosed closes ' +
74
+ 'the loop, and capEnds seals open endpoints. Lathe uses profile:[[radius,height],…] ' +
75
+ 'around local Z, segments (default 96), capEnds (default true) and optional closedProfile for hollow ' +
76
+ 'sections. Use enough profile points to describe smooth shoulders and rounded lips; points form straight segments. ' +
77
+ 'Entity modifiers[] apply in order: {type:"solidify",thickness,offset?}, {type:"mirror",axis:"x"|"y"|"z",merge?}, ' +
78
+ '{type:"array",count,offset:[x,y,z]}, {type:"boolean",operation:"union"|"difference"|"intersect",targetEntityId}, ' +
79
+ 'or {type:"bevel",width,segments?,angle?,miterInner?:"arc"|"sharp"}. Place bevel after boolean to round its new edges; segments default 4, angle defaults 30 degrees, miterInner defaults arc for compatibility; sharp can avoid inner-corner artifacts around curved bores. ' +
80
+ 'Boolean operands must be separate single-mesh entities; visible:false hides an operand while preserving its geometry for the operation. ' +
81
+ 'Material parameters use Blender 5 socket ' +
69
82
  'names: baseColor [r,g,b,a], metallic, roughness, ior, alpha, emissionColor, emissionStrength, ' +
70
83
  'coatWeight, transmissionWeight. A camera aims itself at `targetEntityId` (or `targetPoint`) and needs ' +
71
84
  '`transform.location` for framing.',
@@ -81,6 +94,7 @@ const SCENE_SPEC_PARAMETER = {
81
94
  * @param {import('@deepseek-ai/cordis').Context} ctx
82
95
  */
83
96
  export function apply(ctx) {
97
+ ctx.tools.register(recipeList(ctx))
84
98
  ctx.tools.register(projectCreate(ctx))
85
99
  ctx.tools.register(projectGet(ctx))
86
100
  ctx.tools.register(sceneGet(ctx))
@@ -91,6 +105,33 @@ export function apply(ctx) {
91
105
  ctx.tools.register(assetIngest(ctx))
92
106
  }
93
107
 
108
+ /** Read verified local package metadata through the Host; never load a package in the tool plane. */
109
+ function recipeList(ctx) {
110
+ return defineTool({
111
+ name: 'blender_recipe_list',
112
+ description:
113
+ 'List locally available, validated product recipes without launching Blender or creating a project. ' +
114
+ 'Returns recipes with id, version, digest, title, author, declared license, source, preview URL and ' +
115
+ 'finite parameter definitions, plus errors for packages that could not be accepted. Content hashes ' +
116
+ 'check loaded bytes; they do not certify authorship or artistic quality. Use the returned id, version ' +
117
+ 'and digest together in blender_project_create.recipe. Only pass parameter IDs and ranges declared ' +
118
+ 'by that recipe. This tool never downloads or installs anything.',
119
+ parameters: {},
120
+ output: TOOL_OUTPUT,
121
+ async execute() {
122
+ const resolved = resolveStudio(ctx)
123
+ if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
124
+ try {
125
+ const { data, canonicalWarnings } = await canonicalCall(resolved.studio.listRecipes(), warning)
126
+ return { ok: true, text: renderSuccess('Available product recipes.', data, { warnings: canonicalWarnings }), data }
127
+ } catch (cause) {
128
+ return { ok: false, ...renderFailure(cause, 'RECIPE_LIST_FAILED') }
129
+ }
130
+ },
131
+ presentCall: () => ({ card: 'generic', title: 'List product recipes', kind: 'read' }),
132
+ })
133
+ }
134
+
94
135
  // ---------------------------------------------------------------------------
95
136
  // project_create
96
137
  // ---------------------------------------------------------------------------
@@ -102,7 +143,9 @@ function projectCreate(ctx) {
102
143
  'Create a DeepBlend 3D project and commit its first revision. Returns the projectId and the initial ' +
103
144
  'revision id — keep BOTH, because every later write must name the project and the revision it read. ' +
104
145
  'Give it the operator\'s brief as `goal`: it is stored with the project and never parsed, so write it ' +
105
- 'in full. Omit `sceneSpec` to start from a minimal renderable scaffold and build up with ' +
146
+ 'in full. For an existing product design, call blender_recipe_list and pass its id/version/digest ' +
147
+ 'as recipe, with only its declared parameter overrides. recipe and sceneSpec are mutually exclusive. ' +
148
+ 'Omit both to start from a minimal renderable scaffold and build up with ' +
106
149
  'blender_scene_patch; pass one only when you already know the whole scene. ' +
107
150
  'Set saveCheckpoint:false to skip the Blender compile and store the SceneSpec alone. The revision then ' +
108
151
  'has no .blend of its own, and the first render of it compiles one from the spec — which costs a few ' +
@@ -122,6 +165,16 @@ function projectCreate(ctx) {
122
165
  'The operator\'s natural-language brief, stored verbatim for audit. It never affects compilation.',
123
166
  },
124
167
  sceneSpec: SCENE_SPEC_PARAMETER,
168
+ recipe: {
169
+ type: 'object', additionalProperties: false,
170
+ description: 'An exact local recipe selection returned by blender_recipe_list. The Host rechecks package bytes before creating the project; do not combine with sceneSpec.',
171
+ properties: {
172
+ id: { type: 'string', required: true, description: 'Recipe id from the catalog.' },
173
+ version: { type: 'string', required: true, description: 'Recipe version from the catalog.' },
174
+ digest: { type: 'string', required: true, description: 'Exact current catalog digest; refresh the catalog if the package changed.' },
175
+ parameters: { type: 'object', additionalProperties: true, description: 'Optional parameter-ID/value overrides from this recipe. Unknown keys or invalid ranges are refused by the Host; omitted values use defaults. Color values are three scene-linear RGB numbers.' },
176
+ },
177
+ },
125
178
  projectId: {
126
179
  type: 'string',
127
180
  description:
@@ -132,7 +185,7 @@ function projectCreate(ctx) {
132
185
  type: 'boolean',
133
186
  description:
134
187
  'Compile the scene in Blender and store `<revision>/scene.blend` as a checkpoint. Default true. ' +
135
- 'A preview can only be rendered from a checkpoint, so leave this on unless you are only reshaping the spec.',
188
+ 'If false, the first preview compiles this revision lazily before rendering.',
136
189
  },
137
190
  renderPreview: {
138
191
  type: 'boolean',
@@ -145,11 +198,13 @@ function projectCreate(ctx) {
145
198
  const resolved = resolveStudio(ctx)
146
199
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
147
200
  try {
201
+ if (args.recipe !== undefined && args.sceneSpec !== undefined) throw new BlenderError('RECIPE_REQUEST_INVALID', 'Pass either recipe or sceneSpec, not both')
148
202
  const { data, canonicalWarnings } = await canonicalCall(resolved.studio.createProject({
149
203
  ...definedFields({
150
204
  title: args.title,
151
205
  goal: args.goal,
152
206
  sceneSpec: args.sceneSpec,
207
+ recipe: args.recipe,
153
208
  projectId: args.projectId,
154
209
  saveCheckpoint: args.saveCheckpoint,
155
210
  }),
@@ -162,7 +217,7 @@ function projectCreate(ctx) {
162
217
  `Digest: ${data.revision.digest}`,
163
218
  ]
164
219
  if (data.revision.checkpoint === null) {
165
- notes.push('No checkpoint was saved, so this revision cannot be previewed until a later revision saves one.')
220
+ notes.push('No checkpoint was saved. The first preview will compile this revision lazily.')
166
221
  }
167
222
  if (Array.isArray(data.warnings) && data.warnings.length > 0) {
168
223
  notes.push('')
@@ -303,6 +358,7 @@ function sceneGet(ctx) {
303
358
  )
304
359
  }
305
360
  notes.push('')
361
+ if (data.assetParts?.length) notes.push(`Imported mesh parts (use sourceMaterialSlots indices for binding; materialSlots are the current result): ${JSON.stringify(data.assetParts)}`, '')
306
362
  notes.push('Cameras:')
307
363
  for (const camera of data.cameras ?? []) {
308
364
  notes.push(
@@ -360,8 +416,8 @@ const OPERATION_SUMMARY = [
360
416
  'entity.add {entity} — full entity object',
361
417
  'entity.remove {entityId}',
362
418
  'entity.material.set {entityId, materialId|null} — null restores the default material',
363
- 'material.add {material} — {id, shader, parameters?}',
364
- 'material.parameter.update {materialId, parameter, value} — parameter is a Blender 5 socket name',
419
+ 'material.add {material} — {id, shader, parameters?, tangent?}',
420
+ 'material.parameter.update {materialId, parameter, value} — SceneSpec camelCase parameter; anisotropic and anisotropicRotation use [0,1], rotation is turns; nonzero values/animation require explicit tangent and active anisotropy requires Cycles',
365
421
  'light.add {light}',
366
422
  'light.update {lightId, energy?, color?, size?, transform?, spotSize?, spotBlend?, angle?}',
367
423
  'light.remove {lightId}',
@@ -374,10 +430,17 @@ const OPERATION_SUMMARY = [
374
430
  'shot.remove {shotId}',
375
431
  'project.frameRange.set {frameStart, frameEnd, fps?}',
376
432
  'render.profile.set {profileName, profile} — profileName is "preview" or "final"',
377
- 'world.set {world} — {color?, strength?}; the environment behind the product',
433
+ 'world.set {world} — {color?, strength?, environment?:{assetId,rotation?}}; rotation is world-Z radians; environment uses a declared image',
378
434
  'asset.add {asset} — {id, type, path, sha256, license?}; the file must be ingested first',
379
435
  'asset.remove {assetId} — removing the last one makes the key disappear, not an empty list',
380
436
  'material.texture.set {materialId, texture} — {type: noise|wave|voronoi, scale, …}, or null to remove it',
437
+ 'entity.generator.set {entityId, generator} — replace generator geometry; preserve identity, material and animation',
438
+ 'entity.modifiers.set {entityId, modifiers} — replace the ordered modeling stack; [] removes it',
439
+ 'material.images.set {materialId, images} — replace PBR image maps, or null to remove; baseColor/roughness/metallic/normal/alpha/emissionColor bindings name assetId with optional scale, offset, uvMap; scalar maps support channel r/g/b/a, normals support strength',
440
+ 'entity.materialBindings.set {entityId, materialBindings} — replace imported mesh bindings; copy partId and sourceMaterialSlots index from scene_get.assetParts; each {partId,materialId,slotIndex?}; [] clears; whole-part then slot override',
441
+ 'material.tangent.set {materialId, tangent} — {mode:uv,uvMap:UVMap} uses named mesh UVs; {mode:radial,axis:x|y|z} uses object-local cylindrical tangent; null clears after disabling anisotropy and rotation/animation',
442
+ 'project.brief.set {goal, referenceImages} — replace written goal and up to four reference bindings; each {id,assetId,sha256,label,purposes,notes?}, purposes geometry/materials/lighting/goalFit; bind immutable ingested PNG/JPEG assets; empty goal and [] clear',
443
+ 'project.reviewSubject.set {entityId} — save the main review entity; null restores automatic selection; entity must exist and be non-empty; does not move any camera',
381
444
  ].join('\n ')
382
445
 
383
446
  function scenePatch(ctx) {
@@ -391,8 +454,8 @@ function scenePatch(ctx) {
391
454
  'fails, no revision is created and the current revision is untouched, so a failed patch is always safe ' +
392
455
  'to correct and retry. Retrying an identical patch is also safe — it returns the original outcome ' +
393
456
  'instead of committing twice.\n\n' +
394
- 'Set saveCheckpoint (default true) unless you are only reshaping the spec: a preview can only be ' +
395
- 'rendered from a revision that has a .blend checkpoint.\n\n' +
457
+ 'saveCheckpoint (default true) compiles this revision now. If false, the first preview compiles ' +
458
+ 'the revision lazily before rendering.\n\n' +
396
459
  'Operations (each object needs an "op" key):\n ' + OPERATION_SUMMARY,
397
460
  parameters: {
398
461
  projectId: { type: 'string', required: true, description: 'The project id.' },
@@ -410,8 +473,8 @@ function scenePatch(ctx) {
410
473
  items: { type: 'object', additionalProperties: true },
411
474
  description:
412
475
  'Ordered list of operations. Each needs an "op" key naming the operation (see the tool description). ' +
413
- 'Later operations see the result of earlier ones, so a patch can remove a camera and then the shot ' +
414
- 'that used it — in that order.',
476
+ 'Later operations see the result of earlier ones: remove or retarget every shot that uses a camera ' +
477
+ 'before removing that camera.',
415
478
  },
416
479
  note: {
417
480
  type: 'string',
@@ -489,7 +552,7 @@ function scenePatch(ctx) {
489
552
  if (data.checkpoint !== null && data.checkpoint !== undefined) {
490
553
  notes.push(`Checkpoint: ${data.checkpoint}`)
491
554
  } else {
492
- notes.push('No checkpoint was saved, so this revision cannot be previewed as-is.')
555
+ notes.push('No checkpoint was saved. The first preview will compile this revision lazily.')
493
556
  }
494
557
  if ((data.previews ?? []).length > 0) {
495
558
  for (const preview of data.previews) {
@@ -534,9 +597,14 @@ function previewRender(ctx) {
534
597
  'directly; if it does not, the revision is compiled from its SceneSpec into a scratch directory first ' +
535
598
  '(slower, and reported as a warning). Prefer committing patches with saveCheckpoint:true so previews ' +
536
599
  'stay cheap. Returns the project-relative path of the image, its dimensions, the engine actually used, ' +
537
- 'and the frame that was rendered.',
600
+ 'and the frame that was rendered. Set mode beauty or clay for an independent inspection rebuilt from ' +
601
+ 'SceneSpec with an image attachment; this requires Host API 6 and explicit revision, cameraId and frame. ' +
602
+ 'Clay replaces subject surface materials with gray, removing transparency, emission and textures; ' +
603
+ 'imported shader displacement may change the rendered shape. It does not approve technical or artistic quality.',
538
604
  parameters: {
539
605
  projectId: { type: 'string', required: true, description: 'The project id.' },
606
+ mode: { type: 'string', enum: ['beauty', 'clay'],
607
+ description: 'Independent fixed-view inspection with an image attachment. Requires explicit revision, cameraId and frame. Omit for normal checkpoint preview.' },
540
608
  revision: { type: 'string', description: 'Revision to render. Defaults to the current one.' },
541
609
  cameraId: {
542
610
  type: 'string',
@@ -557,11 +625,16 @@ function previewRender(ctx) {
557
625
  width: { type: 'integer', description: 'Override preview width in pixels.' },
558
626
  height: { type: 'integer', description: 'Override preview height in pixels.' },
559
627
  },
560
- output: TOOL_OUTPUT,
628
+ output: TOOL_OUTPUT_WITH_IMAGE,
561
629
  async execute(args, exec) {
562
630
  const resolved = resolveStudio(ctx)
563
631
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
564
632
  try {
633
+ if (args.mode !== undefined) return await renderInspection(resolved, {
634
+ projectId: args.projectId, revision: args.revision, mode: args.mode,
635
+ cameraId: args.cameraId, frame: args.frame, width: args.width, height: args.height,
636
+ samples: args.samples, signal: exec.signal,
637
+ })
565
638
  const { data, canonicalWarnings } = await canonicalCall(resolved.studio.renderPreview({
566
639
  ...definedFields({
567
640
  projectId: args.projectId,
@@ -599,7 +672,9 @@ function previewRender(ctx) {
599
672
  },
600
673
  presentCall: args => ({
601
674
  card: 'generic',
602
- title: `Render preview of "${args?.projectId ?? ''}"${args?.cameraId ? ` from ${args.cameraId}` : ''}`,
675
+ title: args?.mode
676
+ ? `Render ${args.mode} inspection of "${args.projectId}" · ${args.revision ?? '?'} · ${args.cameraId ?? '?'} · frame ${args.frame ?? '?'}`
677
+ : `Render preview of "${args?.projectId ?? ''}"${args?.cameraId ? ` from ${args.cameraId}` : ''}`,
603
678
  kind: 'other',
604
679
  }),
605
680
  })
@@ -742,6 +817,8 @@ function revisionRestore(ctx) {
742
817
  description:
743
818
  'Move a project back to an earlier revision. Read blender_project_get first and pass the exact ' +
744
819
  'revision id from its history — this does not take an index, a timestamp or "the previous one". ' +
820
+ 'Pass the currentRevision you just read as expectedCurrentRevision so the Host refuses the restore ' +
821
+ 'if another edit changed the current revision. ' +
745
822
  'Nothing is deleted: the revisions after the target stay in the history, and the revision you ' +
746
823
  'leave is still there to restore again. Requires confirm:true, because the scene the model is ' +
747
824
  'reasoning about changes underneath it — after a restore, re-read with blender_scene_get before ' +
@@ -753,6 +830,11 @@ function revisionRestore(ctx) {
753
830
  required: true,
754
831
  description: 'The revision to move the project to, exactly as blender_project_get reports it (e.g. "r0003").',
755
832
  },
833
+ expectedCurrentRevision: {
834
+ type: 'string',
835
+ description: 'The currentRevision just read from blender_project_get. If the current pointer changed, ' +
836
+ 'the Host refuses with REVISION_CONFLICT before moving it. Optional for older callers; supply it to protect other edits.',
837
+ },
756
838
  confirm: {
757
839
  type: 'boolean',
758
840
  required: true,
@@ -765,20 +847,17 @@ function revisionRestore(ctx) {
765
847
  const resolved = resolveStudio(ctx)
766
848
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
767
849
 
768
- // NOTE ON THE ABSENT `confirm` CHECK. An earlier version of this tool ran a
769
- // hand-written refusal when `args.confirm !== true`. It was unreachable: the
770
- // parameter is declared `required`, so the harness rejects a call that omits it
771
- // with `INVALID_ARGS` before `execute` is entered — measured while writing this
772
- // suite, and it is why the branch is gone. A guard that cannot fire reads like
773
- // protection and is not, which is the same defect as a test that cannot fail.
774
- // The confirmation this tool offers IS the schema requirement, and
775
- // `composition/tool-plane-m3.e2e.mjs` asserts both halves: that it is declared,
776
- // and that omitting it never reaches the host.
777
850
  try {
851
+ // A required boolean still accepts false. Refuse it before calling the Host.
852
+ if (args.confirm !== true) {
853
+ throw new BlenderError('REVISION_RESTORE_CONFIRMATION_REQUIRED',
854
+ 'Restore requires confirm:true. No revision was changed.')
855
+ }
778
856
  const { data, canonicalWarnings } = await canonicalCall(
779
857
  resolved.studio.restoreRevision(definedFields({
780
858
  projectId: args.projectId,
781
859
  revision: args.revision,
860
+ expectedCurrentRevision: args.expectedCurrentRevision,
782
861
  })),
783
862
  warning,
784
863
  )
@@ -809,6 +888,17 @@ function revisionRestore(ctx) {
809
888
  }
810
889
  } catch (cause) {
811
890
  const failure = renderFailure(cause, 'REVISION_RESTORE_FAILED')
891
+ if (failure.data.errorCode === BlenderErrorCode.REVISION_CONFLICT) {
892
+ failure.text = [
893
+ 'DeepBlend restore refused.',
894
+ `errorCode: ${failure.data.errorCode}`,
895
+ `message: ${failure.data.message}`,
896
+ ...(failure.data.detail == null ? [] : [`detail: ${JSON.stringify(failure.data.detail)}`]),
897
+ '',
898
+ 'Read blender_project_get again and review the intervening changes before deciding whether to restore. ' +
899
+ 'If restoring is still intended, pass its currentRevision as expectedCurrentRevision.',
900
+ ].join('\n')
901
+ }
812
902
  return { ok: false, ...failure }
813
903
  }
814
904
  },
@@ -858,13 +948,13 @@ function assetIngest(ctx) {
858
948
  'Bring a 3D model file into a project, so a scene can instantiate it. Give EITHER sourcePath (a ' +
859
949
  'file already on this machine) OR sourceUrl (an http/https address); a local file is imported ' +
860
950
  'directly, a remote one needs the operator\'s approval first, so it will pause and ask. ' +
861
- '\n\nThis does NOT change the scene. It copies the bytes into the project\'s assets/raw/, records ' +
951
+ '\n\nThis does NOT change the scene. It copies the bytes into assets/raw/ or a locked glTF resource bundle, records ' +
862
952
  'them in assets/manifest.json, and returns an assetId, a project-relative path and a sha256. ' +
863
953
  'Declare it with blender_scene_patch {op: "asset.add", asset: {...}} and then add an entity of ' +
864
954
  'type "asset-instance" with that assetId — those two steps are what put it in the scene, and ' +
865
955
  'blender_scene_validate will tell you if the format cannot be imported by this Blender build. ' +
866
- '\n\nSupported formats: glb, gltf, fbx, obj, usd, blend. The size ceiling is the deployment\'s ' +
867
- 'assetMaxBytes (SPEC §15).',
956
+ '\n\nSupported formats: glb, gltf, fbx, obj, usd, blend, png, jpg, jpeg, hdr, exr. The size ceiling is the deployment\'s ' +
957
+ 'assetMaxBytes (SPEC §15). Local glTF/GLB includes buffers/images; OBJ includes MTL files and referenced textures. sourceRoot selects their containing directory. Remote external dependencies are refused.',
868
958
  parameters: {
869
959
  projectId: { type: 'string', required: true, description: 'The project to bring the asset into.' },
870
960
  sourcePath: {
@@ -872,6 +962,7 @@ function assetIngest(ctx) {
872
962
  description: 'Absolute path to a local file. No approval needed. Use this whenever the file is ' +
873
963
  'already on the machine — it is faster and it does not leave the machine.',
874
964
  },
965
+ sourceRoot: { type: 'string', description: 'Optional local glTF/GLB/OBJ resource root containing the model and all relative buffer, material and image files. Defaults to its directory; files outside this root are refused.' },
875
966
  sourceUrl: {
876
967
  type: 'string',
877
968
  description: 'An http or https URL. Requires the operator\'s approval, which this tool asks for ' +
@@ -884,7 +975,7 @@ function assetIngest(ctx) {
884
975
  },
885
976
  type: {
886
977
  type: 'string',
887
- description: 'Asset type, when the file extension does not say it. One of glb, gltf, fbx, obj, usd, blend.',
978
+ description: 'Asset type, when the file extension does not say it. One of glb, gltf, fbx, obj, usd, blend, png, jpg, jpeg, hdr, exr.',
888
979
  },
889
980
  license: {
890
981
  type: 'string',
@@ -900,6 +991,7 @@ function assetIngest(ctx) {
900
991
  const ingestRequest = {
901
992
  projectId: args.projectId,
902
993
  sourcePath: args.sourcePath,
994
+ sourceRoot: args.sourceRoot,
903
995
  sourceUrl: args.sourceUrl,
904
996
  assetId: args.assetId,
905
997
  type: args.type,
@@ -20,7 +20,7 @@
20
20
  * they are validated against the review they claim to describe.
21
21
  *
22
22
  * `blender_visual_autofix` the host-owned repair LOOP: propose, apply, re-render,
23
- * re-score, and adopt only what measures better, bounded by the iteration cap
23
+ * re-score, and adopt supported artistic improvements without technical regression, bounded by the iteration cap
24
24
  * and the repeated-issue rule. It returns a handover package when it stops short
25
25
  * of passing.
26
26
  *
@@ -279,7 +279,8 @@ export function describeReviewNotes(data) {
279
279
  const reviewerUnavailable = reviewerError !== null && reviewerError !== undefined
280
280
  const notes = [
281
281
  `Revision: ${data.revision} (rendered from ${data.checkpointRevision})`,
282
- `Score: ${data.score}/100 — ${data.pass ? 'PASSES' : 'does NOT pass'} the delivery threshold`,
282
+ `Score: ${data.score}/100 — ${data.pass ? 'PASSES' : 'does NOT pass'} the technical threshold`,
283
+ `Artistic: ${data.artistic?.status ?? 'unassessable'} — geometry, materials, lighting and goal fit`,
283
284
  `Subject: ${data.subjectId ?? '(none tagged)'}`,
284
285
  `Parts: ${(data.parts ?? []).length > 0 ? data.parts.join(', ') + ' (declared subject-part: they ARE the subject, so they cannot be in its way)' : '(none declared; only the subject is judged for occlusion)'}`,
285
286
  '',
@@ -318,6 +319,9 @@ export function describeReviewNotes(data) {
318
319
  notes.push(`The reviewer proposed ${data.suggestedOperations.length} ScenePatch operation(s); apply them with`)
319
320
  notes.push('blender_scene_patch if you agree, or run blender_visual_autofix to let the host try them.')
320
321
  }
322
+ for (const [dimension, assessment] of Object.entries(data.artistic?.dimensions ?? {})) {
323
+ notes.push(`Artistic ${dimension}: ${assessment.status} — ${assessment.evidence ?? 'insufficient evidence'}`)
324
+ }
321
325
  return notes
322
326
  }
323
327
 
@@ -336,7 +340,8 @@ export function describeReviewNotes(data) {
336
340
  */
337
341
  export function describeLoopNotes(data) {
338
342
  const notes = [
339
- `Score: ${data.startScore} -> ${data.finalScore} (${data.passed ? 'PASSES' : 'still below the threshold'})`,
343
+ `Score: ${data.startScore} -> ${data.finalScore} (${data.passed ? 'PASSES technical and artistic review' : 'review incomplete or needs work'})`,
344
+ `Artistic: ${data.artistic?.status ?? 'unassessable'}`,
340
345
  `Revision: ${data.startRevision} -> ${data.finalRevision}`,
341
346
  `Rounds: ${data.iterations} of ${data.maxIterations} used`,
342
347
  `Stopped: ${data.stopReason}`,
@@ -353,6 +358,9 @@ export function describeLoopNotes(data) {
353
358
  notes.push(` saw: [${finding.category}] ${finding.evidence}`)
354
359
  }
355
360
  }
361
+ for (const [dimension, assessment] of Object.entries(data.artistic?.dimensions ?? {})) {
362
+ notes.push(`Artistic ${dimension}: ${assessment.status} — ${assessment.evidence ?? 'insufficient evidence'}`)
363
+ }
356
364
  if ((data.openIssues ?? []).length > 0) {
357
365
  notes.push('')
358
366
  notes.push('Still open:')
@@ -381,12 +389,13 @@ function visualAutofix(ctx) {
381
389
  name: 'blender_visual_autofix',
382
390
  description:
383
391
  'Run the automated visual repair loop on a revision: review, propose a ScenePatch, commit it, render ' +
384
- 'and measure again, and KEEP the change only if the score actually went up. Stops when the score ' +
385
- `passes, when the iteration cap is reached, or when the same finding recurs without improving. ` +
392
+ 'and measure again. Keep technical improvements without artistic regression, or evidenced artistic ' +
393
+ 'improvements with no technical regression. Passing requires both the technical threshold and ' +
394
+ 'an evidenced artistic review. Stops at the iteration cap or repeated unresolved findings. ' +
386
395
  'Because it stops on its own, a handover package comes back whenever it stopped short of passing: the ' +
387
396
  'revision to work from (never a polluted one), the open issues with their measurements, and concrete ' +
388
397
  'next steps.\n\n' +
389
- 'A round that does not improve the score is rolled back — the revision stays in the history, but the ' +
398
+ 'A round that improves neither technical nor artistic quality is rolled back — the revision stays in the history, but the ' +
390
399
  'project does not move to a worse one. Every round is reported, including the ones that failed, so you ' +
391
400
  'can see what was already tried before proposing another change yourself.\n\n' +
392
401
  MEASUREMENT_GLOSSARY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-tool",
3
- "version": "0.2.20",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Agent-preset tool consumer: registers the model-visible DeepBlend Blender tools. Publishes no service.",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.2.20"
14
+ "@deepblend/dsh-blender-contracts": "0.3.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {