@deepblend/dsh-blender-provider-local 0.21.0 → 0.23.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 +71 -1
- package/package.json +2 -2
- package/python/bootstrap.py +4 -1
- package/python/deepblend_product_framing.py +306 -0
- package/python/deepblend_scene.py +6 -1
package/lib/index.js
CHANGED
|
@@ -41,6 +41,9 @@ import {
|
|
|
41
41
|
verifySurfacePlacementReport,
|
|
42
42
|
validateAssemblyPlacementSelection,
|
|
43
43
|
verifyAssemblyPlacementReport,
|
|
44
|
+
CAMERA_FIT_LIMITS,
|
|
45
|
+
validateCameraFitSelection,
|
|
46
|
+
verifyCameraFitReport,
|
|
44
47
|
BlenderError,
|
|
45
48
|
BlenderErrorCode,
|
|
46
49
|
CANDIDATE_RENDER_ENGINES,
|
|
@@ -278,7 +281,7 @@ export const SESSION_ROUTABLE_ACTIONS = Object.freeze([
|
|
|
278
281
|
'render_preview',
|
|
279
282
|
'render_views',
|
|
280
283
|
])
|
|
281
|
-
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'])
|
|
282
285
|
|
|
283
286
|
/**
|
|
284
287
|
* A live Blender, presented as one shape whatever carries the bytes.
|
|
@@ -1723,6 +1726,73 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1723
1726
|
}
|
|
1724
1727
|
}
|
|
1725
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)
|
|
1782
|
+
for (const source of sources) {
|
|
1783
|
+
await verify(source.path, source.sha256)
|
|
1784
|
+
await verify(source.snapshot, source.sha256)
|
|
1785
|
+
}
|
|
1786
|
+
aborted()
|
|
1787
|
+
return { report, envelope: run.envelope, durationMs: run.durationMs, stdout: run.stdout, stderr: run.stderr }
|
|
1788
|
+
} catch (cause) {
|
|
1789
|
+
aborted()
|
|
1790
|
+
throw cause
|
|
1791
|
+
} finally {
|
|
1792
|
+
this._cleanup(directory)
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1726
1796
|
/**
|
|
1727
1797
|
* Render one preview frame from an existing `.blend` checkpoint.
|
|
1728
1798
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-provider-local",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.23.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
package/python/bootstrap.py
CHANGED
|
@@ -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,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
|
|
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.
|