@deepblend/dsh-blender-host 0.8.1 → 0.10.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,93 @@
1
+ import { mkdirSync, copyFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { BlenderError, BlenderErrorCode, sceneSpecDigest, specHash, validateGeometrySelection,
5
+ verifyGeometryReport, GEOMETRY_INSPECTION_LIMITS } from '@deepblend/dsh-blender-contracts'
6
+ import { fileSha256, fileSize, isFile, resolveInside, removeTree } from './paths.js'
7
+ import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
8
+
9
+ function refuse(code, message) { throw new BlenderError(code, `Native geometry inspection: ${message}`) }
10
+ function cancelled(signal) {
11
+ if (signal?.aborted) refuse(BlenderErrorCode.ABORTED, 'cancelled; the saved source was not changed.')
12
+ }
13
+
14
+ /** Native values are bound to an immutable revision, never inferred from generator declarations. */
15
+ export async function inspectRevisionGeometry(host, projectId, revision, selection, signal) {
16
+ const store = host.store, spec = store.readRevisionSpec(projectId, revision)
17
+ const manifest = store.readRevisionManifest(projectId, revision), digest = sceneSpecDigest(spec)
18
+ const project = store.projectDirectory(projectId), directory = store.revisionDirectory(projectId, revision)
19
+ const checkpoint = resolveInside(project, `revisions/${revision}/scene.blend`, 'inspection checkpoint')
20
+ cancelled(signal)
21
+ // Validate selection even if the historical checkpoint has no hash evidence.
22
+ validateGeometrySelection({ ...selection, checkpointSha256: '0'.repeat(64) })
23
+ if (selection.entityIds.some(id => !(spec.entities ?? []).some(entity => entity.id === id))) refuse(BlenderErrorCode.SCENE_SPEC_INVALID, 'one or more selected entities do not exist in this revision.')
24
+ if (manifest?.projectId !== projectId || manifest.revision !== revision || manifest.digest !== digest || manifest.specHashAfter !== specHash(spec)) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'the revision manifest no longer matches its saved scene.')
25
+ if (!isFile(checkpoint)) refuse(BlenderErrorCode.REVISION_CHECKPOINT_MISSING, 'this revision has no native checkpoint; save a checkpoint before measuring.')
26
+ if (fileSize(checkpoint) > GEOMETRY_INSPECTION_LIMITS.checkpointBytes) refuse(BlenderErrorCode.SCENE_TOO_HEAVY, 'the native checkpoint exceeds the inspection budget.')
27
+ const binding = manifest.checkpointBinding
28
+ let expected
29
+ if (binding) {
30
+ if (binding.sourceDigest !== digest || binding.specHash !== manifest.specHashAfter || binding.bytes !== fileSize(checkpoint)) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'checkpoint binding differs from the selected revision.')
31
+ expected = binding.sha256
32
+ } else {
33
+ // Older immutable revisions retain the producer's exact artifact hash in their compile job.
34
+ const job = typeof manifest.jobId === 'string' ? store.readJobSafe(projectId, manifest.jobId) : null
35
+ if (job?.projectId === projectId && job.revision === revision && job.status === 'succeeded' &&
36
+ ['compile_scene', 'apply_scene_patch'].includes(job.action)) {
37
+ const matches = (job.artifacts ?? []).filter(a => a.kind === 'blend' && a.path === `revisions/${revision}/scene.blend` && a.bytes === fileSize(checkpoint))
38
+ if (matches.length === 1) expected = matches[0].sha256
39
+ }
40
+ }
41
+ if (!/^[a-f0-9]{64}$/.test(expected ?? '')) refuse(BlenderErrorCode.UNSUPPORTED_ACTION, 'this historical checkpoint has no verified producer hash; save a new checkpoint before measuring.')
42
+ if (typeof host.runtime?.inspectGeometry !== 'function') refuse(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'the configured runtime does not support native geometry inspection.')
43
+ const paths = [join(directory, 'scene-spec.json'), join(directory, 'revision-manifest.json')]
44
+ if (!binding) paths.push(store.jobPath(projectId, manifest.jobId))
45
+ const before = new Map(paths.map(path => [path, fileSha256(path)]))
46
+ const verifySource = () => {
47
+ cancelled(signal)
48
+ resolveInside(project, `revisions/${revision}/scene.blend`, 'inspection checkpoint')
49
+ if (!isFile(checkpoint) || fileSha256(checkpoint) !== expected || [...before].some(([path, hash]) => !isFile(path) || fileSha256(path) !== hash)) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'the saved source changed; no measurement is accepted.')
50
+ for (const asset of spec.assets ?? []) {
51
+ if (!/^[a-f0-9]{64}$/.test(asset.sha256 ?? '') || typeof asset.path !== 'string') refuse(BlenderErrorCode.ASSET_HASH_MISMATCH, `asset ${asset.id} has no pinned source.`)
52
+ const file = resolveInside(project, asset.path, 'inspection asset')
53
+ if (!isFile(file) || fileSha256(file) !== asset.sha256) refuse(BlenderErrorCode.ASSET_HASH_MISMATCH, `asset ${asset.id} no longer matches its declared source.`)
54
+ verifyAssetBundle(project, asset); verifyUnbundledGltfAsset(project, asset); verifyUnbundledObjAsset(project, asset)
55
+ }
56
+ }
57
+ verifySource()
58
+ const scratch = resolveInside(host.workspaceRoot, join('tmp', `geometry-${randomUUID()}`), 'geometry workspace')
59
+ mkdirSync(scratch, { recursive: true })
60
+ try {
61
+ const snapshot = join(scratch, 'source.blend'); copyFileSync(checkpoint, snapshot)
62
+ const request = { ...selection, checkpointPath: snapshot, checkpointSha256: expected, signal }
63
+ if (fileSha256(snapshot) !== expected) refuse(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'the staged native input differs from the saved source.')
64
+ const result = await host.runtime.inspectGeometry(request)
65
+ cancelled(signal)
66
+ if (selection.surfaceSamples !== undefined && result?.report?.schemaVersion === 'deepblend.geometry-inspection/v1') {
67
+ refuse(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'the runtime does not support local surface samples; upgrade DeepBlend. Ordinary native geometry reads remain available.')
68
+ }
69
+ const native = verifyGeometryReport(result?.report, request)
70
+ verifySource()
71
+ const hasSamples = selection.surfaceSamples !== undefined
72
+ return { schemaVersion: hasSamples ? 'deepblend.scene-geometry/v2' : 'deepblend.scene-geometry/v1', projectId, sourceRevision: revision,
73
+ sourceDigest: digest, sourceSpecHash: manifest.specHashAfter, sourceCheckpointSha256: expected,
74
+ sourceUnchanged: true, frame: native.frame, objects: native.objects, blenderVersion: native.blenderVersion,
75
+ ...(hasSamples ? { surfaceSamples: native.surfaceSamples } : {}),
76
+ excludedMeasurements: hasSamples ? ['globalWallThickness', 'assemblyClearance', 'minimumClearance', 'collision', 'selfIntersection'] :
77
+ ['wallThickness', 'shellToLinerClearance', 'minimumClearance', 'collision'],
78
+ execution: { transport: 'batch' }, limitations: [
79
+ 'World-axis spans include rotations, handles and protrusions; they are not intrinsic body dimensions.',
80
+ 'This evaluates saved native mesh geometry, not shader displacement or rendered pixels.',
81
+ ...(hasSamples ? [
82
+ 'Surface samples are finite local chords along evaluated triangle-face normals, not shader or smoothed shading normals.',
83
+ 'The normalSurfaceChordMm runs from source entry to source exit. Target normalRaySeparationMm runs from source EXIT to target entry along that inward normal and excludes the source chord. Other scene objects are not checked; this is not full assembly free clearance.',
84
+ 'Closed connected consistently wound positive-volume geometry is required, but absence of self-intersections is not certified.',
85
+ 'No global minimum wall thickness, minimum clearance, collision absence, manufacturing safety or artistic approval is established.',
86
+ 'Unmeasured, missed, overlapping, occluded or unsupported samples must remain unavailable, never be replaced by zero or inferred from bounds.',
87
+ 'Search distance is explicitly requested (0.01–500 mm); chord and target search limits are each 100 mm. Epsilon is 0.0001 mm; distances at or below twice epsilon are unavailable.',
88
+ ] : ['No wall thickness, clearance, collision, topology or artistic approval is established.']),
89
+ 'Do not subtract shell and liner extents or radii to report a gap: both bounds describe outer surfaces, and their difference includes wall material and depends on curvature and orientation.',
90
+ 'Unchanged height, position or scale does not prove that wall thickness or clearance was preserved. Report those conditions as unmeasured unless a separate surface measurement verifies them.',
91
+ ] }
92
+ } finally { removeTree(scratch) }
93
+ }
package/lib/index.js CHANGED
@@ -105,6 +105,7 @@ import { prepareModelBundle, assetPreviewVersion, stageAssetBundle, verifyUnbund
105
105
  import { ASSET_LIBRARY_LIMITS, uploadAssetType, checkAssetUpload, hashAssetFile, previewRasterAsset } from './asset-library.js'
106
106
  import { ASSET_PREVIEW_TEMPLATE, renderAssetPreview } from './asset-preview.js'
107
107
  import { listDiagnostics, renderDiagnostic } from './diagnostic-preview.js'
108
+ import { inspectRevisionGeometry } from './geometry-inspection.js'
108
109
  import { inspectReferenceImage, REFERENCE_IMAGE_MAX_BYTES } from './reference-image.js'
109
110
  import { createReadStream, linkSync } from 'node:fs'
110
111
  import { createHash, randomUUID } from 'node:crypto'
@@ -709,7 +710,7 @@ export default class BlenderStudio extends Service {
709
710
  * whole spec on every turn spends its context on fields it is not changing.
710
711
  *
711
712
  * @param {string} projectId
712
- * @param {{ revision?: string, full?: boolean }} [options]
713
+ * @param {{ revision?: string, full?: boolean, geometry?: { entityIds: string[], frame: number, surfaceSamples?: object[] }, signal?: AbortSignal }} [options]
713
714
  * @returns {Promise<Record<string, unknown>>}
714
715
  */
715
716
  async getScene(projectId, options = {}) {
@@ -723,10 +724,12 @@ export default class BlenderStudio extends Service {
723
724
  digest,
724
725
  })
725
726
  const manifest = this.store.readRevisionManifest(projectId, revision)
727
+ const geometry = options.geometry === undefined ? undefined : await inspectRevisionGeometry(this, projectId, revision, options.geometry, options.signal)
726
728
  return {
727
729
  ...summary,
728
730
  projectId,
729
- isCurrent: revision === record.currentRevision,
731
+ isCurrent: revision === (geometry ? this.store.readRecord(projectId).currentRevision : record.currentRevision),
732
+ ...(geometry ? { geometryInspection: geometry } : {}),
730
733
  assetParts: (manifest?.assetParts ?? []).map(part => {
731
734
  const entity = spec.entities.find(entry => entry.id === part.entityId)
732
735
  const asset = spec.assets.find(entry => entry.id === entity?.assetId)
@@ -0,0 +1,29 @@
1
+ /** Refuse to rebuild away native edits not represented by the saved SceneSpec. */
2
+ import { BlenderError, GEOMETRY_INSPECTION_LIMITS } from '@deepblend/dsh-blender-contracts'
3
+ import { fileSha256, fileSize, isFile, resolveInside } from './paths.js'
4
+
5
+ export function protectRecipeNativeSource(store, projectId, revision) {
6
+ const project = store.projectDirectory(projectId)
7
+ const path = resolveInside(project, `revisions/${revision}/scene.blend`, 'recipe checkpoint')
8
+ const manifest = store.readRevisionManifest(projectId, revision)
9
+ if (!isFile(path) && !manifest.checkpoint && !manifest.checkpointBinding) return () => {}
10
+ const conflict = () => { throw new BlenderError('RECIPE_PARAMETER_CONFLICT',
11
+ 'The saved native file was independently changed or has no verified producer hash. Keep that file and reconcile its edits before resizing; no new revision was published.',
12
+ { detail: { conflicts: [{ entityId: null, property: 'native-checkpoint', revision }] } }) }
13
+ let expected = manifest.checkpointBinding?.sha256, bytes = manifest.checkpointBinding?.bytes
14
+ if (!expected) {
15
+ const job = typeof manifest.jobId === 'string' ? store.readJobSafe(projectId, manifest.jobId) : null
16
+ if (job?.projectId === projectId && job.revision === revision && job.status === 'succeeded'
17
+ && ['compile_scene', 'apply_scene_patch'].includes(job.action)) {
18
+ const matches = (job.artifacts ?? []).filter(a => a.kind === 'blend' && a.path === `revisions/${revision}/scene.blend`)
19
+ if (matches.length === 1) { expected = matches[0].sha256; bytes = matches[0].bytes }
20
+ }
21
+ }
22
+ const verify = () => {
23
+ resolveInside(project, `revisions/${revision}/scene.blend`, 'recipe checkpoint')
24
+ if (!/^[a-f0-9]{64}$/.test(expected ?? '') || !isFile(path)
25
+ || fileSize(path) !== bytes || bytes > GEOMETRY_INSPECTION_LIMITS.checkpointBytes || fileSha256(path) !== expected) conflict()
26
+ }
27
+ verify()
28
+ return verify
29
+ }
@@ -71,6 +71,7 @@ import {
71
71
  import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
72
72
  import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
73
73
  import { readRecipeContext } from './recipe-state.js'
74
+ import { protectRecipeNativeSource } from './recipe-native-source.js'
74
75
  import {
75
76
  fileSha256,
76
77
  fileSize,
@@ -314,6 +315,7 @@ export class RevisionTransaction {
314
315
  revision: this.store.readRevisionManifest(request.projectId, request.baseRevision),
315
316
  parameterState: context.state, preservedCameraIds: planned.preservedCameraIds }
316
317
  }
318
+ const verifyNativeSource = protectRecipeNativeSource(this.store, request.projectId, request.baseRevision)
317
319
  const patch = { projectId: request.projectId, baseRevision: request.baseRevision, operations: planned.operations,
318
320
  saveCheckpoint: request.saveCheckpoint !== false, renderPreview: request.renderPreview === true,
319
321
  idempotencyKey: request.idempotencyKey ?? `recipe-${sha256Canonical({ projectId: request.projectId,
@@ -323,7 +325,7 @@ export class RevisionTransaction {
323
325
  const checked = validateScenePatch(patch)
324
326
  if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, checked.summary)
325
327
  const state = { ...planned.parameterState, originRevision: context.originRevision }
326
- const outcome = await this._applyScenePatch(patch, { ...options, recipeParameters: state })
328
+ const outcome = await this._applyScenePatch(patch, { ...options, recipeParameters: state, verifyNativeSource })
327
329
  const committed = readRecipeContext(this.store, request.projectId, outcome.revision.revision)
328
330
  if (!committed?.state) throw new BlenderError('RECIPE_LOCK_INVALID', 'The recorded outcome has no recipe parameter state')
329
331
  return { ...outcome, parameterState: committed.state, preservedCameraIds: planned.preservedCameraIds }
@@ -478,6 +480,7 @@ export class RevisionTransaction {
478
480
  spec: nextSpec,
479
481
  kind: 'scene_patch',
480
482
  recipeParameters: options.recipeParameters ?? null,
483
+ verifyNativeSource: options.verifyNativeSource,
481
484
  summary,
482
485
  actor: patch.actor ?? null,
483
486
  stage: patch.stage ?? null,
@@ -815,6 +818,12 @@ export class RevisionTransaction {
815
818
  sourceMaterialSlots: sourceMaterialSlots ?? [], materialSlots: materialSlots ?? [],
816
819
  })),
817
820
  checkpoint: isFile(join(staging, 'scene.blend')) ? `revisions/${revision}/scene.blend` : null,
821
+ ...(isFile(join(staging, 'scene.blend')) ? { checkpointBinding: {
822
+ sha256: fileSha256(join(staging, 'scene.blend')),
823
+ bytes: fileSize(join(staging, 'scene.blend')),
824
+ sourceDigest: digest,
825
+ specHash: plan.specHashAfter ?? specHash(plan.spec),
826
+ } } : {}),
818
827
  previews: previews.map(entry => entry.artifact),
819
828
  jobId,
820
829
  runtime: {
@@ -886,6 +895,7 @@ export class RevisionTransaction {
886
895
  // the whole record so a restore away and back cannot hide behind the same id.
887
896
  try {
888
897
  this.store.assertProjectWrite(projectId)
898
+ plan.verifyNativeSource?.()
889
899
  const current = this.store.readRecord(projectId)
890
900
  if (JSON.stringify(current) !== JSON.stringify(record)) {
891
901
  throw new BlenderError(BlenderErrorCode.REVISION_CONFLICT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-host",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
6
6
  "main": "lib/index.js",
@@ -20,7 +20,7 @@
20
20
  "./package.json": "./package.json"
21
21
  },
22
22
  "dependencies": {
23
- "@deepblend/dsh-blender-contracts": "0.8.1",
23
+ "@deepblend/dsh-blender-contracts": "0.10.0",
24
24
  "sharp": "0.35.5"
25
25
  },
26
26
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "deepblend.recipe/v1",
3
3
  "id": "deepblend.metal-lamp",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "title": "香槟金属桌灯",
6
6
  "description": "连续旋压灯罩、沿曲面延伸的金属细纹、珐琅内衬、曲线弯臂与贴地线缆。分别调整旋压件和拉丝件的粗糙度,同步调整金属颜色与摄影曝光;灯罩外径联动金属壁与内衬,颈口保持,提供独立内衬检查相机。",
7
7
  "author": {
@@ -26,7 +26,9 @@
26
26
  "material.procedural",
27
27
  "material.procedural.uv",
28
28
  "recipe.geometry-bindings",
29
- "recipe.lathe-shell-bindings"
29
+ "recipe.lathe-shell-bindings",
30
+ "recipe.curve-point-bindings",
31
+ "recipe.threshold-bindings"
30
32
  ]
31
33
  },
32
34
  "input": {
@@ -43,7 +45,7 @@
43
45
  {
44
46
  "id": "shade-diameter-mm",
45
47
  "title": "灯罩外径(毫米)",
46
- "description": "调整实际灯罩外径,联动金属壁和内衬,保持颈口、弯臂、灯泡与关节;不整体缩放。",
48
+ "description": "调整实际灯罩外径,联动金属壁和内衬;超过230毫米时后移立柱与关节并调整弯臂起段,保持颈口和灯泡。",
47
49
  "type": "number",
48
50
  "default": 209.8,
49
51
  "minimum": 180,
@@ -59,6 +61,94 @@
59
61
  "rimHeight": 0.008,
60
62
  "neckAnchorHeight": 0.118,
61
63
  "multiplier": 0.0005
64
+ },
65
+ {
66
+ "kind": "entity",
67
+ "entityId": "mast-socket",
68
+ "property": "location",
69
+ "axis": "x",
70
+ "multiplier": -0.00055,
71
+ "inputFloor": 230
72
+ },
73
+ {
74
+ "kind": "entity",
75
+ "entityId": "mast-socket-collar",
76
+ "property": "location",
77
+ "axis": "x",
78
+ "multiplier": -0.00055,
79
+ "inputFloor": 230
80
+ },
81
+ {
82
+ "kind": "entity",
83
+ "entityId": "upright-mast",
84
+ "property": "location",
85
+ "axis": "x",
86
+ "multiplier": -0.00055,
87
+ "inputFloor": 230
88
+ },
89
+ {
90
+ "kind": "entity",
91
+ "entityId": "hinge-body",
92
+ "property": "location",
93
+ "axis": "x",
94
+ "multiplier": -0.00055,
95
+ "inputFloor": 230
96
+ },
97
+ {
98
+ "kind": "entity",
99
+ "entityId": "hinge-front-cap",
100
+ "property": "location",
101
+ "axis": "x",
102
+ "multiplier": -0.00055,
103
+ "inputFloor": 230
104
+ },
105
+ {
106
+ "kind": "entity",
107
+ "entityId": "hinge-rear-cap",
108
+ "property": "location",
109
+ "axis": "x",
110
+ "multiplier": -0.00055,
111
+ "inputFloor": 230
112
+ },
113
+ {
114
+ "kind": "entity",
115
+ "entityId": "hinge-fine-ring",
116
+ "property": "location",
117
+ "axis": "x",
118
+ "multiplier": -0.00055,
119
+ "inputFloor": 230
120
+ },
121
+ {
122
+ "kind": "entity",
123
+ "entityId": "hinge-centre-pin",
124
+ "property": "location",
125
+ "axis": "x",
126
+ "multiplier": -0.00055,
127
+ "inputFloor": 230
128
+ },
129
+ {
130
+ "kind": "curve-point",
131
+ "entityId": "swept-upper-arm",
132
+ "pointIndex": 0,
133
+ "axis": "x",
134
+ "multiplier": -0.00055,
135
+ "inputFloor": 230
136
+ },
137
+ {
138
+ "kind": "curve-point",
139
+ "entityId": "swept-upper-arm",
140
+ "pointIndex": 1,
141
+ "axis": "x",
142
+ "multiplier": -0.00055,
143
+ "inputFloor": 230
144
+ },
145
+ {
146
+ "kind": "curve-point",
147
+ "entityId": "swept-upper-arm",
148
+ "pointIndex": 2,
149
+ "axis": "x",
150
+ "multiplier": -0.000275,
151
+ "inputFloor": 230
62
152
  }
63
153
  ]
64
154
  },