@deepblend/dsh-blender-provider-local 0.16.0 → 0.19.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
@@ -36,6 +36,9 @@ import {
36
36
  BLENDER_ENGINE_BY_KEY,
37
37
  BLENDER_PROTOCOL_VERSION,
38
38
  FRAME_PLAN_VERSION,
39
+ SURFACE_PLACEMENT_LIMITS,
40
+ validateSurfacePlacementSelection,
41
+ verifySurfacePlacementReport,
39
42
  BlenderError,
40
43
  BlenderErrorCode,
41
44
  CANDIDATE_RENDER_ENGINES,
@@ -273,7 +276,7 @@ export const SESSION_ROUTABLE_ACTIONS = Object.freeze([
273
276
  'render_preview',
274
277
  'render_views',
275
278
  ])
276
- export const ISOLATED_BOOTSTRAP_ACTIONS = Object.freeze(['inspect_geometry'])
279
+ export const ISOLATED_BOOTSTRAP_ACTIONS = Object.freeze(['inspect_geometry', 'inspect_surface_placement'])
277
280
 
278
281
  /**
279
282
  * A live Blender, presented as one shape whatever carries the bytes.
@@ -1639,6 +1642,72 @@ export default class LocalBlenderRuntime extends Service {
1639
1642
  }
1640
1643
  }
1641
1644
 
1645
+ /** Measure both actual meshes in a private batch; candidate verification also pins its baseline. */
1646
+ async inspectSurfacePlacement(request) {
1647
+ validateSurfacePlacementSelection(request)
1648
+ const aborted = () => {
1649
+ if (request.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Surface placement cancelled.')
1650
+ }
1651
+ aborted()
1652
+ if (!existsSync(this.workspaceRoot)) throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'The placement workspace does not exist.')
1653
+ const root = realpathSync(this.workspaceRoot)
1654
+ const boundedSource = path => {
1655
+ if (typeof path !== 'string' || !isAbsolute(path) || !existsSync(path)) throw new BlenderError(BlenderErrorCode.REVISION_CHECKPOINT_MISSING, 'Surface placement needs an absolute native checkpoint path.')
1656
+ const source = realpathSync(path), local = relative(root, source)
1657
+ if (!local || local === '..' || local.startsWith('..' + sep) || isAbsolute(local)) throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'The placement checkpoint is outside the runtime workspace.')
1658
+ const info = statSync(source)
1659
+ if (!info.isFile() || info.size > SURFACE_PLACEMENT_LIMITS.checkpointBytes) throw new BlenderError(BlenderErrorCode.SCENE_TOO_HEAVY, 'The placement checkpoint is not a bounded native file.')
1660
+ return source
1661
+ }
1662
+ const sources = [{ path: boundedSource(request.checkpointPath), sha256: request.checkpointSha256, name: 'placement-source.blend' }]
1663
+ if (request.baselineCheckpointPath !== undefined) sources.push({ path: boundedSource(request.baselineCheckpointPath), sha256: request.baselineCheckpointSha256, name: 'placement-baseline.blend' })
1664
+ const digest = async path => {
1665
+ const hash = createHash('sha256')
1666
+ for await (const bytes of createReadStream(path, { signal: request.signal })) hash.update(bytes)
1667
+ return hash.digest('hex')
1668
+ }
1669
+ const verify = async (path, expected) => {
1670
+ if (await digest(path) !== expected) throw new BlenderError(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'A placement checkpoint differs from its pinned native source.')
1671
+ }
1672
+ const { directory, jobId } = this._createWorkingDirectory()
1673
+ const correlationId = typeof request.jobId === 'string' && request.jobId.length > 0 ? request.jobId : jobId
1674
+ try {
1675
+ for (const source of sources) {
1676
+ await verify(source.path, source.sha256)
1677
+ source.snapshot = join(directory, source.name)
1678
+ await copyFile(source.path, source.snapshot)
1679
+ aborted()
1680
+ await verify(source.snapshot, source.sha256)
1681
+ }
1682
+ const run = await this.runBootstrap({ action: 'inspect_surface_placement', jobId: correlationId,
1683
+ payload: { subjectEntityId: request.subjectEntityId, supportEntityId: request.supportEntityId,
1684
+ frame: request.frame, clearanceMm: request.clearanceMm ?? 0, checkpointSha256: request.checkpointSha256,
1685
+ ...(sources.length === 1 ? {} : { baselineCheckpointPath: sources[1].snapshot,
1686
+ baselineCheckpointSha256: request.baselineCheckpointSha256, expectedTranslationMeters: request.expectedTranslationMeters }) } },
1687
+ { signal: request.signal, session: false, args: ['--blend', sources[0].snapshot] })
1688
+ aborted()
1689
+ if (run.exitCode !== 0) throw new BlenderError(BlenderErrorCode.NONZERO_EXIT,
1690
+ 'Native placement inspection did not exit successfully; its result was refused.')
1691
+ if (run.envelope?.protocolVersion !== BLENDER_PROTOCOL_VERSION || run.envelope.status !== 'success' ||
1692
+ run.envelope.action !== 'inspect_surface_placement' || run.envelope.jobId !== correlationId || run.envelope.error !== null) {
1693
+ throw new BlenderError(BlenderErrorCode.PROTOCOL_VERSION_MISMATCH,
1694
+ 'Native placement inspection did not return a successful response for this request.')
1695
+ }
1696
+ const report = verifySurfacePlacementReport(run.envelope?.result, request)
1697
+ for (const source of sources) {
1698
+ await verify(source.path, source.sha256)
1699
+ await verify(source.snapshot, source.sha256)
1700
+ }
1701
+ aborted()
1702
+ return { report, envelope: run.envelope, durationMs: run.durationMs, stdout: run.stdout, stderr: run.stderr }
1703
+ } catch (cause) {
1704
+ aborted()
1705
+ throw cause
1706
+ } finally {
1707
+ this._cleanup(directory)
1708
+ }
1709
+ }
1710
+
1642
1711
  /**
1643
1712
  * Render one preview frame from an existing `.blend` checkpoint.
1644
1713
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.16.0",
3
+ "version": "0.19.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.16.0"
14
+ "@deepblend/dsh-blender-contracts": "0.19.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -74,6 +74,10 @@ import sys
74
74
  import time
75
75
  import traceback
76
76
 
77
+ # Blender's isolated Python ignores PYTHONDONTWRITEBYTECODE. Keep installed
78
+ # runtime files unchanged when importing the sibling modules below.
79
+ sys.dont_write_bytecode = True
80
+
77
81
  # --------------------------------------------------------------------------------------
78
82
  # Make the sibling modules importable
79
83
  # --------------------------------------------------------------------------------------
@@ -102,6 +106,7 @@ SUPPORTED_ACTIONS = (
102
106
  "get_capabilities",
103
107
  "compile_scene",
104
108
  "inspect_geometry",
109
+ "inspect_surface_placement",
105
110
  "render_preview",
106
111
  "render_views",
107
112
  "render_frames",
@@ -491,9 +496,11 @@ KNOWN_SESSION_OPTIONS = (
491
496
  )
492
497
 
493
498
  from deepblend_inspect_geometry import inspect_geometry
499
+ from deepblend_surface_placement import inspect_surface_placement
494
500
 
495
501
  ACTIONS = {
496
502
  "inspect_geometry": inspect_geometry,
503
+ "inspect_surface_placement": inspect_surface_placement,
497
504
  "get_capabilities": action_get_capabilities,
498
505
  "compile_scene": action_compile_scene,
499
506
  "render_preview": action_render_preview,
@@ -608,7 +615,7 @@ def _session_dispatch(request, options):
608
615
  % (request.get("protocolVersion"), PROTOCOL_VERSION),
609
616
  }, [], [])
610
617
 
611
- if action == 'inspect_geometry':
618
+ if action in ('inspect_geometry', 'inspect_surface_placement'):
612
619
  return build_envelope(job_id, action, None, {
613
620
  'code': 'BLENDER_UNSUPPORTED_ACTION',
614
621
  'message': 'Native geometry inspection requires an isolated batch and cannot open a checkpoint in a kept or live Blender session.',
@@ -0,0 +1,616 @@
1
+ """Read-only world-Z first-contact placement over complete evaluated triangles.
2
+
3
+ The geometry core is independent of Blender. It maximizes support_z - subject_z
4
+ at a common XY point. This is descent from above, not a stability or containment
5
+ claim. Degenerate XY projections are part of the surface and are never dropped.
6
+ """
7
+ import itertools
8
+ import math
9
+ import os
10
+ import re
11
+ from fractions import Fraction
12
+
13
+ from deepblend_util import ActionError
14
+
15
+ MAX_VERTICES = 2_000_000
16
+ MAX_TRIANGLES = 2_000_000
17
+ MAX_MESH_OBJECTS = 128
18
+ MAX_HIERARCHY_OBJECTS = 4096
19
+ MAX_CANDIDATE_PAIRS = 1_000_000
20
+ TRANSLATION_TOLERANCE_METERS = 0.00002
21
+
22
+
23
+ class SurfacePlacementError(ValueError):
24
+ def __init__(self, reason, message):
25
+ super().__init__(message)
26
+ self.reason = reason
27
+
28
+
29
+ def _failure(reason, message):
30
+ raise SurfacePlacementError(reason, message)
31
+
32
+
33
+ def _finite_number(value):
34
+ if type(value) not in (int, float):
35
+ return False
36
+ try:
37
+ return math.isfinite(value)
38
+ except OverflowError:
39
+ return False
40
+
41
+
42
+ def _bounds(triangle):
43
+ return (min(p[0] for p in triangle), min(p[1] for p in triangle),
44
+ max(p[0] for p in triangle), max(p[1] for p in triangle))
45
+
46
+
47
+ def _overlap(a, b):
48
+ return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
49
+
50
+
51
+ def _upper_z_difference(support_max_z, subject_min_z):
52
+ # One upward ULP encloses correctly rounded binary-float subtraction. Very
53
+ # large integer inputs need exact subtraction before conversion, since a
54
+ # mixed int/float subtraction could first discard integer low bits.
55
+ if any(type(value) is int and abs(value) > 2 ** 53 for value in (support_max_z, subject_min_z)):
56
+ exact = Fraction(support_max_z) - Fraction(subject_min_z)
57
+ try:
58
+ difference = float(exact)
59
+ except OverflowError:
60
+ return math.inf
61
+ else:
62
+ difference = support_max_z - subject_min_z
63
+ return math.nextafter(difference, math.inf)
64
+
65
+
66
+ class _XYTree:
67
+ """Conservative 2D AABB tree; leaf tests also include boundary contact."""
68
+ __slots__ = ('bounds', 'left', 'right', 'indices', 'maximum_z')
69
+
70
+ def __init__(self, bounds, indices, maximum_z=None):
71
+ self.bounds = (min(bounds[i][0] for i in indices), min(bounds[i][1] for i in indices),
72
+ max(bounds[i][2] for i in indices), max(bounds[i][3] for i in indices))
73
+ self.maximum_z = max(maximum_z[i] for i in indices) if maximum_z is not None else math.inf
74
+ self.left = self.right = None
75
+ self.indices = None
76
+ if len(indices) <= 8:
77
+ if maximum_z is not None:
78
+ indices.sort(key=lambda i: maximum_z[i], reverse=True)
79
+ self.indices = indices
80
+ return
81
+ axis = int(self.bounds[3] - self.bounds[1] > self.bounds[2] - self.bounds[0])
82
+ indices.sort(key=lambda i: bounds[i][axis] / 2 + bounds[i][axis + 2] / 2)
83
+ middle = len(indices) // 2
84
+ self.left = _XYTree(bounds, indices[:middle], maximum_z)
85
+ self.right = _XYTree(bounds, indices[middle:], maximum_z)
86
+
87
+ def candidates(self, subject_bounds, support_bounds, subject_min_z=None, best_lower=None, support_max_z=None):
88
+ pending = [self]
89
+ while pending:
90
+ node = pending.pop()
91
+ if not _overlap(subject_bounds, node.bounds):
92
+ continue
93
+ lower = best_lower() if best_lower is not None else None
94
+ if lower is not None and _upper_z_difference(node.maximum_z, subject_min_z) <= lower:
95
+ continue
96
+ if node.indices is not None:
97
+ for i in node.indices:
98
+ lower = best_lower() if best_lower is not None else None
99
+ if (lower is not None and support_max_z is not None and
100
+ _upper_z_difference(support_max_z[i], subject_min_z) <= lower):
101
+ continue
102
+ if _overlap(subject_bounds, support_bounds[i]):
103
+ yield i
104
+ else:
105
+ first, second = node.left, node.right
106
+ if second.maximum_z > first.maximum_z:
107
+ first, second = second, first
108
+ pending.append(second)
109
+ pending.append(first)
110
+
111
+
112
+ def _cross(a, b, c):
113
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
114
+
115
+
116
+ def _height(triangle, point, area):
117
+ a = _cross(triangle[1], triangle[2], point) / area
118
+ b = _cross(triangle[2], triangle[0], point) / area
119
+ c = 1.0 - a - b
120
+ return math.fsum((triangle[0][2], b * (triangle[1][2] - triangle[0][2]),
121
+ c * (triangle[2][2] - triangle[0][2])))
122
+
123
+
124
+ def _clip_pair(subject, support):
125
+ """Floating convex clipping; None delegates ambiguous/vertical cases to LP.
126
+
127
+ An empty tuple is an established empty intersection. Normalization keeps
128
+ predicates independent of scene units. Near-zero predicates go through the
129
+ exact rational fallback, including shared boundaries and skinny triangles.
130
+ """
131
+ origin_x = min(p[0] for p in subject + support)
132
+ origin_y = min(p[1] for p in subject + support)
133
+ span = max(max(p[0] for p in subject + support) - origin_x,
134
+ max(p[1] for p in subject + support) - origin_y)
135
+ if not math.isfinite(span) or span == 0:
136
+ return None
137
+ a = tuple(((p[0] - origin_x) / span, (p[1] - origin_y) / span, p[2]) for p in subject)
138
+ b = tuple(((p[0] - origin_x) / span, (p[1] - origin_y) / span, p[2]) for p in support)
139
+ area_a, area_b = _cross(*a), _cross(*b)
140
+ height_range = max(1.0, *(abs(p[2] - a[0][2]) for p in a),
141
+ *(abs(p[2] - b[0][2]) for p in b))
142
+ # Conservative interpolation-error guard: ill-conditioned projections with
143
+ # substantial Z variation use exact arithmetic instead of a dubious height.
144
+ if min(abs(area_a), abs(area_b)) < max(1e-10, 3e-6 * height_range):
145
+ return None
146
+ sign = 1 if area_b > 0 else -1
147
+ polygon = [(p[0], p[1]) for p in a]
148
+ for i in range(3):
149
+ if not polygon:
150
+ return ()
151
+ edge_a, edge_b = b[i], b[(i + 1) % 3]
152
+ output = []
153
+ previous = polygon[-1]
154
+ previous_distance = sign * _cross(edge_a, edge_b, previous)
155
+ if abs(previous_distance) < 1e-13:
156
+ return None
157
+ for current in polygon:
158
+ current_distance = sign * _cross(edge_a, edge_b, current)
159
+ if abs(current_distance) < 1e-13:
160
+ return None
161
+ if (current_distance > 0) != (previous_distance > 0):
162
+ ratio = previous_distance / (previous_distance - current_distance)
163
+ output.append((previous[0] + ratio * (current[0] - previous[0]),
164
+ previous[1] + ratio * (current[1] - previous[1])))
165
+ if current_distance > 0:
166
+ output.append(current)
167
+ previous, previous_distance = current, current_distance
168
+ polygon = output
169
+ best = None
170
+ for point in polygon:
171
+ subject_z, support_z = _height(a, point, area_a), _height(b, point, area_b)
172
+ difference = support_z - subject_z
173
+ if not math.isfinite(difference):
174
+ return None
175
+ if best is None or difference > best[0]:
176
+ xy = [origin_x + point[0] * span, origin_y + point[1] * span]
177
+ best = (difference, xy + [subject_z], xy + [support_z])
178
+ return best if best is not None else ()
179
+
180
+
181
+ def _solve_square(matrix, right):
182
+ """Small exact linear system. None denotes a singular choice of basis."""
183
+ n = len(right)
184
+ rows = [list(row) + [value] for row, value in zip(matrix, right)]
185
+ for column in range(n):
186
+ pivot = next((i for i in range(column, n) if rows[i][column]), None)
187
+ if pivot is None:
188
+ return None
189
+ rows[column], rows[pivot] = rows[pivot], rows[column]
190
+ divisor = rows[column][column]
191
+ rows[column] = [value / divisor for value in rows[column]]
192
+ for row in range(n):
193
+ if row != column and rows[row][column]:
194
+ multiplier = rows[row][column]
195
+ rows[row] = [x - multiplier * y for x, y in zip(rows[row], rows[column])]
196
+ return [rows[i][-1] for i in range(n)]
197
+
198
+
199
+ def _independent_constraints(matrix, right):
200
+ """Exact row reduction also proves infeasibility of inconsistent equalities."""
201
+ reduced = []
202
+ selected = []
203
+ for source_index, (row, rhs) in enumerate(zip(matrix, right)):
204
+ current = list(row) + [rhs]
205
+ for pivot, basis in reduced:
206
+ if current[pivot]:
207
+ coefficient = current[pivot]
208
+ current = [a - coefficient * b for a, b in zip(current, basis)]
209
+ pivot = next((i for i, value in enumerate(current[:-1]) if value), None)
210
+ if pivot is None:
211
+ if current[-1]:
212
+ return None
213
+ continue
214
+ divisor = current[pivot]
215
+ current = [value / divisor for value in current]
216
+ reduced.append((pivot, current))
217
+ selected.append(source_index)
218
+ return selected
219
+
220
+
221
+ def _exact_pair(subject, support, return_exact=False):
222
+ """Barycentric LP: six nonnegative weights, at most four equalities.
223
+
224
+ A bounded nonempty polytope has an optimal basic feasible solution. Selecting
225
+ independent rows and enumerating bases covers point, line and area projected
226
+ intersections alike, with at most C(6,3) = 20 square systems. Fractions retain
227
+ the exact input float values, so a near miss is not silently turned into an
228
+ overlap by an epsilon and a vertical face is not discarded.
229
+ """
230
+ a = tuple(tuple(Fraction(x) for x in p) for p in subject)
231
+ b = tuple(tuple(Fraction(x) for x in p) for p in support)
232
+ zero, one = Fraction(0), Fraction(1)
233
+ rows = [[one, one, one, zero, zero, zero], [zero, zero, zero, one, one, one],
234
+ [p[0] for p in a] + [-p[0] for p in b],
235
+ [p[1] for p in a] + [-p[1] for p in b]]
236
+ rhs = [one, one, zero, zero]
237
+ selected = _independent_constraints(rows, rhs)
238
+ if selected is None:
239
+ return ()
240
+ independent = [rows[i] for i in selected]
241
+ right = [rhs[i] for i in selected]
242
+ objective = [-p[2] for p in a] + [p[2] for p in b]
243
+ best = None
244
+ for columns in itertools.combinations(range(6), len(selected)):
245
+ basis = _solve_square([[row[c] for c in columns] for row in independent], right)
246
+ if basis is None or any(value < 0 for value in basis):
247
+ continue
248
+ value = sum((objective[c] * weight for c, weight in zip(columns, basis)), zero)
249
+ if best is not None and value <= best[0]:
250
+ continue
251
+ weights = [zero] * 6
252
+ for column, weight in zip(columns, basis):
253
+ weights[column] = weight
254
+ subject_point = [sum((weights[i] * a[i][axis] for i in range(3)), zero) for axis in range(3)]
255
+ support_point = [sum((weights[i + 3] * b[i][axis] for i in range(3)), zero) for axis in range(3)]
256
+ best = (value, subject_point, support_point)
257
+ if best is None:
258
+ return ()
259
+ return best if return_exact else _represent_pair(best)
260
+
261
+
262
+ def _represent_pair(best):
263
+ try:
264
+ result = (float(best[0]), [float(x) for x in best[1]], [float(x) for x in best[2]])
265
+ except OverflowError:
266
+ _failure('numerical-ambiguity', 'The required translation exceeds finite numeric range.')
267
+ if not all(math.isfinite(x) for x in [result[0]] + result[1] + result[2]):
268
+ _failure('numerical-ambiguity', 'The surface contact cannot be represented finitely.')
269
+ return result
270
+
271
+
272
+ def _validate_triangles(triangles, label):
273
+ if not isinstance(triangles, (list, tuple)) or not triangles:
274
+ _failure('invalid-geometry', label + ' must contain at least one world-space triangle.')
275
+ if len(triangles) > MAX_TRIANGLES:
276
+ _failure('geometry-budget-exceeded', label + ' exceeds the triangle budget.')
277
+ result = []
278
+ for triangle in triangles:
279
+ if (not isinstance(triangle, (list, tuple)) or len(triangle) != 3 or
280
+ any(not isinstance(p, (list, tuple)) or len(p) != 3 or
281
+ any(not _finite_number(x) for x in p) for p in triangle)):
282
+ _failure('invalid-geometry', label + ' must contain finite 3D triangles.')
283
+ # Keep existing tuples, and thus shared vertex storage, when available.
284
+ result.append(tuple(tuple(p) for p in triangle))
285
+ return result
286
+
287
+
288
+ def solve_surface_placement(subject_triangles, support_triangles, clearance_meters=0.0,
289
+ max_candidate_pairs=MAX_CANDIDATE_PAIRS):
290
+ """Return the exact-surface descent offset and an actual maximizing witness.
291
+
292
+ Inputs are world-space triangle lists. No surfaces, candidates or degenerate
293
+ triangles are sampled away. Reaching a budget raises instead of returning a
294
+ partial maximum. The returned subject contact is before translation.
295
+ """
296
+ if not _finite_number(clearance_meters) or clearance_meters < 0:
297
+ _failure('invalid-input', 'Clearance must be a finite nonnegative number of metres.')
298
+ if type(max_candidate_pairs) is not int or not 1 <= max_candidate_pairs <= MAX_CANDIDATE_PAIRS:
299
+ _failure('invalid-input', 'The candidate-pair budget must be an integer from 1 to 1000000.')
300
+ subject = _validate_triangles(subject_triangles, 'Subject')
301
+ support = _validate_triangles(support_triangles, 'Support')
302
+ support_bounds = [_bounds(t) for t in support]
303
+ support_max_z = [max(p[2] for p in triangle) for triangle in support]
304
+ subject_min_z = [min(p[2] for p in triangle) for triangle in subject]
305
+ tree = _XYTree(support_bounds, list(range(len(support))), support_max_z)
306
+ candidates = exact_pairs = overlapping_pairs = pruned_subjects = 0
307
+ best = best_exact_value = certified_lower = None
308
+ # Low subject triangles and high support nodes tend to establish a useful
309
+ # witness early. Every skipped triangle/node/pair has a proven Z upper bound
310
+ # no higher than an exact feasible witness's downward-rounded objective.
311
+ for subject_index in sorted(range(len(subject)), key=subject_min_z.__getitem__):
312
+ triangle = subject[subject_index]
313
+ minimum_z = subject_min_z[subject_index]
314
+ if certified_lower is not None and _upper_z_difference(tree.maximum_z, minimum_z) <= certified_lower:
315
+ pruned_subjects += 1
316
+ continue
317
+ for support_index in tree.candidates(_bounds(triangle), support_bounds, minimum_z,
318
+ lambda: certified_lower, support_max_z):
319
+ candidates += 1
320
+ if candidates > max_candidate_pairs:
321
+ _failure('candidate-budget-exceeded', 'Projected candidate pairs exceed the exact placement budget.')
322
+ pair = _clip_pair(triangle, support[support_index])
323
+ exact = None
324
+ if pair is None:
325
+ exact_pairs += 1
326
+ exact = _exact_pair(triangle, support[support_index], return_exact=True)
327
+ pair = _represent_pair(exact) if exact else ()
328
+ if not pair:
329
+ continue
330
+ overlapping_pairs += 1
331
+ if best is None or pair[0] > best[0]:
332
+ if exact is None:
333
+ exact_pairs += 1
334
+ exact = _exact_pair(triangle, support[support_index], return_exact=True)
335
+ # A floating clip result is never used as proof for pruning.
336
+ if exact and (best_exact_value is None or exact[0] > best_exact_value):
337
+ pair = _represent_pair(exact)
338
+ best = (*pair, subject_index, support_index)
339
+ best_exact_value = exact[0]
340
+ certified_lower = math.nextafter(float(best_exact_value), -math.inf)
341
+ if best is None:
342
+ _failure('no-projected-overlap', 'Subject and support surfaces have no common world XY point.')
343
+ delta_z = best[0] + clearance_meters
344
+ placed = list(best[1])
345
+ placed[2] += delta_z
346
+ if not math.isfinite(delta_z) or not all(math.isfinite(x) for x in placed):
347
+ _failure('numerical-ambiguity', 'The placed contact cannot be represented finitely.')
348
+ return {'deltaZ': delta_z, 'subjectContactMeters': best[1], 'supportContactMeters': best[2],
349
+ 'placedSubjectContactMeters': placed, 'subjectTriangleIndex': best[3],
350
+ 'supportTriangleIndex': best[4],
351
+ 'statistics': {'candidatePairs': candidates, 'exactPairs': exact_pairs,
352
+ 'overlappingPairs': overlapping_pairs, 'prunedSubjectTriangles': pruned_subjects}}
353
+
354
+
355
+ def _unsupported(reason, message):
356
+ code = 'SCENE_TOO_HEAVY' if reason in ('geometry-budget-exceeded', 'candidate-budget-exceeded') else 'BLENDER_UNSUPPORTED_ACTION'
357
+ raise ActionError(code, message, {'reason': reason})
358
+
359
+
360
+ def _active_animation(data):
361
+ animation = getattr(data, 'animation_data', None)
362
+ return animation is not None and (animation.action is not None or bool(animation.drivers) or
363
+ any(bool(track.strips) for track in animation.nla_tracks))
364
+
365
+
366
+ def _validate_static_part(part):
367
+ if (_active_animation(part) or _active_animation(part.data) or
368
+ _active_animation(getattr(part.data, 'shape_keys', None)) or part.constraints or
369
+ part.rigid_body is not None or part.rigid_body_constraint is not None):
370
+ _unsupported('unsupported-animation-or-constraint',
371
+ 'Surface placement requires static entity geometry without animation, drivers, constraints or physics.')
372
+ supported = {'BEVEL', 'ARRAY', 'SUBSURF', 'SOLIDIFY', 'MIRROR', 'TRIANGULATE', 'DECIMATE',
373
+ 'WELD', 'WEIGHTED_NORMAL', 'EDGE_SPLIT', 'SMOOTH'}
374
+ for modifier in part.modifiers:
375
+ # References to external objects/collections can change geometry after a
376
+ # root translation. Refuse even disabled references, making the scope
377
+ # explicit rather than relying on one currently evaluated arrangement.
378
+ for prop in modifier.bl_rna.properties:
379
+ if prop.type == 'POINTER' and prop.identifier != 'rna_type':
380
+ reference = getattr(modifier, prop.identifier, None)
381
+ if reference is not None and getattr(reference, 'bl_rna', None) is not None:
382
+ identifier = reference.bl_rna.identifier
383
+ if identifier in ('Object', 'Collection'):
384
+ _unsupported('unsupported-cross-object-dependency',
385
+ 'Surface placement cannot verify this cross-object geometry dependency: ' + modifier.name)
386
+ if modifier.type not in supported:
387
+ _unsupported('unsupported-modifier',
388
+ 'Surface placement cannot verify this geometry modifier: ' + modifier.type)
389
+ if modifier.show_viewport != modifier.show_render:
390
+ _unsupported('different-viewport-render-geometry',
391
+ 'Surface placement requires the same enabled geometry modifiers in viewport and render.')
392
+ if modifier.type == 'SUBSURF' and modifier.levels != modifier.render_levels:
393
+ _unsupported('different-viewport-render-geometry',
394
+ 'Surface placement requires matching viewport and render subdivision levels.')
395
+
396
+
397
+ def _matrix_values(matrix):
398
+ values = [[float(value) for value in row] for row in matrix]
399
+ if (len(values) != 4 or any(len(row) != 4 for row in values) or
400
+ not all(math.isfinite(value) and abs(value) <= 1e6 for row in values for value in row) or
401
+ values[3] != [0, 0, 0, 1]):
402
+ _unsupported('invalid-world-transform', 'Surface placement requires a finite affine world transform within its numeric budget.')
403
+ return values
404
+
405
+
406
+ def _capture_entity(obj, graph, render_visible):
407
+ """Capture every supported mesh in the selected root's complete hierarchy."""
408
+ import bpy
409
+ if obj.parent is not None:
410
+ _unsupported('parented-entity-root', 'Surface placement requires an unparented entity root for a world-Z translation.')
411
+ pending, parts, identities, visited = [obj], [], [], set()
412
+ owner = obj.get('deepblend_id')
413
+ while pending:
414
+ part = pending.pop()
415
+ pointer = part.as_pointer()
416
+ if pointer in visited:
417
+ _unsupported('unsupported-hierarchy', 'Surface placement hierarchy is cyclic.')
418
+ if len(visited) >= MAX_HIERARCHY_OBJECTS:
419
+ _unsupported('geometry-budget-exceeded', 'Surface placement exceeds the 4096 hierarchy objects per entity budget.')
420
+ visited.add(pointer)
421
+ if part != obj and part.name.startswith(('db_entity__', 'db_camera__', 'db_light__')):
422
+ owned_part = (isinstance(owner, str) and bool(owner) and part.name.startswith(obj.name + '_') and
423
+ part.get('deepblend_id') == owner and part.get('deepblend_kind') == 'entity' and
424
+ isinstance(part.get('deepblend_part_id'), str) and bool(part.get('deepblend_part_id')))
425
+ if not owned_part:
426
+ _unsupported('unsupported-hierarchy', 'Surface placement cannot include another entity root or an unmarked managed child.')
427
+ if part.type not in ('EMPTY', 'MESH') or part.instance_type != 'NONE':
428
+ _unsupported('unsupported-hierarchy', 'Surface placement needs a complete hierarchy of mesh and empty objects without instancing.')
429
+ if (not part.visible_get(view_layer=bpy.context.view_layer) or part.hide_render or
430
+ pointer not in render_visible):
431
+ _unsupported('hidden-hierarchy-part', 'Surface placement cannot silently omit hidden entity geometry.')
432
+ _validate_static_part(part)
433
+ markers = tuple(part.get(key) for key in ('deepblend_id', 'deepblend_part_id', 'deepblend_kind'))
434
+ if any(value is not None and not isinstance(value, str) for value in markers):
435
+ _unsupported('unsupported-hierarchy', 'Surface placement requires plain-text entity ownership markers.')
436
+ identity = (part.name, part.parent.name if part.parent else None, part.type, *markers)
437
+ identities.append(identity)
438
+ if part.type == 'MESH':
439
+ parts.append((part, identity))
440
+ if len(parts) > MAX_MESH_OBJECTS:
441
+ _unsupported('geometry-budget-exceeded', 'Surface placement exceeds the 128 mesh objects per entity budget.')
442
+ pending.extend(sorted(part.children, key=lambda item: item.name, reverse=True))
443
+ if not parts:
444
+ _unsupported('empty-or-invalid-geometry', 'Surface placement requires triangle surfaces in both entities.')
445
+ triangles, captured_parts = [], []
446
+ vertex_count = 0
447
+ low, high = [math.inf] * 3, [-math.inf] * 3
448
+ for part, identity in parts:
449
+ evaluated = part.evaluated_get(graph)
450
+ _matrix_values(evaluated.matrix_world)
451
+ mesh = evaluated.to_mesh()
452
+ try:
453
+ if mesh is None or not mesh.vertices or not mesh.polygons:
454
+ _unsupported('empty-or-invalid-geometry', 'Every entity mesh must provide a nonempty evaluated triangle surface.')
455
+ vertex_count += len(mesh.vertices)
456
+ estimated_triangles = sum(max(0, len(polygon.vertices) - 2) for polygon in mesh.polygons)
457
+ if vertex_count > MAX_VERTICES or len(triangles) + estimated_triangles > MAX_TRIANGLES:
458
+ _unsupported('geometry-budget-exceeded', 'Surface placement exceeds two million evaluated vertices or triangles per entity.')
459
+ mesh.calc_loop_triangles()
460
+ if not mesh.loop_triangles:
461
+ _unsupported('empty-or-invalid-geometry', 'Every entity mesh must provide a nonempty evaluated triangle surface.')
462
+ if len(triangles) + len(mesh.loop_triangles) > MAX_TRIANGLES:
463
+ _unsupported('geometry-budget-exceeded', 'Surface placement exceeds its evaluated triangle budget.')
464
+ vertices = []
465
+ for vertex in mesh.vertices:
466
+ point = tuple(float(value) for value in evaluated.matrix_world @ vertex.co)
467
+ if not all(math.isfinite(value) and abs(value) <= 1e6 for value in point):
468
+ _unsupported('invalid-world-geometry', 'Surface placement geometry exceeds the finite one-million-metre coordinate budget.')
469
+ vertices.append(point)
470
+ for axis in range(3):
471
+ low[axis] = min(low[axis], point[axis])
472
+ high[axis] = max(high[axis], point[axis])
473
+ topology = [tuple(face.vertices) for face in mesh.loop_triangles]
474
+ triangles.extend(tuple(vertices[i] for i in indices) for indices in topology)
475
+ captured_parts.append({'identity': identity, 'vertices': vertices, 'topology': topology})
476
+ finally:
477
+ evaluated.to_mesh_clear()
478
+ metadata = {'meshObjectCount': len(parts), 'vertexCount': vertex_count, 'triangleCount': len(triangles),
479
+ 'rootWorldMatrix': _matrix_values(obj.evaluated_get(graph).matrix_world),
480
+ 'worldBoundsMeters': {'min': low, 'max': high}}
481
+ return {'metadata': metadata, 'triangles': triangles, 'parts': captured_parts,
482
+ 'hierarchyIdentity': identities}
483
+
484
+
485
+ def _verified_checkpoint(path, expected):
486
+ from deepblend_inspect_geometry import file_digest
487
+ if not isinstance(path, str) or not path or '\x00' in path or not os.path.isfile(path):
488
+ raise ActionError('REVISION_CHECKPOINT_MISSING', 'Surface placement needs an existing native checkpoint.')
489
+ if os.path.getsize(path) > 1024 ** 3 or file_digest(path) != expected:
490
+ raise ActionError('SCENE_VALIDATION_FAILED', 'The surface-placement checkpoint differs from its expected source.')
491
+
492
+
493
+ def _capture_checkpoint(path, expected, frame, subject_id, support_id):
494
+ import bpy
495
+ _verified_checkpoint(path, expected)
496
+ try:
497
+ bpy.ops.wm.open_mainfile(filepath=path, load_ui=False, use_scripts=False)
498
+ except Exception as exc:
499
+ raise ActionError('SCENE_VALIDATION_FAILED', 'The native checkpoint cannot be opened for surface placement.') from exc
500
+ if bpy.data.libraries:
501
+ _unsupported('linked-blender-libraries', 'Surface placement requires self-contained geometry without external Blender libraries.')
502
+ scale = float(bpy.context.scene.unit_settings.scale_length)
503
+ if not math.isfinite(scale) or abs(scale - 1.0) > 1e-8:
504
+ _unsupported('unsupported-unit-scale', 'Surface placement requires one metre per native Blender unit.')
505
+ bpy.context.scene.frame_set(frame)
506
+ if int(bpy.context.scene.frame_current) != frame:
507
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Blender did not evaluate the requested surface-placement frame.')
508
+ graph = bpy.context.evaluated_depsgraph_get()
509
+ # Render visibility follows complete collection ancestry; a directly visible
510
+ # collection may still live below a render-disabled ancestor. Objects linked
511
+ # through another enabled path remain visible and are not falsely rejected.
512
+ render_visible, visited_collections = set(), set()
513
+ pending_collections = [bpy.context.scene.collection]
514
+ while pending_collections:
515
+ collection = pending_collections.pop()
516
+ pointer = collection.as_pointer()
517
+ if pointer in visited_collections or collection.hide_render:
518
+ continue
519
+ visited_collections.add(pointer)
520
+ render_visible.update(item.as_pointer() for item in collection.objects)
521
+ pending_collections.extend(collection.children)
522
+ result = []
523
+ for entity_id in (subject_id, support_id):
524
+ obj = bpy.data.objects.get('db_entity__' + entity_id)
525
+ if obj is None:
526
+ raise ActionError('SCENE_SPEC_INVALID', 'The selected native entity does not exist: ' + entity_id)
527
+ result.append(_capture_entity(obj, graph, render_visible))
528
+ _verified_checkpoint(path, expected)
529
+ return result
530
+
531
+
532
+ def _compare_translation(baseline, current, expected, label):
533
+ if (baseline['hierarchyIdentity'] != current['hierarchyIdentity'] or
534
+ len(baseline['parts']) != len(current['parts'])):
535
+ raise ActionError('SCENE_VALIDATION_FAILED', label + ' hierarchy identity changed while verifying surface placement.')
536
+ maximum_error = 0.0
537
+ for old_part, new_part in zip(baseline['parts'], current['parts']):
538
+ if (old_part['identity'] != new_part['identity'] or old_part['topology'] != new_part['topology'] or
539
+ len(old_part['vertices']) != len(new_part['vertices'])):
540
+ raise ActionError('SCENE_VALIDATION_FAILED', label + ' mesh identity or topology changed while verifying surface placement.')
541
+ for before, after in zip(old_part['vertices'], new_part['vertices']):
542
+ error = math.dist(tuple(before[axis] + expected[axis] for axis in range(3)), after)
543
+ maximum_error = max(maximum_error, error)
544
+ if error > TRANSLATION_TOLERANCE_METERS:
545
+ raise ActionError('SCENE_VALIDATION_FAILED', label + ' geometry is not the required rigid world translation.',
546
+ {'maxErrorMeters': maximum_error, 'toleranceMeters': TRANSLATION_TOLERANCE_METERS})
547
+ before_matrix = baseline['metadata']['rootWorldMatrix']
548
+ after_matrix = current['metadata']['rootWorldMatrix']
549
+ for row in range(4):
550
+ for column in range(4):
551
+ difference = abs(after_matrix[row][column] - before_matrix[row][column] -
552
+ (expected[row] if row < 3 and column == 3 else 0.0))
553
+ if difference > (TRANSLATION_TOLERANCE_METERS if column == 3 else 1e-8):
554
+ raise ActionError('SCENE_VALIDATION_FAILED', label + ' root transform changed beyond the required world translation.')
555
+ return maximum_error
556
+
557
+
558
+ def inspect_surface_placement(request, options):
559
+ """Inspect a hash-bound checkpoint, optionally proving a saved rigid move."""
560
+ import bpy
561
+ payload = request.get('payload')
562
+ allowed = {'subjectEntityId', 'supportEntityId', 'frame', 'clearanceMm', 'checkpointSha256',
563
+ 'baselineCheckpointPath', 'baselineCheckpointSha256', 'expectedTranslationMeters'}
564
+ if not isinstance(payload, dict) or set(payload) - allowed:
565
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Surface placement payload contains unsupported fields.')
566
+ subject_id, support_id = payload.get('subjectEntityId'), payload.get('supportEntityId')
567
+ frame, expected = payload.get('frame'), payload.get('checkpointSha256')
568
+ clearance = payload.get('clearanceMm', 0)
569
+ valid_id = lambda value: isinstance(value, str) and re.fullmatch(r'[A-Za-z][A-Za-z0-9._-]{0,127}', value) is not None
570
+ valid_hash = lambda value: isinstance(value, str) and re.fullmatch('[a-f0-9]{64}', value) is not None
571
+ if (not valid_id(subject_id) or not valid_id(support_id) or subject_id == support_id or
572
+ type(frame) is not int or not 0 <= frame <= 1_000_000 or not valid_hash(expected) or
573
+ not _finite_number(clearance) or not 0 <= clearance <= 1000):
574
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Surface placement requires distinct entity IDs, an explicit frame, checkpoint SHA256 and 0–1000 mm clearance.')
575
+ baseline_fields = {'baselineCheckpointPath', 'baselineCheckpointSha256', 'expectedTranslationMeters'}
576
+ has_baseline = bool(baseline_fields & set(payload))
577
+ if has_baseline:
578
+ translation = payload.get('expectedTranslationMeters')
579
+ if (not baseline_fields <= set(payload) or not valid_hash(payload['baselineCheckpointSha256']) or
580
+ not isinstance(payload['baselineCheckpointPath'], str) or
581
+ not isinstance(translation, list) or len(translation) != 3 or
582
+ any(not _finite_number(value) for value in translation) or translation[:2] != [0, 0]):
583
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Placement verification requires a baseline checkpoint, its SHA256 and a finite world-Z translation.')
584
+ baseline = _capture_checkpoint(payload['baselineCheckpointPath'], payload['baselineCheckpointSha256'],
585
+ frame, subject_id, support_id)
586
+ checkpoint = options.get('blend')
587
+ current = _capture_checkpoint(checkpoint, expected, frame, subject_id, support_id)
588
+ verification = None
589
+ if has_baseline:
590
+ subject_error = _compare_translation(baseline[0], current[0], translation, 'Subject')
591
+ support_error = _compare_translation(baseline[1], current[1], [0, 0, 0], 'Support')
592
+ _verified_checkpoint(payload['baselineCheckpointPath'], payload['baselineCheckpointSha256'])
593
+ verification = {'checkpointSha256': payload['baselineCheckpointSha256'], 'checkpointUnchanged': True,
594
+ 'translationMeters': translation, 'subjectRigidTranslation': True, 'supportUnchanged': True,
595
+ 'subjectMaxErrorMeters': subject_error, 'supportMaxErrorMeters': support_error,
596
+ 'toleranceMeters': TRANSLATION_TOLERANCE_METERS}
597
+ try:
598
+ solution = solve_surface_placement(current[0]['triangles'], current[1]['triangles'], clearance / 1000)
599
+ except SurfacePlacementError as exc:
600
+ _unsupported(exc.reason, str(exc))
601
+ report = {'schemaVersion': 'deepblend.surface-placement/v1', 'axis': 'world-z',
602
+ 'checkpointSha256': expected, 'checkpointUnchanged': True, 'frame': frame,
603
+ 'blenderVersion': bpy.app.version_string, 'subjectEntityId': subject_id,
604
+ 'supportEntityId': support_id, 'clearanceMm': clearance,
605
+ 'translationMeters': [0, 0, solution['deltaZ']],
606
+ 'subjectContactMeters': solution['subjectContactMeters'],
607
+ 'supportContactMeters': solution['supportContactMeters'],
608
+ 'placedSubjectContactMeters': solution['placedSubjectContactMeters'],
609
+ 'subjectTriangleIndex': solution['subjectTriangleIndex'],
610
+ 'supportTriangleIndex': solution['supportTriangleIndex'],
611
+ 'statistics': {'candidatePairs': solution['statistics']['candidatePairs']},
612
+ 'subject': current[0]['metadata'], 'support': current[1]['metadata']}
613
+ if verification is not None:
614
+ report['baselineVerification'] = verification
615
+ _verified_checkpoint(checkpoint, expected)
616
+ return report, [], []