@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.
- package/lib/geometry-inspection.js +2 -0
- package/lib/index.js +63 -4
- package/package.json +2 -2
- package/python/bootstrap.py +10 -0
- package/python/deepblend_inspect_geometry.py +100 -0
- package/python/deepblend_inspect_surface.py +130 -0
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 {
|
|
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
|
-
*
|
|
232
|
-
*
|
|
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.
|
|
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.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.10.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
package/python/bootstrap.py
CHANGED
|
@@ -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]
|