@deepblend/dsh-blender-provider-local 0.20.0 → 0.22.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
@@ -39,6 +39,11 @@ import {
39
39
  SURFACE_PLACEMENT_LIMITS,
40
40
  validateSurfacePlacementSelection,
41
41
  verifySurfacePlacementReport,
42
+ validateAssemblyPlacementSelection,
43
+ verifyAssemblyPlacementReport,
44
+ CAMERA_FIT_LIMITS,
45
+ validateCameraFitSelection,
46
+ verifyCameraFitReport,
42
47
  BlenderError,
43
48
  BlenderErrorCode,
44
49
  CANDIDATE_RENDER_ENGINES,
@@ -276,7 +281,7 @@ export const SESSION_ROUTABLE_ACTIONS = Object.freeze([
276
281
  'render_preview',
277
282
  'render_views',
278
283
  ])
279
- export const ISOLATED_BOOTSTRAP_ACTIONS = Object.freeze(['inspect_geometry', 'inspect_surface_placement'])
284
+ export const ISOLATED_BOOTSTRAP_ACTIONS = Object.freeze(['inspect_geometry', 'inspect_surface_placement', 'inspect_camera_fit'])
280
285
 
281
286
  /**
282
287
  * A live Blender, presented as one shape whatever carries the bytes.
@@ -1644,7 +1649,17 @@ export default class LocalBlenderRuntime extends Service {
1644
1649
 
1645
1650
  /** Measure both actual meshes in a private batch; candidate verification also pins its baseline. */
1646
1651
  async inspectSurfacePlacement(request) {
1647
- validateSurfacePlacementSelection(request)
1652
+ return this._inspectSurfacePlacement(request, false)
1653
+ }
1654
+
1655
+ async inspectAssemblyPlacement(request) {
1656
+ return this._inspectSurfacePlacement(request, true)
1657
+ }
1658
+
1659
+ async _inspectSurfacePlacement(request, assembly) {
1660
+ const validate = assembly ? validateAssemblyPlacementSelection : validateSurfacePlacementSelection
1661
+ const verifyReport = assembly ? verifyAssemblyPlacementReport : verifySurfacePlacementReport
1662
+ validate(request)
1648
1663
  const aborted = () => {
1649
1664
  if (request.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Surface placement cancelled.')
1650
1665
  }
@@ -1682,8 +1697,11 @@ export default class LocalBlenderRuntime extends Service {
1682
1697
  const run = await this.runBootstrap({ action: 'inspect_surface_placement', jobId: correlationId,
1683
1698
  payload: { subjectEntityId: request.subjectEntityId, supportEntityId: request.supportEntityId,
1684
1699
  frame: request.frame, clearanceMm: request.clearanceMm ?? 0, checkpointSha256: request.checkpointSha256,
1700
+ ...(assembly ? { scope: 'recipe-assembly', memberEntityIds: request.memberEntityIds, pose: request.pose } : {}),
1685
1701
  ...(sources.length === 1 ? {} : { baselineCheckpointPath: sources[1].snapshot,
1686
- baselineCheckpointSha256: request.baselineCheckpointSha256, expectedTranslationMeters: request.expectedTranslationMeters }) } },
1702
+ baselineCheckpointSha256: request.baselineCheckpointSha256,
1703
+ ...(assembly ? { expectedWorldMatrixMeters: request.expectedWorldMatrixMeters }
1704
+ : { expectedTranslationMeters: request.expectedTranslationMeters }) }) } },
1687
1705
  { signal: request.signal, session: false, args: ['--blend', sources[0].snapshot] })
1688
1706
  aborted()
1689
1707
  if (run.exitCode !== 0) throw new BlenderError(BlenderErrorCode.NONZERO_EXIT,
@@ -1693,7 +1711,74 @@ export default class LocalBlenderRuntime extends Service {
1693
1711
  throw new BlenderError(BlenderErrorCode.PROTOCOL_VERSION_MISMATCH,
1694
1712
  'Native placement inspection did not return a successful response for this request.')
1695
1713
  }
1696
- const report = verifySurfacePlacementReport(run.envelope?.result, request)
1714
+ const report = verifyReport(run.envelope?.result, request)
1715
+ for (const source of sources) {
1716
+ await verify(source.path, source.sha256)
1717
+ await verify(source.snapshot, source.sha256)
1718
+ }
1719
+ aborted()
1720
+ return { report, envelope: run.envelope, durationMs: run.durationMs, stdout: run.stdout, stderr: run.stderr }
1721
+ } catch (cause) {
1722
+ aborted()
1723
+ throw cause
1724
+ } finally {
1725
+ this._cleanup(directory)
1726
+ }
1727
+ }
1728
+
1729
+ /** Read-only full-selection proposal or candidate check in a hash-pinned private batch. */
1730
+ async inspectCameraFit(request) {
1731
+ validateCameraFitSelection(request)
1732
+ const aborted = () => {
1733
+ if (request.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Camera fit cancelled.')
1734
+ }
1735
+ aborted()
1736
+ if (!existsSync(this.workspaceRoot)) throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'The camera-fit workspace does not exist.')
1737
+ const root = realpathSync(this.workspaceRoot)
1738
+ const boundedSource = path => {
1739
+ if (typeof path !== 'string' || !isAbsolute(path) || !existsSync(path)) throw new BlenderError(BlenderErrorCode.REVISION_CHECKPOINT_MISSING, 'Camera fit needs an absolute native checkpoint path.')
1740
+ const source = realpathSync(path), local = relative(root, source)
1741
+ if (!local || local === '..' || local.startsWith('..' + sep) || isAbsolute(local)) throw new BlenderError(BlenderErrorCode.PATH_OUTSIDE_WORKSPACE, 'The camera-fit checkpoint is outside the runtime workspace.')
1742
+ const info = statSync(source)
1743
+ if (!info.isFile() || info.size > CAMERA_FIT_LIMITS.checkpointBytes) throw new BlenderError(BlenderErrorCode.SCENE_TOO_HEAVY, 'The camera-fit checkpoint is not a bounded native file.')
1744
+ return source
1745
+ }
1746
+ const sources = [{ path: boundedSource(request.checkpointPath), sha256: request.checkpointSha256, name: 'camera-fit-source.blend' }]
1747
+ if (request.baselineCheckpointPath !== undefined) sources.push({ path: boundedSource(request.baselineCheckpointPath), sha256: request.baselineCheckpointSha256, name: 'camera-fit-baseline.blend' })
1748
+ const digest = async path => {
1749
+ const hash = createHash('sha256')
1750
+ for await (const bytes of createReadStream(path, { signal: request.signal })) hash.update(bytes)
1751
+ return hash.digest('hex')
1752
+ }
1753
+ const verify = async (path, expected) => {
1754
+ if (await digest(path) !== expected) throw new BlenderError(BlenderErrorCode.SCENE_VALIDATION_FAILED, 'A camera-fit checkpoint differs from its pinned native source.')
1755
+ }
1756
+ const { directory, jobId } = this._createWorkingDirectory()
1757
+ const correlationId = typeof request.jobId === 'string' && request.jobId.length > 0 ? request.jobId : jobId
1758
+ try {
1759
+ for (const source of sources) {
1760
+ await verify(source.path, source.sha256)
1761
+ source.snapshot = join(directory, source.name)
1762
+ await copyFile(source.path, source.snapshot)
1763
+ aborted()
1764
+ await verify(source.snapshot, source.sha256)
1765
+ }
1766
+ const run = await this.runBootstrap({ action: 'inspect_camera_fit', jobId: correlationId,
1767
+ payload: { entityIds: request.entityIds, assetInstanceIds: request.assetInstanceIds,
1768
+ frame: request.frame, cameraId: request.cameraId, fittedCameraId: request.fittedCameraId,
1769
+ margin: request.margin, checkpointSha256: request.checkpointSha256,
1770
+ ...(sources.length === 1 ? {} : { baselineCheckpointPath: sources[1].snapshot,
1771
+ baselineCheckpointSha256: request.baselineCheckpointSha256 }) } },
1772
+ { signal: request.signal, session: false, args: ['--blend', sources[0].snapshot] })
1773
+ aborted()
1774
+ if (run.exitCode !== 0) throw new BlenderError(BlenderErrorCode.NONZERO_EXIT,
1775
+ 'Native camera-fit inspection did not exit successfully; its result was refused.')
1776
+ if (run.envelope?.protocolVersion !== BLENDER_PROTOCOL_VERSION || run.envelope.status !== 'success' ||
1777
+ run.envelope.action !== 'inspect_camera_fit' || run.envelope.jobId !== correlationId || run.envelope.error !== null) {
1778
+ throw new BlenderError(BlenderErrorCode.PROTOCOL_VERSION_MISMATCH,
1779
+ 'Native camera-fit inspection did not return a successful response for this request.')
1780
+ }
1781
+ const report = verifyCameraFitReport(run.envelope?.result, request)
1697
1782
  for (const source of sources) {
1698
1783
  await verify(source.path, source.sha256)
1699
1784
  await verify(source.snapshot, source.sha256)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.20.0",
3
+ "version": "0.22.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.20.0"
14
+ "@deepblend/dsh-blender-contracts": "0.22.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -107,6 +107,7 @@ SUPPORTED_ACTIONS = (
107
107
  "compile_scene",
108
108
  "inspect_geometry",
109
109
  "inspect_surface_placement",
110
+ "inspect_camera_fit",
110
111
  "render_preview",
111
112
  "render_views",
112
113
  "render_frames",
@@ -497,10 +498,12 @@ KNOWN_SESSION_OPTIONS = (
497
498
 
498
499
  from deepblend_inspect_geometry import inspect_geometry
499
500
  from deepblend_surface_placement import inspect_surface_placement
501
+ from deepblend_product_framing import inspect_camera_fit
500
502
 
501
503
  ACTIONS = {
502
504
  "inspect_geometry": inspect_geometry,
503
505
  "inspect_surface_placement": inspect_surface_placement,
506
+ "inspect_camera_fit": inspect_camera_fit,
504
507
  "get_capabilities": action_get_capabilities,
505
508
  "compile_scene": action_compile_scene,
506
509
  "render_preview": action_render_preview,
@@ -615,7 +618,7 @@ def _session_dispatch(request, options):
615
618
  % (request.get("protocolVersion"), PROTOCOL_VERSION),
616
619
  }, [], [])
617
620
 
618
- if action in ('inspect_geometry', 'inspect_surface_placement'):
621
+ if action in ('inspect_geometry', 'inspect_surface_placement', 'inspect_camera_fit'):
619
622
  return build_envelope(job_id, action, None, {
620
623
  'code': 'BLENDER_UNSUPPORTED_ACTION',
621
624
  'message': 'Native geometry inspection requires an isolated batch and cannot open a checkpoint in a kept or live Blender session.',
@@ -0,0 +1,249 @@
1
+ """Read-only whole-assembly pose/settle measurement and complete candidate proof.
2
+
3
+ The public Host owns recipe provenance and complete membership. This native layer
4
+ measures the exact supplied union, then independently reopens both pinned files
5
+ and checks every evaluated member against one rigid upright world transform.
6
+ """
7
+ import math
8
+ import re
9
+
10
+ from deepblend_util import ActionError
11
+ from deepblend_surface_placement import (
12
+ _capture_checkpoint, _verified_checkpoint, _unsupported, _finite_number, _active_animation,
13
+ solve_surface_placement, SurfacePlacementError, MAX_MESH_OBJECTS, MAX_VERTICES,
14
+ MAX_TRIANGLES, MAX_HIERARCHY_OBJECTS, TRANSLATION_TOLERANCE_METERS,
15
+ )
16
+
17
+ BASIS_TOLERANCE = 1e-6
18
+ UV_TOLERANCE = 1e-6
19
+ IDENTITY = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
20
+
21
+
22
+ def _fail(message):
23
+ raise ActionError('SCENE_VALIDATION_FAILED', message)
24
+
25
+
26
+ def _matrix_product(a, b):
27
+ return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
28
+
29
+
30
+ def _point(matrix, point):
31
+ return tuple(sum(matrix[i][j] * point[j] for j in range(3)) + matrix[i][3] for i in range(3))
32
+
33
+
34
+ def _valid_pose(pose):
35
+ return (isinstance(pose, dict) and set(pose) == {'translationMm', 'yawDegrees', 'pivotMm'}
36
+ and all(isinstance(pose[key], list) and len(pose[key]) == 3
37
+ and all(_finite_number(value) and abs(value) <= 1_000_000 for value in pose[key])
38
+ for key in ('translationMm', 'pivotMm'))
39
+ and _finite_number(pose['yawDegrees']) and abs(pose['yawDegrees']) <= 360)
40
+
41
+
42
+ def _pose_matrix(pose):
43
+ angle = pose['yawDegrees'] * math.pi / 180
44
+ c, s = math.cos(angle), math.sin(angle)
45
+ x, y = (value / 1000 for value in pose['pivotMm'][:2])
46
+ dx, dy, dz = (value / 1000 for value in pose['translationMm'])
47
+ return [[c, -s, 0, x-c*x+s*y+dx], [s, c, 0, y-s*x-c*y+dy],
48
+ [0, 0, 1, dz], [0, 0, 0, 1]]
49
+
50
+
51
+ def _upright_matrix(matrix):
52
+ if (not isinstance(matrix, list) or len(matrix) != 4
53
+ or any(not isinstance(row, list) or len(row) != 4
54
+ or any(not _finite_number(value) or abs(value) > 1e6 for value in row) for row in matrix)):
55
+ return False
56
+ near = lambda a, b: abs(a-b) <= 1e-9
57
+ return (all(near(matrix[3][i], value) for i, value in enumerate((0, 0, 0, 1)))
58
+ and all(near(matrix[2][i], value) for i, value in enumerate((0, 0, 1)))
59
+ and near(matrix[0][2], 0) and near(matrix[1][2], 0)
60
+ and near(matrix[0][0], matrix[1][1]) and near(matrix[0][1], -matrix[1][0])
61
+ and near(matrix[0][0]**2+matrix[1][0]**2, 1))
62
+
63
+
64
+ def _cycle(values):
65
+ values = tuple(values)
66
+ return min(values[i:] + values[:i] for i in range(len(values)))
67
+
68
+
69
+ def _socket_value(value):
70
+ if isinstance(value, (str, bool, int, float)):
71
+ return value
72
+ if hasattr(value, '__len__') and not hasattr(value, 'bl_rna'):
73
+ return tuple(value)
74
+ if value is None:
75
+ return None
76
+ return (getattr(getattr(value, 'bl_rna', None), 'identifier', type(value).__name__), getattr(value, 'name', None))
77
+
78
+
79
+ def _material_facts(material):
80
+ if material is None:
81
+ return None
82
+ tree = material.node_tree
83
+ if _active_animation(material) or _active_animation(tree):
84
+ _unsupported('animated-assembly-material', 'Complete assembly placement requires static materials.')
85
+ nodes = []
86
+ for node in tree.nodes if tree else []:
87
+ properties = tuple((key, getattr(node, key)) for key in ('operation', 'blend_type', 'noise_dimensions',
88
+ 'normalize', 'noise_type', 'distribution', 'subsurface_method', 'gradient_type', 'voronoi_dimensions',
89
+ 'feature', 'distance', 'interpolation', 'extension', 'projection', 'vector_type', 'convert_from', 'convert_to')
90
+ if hasattr(node, key))
91
+ nodes.append((node.name, node.bl_idname, properties,
92
+ tuple((socket.identifier, _socket_value(socket.default_value)) for socket in node.inputs
93
+ if hasattr(socket, 'default_value'))))
94
+ links = sorted((link.from_node.name, link.from_socket.identifier, link.to_node.name, link.to_socket.identifier)
95
+ for link in tree.links) if tree else []
96
+ return (material.name, tuple(material.diffuse_color), bool(material.use_nodes), sorted(nodes), links)
97
+
98
+
99
+ def _capture(path, expected, frame, member_ids, support_id):
100
+ """Reuse the static, visibility, source hash and cross-object checks of v1."""
101
+ import bpy
102
+ captures = _capture_checkpoint(path, expected, frame, member_ids, support_id)
103
+ graph = bpy.context.evaluated_depsgraph_get()
104
+ for capture in captures:
105
+ for part in capture['parts']:
106
+ obj = bpy.data.objects[part['identity'][0]]
107
+ evaluated = obj.evaluated_get(graph)
108
+ mesh = evaluated.to_mesh()
109
+ try:
110
+ # Polygon order and cyclic start are not identity. Preserve the
111
+ # complete oriented faces, per-face material and loop UV data.
112
+ faces = []
113
+ for face in mesh.polygons:
114
+ corners = []
115
+ for loop_index in face.loop_indices:
116
+ corners.append((mesh.loops[loop_index].vertex_index,
117
+ tuple(tuple(layer.uv[loop_index].vector) for layer in mesh.uv_layers)))
118
+ faces.append((_cycle(corners), face.material_index, bool(face.use_smooth)))
119
+ part['appearance'] = (tuple(layer.name for layer in mesh.uv_layers), sorted(faces),
120
+ tuple(_material_facts(slot.material) for slot in obj.material_slots))
121
+ finally:
122
+ evaluated.to_mesh_clear()
123
+ total_meshes = sum(item['metadata']['meshObjectCount'] for item in captures[:-1])
124
+ total_vertices = sum(item['metadata']['vertexCount'] for item in captures[:-1])
125
+ total_triangles = sum(item['metadata']['triangleCount'] for item in captures[:-1])
126
+ if (total_meshes > MAX_MESH_OBJECTS or total_vertices > MAX_VERTICES or total_triangles > MAX_TRIANGLES
127
+ or sum(len(item['hierarchyIdentity']) for item in captures[:-1]) > MAX_HIERARCHY_OBJECTS):
128
+ _unsupported('geometry-budget-exceeded', 'The complete subject union exceeds the bounded native geometry budget.')
129
+ _verified_checkpoint(path, expected)
130
+ return captures
131
+
132
+
133
+ def _transformed(capture, matrix):
134
+ triangles = [tuple(_point(matrix, point) for point in triangle) for triangle in capture['triangles']]
135
+ parts = [{**part, 'vertices': [_point(matrix, point) for point in part['vertices']]} for part in capture['parts']]
136
+ points = [point for part in parts for point in part['vertices']]
137
+ metadata = {**capture['metadata'], 'rootWorldMatrix': _matrix_product(matrix, capture['metadata']['rootWorldMatrix']),
138
+ 'worldBoundsMeters': {'min': [min(point[a] for point in points) for a in range(3)],
139
+ 'max': [max(point[a] for point in points) for a in range(3)]}}
140
+ return {**capture, 'parts': parts, 'triangles': triangles, 'metadata': metadata}
141
+
142
+
143
+ def _compare_appearance(old, new, label):
144
+ if old[0] != new[0] or old[2] != new[2] or len(old[1]) != len(new[1]):
145
+ _fail(label + ' native UV layers, materials or mesh faces changed.')
146
+ maximum_uv_error = 0.0
147
+ for a, b in zip(old[1], new[1]):
148
+ if a[1:] != b[1:] or len(a[0]) != len(b[0]):
149
+ _fail(label + ' native face identity, material indices or smooth flags changed.')
150
+ for ac, bc in zip(a[0], b[0]):
151
+ if ac[0] != bc[0] or len(ac[1]) != len(bc[1]):
152
+ _fail(label + ' native oriented topology or loop UV identity changed.')
153
+ for auv, buv in zip(ac[1], bc[1]):
154
+ maximum_uv_error = max(maximum_uv_error, max(abs(a-b) for a, b in zip(auv, buv)))
155
+ if maximum_uv_error > UV_TOLERANCE:
156
+ _fail(label + ' evaluated UV coordinates changed beyond float32 reconstruction tolerance.')
157
+ return maximum_uv_error
158
+
159
+
160
+ def _compare_rigid(baseline, current, matrix, label):
161
+ if baseline['hierarchyIdentity'] != current['hierarchyIdentity'] or len(baseline['parts']) != len(current['parts']):
162
+ _fail(label + ' hierarchy identity changed.')
163
+ maximum, maximum_uv_error = 0.0, 0.0
164
+ for old, new in zip(baseline['parts'], current['parts']):
165
+ if old['identity'] != new['identity'] or len(old['vertices']) != len(new['vertices']):
166
+ _fail(label + ' mesh topology or identity changed.')
167
+ maximum_uv_error = max(maximum_uv_error, _compare_appearance(old['appearance'], new['appearance'], label))
168
+ for before, after in zip(old['vertices'], new['vertices']):
169
+ maximum = max(maximum, math.dist(_point(matrix, before), after))
170
+ expected = _matrix_product(matrix, baseline['metadata']['rootWorldMatrix'])
171
+ actual = current['metadata']['rootWorldMatrix']
172
+ position_error = max(abs(expected[i][3]-actual[i][3]) for i in range(3))
173
+ basis_error = max(abs(expected[i][j]-actual[i][j]) for i in range(4) for j in range(4) if j != 3)
174
+ if maximum > TRANSLATION_TOLERANCE_METERS or position_error > TRANSLATION_TOLERANCE_METERS or basis_error > BASIS_TOLERANCE:
175
+ _fail(label + ' did not follow the complete rigid world transform.')
176
+ return {'rigidTransform': True, 'maxVertexErrorMeters': maximum,
177
+ 'maxRootPositionErrorMeters': position_error, 'maxRootBasisError': basis_error, 'maxUvError': maximum_uv_error}
178
+
179
+
180
+ def inspect_assembly_placement(request, options):
181
+ import bpy
182
+ payload = request.get('payload')
183
+ allowed = {'scope', 'subjectEntityId', 'memberEntityIds', 'supportEntityId', 'frame', 'clearanceMm', 'pose',
184
+ 'checkpointSha256', 'baselineCheckpointPath', 'baselineCheckpointSha256', 'expectedWorldMatrixMeters'}
185
+ valid_id = lambda value: isinstance(value, str) and re.fullmatch(r'[A-Za-z][A-Za-z0-9._-]{0,127}', value) is not None
186
+ valid_hash = lambda value: isinstance(value, str) and re.fullmatch('[a-f0-9]{64}', value) is not None
187
+ if not isinstance(payload, dict) or set(payload) - allowed or payload.get('scope') != 'recipe-assembly':
188
+ _fail('Unsupported complete assembly measurement payload.')
189
+ ids, subject, support = payload.get('memberEntityIds'), payload.get('subjectEntityId'), payload.get('supportEntityId')
190
+ frame, expected, pose, clearance = payload.get('frame'), payload.get('checkpointSha256'), payload.get('pose'), payload.get('clearanceMm', 0)
191
+ if (not isinstance(ids, list) or not 1 <= len(ids) <= MAX_MESH_OBJECTS or not all(valid_id(value) for value in ids)
192
+ or ids != sorted(set(ids)) or not valid_id(subject) or subject not in ids or not valid_id(support) or support in ids
193
+ or type(frame) is not int or not 0 <= frame <= 1_000_000 or not valid_hash(expected)
194
+ or not _valid_pose(pose) or not _finite_number(clearance) or not 0 <= clearance <= 1000):
195
+ _fail('Complete assembly selection, pose, frame or checkpoint binding is invalid.')
196
+ baseline_fields = {'baselineCheckpointPath', 'baselineCheckpointSha256', 'expectedWorldMatrixMeters'}
197
+ has_baseline = bool(baseline_fields & set(payload))
198
+ baseline = None
199
+ if has_baseline:
200
+ if (not baseline_fields <= set(payload) or not valid_hash(payload['baselineCheckpointSha256'])
201
+ or not isinstance(payload['baselineCheckpointPath'], str) or not _upright_matrix(payload['expectedWorldMatrixMeters'])):
202
+ _fail('Complete candidate verification requires a pinned baseline and a rigid upright matrix.')
203
+ baseline = _capture(payload['baselineCheckpointPath'], payload['baselineCheckpointSha256'], frame, ids, support)
204
+ checkpoint = options.get('blend')
205
+ current = _capture(checkpoint, expected, frame, ids, support)
206
+ verification = None
207
+ if has_baseline:
208
+ members = [{'entityId': entity_id, **_compare_rigid(baseline[index], current[index], payload['expectedWorldMatrixMeters'], entity_id)}
209
+ for index, entity_id in enumerate(ids)]
210
+ support_proof = _compare_rigid(baseline[-1], current[-1], IDENTITY, support)
211
+ _verified_checkpoint(payload['baselineCheckpointPath'], payload['baselineCheckpointSha256'])
212
+ verification = {'checkpointSha256': payload['baselineCheckpointSha256'], 'checkpointUnchanged': True,
213
+ 'worldMatrixMeters': payload['expectedWorldMatrixMeters'], 'members': members,
214
+ 'supportUnchanged': True, 'supportMaxErrorMeters': support_proof['maxVertexErrorMeters'],
215
+ 'toleranceMeters': TRANSLATION_TOLERANCE_METERS, 'basisTolerance': BASIS_TOLERANCE, 'uvTolerance': UV_TOLERANCE}
216
+ matrix = _pose_matrix(pose)
217
+ transformed = [_transformed(item, matrix) for item in current[:-1]]
218
+ triangles = [triangle for item in transformed for triangle in item['triangles']]
219
+ support_capture = current[-1]
220
+ try:
221
+ solution = solve_surface_placement(triangles, support_capture['triangles'], clearance / 1000)
222
+ except SurfacePlacementError as exc:
223
+ _unsupported(exc.reason, str(exc))
224
+ result_matrix = [list(row) for row in matrix]; result_matrix[2][3] += solution['deltaZ']
225
+ members = [{'entityId': entity_id, 'geometry': transformed[index]['metadata']} for index, entity_id in enumerate(ids)]
226
+ offset, contact_member = 0, None
227
+ for member in members:
228
+ count = member['geometry']['triangleCount']
229
+ if offset <= solution['subjectTriangleIndex'] < offset + count:
230
+ contact_member = member['entityId']
231
+ offset += count
232
+ union = {key: sum(member['geometry'][key] for member in members) for key in ('meshObjectCount', 'vertexCount', 'triangleCount')}
233
+ union['rootWorldMatrix'] = next(member['geometry']['rootWorldMatrix'] for member in members if member['entityId'] == subject)
234
+ union['worldBoundsMeters'] = {'min': [min(member['geometry']['worldBoundsMeters']['min'][a] for member in members) for a in range(3)],
235
+ 'max': [max(member['geometry']['worldBoundsMeters']['max'][a] for member in members) for a in range(3)]}
236
+ report = {'schemaVersion': 'deepblend.assembly-placement/v1', 'axis': 'world-z', 'checkpointSha256': expected,
237
+ 'checkpointUnchanged': True, 'frame': frame, 'blenderVersion': bpy.app.version_string,
238
+ 'subjectEntityId': subject, 'memberEntityIds': ids, 'members': members, 'supportEntityId': support,
239
+ 'pose': pose, 'poseMatrixMeters': matrix, 'resultWorldMatrixMeters': result_matrix,
240
+ 'clearanceMm': clearance, 'translationMeters': [0, 0, solution['deltaZ']],
241
+ 'subjectContactEntityId': contact_member, 'subjectContactMeters': solution['subjectContactMeters'],
242
+ 'supportContactMeters': solution['supportContactMeters'], 'placedSubjectContactMeters': solution['placedSubjectContactMeters'],
243
+ 'subjectTriangleIndex': solution['subjectTriangleIndex'], 'supportTriangleIndex': solution['supportTriangleIndex'],
244
+ 'statistics': {'candidatePairs': solution['statistics']['candidatePairs']},
245
+ 'subject': union, 'support': support_capture['metadata']}
246
+ if verification is not None:
247
+ report['baselineVerification'] = verification
248
+ _verified_checkpoint(checkpoint, expected)
249
+ return report, [], []
@@ -0,0 +1,306 @@
1
+ """Fit an explicit static view without changing the evaluated viewing rotation.
2
+
3
+ The perspective solve uses every selected vertex, in camera coordinates. The
4
+ verification path must project the compiled candidate independently of this
5
+ formula; a proposed location alone is never evidence of a successful fit.
6
+ """
7
+ import math
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import struct
13
+
14
+ from deepblend_util import ActionError
15
+
16
+ VERSION = 'deepblend.camera-fit/v1'
17
+ TOLERANCE = .00002
18
+
19
+
20
+ def _refuse(message, code='SCENE_VALIDATION_FAILED'):
21
+ raise ActionError(code, 'Camera fit: '+message)
22
+
23
+
24
+ def fit_perspective_points(points, limits, clipping, margin):
25
+ """Return the new origin in the source camera's normalized local frame.
26
+
27
+ Recenter the vertex bounds and find a distance satisfying all four frustum
28
+ planes and the near clip. Shifted frusta are handled by moving the origin
29
+ opposite their midpoint slope. Far clipping is a hard refusal.
30
+ """
31
+ if (not isinstance(points, (list, tuple)) or len(limits) != 4 or len(clipping) != 2 or
32
+ not all(math.isfinite(x) for x in [*limits, *clipping, margin]) or
33
+ not 0 <= margin <= .25 or not 0 < clipping[0] < clipping[1]):
34
+ raise ValueError('Invalid perspective calibration')
35
+ left, right, bottom, top = limits
36
+ if not left < right or not bottom < top:
37
+ raise ValueError('Degenerate perspective calibration')
38
+ width, height = right-left, top-bottom
39
+ left, right = left+width*margin, right-width*margin
40
+ bottom, top = bottom+height*margin, top-height*margin
41
+ middle_x, middle_y = (left+right)/2, (bottom+top)/2
42
+ half_x, half_y = (right-left)/2, (top-bottom)/2
43
+ low, high, count = [math.inf]*3, [-math.inf]*3, 0
44
+ for point in points:
45
+ count += 1
46
+ if count > 2_000_000 or len(point) != 3 or not all(math.isfinite(x) and abs(x) <= 1e6 for x in point):
47
+ raise ValueError('Invalid or excessive evaluated vertices')
48
+ for axis in range(3):
49
+ low[axis], high[axis] = min(low[axis], point[axis]), max(high[axis], point[axis])
50
+ if count == 0:
51
+ raise ValueError('No evaluated vertices')
52
+ center = [(low[i]+high[i])/2 for i in range(3)]
53
+ distance = 0.0
54
+ for point in points:
55
+ x, y, z = [point[i]-center[i] for i in range(3)]
56
+ distance = max(distance, (x+right*z)/half_x, -(x+left*z)/half_x,
57
+ (y+top*z)/half_y, -(y+bottom*z)/half_y,
58
+ clipping[0]+z)
59
+ # Recompilation and matrix operations use Blender's single-precision values.
60
+ distance += max(.0001, distance*1e-5)
61
+ nearest, farthest = distance-(high[2]-center[2]), distance-(low[2]-center[2])
62
+ if not math.isfinite(distance) or farthest >= clipping[1]-.00001:
63
+ raise ValueError('The complete selection cannot fit inside the camera clip range')
64
+ return {'origin': [center[0]-middle_x*distance, center[1]-middle_y*distance, center[2]+distance],
65
+ 'center': center, 'distanceMeters': distance,
66
+ 'nearestDepthMeters': nearest, 'farthestDepthMeters': farthest,
67
+ 'vertexCount': count}
68
+
69
+
70
+ def _hash_file(path, expected):
71
+ from deepblend_inspect_geometry import file_digest
72
+ if not isinstance(path, str) or not os.path.isfile(path) or file_digest(path) != expected:
73
+ _refuse('The checkpoint differs from its pinned native source.')
74
+
75
+
76
+ def _camera_settings(camera):
77
+ data = camera.data
78
+ if (data.type != 'PERSP' or data.dof.aperture_blades != 0 or
79
+ data.dof.aperture_rotation != 0 or data.dof.aperture_ratio != 1):
80
+ _refuse('This camera has unsupported projection or aperture settings.', 'BLENDER_UNSUPPORTED_ACTION')
81
+ return {'lens': float(data.lens), 'sensorWidth': float(data.sensor_width),
82
+ 'sensorHeight': float(data.sensor_height), 'sensorFit': data.sensor_fit,
83
+ 'shift': [float(data.shift_x), float(data.shift_y)],
84
+ 'clipping': [float(data.clip_start), float(data.clip_end)],
85
+ **({'fStop': float(data.dof.aperture_fstop)} if data.dof.use_dof else {})}
86
+
87
+
88
+ def canonical_face(indices):
89
+ """Face order and cyclic starting corners do not change oriented topology."""
90
+ values = tuple(indices)
91
+ if not values:
92
+ _refuse('An evaluated face has no corners.')
93
+ start = min(range(len(values)), key=values.__getitem__)
94
+ return values[start:]+values[:start]
95
+
96
+
97
+ def _capture(path, expected, selection, camera_id):
98
+ import bpy
99
+ from mathutils import Vector
100
+ from bpy_extras.object_utils import world_to_camera_view
101
+ from deepblend_geometry_framing import framing_camera
102
+ _hash_file(path, expected)
103
+ bpy.ops.wm.open_mainfile(filepath=path, load_ui=False, use_scripts=False)
104
+ scene = bpy.context.scene
105
+ if len(scene.objects) > 100_000:
106
+ _refuse('The native scene exceeds the original-object verification budget.', 'SCENE_TOO_HEAVY')
107
+ if bpy.data.libraries or abs(scene.unit_settings.scale_length-1) > 1e-8:
108
+ _refuse('A self-contained checkpoint with metre units is required.', 'BLENDER_UNSUPPORTED_ACTION')
109
+ scene.frame_set(selection['frame'])
110
+ if scene.frame_current != selection['frame']:
111
+ _refuse('The requested frame was not evaluated.')
112
+ graph = bpy.context.evaluated_depsgraph_get()
113
+ inverse, limits, _ = framing_camera(scene, graph, camera_id, selection['margin'])
114
+ camera = bpy.data.objects['db_camera__'+camera_id].evaluated_get(graph)
115
+ settings = _camera_settings(camera)
116
+ matrix = camera.matrix_world.normalized().copy()
117
+ if (matrix.to_3x3().determinant() < 0 or
118
+ any(abs(matrix.col[a].to_3d().dot(matrix.col[b].to_3d())) > 1e-6
119
+ for a in range(3) for b in range(a+1, 3))):
120
+ _refuse('A nonsheared viewing rotation is required.', 'BLENDER_UNSUPPORTED_ACTION')
121
+ render_visible, visited, pending = set(), set(), [scene.collection]
122
+ while pending:
123
+ collection = pending.pop()
124
+ pointer = collection.as_pointer()
125
+ if pointer in visited or collection.hide_render:
126
+ continue
127
+ visited.add(pointer)
128
+ render_visible.update(obj.as_pointer() for obj in collection.objects)
129
+ pending.extend(collection.children)
130
+ parts, members, total_vertices, total_polygons = [], [], 0, 0
131
+ owned = set()
132
+ all_world = []
133
+ asset_ids = selection.get('assetInstanceIds', [])
134
+ for entity_id in selection['entityIds']:
135
+ obj = bpy.data.objects.get('db_entity__'+entity_id)
136
+ if obj is None:
137
+ _refuse('The selected native entity does not exist: '+entity_id, 'SCENE_SPEC_INVALID')
138
+ if entity_id not in asset_ids and (obj.type != 'MESH' or obj.children):
139
+ _refuse('Select whole mesh entities or explicit asset instances.', 'BLENDER_UNSUPPORTED_ACTION')
140
+ pending, hierarchy, meshes = [obj], [], []
141
+ while pending:
142
+ part = pending.pop()
143
+ pointer = part.as_pointer()
144
+ if pointer in owned or len(owned) >= 4096:
145
+ _refuse('Overlapping selections or an excessive hierarchy cannot be fitted.', 'BLENDER_UNSUPPORTED_ACTION')
146
+ owned.add(pointer)
147
+ if part != obj and part.name.startswith(('db_entity__', 'db_camera__', 'db_light__')):
148
+ if not (part.name.startswith(obj.name+'_') and part.get('deepblend_id') == entity_id and
149
+ part.get('deepblend_kind') == 'entity' and isinstance(part.get('deepblend_part_id'), str) and part['deepblend_part_id']):
150
+ _refuse('An asset hierarchy includes another managed entity.', 'BLENDER_UNSUPPORTED_ACTION')
151
+ if (part.type not in ('EMPTY', 'MESH') or part.instance_type != 'NONE' or
152
+ not part.visible_get(view_layer=bpy.context.view_layer) or part.hide_render or
153
+ pointer not in render_visible):
154
+ _refuse('Hidden, instanced or nonmesh hierarchy parts cannot be silently omitted.', 'BLENDER_UNSUPPORTED_ACTION')
155
+ if part.rigid_body is not None or part.rigid_body_constraint is not None:
156
+ _refuse('Physics-dependent geometry is outside a static fitted view.', 'BLENDER_UNSUPPORTED_ACTION')
157
+ for modifier in part.modifiers:
158
+ if (modifier.show_viewport != modifier.show_render or
159
+ modifier.type in ('CLOTH', 'SOFT_BODY', 'FLUID', 'PARTICLE_SYSTEM', 'NODES') or
160
+ modifier.type == 'SUBSURF' and modifier.levels != modifier.render_levels):
161
+ _refuse('The selected viewport geometry does not provide a bounded render-equivalent mesh.', 'BLENDER_UNSUPPORTED_ACTION')
162
+ hierarchy.append([part.name, part.parent.name if part.parent else None, part.type])
163
+ if part.type == 'MESH':
164
+ meshes.append(part)
165
+ pending.extend(sorted(part.children, key=lambda p: p.name, reverse=True))
166
+ if not meshes or len(parts)+len(meshes) > 128:
167
+ _refuse('Select 1–128 mesh objects in total.', 'SCENE_TOO_HEAVY')
168
+ world, low, high, digest = [], [math.inf]*3, [-math.inf]*3, hashlib.sha256()
169
+ for part in meshes:
170
+ evaluated = part.evaluated_get(graph)
171
+ mesh = evaluated.to_mesh()
172
+ try:
173
+ if mesh is None or not mesh.vertices or not mesh.polygons:
174
+ _refuse('A selected mesh is empty.')
175
+ total_vertices += len(mesh.vertices)
176
+ total_polygons += len(mesh.polygons)
177
+ if total_vertices > 2_000_000 or total_polygons > 2_000_000:
178
+ _refuse('The complete selection exceeds two million vertices or polygons.', 'SCENE_TOO_HEAVY')
179
+ vertices = []
180
+ for vertex in mesh.vertices:
181
+ point = tuple(float(x) for x in evaluated.matrix_world @ vertex.co)
182
+ if not all(math.isfinite(x) and abs(x) <= 1e6 for x in point):
183
+ _refuse('Nonfinite or excessive world coordinates.')
184
+ digest.update(struct.pack('<3d', *point))
185
+ vertices.append(point)
186
+ for axis in range(3):
187
+ low[axis], high[axis] = min(low[axis], point[axis]), max(high[axis], point[axis])
188
+ topology = sorted(canonical_face(p.vertices) for p in mesh.polygons)
189
+ digest.update(json.dumps(topology, separators=(',', ':')).encode())
190
+ parts.append({'name': part.name, 'vertices': vertices, 'topology': topology})
191
+ world.extend(vertices)
192
+ finally:
193
+ evaluated.to_mesh_clear()
194
+ projected = [world_to_camera_view(scene, camera, Vector(p)) for p in world]
195
+ nearest, farthest = min(p.z for p in projected), max(p.z for p in projected)
196
+ projection = {'containedInClip': nearest >= settings['clipping'][0] and farthest <= settings['clipping'][1],
197
+ 'nearestDepthMeters': nearest, 'farthestDepthMeters': farthest,
198
+ 'containedInFrame': False}
199
+ if nearest > 1e-8:
200
+ minimum, maximum = [min(p[i] for p in projected) for i in range(2)], [max(p[i] for p in projected) for i in range(2)]
201
+ projection.update(normalizedBounds={'min': minimum, 'max': maximum},
202
+ containedInFrame=all(minimum[i] >= selection['margin'] and maximum[i] <= 1-selection['margin'] for i in range(2)))
203
+ members.append({'entityId': entity_id, 'meshObjectCount': len(meshes), 'vertexCount': len(world),
204
+ 'worldBoundsMeters': {'min': low, 'max': high}, 'geometrySha256': digest.hexdigest(),
205
+ 'projection': projection, 'hierarchy': hierarchy})
206
+ all_world.extend(world)
207
+ # Preserve original scene objects, cameras and lights at this same frame.
208
+ states = {}
209
+ for obj in scene.objects:
210
+ evaluated = obj.evaluated_get(graph)
211
+ state = {'type': obj.type, 'matrix': [list(row) for row in evaluated.matrix_world]}
212
+ if obj.type == 'CAMERA':
213
+ from deepblend_render import _camera_facts
214
+ state['camera'] = _camera_facts(scene, obj)
215
+ if obj.type == 'LIGHT':
216
+ state['light'] = {'type': evaluated.data.type, 'energy': float(evaluated.data.energy), 'color': list(evaluated.data.color)}
217
+ states[obj.name] = state
218
+ view = {'resolution': [scene.render.resolution_x, scene.render.resolution_y],
219
+ 'percentage': scene.render.resolution_percentage,
220
+ 'pixelAspect': [float(scene.render.pixel_aspect_x), float(scene.render.pixel_aspect_y)],
221
+ 'activeCamera': scene.camera.name if scene.camera else None}
222
+ _hash_file(path, expected)
223
+ return {'matrix': matrix, 'inverse': inverse, 'limits': limits, 'settings': settings,
224
+ 'world': all_world, 'parts': parts, 'members': members, 'states': states, 'view': view,
225
+ 'focusDistance': float(camera.data.dof.focus_distance),
226
+ 'focusObject': camera.data.dof.focus_object.name if camera.data.dof.focus_object else None}
227
+
228
+
229
+ def _near_tree(a, b):
230
+ if isinstance(a, (int, float)) and not isinstance(a, bool):
231
+ return isinstance(b, (int, float)) and math.isfinite(a) and math.isfinite(b) and abs(a-b) <= 1e-6
232
+ if isinstance(a, dict):
233
+ return isinstance(b, dict) and a.keys() == b.keys() and all(_near_tree(a[k], b[k]) for k in a)
234
+ if isinstance(a, (list, tuple)):
235
+ return isinstance(b, (list, tuple)) and len(a) == len(b) and all(_near_tree(x, y) for x, y in zip(a, b))
236
+ return type(a) is type(b) and a == b
237
+
238
+
239
+ def inspect_camera_fit(request, options):
240
+ """Read-only proposal or independent candidate verification in a private batch."""
241
+ import bpy
242
+ from mathutils import Vector
243
+ payload = request.get('payload')
244
+ required = {'entityIds', 'assetInstanceIds', 'frame', 'cameraId', 'fittedCameraId', 'margin', 'checkpointSha256'}
245
+ baseline_keys = {'baselineCheckpointPath', 'baselineCheckpointSha256'}
246
+ candidate = isinstance(payload, dict) and bool(baseline_keys & payload.keys())
247
+ if (not isinstance(payload, dict) or set(payload) != required | (baseline_keys if candidate else set()) or
248
+ not isinstance(payload.get('entityIds'), list) or not 1 <= len(payload['entityIds']) <= 64 or
249
+ len(set(payload['entityIds'])) != len(payload['entityIds']) or
250
+ not isinstance(payload.get('assetInstanceIds'), list) or len(set(payload['assetInstanceIds'])) != len(payload['assetInstanceIds']) or
251
+ not set(payload['assetInstanceIds']) <= set(payload['entityIds']) or
252
+ any(not isinstance(x, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9._-]{0,127}', x)
253
+ for x in [*payload['entityIds'], payload.get('cameraId'), payload.get('fittedCameraId')]) or
254
+ payload['cameraId'] == payload['fittedCameraId'] or
255
+ type(payload.get('frame')) is not int or not 0 <= payload['frame'] <= 1_000_000 or
256
+ type(payload.get('margin')) not in (int, float) or not math.isfinite(payload['margin']) or not 0 <= payload['margin'] <= .25 or
257
+ not isinstance(payload.get('checkpointSha256'), str) or not re.fullmatch(r'[a-f0-9]{64}', payload['checkpointSha256']) or
258
+ candidate and (not isinstance(payload.get('baselineCheckpointSha256'), str) or not re.fullmatch(r'[a-f0-9]{64}', payload['baselineCheckpointSha256']))):
259
+ _refuse('Expected one bounded frame, camera, distinct new camera and complete selection.', 'SCENE_SPEC_INVALID')
260
+ path = options.get('blend')
261
+ measured = _capture(path, payload['checkpointSha256'], payload, payload['fittedCameraId'] if candidate else payload['cameraId'])
262
+ report = {'schemaVersion': VERSION, **{k: payload[k] for k in required},
263
+ 'checkpointUnchanged': True, 'blenderVersion': bpy.app.version_string,
264
+ 'members': [{k: v for k, v in member.items() if k != 'hierarchy'} for member in measured['members']],
265
+ 'view': measured['view'], 'camera': {'id': payload['fittedCameraId'] if candidate else payload['cameraId'],
266
+ **measured['settings'], 'matrixWorld': [list(row) for row in measured['matrix']],
267
+ 'focusDistance': measured['focusDistance'], 'focusObject': measured['focusObject']}}
268
+ if candidate:
269
+ baseline = _capture(payload['baselineCheckpointPath'], payload['baselineCheckpointSha256'], payload, payload['cameraId'])
270
+ if not all(m['projection']['containedInFrame'] and m['projection']['containedInClip'] for m in measured['members']):
271
+ _refuse('The compiled candidate does not contain the complete selection.')
272
+ if len(baseline['parts']) != len(measured['parts']):
273
+ _refuse('The candidate changed selected mesh ownership.')
274
+ max_error = 0.0
275
+ for a, b in zip(baseline['parts'], measured['parts']):
276
+ if a['name'] != b['name'] or a['topology'] != b['topology'] or len(a['vertices']) != len(b['vertices']):
277
+ _refuse('The candidate changed selected geometry topology.')
278
+ for p, q in zip(a['vertices'], b['vertices']):
279
+ max_error = max(max_error, math.dist(p, q))
280
+ original_states = dict(measured['states'])
281
+ new_state = original_states.pop('db_camera__'+payload['fittedCameraId'], None)
282
+ if (max_error > TOLERANCE or new_state is None or not _near_tree(original_states, baseline['states']) or
283
+ not _near_tree(measured['view'], baseline['view']) or
284
+ not _near_tree(measured['settings'], baseline['settings']) or
285
+ any(abs(measured['matrix'][i][j]-baseline['matrix'][i][j]) > 1e-6 for i in range(3) for j in range(3))):
286
+ _refuse('The candidate changed original geometry, photography or viewing direction.')
287
+ report['baselineVerification'] = {'checkpointSha256': payload['baselineCheckpointSha256'], 'checkpointUnchanged': True,
288
+ 'selectedGeometryUnchanged': True, 'originalObjectsUnchanged': True, 'opticsAndRotationPreserved': True,
289
+ 'maxVertexErrorMeters': max_error, 'toleranceMeters': TOLERANCE,
290
+ 'originalObjectCount': len(baseline['states'])}
291
+ else:
292
+ if 'db_camera__'+payload['fittedCameraId'] in measured['states']:
293
+ _refuse('The new camera ID is already in use.', 'SCENE_SPEC_INVALID')
294
+ try:
295
+ fitted = fit_perspective_points([list(measured['inverse'] @ Vector(p)) for p in measured['world']],
296
+ measured['limits'], measured['settings']['clipping'], payload['margin'])
297
+ except ValueError as exc:
298
+ _refuse(str(exc))
299
+ camera = {'id': payload['fittedCameraId'], 'role': 'detail', **measured['settings'],
300
+ 'transform': {'location': list(measured['matrix'] @ Vector(fitted['origin'])),
301
+ 'rotationEuler': list(measured['matrix'].to_euler('XYZ')), 'scale': [1, 1, 1]}}
302
+ if 'fStop' in camera:
303
+ camera['focusDistance'] = fitted['distanceMeters']
304
+ report['proposal'] = camera
305
+ _hash_file(path, payload['checkpointSha256'])
306
+ return report, [], []
@@ -892,6 +892,9 @@ def build_camera(spec, entity_positions):
892
892
  data = bpy.data.cameras.new(name="%s%s" % (CAMERA_PREFIX, camera_id))
893
893
  data.lens = float(spec.get("lens", 50.0))
894
894
  data.sensor_width = float(spec.get("sensorWidth", 36.0))
895
+ data.sensor_height = float(spec.get("sensorHeight", 24.0))
896
+ data.sensor_fit = spec.get("sensorFit", "AUTO")
897
+ data.shift_x, data.shift_y = spec.get("shift", [0.0, 0.0])
895
898
  clipping = spec.get("clipping")
896
899
  if clipping:
897
900
  data.clip_start = float(clipping[0])
@@ -947,7 +950,9 @@ def build_camera(spec, entity_positions):
947
950
  focus_object = None
948
951
  if target_entity is not None:
949
952
  focus_object = bpy.data.objects.get("%s%s" % (ENTITY_PREFIX, target_entity))
950
- if focus_object is not None:
953
+ if spec.get("focusDistance") is not None:
954
+ data.dof.focus_distance = float(spec["focusDistance"])
955
+ elif focus_object is not None:
951
956
  # Focusing the object rather than a distance keeps a MOVING subject
952
957
  # sharp for the whole shot instead of only at the frame it was
953
958
  # measured on.
@@ -520,7 +520,8 @@ def _capture_checkpoint(path, expected, frame, subject_id, support_id):
520
520
  render_visible.update(item.as_pointer() for item in collection.objects)
521
521
  pending_collections.extend(collection.children)
522
522
  result = []
523
- for entity_id in (subject_id, support_id):
523
+ entity_ids = [*subject_id, support_id] if isinstance(subject_id, list) else [subject_id, support_id]
524
+ for entity_id in entity_ids:
524
525
  obj = bpy.data.objects.get('db_entity__' + entity_id)
525
526
  if obj is None:
526
527
  raise ActionError('SCENE_SPEC_INVALID', 'The selected native entity does not exist: ' + entity_id)
@@ -559,6 +560,9 @@ def inspect_surface_placement(request, options):
559
560
  """Inspect a hash-bound checkpoint, optionally proving a saved rigid move."""
560
561
  import bpy
561
562
  payload = request.get('payload')
563
+ if isinstance(payload, dict) and payload.get('scope') == 'recipe-assembly':
564
+ from deepblend_assembly_placement import inspect_assembly_placement
565
+ return inspect_assembly_placement(request, options)
562
566
  allowed = {'subjectEntityId', 'supportEntityId', 'frame', 'clearanceMm', 'checkpointSha256',
563
567
  'baselineCheckpointPath', 'baselineCheckpointSha256', 'expectedTranslationMeters'}
564
568
  if not isinstance(payload, dict) or set(payload) - allowed: