@deepblend/dsh-blender-provider-local 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,2 @@
1
+ // Compatibility entry; Host and Provider share one validated wire contract.
2
+ export { GEOMETRY_INSPECTION_VERSION, GEOMETRY_INSPECTION_LIMITS, validateGeometrySelection, verifyGeometryReport } from '@deepblend/dsh-blender-contracts'
package/lib/index.js CHANGED
@@ -20,10 +20,13 @@
20
20
  */
21
21
 
22
22
  import { spawnSync } from 'node:child_process'
23
+ import { createHash } from 'node:crypto'
24
+ import { createReadStream } from 'node:fs'
23
25
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
24
26
  import net from 'node:net'
25
- import { readFile } from 'node:fs/promises'
26
- import { delimiter, isAbsolute, join, resolve, sep } from 'node:path'
27
+ import { readFile, copyFile } from 'node:fs/promises'
28
+ import { GEOMETRY_INSPECTION_LIMITS, validateGeometrySelection, verifyGeometryReport } from './geometry-inspection.js'
29
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path'
27
30
  import { randomUUID } from 'node:crypto'
28
31
 
29
32
  import { Service } from '@deepseek-ai/cordis'
@@ -228,8 +231,9 @@ async function connect(socketPath, timeoutMs) {
228
231
  /**
229
232
  * The actions this provider can serve from a kept session, and the ONLY ones it can.
230
233
  *
231
- * DERIVED, NOT CHOSEN: these are exactly the actions whose calls go through `runBootstrap`, which is
232
- * the one place the session path is decided. Everything else spawns its own process on purpose —
234
+ * These are the actions whose calls permit session routing through `runBootstrap`.
235
+ * Explicitly isolated actions also use that dispatcher but force session:false.
236
+ * Everything else spawns its own process on purpose —
233
237
  * `render_frames` most of all, because cancelling a render has to kill exactly that render, and a
234
238
  * request inside a kept session cannot be killed without ending the session (and, attached, the
235
239
  * user's own Blender).
@@ -246,6 +250,7 @@ export const SESSION_ROUTABLE_ACTIONS = Object.freeze([
246
250
  'render_preview',
247
251
  'render_views',
248
252
  ])
253
+ export const ISOLATED_BOOTSTRAP_ACTIONS = Object.freeze(['inspect_geometry'])
249
254
 
250
255
  /**
251
256
  * A live Blender, presented as one shape whatever carries the bytes.
@@ -1537,6 +1542,60 @@ export default class LocalBlenderRuntime extends Service {
1537
1542
  }
1538
1543
  }
1539
1544
 
1545
+ /** Optional read-only measurement; always isolated from a kept or interactive session. */
1546
+ async inspectGeometry(request) {
1547
+ validateGeometrySelection(request)
1548
+ const aborted = () => {
1549
+ if (request.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Geometry inspection cancelled.')
1550
+ }
1551
+ aborted()
1552
+ if (typeof request.checkpointPath !== 'string' || !isAbsolute(request.checkpointPath) || !existsSync(request.checkpointPath)) {
1553
+ throw new BlenderError(BlenderErrorCode.REVISION_CHECKPOINT_MISSING, 'Geometry inspection needs an absolute native checkpoint path.')
1554
+ }
1555
+ if (!existsSync(this.workspaceRoot)) throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'The inspection workspace does not exist.')
1556
+ const source = realpathSync(request.checkpointPath), root = realpathSync(this.workspaceRoot)
1557
+ const local = relative(root, source)
1558
+ if (!local || local === '..' || local.startsWith('..' + sep) || isAbsolute(local)) throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'The inspected checkpoint is outside the runtime workspace.')
1559
+ const info = statSync(source)
1560
+ if (!info.isFile() || info.size > GEOMETRY_INSPECTION_LIMITS.checkpointBytes) {
1561
+ throw new BlenderError(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'The inspected checkpoint is not a bounded native file.')
1562
+ }
1563
+ const digest = async path => {
1564
+ const hash = createHash('sha256')
1565
+ for await (const bytes of createReadStream(path, { signal: request.signal })) hash.update(bytes)
1566
+ return hash.digest('hex')
1567
+ }
1568
+ const expected = request.checkpointSha256
1569
+ const assertDigest = actual => {
1570
+ if (actual !== expected) throw new BlenderError(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'The checkpoint differs from the expected inspection source.')
1571
+ }
1572
+ const { directory } = this._createWorkingDirectory()
1573
+ try {
1574
+ assertDigest(await digest(source))
1575
+ const snapshot = join(directory, 'inspection-source.blend')
1576
+ await copyFile(source, snapshot)
1577
+ aborted()
1578
+ assertDigest(await digest(snapshot))
1579
+ const run = await this.runBootstrap({ action: 'inspect_geometry', jobId: request.jobId,
1580
+ payload: { entityIds: request.entityIds, frame: request.frame, checkpointSha256: expected,
1581
+ ...(request.surfaceSamples === undefined ? {} : { surfaceSamples: request.surfaceSamples }) } },
1582
+ { signal: request.signal, session: false, args: ['--blend', snapshot] })
1583
+ aborted()
1584
+ if (request.surfaceSamples !== undefined && run.envelope?.result?.schemaVersion === 'deepblend.geometry-inspection/v1') {
1585
+ throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support local surface sampling. Upgrade DeepBlend; ordinary native geometry reads remain available.')
1586
+ }
1587
+ const report = verifyGeometryReport(run.envelope?.result, request)
1588
+ assertDigest(await digest(source))
1589
+ aborted()
1590
+ return { report, envelope: run.envelope, durationMs: run.durationMs, stdout: run.stdout, stderr: run.stderr }
1591
+ } catch (cause) {
1592
+ aborted()
1593
+ throw cause
1594
+ } finally {
1595
+ this._cleanup(directory)
1596
+ }
1597
+ }
1598
+
1540
1599
  /**
1541
1600
  * Render one preview frame from an existing `.blend` checkpoint.
1542
1601
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
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.8.1"
14
+ "@deepblend/dsh-blender-contracts": "0.10.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -99,6 +99,7 @@ from deepblend_util import ( # noqa: E402 (path set up above)
99
99
  SUPPORTED_ACTIONS = (
100
100
  "get_capabilities",
101
101
  "compile_scene",
102
+ "inspect_geometry",
102
103
  "render_preview",
103
104
  "render_views",
104
105
  "render_frames",
@@ -474,7 +475,10 @@ KNOWN_SESSION_OPTIONS = (
474
475
  "--frames", "--events", "--proc",
475
476
  )
476
477
 
478
+ from deepblend_inspect_geometry import inspect_geometry
479
+
477
480
  ACTIONS = {
481
+ "inspect_geometry": inspect_geometry,
478
482
  "get_capabilities": action_get_capabilities,
479
483
  "compile_scene": action_compile_scene,
480
484
  "render_preview": action_render_preview,
@@ -588,6 +592,12 @@ def _session_dispatch(request, options):
588
592
  % (request.get("protocolVersion"), PROTOCOL_VERSION),
589
593
  }, [], [])
590
594
 
595
+ if action == 'inspect_geometry':
596
+ return build_envelope(job_id, action, None, {
597
+ 'code': 'BLENDER_UNSUPPORTED_ACTION',
598
+ 'message': 'Native geometry inspection requires an isolated batch and cannot open a checkpoint in a kept or live Blender session.',
599
+ }, [], [])
600
+
591
601
  handler = ACTIONS.get(action)
592
602
  if handler is None:
593
603
  return build_envelope(job_id, action, None, {
@@ -0,0 +1,100 @@
1
+ """Bounded, read-only evaluation of a hash-pinned native checkpoint."""
2
+ import hashlib
3
+ import math
4
+ import os
5
+ import re
6
+
7
+ from deepblend_util import ActionError
8
+
9
+
10
+ def file_digest(path):
11
+ digest = hashlib.sha256()
12
+ with open(path, 'rb') as stream:
13
+ for chunk in iter(lambda: stream.read(1024 * 1024), b''):
14
+ digest.update(chunk)
15
+ return digest.hexdigest()
16
+
17
+
18
+ def inspect_geometry(request, options):
19
+ import bpy
20
+ payload = request.get('payload')
21
+ if not isinstance(payload, dict):
22
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Geometry inspection payload must be an object.')
23
+ ids, frame, expected = payload.get('entityIds'), payload.get('frame'), payload.get('checkpointSha256')
24
+ if (not isinstance(ids, list) or not 1 <= len(ids) <= 8 or
25
+ any(not isinstance(x, str) or not x or x.strip() != x or len(x) > 128 or '\x00' in x for x in ids) or
26
+ len(set(ids)) != len(ids) or type(frame) is not int or not 0 <= frame <= 1000000 or
27
+ not isinstance(expected, str) or re.fullmatch('[a-f0-9]{64}', expected) is None):
28
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Geometry inspection requires 1–8 distinct entity IDs, an explicit frame and checkpoint SHA256.')
29
+ samples = payload.get('surfaceSamples')
30
+ if 'surfaceSamples' in payload:
31
+ from deepblend_inspect_surface import validate_surface_selection
32
+ validate_surface_selection(samples, ids)
33
+ checkpoint = options.get('blend')
34
+ if not checkpoint or not os.path.isfile(checkpoint):
35
+ raise ActionError('REVISION_CHECKPOINT_MISSING', 'Geometry inspection needs a native checkpoint.')
36
+ if os.path.getsize(checkpoint) > 1024 ** 3 or file_digest(checkpoint) != expected:
37
+ raise ActionError('SCENE_VALIDATION_FAILED', 'The inspected checkpoint differs from the expected source.')
38
+ try:
39
+ bpy.ops.wm.open_mainfile(filepath=checkpoint, load_ui=False, use_scripts=False)
40
+ except Exception as exc:
41
+ raise ActionError('SCENE_VALIDATION_FAILED', 'The native checkpoint cannot be opened for inspection.') from exc
42
+ if bpy.data.libraries:
43
+ raise ActionError('BLENDER_UNSUPPORTED_ACTION', 'Inspection requires self-contained geometry; externally linked Blender libraries are not hash-bound by this checkpoint.')
44
+ unit_scale = float(bpy.context.scene.unit_settings.scale_length)
45
+ if not math.isfinite(unit_scale) or abs(unit_scale - 1.0) > 1e-8:
46
+ raise ActionError('BLENDER_UNSUPPORTED_ACTION', 'Inspection requires a native unit scale of 1 metre per Blender unit; rescaled display units cannot be reported as millimetres.')
47
+ bpy.context.scene.frame_set(frame)
48
+ measured_frame = int(bpy.context.scene.frame_current)
49
+ if measured_frame != frame:
50
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Blender did not evaluate the requested inspection frame.')
51
+ graph = bpy.context.evaluated_depsgraph_get()
52
+ objects = []
53
+ for entity_id in ids:
54
+ obj = bpy.data.objects.get('db_entity__' + entity_id)
55
+ if obj is None:
56
+ raise ActionError('SCENE_SPEC_INVALID', 'The selected native entity does not exist: ' + entity_id)
57
+ entry = {'entityId': entity_id, 'type': obj.type, 'available': False}
58
+ if obj.type != 'MESH':
59
+ entry['unavailableReason'] = 'not-mesh'
60
+ elif not obj.visible_get(view_layer=bpy.context.view_layer):
61
+ entry['unavailableReason'] = 'viewport-disabled'
62
+ else:
63
+ evaluated = obj.evaluated_get(graph)
64
+ mesh = evaluated.to_mesh()
65
+ try:
66
+ if mesh is None or not mesh.vertices:
67
+ entry['unavailableReason'] = 'empty-or-invalid-geometry'
68
+ else:
69
+ low = [math.inf] * 3
70
+ high = [-math.inf] * 3
71
+ finite = True
72
+ for vertex in mesh.vertices:
73
+ point = evaluated.matrix_world @ vertex.co
74
+ if not all(math.isfinite(x) for x in point):
75
+ finite = False
76
+ break
77
+ for axis in range(3):
78
+ low[axis] = min(low[axis], float(point[axis]))
79
+ high[axis] = max(high[axis], float(point[axis]))
80
+ matrix = [[float(x) for x in row] for row in evaluated.matrix_world]
81
+ scale = [float(x) for x in obj.scale]
82
+ if not finite or not all(math.isfinite(x) for row in matrix for x in row) or not all(math.isfinite(x) for x in scale):
83
+ entry['unavailableReason'] = 'empty-or-invalid-geometry'
84
+ else:
85
+ entry.update(available=True, worldBoundsMeters={'min': low, 'max': high},
86
+ worldAxisExtentMm=[(high[i] - low[i]) * 1000 for i in range(3)],
87
+ worldMatrix=matrix, scale=scale, vertexCount=len(mesh.vertices), polygonCount=len(mesh.polygons))
88
+ finally:
89
+ evaluated.to_mesh_clear()
90
+ objects.append(entry)
91
+ report = {'schemaVersion': 'deepblend.geometry-inspection/v1', 'checkpointSha256': expected,
92
+ 'checkpointUnchanged': True, 'frame': measured_frame, 'blenderVersion': bpy.app.version_string,
93
+ 'objects': objects}
94
+ if 'surfaceSamples' in payload:
95
+ from deepblend_inspect_surface import inspect_surface_samples
96
+ report['schemaVersion'] = 'deepblend.geometry-inspection/v2'
97
+ report['surfaceSamples'] = inspect_surface_samples(samples, graph)
98
+ if file_digest(checkpoint) != expected:
99
+ raise ActionError('SCENE_VALIDATION_FAILED', 'The checkpoint changed during geometry inspection.')
100
+ return report, [], []
@@ -0,0 +1,130 @@
1
+ """Finite local surface rays. This does not establish global wall or clearance minima."""
2
+ import math
3
+ import re
4
+ from deepblend_util import ActionError
5
+
6
+ EPSILON = 1e-7 # metres: 0.0001 mm; smaller separations are unavailable
7
+ MAX_SURFACE_DISTANCE = .1
8
+
9
+
10
+ def validate_surface_selection(samples, ids):
11
+ if not isinstance(samples, list) or not 1 <= len(samples) <= 8:
12
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Select 1–8 surface samples.')
13
+ seen = set()
14
+ allowed = {'sampleId', 'entityId', 'targetEntityId', 'originMeters', 'direction', 'searchDistanceMm'}
15
+ for q in samples:
16
+ if (not isinstance(q, dict) or set(q) - allowed or
17
+ not isinstance(q.get('sampleId'), str) or re.fullmatch('[a-zA-Z0-9_-]{1,64}', q['sampleId']) is None or
18
+ q['sampleId'] in seen or q.get('entityId') not in ids or
19
+ ('targetEntityId' in q and (q['targetEntityId'] not in ids or q['targetEntityId'] == q['entityId']))):
20
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Invalid surface identity or unsupported fields.')
21
+ seen.add(q['sampleId'])
22
+ for key in ('originMeters', 'direction'):
23
+ v = q.get(key)
24
+ if not isinstance(v, list) or len(v) != 3 or any(type(x) not in (int, float) or not math.isfinite(x) for x in v):
25
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Surface seeds and directions must be finite vectors.')
26
+ norm = math.hypot(*q['direction'])
27
+ search = q.get('searchDistanceMm')
28
+ if (any(abs(x) > 1000 for x in q['originMeters']) or not 1e-12 <= norm <= 1e6 or
29
+ type(search) not in (int, float) or not math.isfinite(search) or not .01 <= search <= 500):
30
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Surface seeds, directions or search distances exceed the budget.')
31
+
32
+
33
+ def evaluated_surface(obj, graph):
34
+ import bpy
35
+ import bmesh
36
+ from mathutils.bvhtree import BVHTree
37
+ if obj.type != 'MESH':
38
+ return {'reason': 'not-mesh'}
39
+ if not obj.visible_get(view_layer=bpy.context.view_layer):
40
+ return {'reason': 'viewport-disabled'}
41
+ evaluated = obj.evaluated_get(graph)
42
+ mesh = evaluated.to_mesh()
43
+ bm = bmesh.new()
44
+ try:
45
+ if mesh is None or not mesh.vertices or not mesh.polygons:
46
+ return {'reason': 'empty-or-invalid-geometry'}
47
+ if len(mesh.vertices) > 250_000 or len(mesh.polygons) > 250_000:
48
+ return {'reason': 'geometry-budget-exceeded'}
49
+ bm.from_mesh(mesh)
50
+ bm.transform(evaluated.matrix_world)
51
+ bm.normal_update()
52
+ if any(not math.isfinite(x) for v in bm.verts for x in v.co):
53
+ return {'reason': 'empty-or-invalid-geometry'}
54
+ volume = bm.calc_volume(signed=True)
55
+ if any(not e.is_manifold or not e.is_contiguous for e in bm.edges) or not math.isfinite(volume) or volume <= 0:
56
+ return {'reason': 'unsupported-topology-or-winding'}
57
+ remaining = set(bm.verts)
58
+ pending = [remaining.pop()]
59
+ while pending:
60
+ for edge in pending.pop().link_edges:
61
+ for vertex in edge.verts:
62
+ if vertex in remaining:
63
+ remaining.remove(vertex)
64
+ pending.append(vertex)
65
+ if remaining:
66
+ return {'reason': 'unsupported-topology-or-winding'}
67
+ return {'bvh': BVHTree.FromBMesh(bm)}
68
+ finally:
69
+ bm.free()
70
+ evaluated.to_mesh_clear()
71
+
72
+
73
+ def surface_sample(q, surfaces):
74
+ from mathutils import Vector
75
+ magnitude = math.hypot(*q['direction'])
76
+ direction = Vector([x / magnitude for x in q['direction']]).normalized()
77
+ result = {k: q[k] for k in ('sampleId', 'entityId', 'originMeters', 'searchDistanceMm')}
78
+ result['direction'] = list(direction)
79
+ if 'targetEntityId' in q:
80
+ result['targetEntityId'] = q['targetEntityId']
81
+ source = surfaces[q['entityId']]
82
+
83
+ def unavailable(reason):
84
+ return dict(result, status=reason)
85
+
86
+ if 'reason' in source:
87
+ return unavailable(source['reason'])
88
+ outer, normal, _, _ = source['bvh'].ray_cast(Vector(q['originMeters']), direction, q['searchDistanceMm'] / 1000)
89
+ if outer is None:
90
+ return unavailable('no-entry')
91
+ if normal.dot(direction) >= -1e-6:
92
+ return unavailable('entry-not-inward')
93
+ inward = -normal.normalized()
94
+ inner, opposite_normal, _, _ = source['bvh'].ray_cast(outer + inward * EPSILON, inward, MAX_SURFACE_DISTANCE)
95
+ if inner is None:
96
+ return unavailable('no-opposite-surface')
97
+ if opposite_normal.dot(inward) <= 1e-6:
98
+ return unavailable('exit-not-outward')
99
+ chord = (inner - outer).length * 1000
100
+ if not math.isfinite(chord) or chord <= 2 * EPSILON * 1000:
101
+ return unavailable('below-resolution')
102
+ result.update(status='sampled', entryMeters=list(outer), exitMeters=list(inner),
103
+ inwardUnitNormal=list(inward), normalSurfaceChordMm=chord)
104
+ if 'targetEntityId' in q:
105
+ target = surfaces[q['targetEntityId']]
106
+ if 'reason' in target:
107
+ result['gap'] = {'status': target['reason']}
108
+ else:
109
+ start = inner + inward * EPSILON
110
+ point, n, _, _ = target['bvh'].ray_cast(start, inward, MAX_SURFACE_DISTANCE)
111
+ obstruction, _, _, _ = source['bvh'].ray_cast(start, inward, MAX_SURFACE_DISTANCE)
112
+ if point is None:
113
+ result['gap'] = {'status': 'no-target-entry'}
114
+ elif n.dot(inward) >= -1e-6:
115
+ result['gap'] = {'status': 'target-not-inward'}
116
+ elif obstruction is not None and (obstruction - inner).length <= (point - inner).length + EPSILON:
117
+ result['gap'] = {'status': 'source-surface-before-target'}
118
+ elif (point - inner).length <= 2 * EPSILON:
119
+ result['gap'] = {'status': 'below-resolution'}
120
+ else:
121
+ result['gap'] = {'status': 'sampled', 'targetEntryMeters': list(point),
122
+ 'normalRaySeparationMm': (point - inner).length * 1000}
123
+ return result
124
+
125
+
126
+ def inspect_surface_samples(samples, graph):
127
+ import bpy
128
+ ids = {q['entityId'] for q in samples} | {q['targetEntityId'] for q in samples if 'targetEntityId' in q}
129
+ surfaces = {id: evaluated_surface(bpy.data.objects['db_entity__' + id], graph) for id in sorted(ids)}
130
+ return [surface_sample(q, surfaces) for q in samples]