@deepblend/dsh-blender-provider-local 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1577,9 +1577,13 @@ export default class LocalBlenderRuntime extends Service {
1577
1577
  aborted()
1578
1578
  assertDigest(await digest(snapshot))
1579
1579
  const run = await this.runBootstrap({ action: 'inspect_geometry', jobId: request.jobId,
1580
- payload: { entityIds: request.entityIds, frame: request.frame, checkpointSha256: expected } },
1580
+ payload: { entityIds: request.entityIds, frame: request.frame, checkpointSha256: expected,
1581
+ ...(request.surfaceSamples === undefined ? {} : { surfaceSamples: request.surfaceSamples }) } },
1581
1582
  { signal: request.signal, session: false, args: ['--blend', snapshot] })
1582
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
+ }
1583
1587
  const report = verifyGeometryReport(run.envelope?.result, request)
1584
1588
  assertDigest(await digest(source))
1585
1589
  aborted()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.9.0",
3
+ "version": "0.11.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.9.0"
14
+ "@deepblend/dsh-blender-contracts": "0.11.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -26,6 +26,10 @@ def inspect_geometry(request, options):
26
26
  len(set(ids)) != len(ids) or type(frame) is not int or not 0 <= frame <= 1000000 or
27
27
  not isinstance(expected, str) or re.fullmatch('[a-f0-9]{64}', expected) is None):
28
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)
29
33
  checkpoint = options.get('blend')
30
34
  if not checkpoint or not os.path.isfile(checkpoint):
31
35
  raise ActionError('REVISION_CHECKPOINT_MISSING', 'Geometry inspection needs a native checkpoint.')
@@ -84,8 +88,13 @@ def inspect_geometry(request, options):
84
88
  finally:
85
89
  evaluated.to_mesh_clear()
86
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)
87
98
  if file_digest(checkpoint) != expected:
88
99
  raise ActionError('SCENE_VALIDATION_FAILED', 'The checkpoint changed during geometry inspection.')
89
- return {'schemaVersion': 'deepblend.geometry-inspection/v1', 'checkpointSha256': expected,
90
- 'checkpointUnchanged': True, 'frame': measured_frame, 'blenderVersion': bpy.app.version_string,
91
- 'objects': objects}, [], []
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]