@deepblend/dsh-blender-provider-local 0.14.0 → 0.16.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 +57 -21
- package/package.json +2 -2
- package/python/bootstrap.py +12 -4
- package/python/deepblend_frames.py +72 -7
- package/python/deepblend_geometry_framing.py +68 -0
- package/python/deepblend_images.py +19 -5
- package/python/deepblend_inspect_geometry.py +90 -1
- package/python/deepblend_scatter.py +42 -4
- package/python/deepblend_scene.py +6 -1
package/lib/index.js
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
BlenderWarningCode,
|
|
45
45
|
assertKnownConfigKeys,
|
|
46
46
|
managedBlenderCandidates,
|
|
47
|
+
normalizeShotCameraSelection,
|
|
47
48
|
resolveWorkspaceRoot,
|
|
48
49
|
warning,
|
|
49
50
|
} from '@deepblend/dsh-blender-contracts'
|
|
@@ -55,6 +56,26 @@ export const BLENDER_RUNTIME_SERVICE = 'blenderRuntime'
|
|
|
55
56
|
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024
|
|
56
57
|
/** Default cap for the spill file that holds the complete stream. */
|
|
57
58
|
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
|
59
|
+
/** Cumulative decoded scene images; individual image limits remain separate. */
|
|
60
|
+
const DEFAULT_SCENE_IMAGE_MIB = 1024
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build one scrubbed environment without requiring private receiver methods.
|
|
64
|
+
* @param {{ maxSceneImageMiB?: number }} config
|
|
65
|
+
* @param {unknown} jobId
|
|
66
|
+
* @returns {Record<string, string>}
|
|
67
|
+
*/
|
|
68
|
+
function bootstrapEnvironment(config, jobId) {
|
|
69
|
+
return {
|
|
70
|
+
PATH: process.env.PATH ?? '',
|
|
71
|
+
HOME: process.env.HOME ?? '',
|
|
72
|
+
TMPDIR: process.env.TMPDIR ?? '',
|
|
73
|
+
PYTHONUNBUFFERED: '1',
|
|
74
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
75
|
+
DEEPBLEND_JOB_ID: String(jobId ?? ''),
|
|
76
|
+
DEEPBLEND_SCENE_IMAGE_BUDGET_MIB: String(config.maxSceneImageMiB ?? DEFAULT_SCENE_IMAGE_MIB),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
58
79
|
|
|
59
80
|
/** Grace period between SIGTERM and SIGKILL when terminating Blender. */
|
|
60
81
|
const TERMINATE_GRACE_MS = 10_000
|
|
@@ -140,6 +161,8 @@ export const ProviderConfig = z.object({
|
|
|
140
161
|
maxOutputBytes: z.number().default(DEFAULT_MAX_OUTPUT_BYTES),
|
|
141
162
|
/** Cap on the spill file holding the complete stream. */
|
|
142
163
|
maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
|
|
164
|
+
/** Pre-build decoded-image estimate in MiB; scene/tool arguments cannot raise it. */
|
|
165
|
+
maxSceneImageMiB: z.number().min(16).max(8192).step(1).default(DEFAULT_SCENE_IMAGE_MIB),
|
|
143
166
|
/** Extra directories added to the executable allowlist. */
|
|
144
167
|
executableAllowlist: z.array(z.string()).default([]),
|
|
145
168
|
/** How long a capabilities result may be reused before re-probing. */
|
|
@@ -614,6 +637,10 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
614
637
|
// See the host's constructor: a key this row does not read is a startup error, because the schema
|
|
615
638
|
// accepts it silently (SPEC §17's nested groups versus this package's flat keys).
|
|
616
639
|
assertKnownConfigKeys(ProviderConfig, config, 'deepblend-blender-runtime')
|
|
640
|
+
const imageMiB = config.maxSceneImageMiB ?? DEFAULT_SCENE_IMAGE_MIB
|
|
641
|
+
if (!Number.isInteger(imageMiB) || imageMiB < 16 || imageMiB > 8192) {
|
|
642
|
+
throw new Error('maxSceneImageMiB must be an integer from 16 through 8192 MiB.')
|
|
643
|
+
}
|
|
617
644
|
// A CONFIGURED ACTION THAT CANNOT BE ROUTED IS REFUSED AT STARTUP, not ignored. Naming a render
|
|
618
645
|
// here would leave the operator believing their renders now run in their own Blender, while the
|
|
619
646
|
// render quietly kept its own process — the same shape as a key this row does not read, which the
|
|
@@ -623,7 +650,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
623
650
|
throw new Error(
|
|
624
651
|
`sessionActions names ${unroutable.map(action => `"${action}"`).join(', ')}, and this provider can only ` +
|
|
625
652
|
`serve ${SESSION_ROUTABLE_ACTIONS.join(', ')} from a kept session. The renders that must be cancellable ` +
|
|
626
|
-
'(`render_frames`) spawn their own process on purpose: cancelling one kills exactly that render, and a ' +
|
|
653
|
+
'(`render_frames`, `render_shot_frames`) spawn their own process on purpose: cancelling one kills exactly that render, and a ' +
|
|
627
654
|
'request inside a kept session cannot be killed without ending the session — which, attached, is the ' +
|
|
628
655
|
'user\'s own Blender.',
|
|
629
656
|
)
|
|
@@ -859,6 +886,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
859
886
|
}
|
|
860
887
|
}
|
|
861
888
|
|
|
889
|
+
|
|
862
890
|
/**
|
|
863
891
|
* Run one bootstrap action end-to-end.
|
|
864
892
|
*
|
|
@@ -902,6 +930,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
902
930
|
* @param {{ cwd?: string, args?: string[], signal?: AbortSignal }} [options]
|
|
903
931
|
* @returns {Promise<{ pid: number|null, run: (request: object, options?: object) => Promise<object>, close: () => Promise<void> }>}
|
|
904
932
|
*/
|
|
933
|
+
|
|
905
934
|
async openSession(options = {}) {
|
|
906
935
|
if (!existsSync(this.bootstrapPath)) {
|
|
907
936
|
throw new BlenderError(
|
|
@@ -945,6 +974,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
945
974
|
},
|
|
946
975
|
graceMs: TERMINATE_GRACE_MS,
|
|
947
976
|
signal: options.signal,
|
|
977
|
+
env: bootstrapEnvironment(this.config, jobId),
|
|
948
978
|
})
|
|
949
979
|
|
|
950
980
|
const session = new BlenderSession({
|
|
@@ -975,6 +1005,11 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
975
1005
|
* @returns {Promise<{ pid: number|null, run: Function, close: Function }>}
|
|
976
1006
|
*/
|
|
977
1007
|
async attachSession(socketPath, options = {}) {
|
|
1008
|
+
if ((this.config.maxSceneImageMiB ?? DEFAULT_SCENE_IMAGE_MIB) !== DEFAULT_SCENE_IMAGE_MIB) {
|
|
1009
|
+
throw new BlenderError(BlenderErrorCode.UNSUPPORTED_ACTION,
|
|
1010
|
+
'A custom maxSceneImageMiB requires a managed Blender process. Clear sessionSocket, ' +
|
|
1011
|
+
'or use the default 1024 MiB when attaching to an existing Blender.')
|
|
1012
|
+
}
|
|
978
1013
|
if (typeof socketPath !== 'string' || socketPath.length === 0) {
|
|
979
1014
|
throw new BlenderError(
|
|
980
1015
|
BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
@@ -1207,17 +1242,9 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1207
1242
|
},
|
|
1208
1243
|
graceMs: TERMINATE_GRACE_MS,
|
|
1209
1244
|
signal: composedSignal,
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
PATH: process.env.PATH ?? '',
|
|
1214
|
-
HOME: process.env.HOME ?? '',
|
|
1215
|
-
TMPDIR: process.env.TMPDIR ?? '',
|
|
1216
|
-
// Deterministic, unbuffered Python so partial output is never lost.
|
|
1217
|
-
PYTHONUNBUFFERED: '1',
|
|
1218
|
-
PYTHONDONTWRITEBYTECODE: '1',
|
|
1219
|
-
DEEPBLEND_JOB_ID: correlationId,
|
|
1220
|
-
},
|
|
1245
|
+
// The operator budget and scrubbed environment apply to every owned
|
|
1246
|
+
// process; caller payloads and inherited environment cannot raise it.
|
|
1247
|
+
env: bootstrapEnvironment(this.config, correlationId),
|
|
1221
1248
|
})
|
|
1222
1249
|
} catch (cause) {
|
|
1223
1250
|
clearTimeout(timer)
|
|
@@ -1587,6 +1614,8 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1587
1614
|
assertDigest(await digest(snapshot))
|
|
1588
1615
|
const run = await this.runBootstrap({ action: 'inspect_geometry', jobId: request.jobId,
|
|
1589
1616
|
payload: { entityIds: request.entityIds, frame: request.frame, checkpointSha256: expected,
|
|
1617
|
+
...(request.assetInstanceIds === undefined ? {} : { assetInstanceIds: request.assetInstanceIds }),
|
|
1618
|
+
...(request.cameraId === undefined ? {} : { cameraId: request.cameraId, cameraMargin: request.cameraMargin ?? .03 }),
|
|
1590
1619
|
...(request.objectAxes === true ? { objectAxes: true } : {}),
|
|
1591
1620
|
...(request.surfaceSamples === undefined ? {} : { surfaceSamples: request.surfaceSamples }) } },
|
|
1592
1621
|
{ signal: request.signal, session: false, args: ['--blend', snapshot] })
|
|
@@ -1597,6 +1626,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1597
1626
|
if (request.objectAxes === true && run.envelope?.result?.schemaVersion !== 'deepblend.geometry-inspection/v3') {
|
|
1598
1627
|
throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support object-local native bounds. Upgrade DeepBlend; ordinary reads remain available.')
|
|
1599
1628
|
}
|
|
1629
|
+
if ((request.assetInstanceIds !== undefined || request.cameraId !== undefined) && run.envelope?.result?.schemaVersion !== 'deepblend.geometry-inspection/v4') throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support aggregate asset bounds; upgrade DeepBlend.')
|
|
1600
1630
|
const report = verifyGeometryReport(run.envelope?.result, request)
|
|
1601
1631
|
assertDigest(await digest(source))
|
|
1602
1632
|
aborted()
|
|
@@ -1730,6 +1760,18 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1730
1760
|
* processPath: string, argv: string[], startedAt: number, executable: string}>}
|
|
1731
1761
|
*/
|
|
1732
1762
|
async startFrameSequence(request) {
|
|
1763
|
+
return LocalBlenderRuntime.prototype._startFrameSequence.call(this, request, { action: 'render_frames' })
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/** Explicit opt-in; an older bootstrap refuses the distinct action before rendering. */
|
|
1767
|
+
async startShotFrameSequence(request) {
|
|
1768
|
+
if (request?.cameraId != null) throw new BlenderError(BlenderErrorCode.SCENE_SPEC_INVALID,
|
|
1769
|
+
'A shot-camera sequence cannot also select one camera.')
|
|
1770
|
+
const cameraSelection = normalizeShotCameraSelection(request?.cameraSelection, request?.frames)
|
|
1771
|
+
return LocalBlenderRuntime.prototype._startFrameSequence.call(this, request, { action: 'render_shot_frames', cameraSelection })
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
async _startFrameSequence(request, selection) {
|
|
1733
1775
|
if (typeof request?.checkpointPath !== 'string' || request.checkpointPath.length === 0) {
|
|
1734
1776
|
throw new BlenderError(
|
|
1735
1777
|
BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
|
|
@@ -1808,6 +1850,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1808
1850
|
attemptToken: request.attemptToken ?? null,
|
|
1809
1851
|
checkpoint: request.checkpointPath,
|
|
1810
1852
|
cameraId: request.cameraId ?? null,
|
|
1853
|
+
...(selection.cameraSelection === undefined ? {} : { cameraSelection: selection.cameraSelection }),
|
|
1811
1854
|
profileName: request.profileName ?? null,
|
|
1812
1855
|
profile: request.profile ?? {},
|
|
1813
1856
|
frameRange: request.frameRange ?? null,
|
|
@@ -1820,7 +1863,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1820
1863
|
writeFileSync(requestPath, JSON.stringify({
|
|
1821
1864
|
protocolVersion: BLENDER_PROTOCOL_VERSION,
|
|
1822
1865
|
jobId: request.jobId ?? null,
|
|
1823
|
-
action:
|
|
1866
|
+
action: selection.action,
|
|
1824
1867
|
}, null, 2), 'utf8')
|
|
1825
1868
|
|
|
1826
1869
|
const argv = [
|
|
@@ -1859,14 +1902,7 @@ export default class LocalBlenderRuntime extends Service {
|
|
|
1859
1902
|
// timeout indistinguishable from a caller's cancellation in the record.
|
|
1860
1903
|
// Cancellation goes through `handle.terminate()`, which the host calls
|
|
1861
1904
|
// from `cancelJob`.
|
|
1862
|
-
env:
|
|
1863
|
-
PATH: process.env.PATH ?? '',
|
|
1864
|
-
HOME: process.env.HOME ?? '',
|
|
1865
|
-
TMPDIR: process.env.TMPDIR ?? '',
|
|
1866
|
-
PYTHONUNBUFFERED: '1',
|
|
1867
|
-
PYTHONDONTWRITEBYTECODE: '1',
|
|
1868
|
-
DEEPBLEND_JOB_ID: String(request.jobId ?? ''),
|
|
1869
|
-
},
|
|
1905
|
+
env: bootstrapEnvironment(this.config, request.jobId),
|
|
1870
1906
|
})
|
|
1871
1907
|
} catch (cause) {
|
|
1872
1908
|
throw new BlenderError(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-provider-local",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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.16.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
package/python/bootstrap.py
CHANGED
|
@@ -33,6 +33,8 @@ ACTIONS (M3)
|
|
|
33
33
|
at a named SceneSpec render profile, into a persistent
|
|
34
34
|
directory. Long-running and resumable: the host passes only
|
|
35
35
|
the frames the frame ledger says are missing.
|
|
36
|
+
``render_shot_frames`` the same isolated process boundary, with a strict saved-shot
|
|
37
|
+
selection and actual per-frame camera/PNG attribution.
|
|
36
38
|
|
|
37
39
|
ACTIONS (M2)
|
|
38
40
|
------------
|
|
@@ -103,6 +105,7 @@ SUPPORTED_ACTIONS = (
|
|
|
103
105
|
"render_preview",
|
|
104
106
|
"render_views",
|
|
105
107
|
"render_frames",
|
|
108
|
+
"render_shot_frames",
|
|
106
109
|
)
|
|
107
110
|
|
|
108
111
|
# Process exit codes. 0 is reserved for a successfully written success envelope.
|
|
@@ -370,7 +373,7 @@ def action_render_views(request, options):
|
|
|
370
373
|
return payload, guard.warnings, guard.notices
|
|
371
374
|
|
|
372
375
|
|
|
373
|
-
def action_render_frames(request, options):
|
|
376
|
+
def action_render_frames(request, options, require_camera_selection=False):
|
|
374
377
|
"""Render an explicit frame list at a named render profile (M3).
|
|
375
378
|
|
|
376
379
|
The plan travels as a FILE for the same reason the view plan does, and the
|
|
@@ -408,10 +411,14 @@ def action_render_frames(request, options):
|
|
|
408
411
|
plan["events"] = options.get("events") or plan.get("events")
|
|
409
412
|
|
|
410
413
|
guard = Guard()
|
|
411
|
-
payload = render_frames(plan, guard)
|
|
414
|
+
payload = render_frames(plan, guard, require_camera_selection=require_camera_selection)
|
|
412
415
|
return payload, guard.warnings, guard.notices
|
|
413
416
|
|
|
414
417
|
|
|
418
|
+
def action_render_shot_frames(request, options):
|
|
419
|
+
return action_render_frames(request, options, require_camera_selection=True)
|
|
420
|
+
|
|
421
|
+
|
|
415
422
|
def write_process_identity(path, job_id, action, attempt_token=None):
|
|
416
423
|
"""Record this process's own pid, atomically, before any Blender work.
|
|
417
424
|
|
|
@@ -449,11 +456,11 @@ def write_process_identity(path, job_id, action, attempt_token=None):
|
|
|
449
456
|
def _attempt_token_from_plan(action, options):
|
|
450
457
|
"""The attempt token, when the action's input document carries one.
|
|
451
458
|
|
|
452
|
-
|
|
459
|
+
Both frame actions have a plan file; every other action answers None, and the
|
|
453
460
|
identity document simply records no token. Read here rather than threaded
|
|
454
461
|
through every action because the token belongs to the PROCESS, not to the work.
|
|
455
462
|
"""
|
|
456
|
-
if action
|
|
463
|
+
if action not in ("render_frames", "render_shot_frames"):
|
|
457
464
|
return None
|
|
458
465
|
plan_path = options.get("frames")
|
|
459
466
|
if not plan_path or not os.path.isfile(plan_path):
|
|
@@ -492,6 +499,7 @@ ACTIONS = {
|
|
|
492
499
|
"render_preview": action_render_preview,
|
|
493
500
|
"render_views": action_render_views,
|
|
494
501
|
"render_frames": action_render_frames,
|
|
502
|
+
"render_shot_frames": action_render_shot_frames,
|
|
495
503
|
}
|
|
496
504
|
|
|
497
505
|
|
|
@@ -48,6 +48,7 @@ Runs inside Blender's embedded interpreter.
|
|
|
48
48
|
import hashlib
|
|
49
49
|
import json
|
|
50
50
|
import os
|
|
51
|
+
import re
|
|
51
52
|
import time
|
|
52
53
|
|
|
53
54
|
import bpy
|
|
@@ -258,7 +259,55 @@ def verify_frame(path, expected_width, expected_height):
|
|
|
258
259
|
return {"ok": True, "bytes": size, "width": width, "height": height}
|
|
259
260
|
|
|
260
261
|
|
|
261
|
-
def
|
|
262
|
+
def shot_camera_selection(scene, plan, requested, required):
|
|
263
|
+
value = plan.get("cameraSelection")
|
|
264
|
+
if not required:
|
|
265
|
+
if value is not None:
|
|
266
|
+
raise ActionError("SCENE_SPEC_INVALID", "Shot camera selection requires render_shot_frames; the legacy render action never guesses cuts.")
|
|
267
|
+
return None, {}
|
|
268
|
+
fail = lambda message: ActionError("SCENE_SPEC_INVALID", "Shot camera selection: " + message)
|
|
269
|
+
if (not isinstance(value, dict) or set(value) != {"schemaVersion", "mode", "shots"}
|
|
270
|
+
or value.get("schemaVersion") != "deepblend.shot-camera-selection/v1" or value.get("mode") != "shots"
|
|
271
|
+
or not isinstance(value.get("shots"), list) or not 1 <= len(value["shots"]) <= 64
|
|
272
|
+
or plan.get("cameraId") is not None):
|
|
273
|
+
raise fail("an explicit bounded saved-shot selection without a single-camera override is required.")
|
|
274
|
+
if any(isinstance(frame, bool) or not isinstance(frame, int) or abs(frame) > 9007199254740991 for frame in plan.get("frames", [])):
|
|
275
|
+
raise fail("requested frames must be safe integers.")
|
|
276
|
+
shots, seen = [], set()
|
|
277
|
+
for shot in value["shots"]:
|
|
278
|
+
if not isinstance(shot, dict) or set(shot) != {"id", "cameraId", "frameRange"}:
|
|
279
|
+
raise fail("each shot needs exactly id, cameraId and frameRange.")
|
|
280
|
+
for key in ("id", "cameraId"):
|
|
281
|
+
if not isinstance(shot[key], str) or not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9._-]{0,127}", shot[key]):
|
|
282
|
+
raise fail("shot and camera IDs must be saved identifiers.")
|
|
283
|
+
bounds = shot["frameRange"]
|
|
284
|
+
if (shot["id"] in seen or not isinstance(bounds, list) or len(bounds) != 2
|
|
285
|
+
or any(isinstance(v, bool) or not isinstance(v, int) or abs(v) > 9007199254740991 for v in bounds)
|
|
286
|
+
or bounds[0] > bounds[1]):
|
|
287
|
+
raise fail("distinct shots need ordered integer frame ranges.")
|
|
288
|
+
seen.add(shot["id"])
|
|
289
|
+
shots.append({"id": shot["id"], "cameraId": shot["cameraId"], "frameRange": list(bounds)})
|
|
290
|
+
shots.sort(key=lambda shot: (shot["frameRange"][0], shot["id"]))
|
|
291
|
+
for before, after in zip(shots, shots[1:]):
|
|
292
|
+
if after["frameRange"][0] <= before["frameRange"][1]:
|
|
293
|
+
raise fail("saved shot ranges overlap.")
|
|
294
|
+
selection = {"schemaVersion": "deepblend.shot-camera-selection/v1", "mode": "shots", "shots": shots}
|
|
295
|
+
if len(json.dumps(selection, separators=(",", ":")).encode("utf-8")) > 8192:
|
|
296
|
+
raise fail("selection exceeds its 8192-byte metadata budget.")
|
|
297
|
+
# Resolve every named native camera before writing any frame. An absent
|
|
298
|
+
# camera is a refusal, never a fallback to the checkpoint's active camera.
|
|
299
|
+
cameras = {shot["cameraId"]: find_camera(scene, shot["cameraId"])[0] for shot in shots}
|
|
300
|
+
selected = {}
|
|
301
|
+
for frame in requested:
|
|
302
|
+
matches = [shot for shot in shots if shot["frameRange"][0] <= frame <= shot["frameRange"][1]]
|
|
303
|
+
if len(matches) != 1:
|
|
304
|
+
raise fail("frame %d must belong to exactly one saved shot." % frame)
|
|
305
|
+
shot = matches[0]
|
|
306
|
+
selected[frame] = (cameras[shot["cameraId"]], shot["id"], shot["cameraId"])
|
|
307
|
+
return selection, selected
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def render_frames(plan, guard, require_camera_selection=False):
|
|
262
311
|
"""Render every frame in ``plan`` and return the per-frame artifact report.
|
|
263
312
|
|
|
264
313
|
``plan`` is a document (not a flag list) for the same reason the view plan is:
|
|
@@ -293,9 +342,11 @@ def render_frames(plan, guard):
|
|
|
293
342
|
report_progress("open_checkpoint", 2)
|
|
294
343
|
scene = open_checkpoint(checkpoint)
|
|
295
344
|
|
|
296
|
-
|
|
297
|
-
camera
|
|
298
|
-
|
|
345
|
+
camera_selection, selected_cameras = shot_camera_selection(scene, plan, requested, require_camera_selection)
|
|
346
|
+
camera = None
|
|
347
|
+
if camera_selection is None:
|
|
348
|
+
camera, _ = find_camera(scene, plan.get("cameraId"))
|
|
349
|
+
scene.camera = camera
|
|
299
350
|
|
|
300
351
|
profile = plan.get("profile") or {}
|
|
301
352
|
report_progress("configure_render", 4)
|
|
@@ -328,6 +379,11 @@ def render_frames(plan, guard):
|
|
|
328
379
|
percent = 5 + int(90 * index / total)
|
|
329
380
|
report_progress("frame", percent, {"frame": frame, "index": index, "total": total})
|
|
330
381
|
scene.frame_set(frame)
|
|
382
|
+
if camera_selection is not None:
|
|
383
|
+
camera, shot_id, selected_id = selected_cameras[frame]
|
|
384
|
+
# Apply AFTER native animation/marker evaluation; no native file is
|
|
385
|
+
# saved and the legacy single-camera route remains unchanged.
|
|
386
|
+
scene.camera = camera
|
|
331
387
|
path = frame_path(output_directory, frame, prefix, padding)
|
|
332
388
|
# Explicit, absolute, WITH the extension — see the naming trap above.
|
|
333
389
|
scene.render.filepath = path
|
|
@@ -342,6 +398,13 @@ def render_frames(plan, guard):
|
|
|
342
398
|
{"frame": frame, "engine": scene.render.engine},
|
|
343
399
|
)
|
|
344
400
|
elapsed_ms = int((time.time() - started) * 1000)
|
|
401
|
+
camera_evidence = {}
|
|
402
|
+
if camera_selection is not None:
|
|
403
|
+
actual_id = scene.camera.get("deepblend_id") if scene.camera is not None else None
|
|
404
|
+
if actual_id != selected_id:
|
|
405
|
+
append_event(events_path, {"type": "frame_failed", "frame": frame, "expectedCameraId": selected_id, "actualCameraId": actual_id})
|
|
406
|
+
raise ActionError("RENDER_NO_OUTPUT", "Frame %d did not retain its selected shot camera; no camera attribution was accepted." % frame)
|
|
407
|
+
camera_evidence = {"cameraId": actual_id, "shotId": shot_id}
|
|
345
408
|
verdict = verify_frame(path, expected_width, expected_height)
|
|
346
409
|
if not verdict.get("ok"):
|
|
347
410
|
append_event(events_path, {"type": "frame_failed", "frame": frame, "verify": verdict})
|
|
@@ -359,6 +422,7 @@ def render_frames(plan, guard):
|
|
|
359
422
|
"width": verdict["width"],
|
|
360
423
|
"height": verdict["height"],
|
|
361
424
|
"ms": elapsed_ms,
|
|
425
|
+
**camera_evidence,
|
|
362
426
|
}
|
|
363
427
|
entries.append(entry)
|
|
364
428
|
written.append(frame)
|
|
@@ -370,7 +434,7 @@ def render_frames(plan, guard):
|
|
|
370
434
|
for chunk in iter(lambda: stream.read(256 * 1024), b""):
|
|
371
435
|
digest.update(chunk)
|
|
372
436
|
append_event(events_path, {"type": "frame", "frame": frame, "bytes": verdict["bytes"], "ms": elapsed_ms,
|
|
373
|
-
"attemptToken": attempt_token, "sha256": digest.hexdigest()})
|
|
437
|
+
"attemptToken": attempt_token, "sha256": digest.hexdigest(), **camera_evidence})
|
|
374
438
|
|
|
375
439
|
report_progress("frames_done", 97, {"rendered": len(written)})
|
|
376
440
|
append_event(events_path, {"type": "frames_done", "rendered": written})
|
|
@@ -379,8 +443,9 @@ def render_frames(plan, guard):
|
|
|
379
443
|
"renderKind": "frame-sequence",
|
|
380
444
|
"profileName": plan.get("profileName"),
|
|
381
445
|
"checkpoint": checkpoint,
|
|
382
|
-
"cameraId": camera.get("deepblend_id") or camera.name,
|
|
383
|
-
"cameraName": camera.name,
|
|
446
|
+
"cameraId": (camera.get("deepblend_id") or camera.name) if camera_selection is None else None,
|
|
447
|
+
"cameraName": camera.name if camera_selection is None else None,
|
|
448
|
+
**({"cameraSelection": camera_selection} if camera_selection is not None else {}),
|
|
384
449
|
"outputDirectory": output_directory,
|
|
385
450
|
"filePrefix": prefix,
|
|
386
451
|
"padding": padding,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Read-only perspective projection of actual evaluated vertices at one frame."""
|
|
2
|
+
import math
|
|
3
|
+
import bpy
|
|
4
|
+
from deepblend_util import ActionError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def framing_camera(scene, graph, camera_id, margin):
|
|
8
|
+
# A full-camera frustum cannot certify a masked/cropped render region or
|
|
9
|
+
# multiple eye views. Keep manual settings intact and refuse that claim.
|
|
10
|
+
if scene.render.use_border or scene.render.use_multiview:
|
|
11
|
+
raise ActionError('BLENDER_UNSUPPORTED_ACTION', 'Native camera framing does not support render borders or multiview; manual settings are unchanged. Geometry inspection without a camera remains available.')
|
|
12
|
+
obj = bpy.data.objects.get('db_camera__' + camera_id)
|
|
13
|
+
if obj is None or obj.type != 'CAMERA':
|
|
14
|
+
raise ActionError('SCENE_SPEC_INVALID', 'Selected native framing camera does not exist.')
|
|
15
|
+
camera = obj.evaluated_get(graph)
|
|
16
|
+
if camera.data.type != 'PERSP':
|
|
17
|
+
raise ActionError('BLENDER_UNSUPPORTED_ACTION', 'Native vertex framing currently requires a perspective camera.')
|
|
18
|
+
# Blender's camera projection ignores object scale (world_to_camera_view
|
|
19
|
+
# normalizes the matrix too). Respect old projects' manual camera scale.
|
|
20
|
+
normalized = camera.matrix_world.normalized()
|
|
21
|
+
if not math.isfinite(normalized.determinant()) or abs(normalized.determinant()) < 1e-12:
|
|
22
|
+
raise ActionError('SCENE_VALIDATION_FAILED', 'Degenerate native camera transform.')
|
|
23
|
+
inverse = normalized.inverted()
|
|
24
|
+
corners = camera.data.view_frame(scene=scene)
|
|
25
|
+
slopes = [(p.x/-p.z, p.y/-p.z) for p in corners]
|
|
26
|
+
limits = [min(p[0] for p in slopes), max(p[0] for p in slopes), min(p[1] for p in slopes), max(p[1] for p in slopes)]
|
|
27
|
+
if not all(math.isfinite(v) for v in limits) or limits[0] >= limits[1] or limits[2] >= limits[3]:
|
|
28
|
+
raise ActionError('SCENE_VALIDATION_FAILED', 'Invalid native camera frustum.')
|
|
29
|
+
return inverse, limits, {'id': camera_id, 'type': 'PERSP', 'margin': margin,
|
|
30
|
+
'clipStartMeters': float(camera.data.clip_start), 'clipEndMeters': float(camera.data.clip_end)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def project_geometry(obj, aggregate, graph, calibration):
|
|
34
|
+
inverse, (left, right, bottom, top), camera = calibration
|
|
35
|
+
parts = ([obj] + list(obj.children_recursive)) if aggregate else [obj]
|
|
36
|
+
meshes = [p for p in parts if p.type == 'MESH']
|
|
37
|
+
if any(p.hide_render for p in parts):
|
|
38
|
+
return {'status': 'unavailable-render-hidden'}
|
|
39
|
+
low, high, nearest, farthest, count = [math.inf]*2, [-math.inf]*2, math.inf, -math.inf, 0
|
|
40
|
+
for part in meshes:
|
|
41
|
+
evaluated = part.evaluated_get(graph)
|
|
42
|
+
mesh = evaluated.to_mesh()
|
|
43
|
+
try:
|
|
44
|
+
count += len(mesh.vertices) if mesh else 0
|
|
45
|
+
if count > 2000000:
|
|
46
|
+
return {'status': 'geometry-budget-exceeded'}
|
|
47
|
+
if mesh is None or not mesh.vertices:
|
|
48
|
+
raise ActionError('SCENE_VALIDATION_FAILED', 'Geometry disappeared during framing inspection.')
|
|
49
|
+
for vertex in mesh.vertices:
|
|
50
|
+
point = inverse @ (evaluated.matrix_world @ vertex.co)
|
|
51
|
+
depth = -float(point.z)
|
|
52
|
+
if not all(math.isfinite(v) for v in point):
|
|
53
|
+
raise ActionError('SCENE_VALIDATION_FAILED', 'Non-finite native framing vertex.')
|
|
54
|
+
nearest, farthest = min(nearest, depth), max(farthest, depth)
|
|
55
|
+
if depth > 1e-8:
|
|
56
|
+
uv = [(float(point.x)/depth-left)/(right-left), (float(point.y)/depth-bottom)/(top-bottom)]
|
|
57
|
+
for axis in range(2):
|
|
58
|
+
low[axis], high[axis] = min(low[axis], uv[axis]), max(high[axis], uv[axis])
|
|
59
|
+
finally:
|
|
60
|
+
evaluated.to_mesh_clear()
|
|
61
|
+
result = {'status': 'not-fully-in-front' if nearest <= 1e-8 else 'measured',
|
|
62
|
+
'nearestDepthMeters': nearest, 'farthestDepthMeters': farthest,
|
|
63
|
+
'containedInClip': nearest >= camera['clipStartMeters'] and farthest <= camera['clipEndMeters']}
|
|
64
|
+
if result['status'] == 'measured':
|
|
65
|
+
margin = camera['margin']
|
|
66
|
+
result.update(normalizedBounds={'min': low, 'max': high},
|
|
67
|
+
containedInFrame=all(low[i] >= margin and high[i] <= 1-margin for i in range(2)))
|
|
68
|
+
return result
|
|
@@ -11,6 +11,18 @@ SOCKETS = {'baseColor': 'Base Color', 'roughness': 'Roughness', 'metallic': 'Met
|
|
|
11
11
|
'normal': 'Normal', 'alpha': 'Alpha', 'emissionColor': 'Emission Color'}
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def scene_image_budget_bytes():
|
|
15
|
+
"""Read the managed process's operator limit; scene data cannot raise it.
|
|
16
|
+
|
|
17
|
+
This changes only the cumulative scene allocation. Individual image, header,
|
|
18
|
+
format and per-import limits retain their existing resource boundaries.
|
|
19
|
+
"""
|
|
20
|
+
value = os.environ.get('DEEPBLEND_SCENE_IMAGE_BUDGET_MIB', str(MAX_IMAGE_DECODE_BYTES // (1024 * 1024)))
|
|
21
|
+
if not 1 <= len(value) <= 4 or not value.isascii() or not value.isdecimal() or not 16 <= int(value) <= 8192:
|
|
22
|
+
raise ActionError('SCENE_SPEC_INVALID', 'operator scene image budget must be an integer from 16 through 8192 MiB')
|
|
23
|
+
return int(value) * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
14
26
|
def image_asset_path(asset, project_root):
|
|
15
27
|
root = os.path.realpath(project_root or '.')
|
|
16
28
|
path = os.path.realpath(os.path.join(root, asset['path']))
|
|
@@ -26,6 +38,8 @@ def check_scene_image_budget(spec, project_root):
|
|
|
26
38
|
cache of header facts must not collapse their allocation costs. glTF/GLB/OBJ
|
|
27
39
|
image declarations are conservatively counted per imported instance.
|
|
28
40
|
"""
|
|
41
|
+
limit = scene_image_budget_bytes()
|
|
42
|
+
label = '1 GiB' if limit == MAX_IMAGE_DECODE_BYTES else '%d MiB' % (limit // (1024 * 1024))
|
|
29
43
|
assets = {asset['id']: asset for asset in spec.get('assets') or []}
|
|
30
44
|
bindings = []
|
|
31
45
|
environment = (spec.get('world') or {}).get('environment')
|
|
@@ -43,8 +57,8 @@ def check_scene_image_budget(spec, project_root):
|
|
|
43
57
|
if path not in facts:
|
|
44
58
|
facts[path] = check_image_header(path)
|
|
45
59
|
total += facts[path]['decodedBytes']
|
|
46
|
-
if total >
|
|
47
|
-
raise ActionError('ASSET_CONTENT_MISMATCH', 'declared scene images exceed the
|
|
60
|
+
if total > limit:
|
|
61
|
+
raise ActionError('ASSET_CONTENT_MISMATCH', 'declared scene images exceed the %s decoded pixel budget' % label)
|
|
48
62
|
imported_images, imported_facts = 0, {}
|
|
49
63
|
for entity in spec.get('entities') or []:
|
|
50
64
|
if entity.get('type') != 'asset-instance':
|
|
@@ -58,9 +72,9 @@ def check_scene_image_budget(spec, project_root):
|
|
|
58
72
|
imported_facts[key] = inspect(project_root, asset)
|
|
59
73
|
total += imported_facts[key]['decodedBytes']
|
|
60
74
|
imported_images += imported_facts[key]['images']
|
|
61
|
-
if total >
|
|
62
|
-
raise ActionError('ASSET_CONTENT_MISMATCH', 'declared and imported scene images exceed the
|
|
63
|
-
return {'bindings': len(bindings), 'importedImages': imported_images, 'decodedBytes': total}
|
|
75
|
+
if total > limit:
|
|
76
|
+
raise ActionError('ASSET_CONTENT_MISMATCH', 'declared and imported scene images exceed the %s decoded pixel budget' % label)
|
|
77
|
+
return {'bindings': len(bindings), 'importedImages': imported_images, 'decodedBytes': total, 'limitBytes': limit}
|
|
64
78
|
|
|
65
79
|
|
|
66
80
|
def pack_imported_material_images(objects):
|
|
@@ -29,6 +29,72 @@ def object_axis_frame(matrix):
|
|
|
29
29
|
return lengths, basis
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def measure_asset_bounds(obj, graph):
|
|
33
|
+
"""Whole imported instance, never a partial/declared or transformed AABB guess."""
|
|
34
|
+
import bpy
|
|
35
|
+
entry = {'type': 'ASSET_MESH_SET', 'available': False}
|
|
36
|
+
def unavailable(reason):
|
|
37
|
+
return {**entry, 'unavailableReason': reason}
|
|
38
|
+
pending, parts, visited = [obj], [], set()
|
|
39
|
+
while pending:
|
|
40
|
+
part = pending.pop()
|
|
41
|
+
if part.as_pointer() in visited or len(visited) >= 4096:
|
|
42
|
+
return unavailable('unsupported-hierarchy')
|
|
43
|
+
visited.add(part.as_pointer())
|
|
44
|
+
if part != obj and part.name.startswith(('db_entity__', 'db_camera__', 'db_light__')):
|
|
45
|
+
# Reused imports carry the owning entity prefix on their parts.
|
|
46
|
+
# Only that explicit ownership/part marker is exempt; another
|
|
47
|
+
# entity root or an unmarked manual object remains unsupported.
|
|
48
|
+
owner = obj.get('deepblend_id')
|
|
49
|
+
own_part = (isinstance(owner, str) and bool(owner)
|
|
50
|
+
and part.name.startswith(obj.name + '_')
|
|
51
|
+
and part.get('deepblend_id') == owner
|
|
52
|
+
and part.get('deepblend_kind') == 'entity'
|
|
53
|
+
and isinstance(part.get('deepblend_part_id'), str)
|
|
54
|
+
and bool(part.get('deepblend_part_id')))
|
|
55
|
+
if not own_part:
|
|
56
|
+
return unavailable('unsupported-hierarchy')
|
|
57
|
+
if part.type not in ('EMPTY', 'MESH') or part.instance_type != 'NONE':
|
|
58
|
+
return unavailable('unsupported-hierarchy')
|
|
59
|
+
if not part.visible_get(view_layer=bpy.context.view_layer):
|
|
60
|
+
return unavailable('viewport-disabled')
|
|
61
|
+
if part.type == 'MESH':
|
|
62
|
+
parts.append(part)
|
|
63
|
+
if len(parts) > 128:
|
|
64
|
+
return unavailable('geometry-budget-exceeded')
|
|
65
|
+
pending.extend(part.children)
|
|
66
|
+
if not parts:
|
|
67
|
+
return unavailable('empty-or-invalid-geometry')
|
|
68
|
+
low, high, vertices, polygons = [math.inf]*3, [-math.inf]*3, 0, 0
|
|
69
|
+
for part in parts:
|
|
70
|
+
evaluated = part.evaluated_get(graph)
|
|
71
|
+
mesh = evaluated.to_mesh()
|
|
72
|
+
try:
|
|
73
|
+
if mesh is None or not mesh.vertices:
|
|
74
|
+
return unavailable('empty-or-invalid-geometry')
|
|
75
|
+
vertices += len(mesh.vertices)
|
|
76
|
+
polygons += len(mesh.polygons)
|
|
77
|
+
if vertices > 2000000 or polygons > 2000000:
|
|
78
|
+
return unavailable('geometry-budget-exceeded')
|
|
79
|
+
for vertex in mesh.vertices:
|
|
80
|
+
point = evaluated.matrix_world @ vertex.co
|
|
81
|
+
if not all(math.isfinite(x) for x in point):
|
|
82
|
+
return unavailable('empty-or-invalid-geometry')
|
|
83
|
+
for axis in range(3):
|
|
84
|
+
low[axis] = min(low[axis], float(point[axis]))
|
|
85
|
+
high[axis] = max(high[axis], float(point[axis]))
|
|
86
|
+
finally:
|
|
87
|
+
evaluated.to_mesh_clear()
|
|
88
|
+
matrix = [[float(x) for x in row] for row in obj.evaluated_get(graph).matrix_world]
|
|
89
|
+
scale = [float(x) for x in obj.scale]
|
|
90
|
+
if not all(math.isfinite(x) for row in matrix for x in row) or not all(math.isfinite(x) for x in scale):
|
|
91
|
+
return unavailable('empty-or-invalid-geometry')
|
|
92
|
+
return {**entry, 'available': True, 'worldBoundsMeters': {'min': low, 'max': high},
|
|
93
|
+
'worldAxisExtentMm': [(high[i]-low[i])*1000 for i in range(3)],
|
|
94
|
+
'worldMatrix': matrix, 'scale': scale, 'vertexCount': vertices,
|
|
95
|
+
'polygonCount': polygons, 'meshObjectCount': len(parts)}
|
|
96
|
+
|
|
97
|
+
|
|
32
98
|
def inspect_geometry(request, options):
|
|
33
99
|
import bpy
|
|
34
100
|
payload = request.get('payload')
|
|
@@ -43,6 +109,17 @@ def inspect_geometry(request, options):
|
|
|
43
109
|
object_axes = payload.get('objectAxes', False)
|
|
44
110
|
if type(object_axes) is not bool:
|
|
45
111
|
raise ActionError('BLENDER_SCRIPT_ERROR', 'objectAxes must be boolean.')
|
|
112
|
+
camera_id = payload.get('cameraId')
|
|
113
|
+
camera_margin = payload.get('cameraMargin', .03)
|
|
114
|
+
if camera_id is not None and (not isinstance(camera_id, str) or re.fullmatch(r'[a-zA-Z][a-zA-Z0-9._-]{0,127}', camera_id) is None or object_axes or 'surfaceSamples' in payload):
|
|
115
|
+
raise ActionError('BLENDER_SCRIPT_ERROR', 'Framing requires one explicit camera without axes or samples.')
|
|
116
|
+
if 'cameraMargin' in payload and camera_id is None or type(camera_margin) not in (int, float) or not math.isfinite(camera_margin) or not 0 <= camera_margin <= .25:
|
|
117
|
+
raise ActionError('BLENDER_SCRIPT_ERROR', 'Framing margin must be finite from 0 to 0.25 and requires cameraId.')
|
|
118
|
+
asset_ids = payload.get('assetInstanceIds')
|
|
119
|
+
if asset_ids is not None and (not isinstance(asset_ids, list) or not asset_ids or
|
|
120
|
+
any(not isinstance(x, str) or x not in ids for x in asset_ids) or len(set(asset_ids)) != len(asset_ids) or
|
|
121
|
+
object_axes or 'surfaceSamples' in payload):
|
|
122
|
+
raise ActionError('BLENDER_SCRIPT_ERROR', 'Asset bounds require distinct selected asset roots without object axes or surface samples.')
|
|
46
123
|
samples = payload.get('surfaceSamples')
|
|
47
124
|
if 'surfaceSamples' in payload:
|
|
48
125
|
from deepblend_inspect_surface import validate_surface_selection
|
|
@@ -66,13 +143,19 @@ def inspect_geometry(request, options):
|
|
|
66
143
|
if measured_frame != frame:
|
|
67
144
|
raise ActionError('SCENE_VALIDATION_FAILED', 'Blender did not evaluate the requested inspection frame.')
|
|
68
145
|
graph = bpy.context.evaluated_depsgraph_get()
|
|
146
|
+
calibration = None
|
|
147
|
+
if camera_id is not None:
|
|
148
|
+
from deepblend_geometry_framing import framing_camera, project_geometry
|
|
149
|
+
calibration = framing_camera(bpy.context.scene, graph, camera_id, camera_margin)
|
|
69
150
|
objects = []
|
|
70
151
|
for entity_id in ids:
|
|
71
152
|
obj = bpy.data.objects.get('db_entity__' + entity_id)
|
|
72
153
|
if obj is None:
|
|
73
154
|
raise ActionError('SCENE_SPEC_INVALID', 'The selected native entity does not exist: ' + entity_id)
|
|
74
155
|
entry = {'entityId': entity_id, 'type': obj.type, 'available': False}
|
|
75
|
-
if
|
|
156
|
+
if asset_ids is not None and entity_id in asset_ids:
|
|
157
|
+
entry = {'entityId': entity_id, **measure_asset_bounds(obj, graph)}
|
|
158
|
+
elif obj.type != 'MESH':
|
|
76
159
|
entry['unavailableReason'] = 'not-mesh'
|
|
77
160
|
elif not obj.visible_get(view_layer=bpy.context.view_layer):
|
|
78
161
|
entry['unavailableReason'] = 'viewport-disabled'
|
|
@@ -114,6 +197,8 @@ def inspect_geometry(request, options):
|
|
|
114
197
|
objectAxisExtentMm=[(local_high[i] - local_low[i]) * 1000 for i in range(3)])
|
|
115
198
|
finally:
|
|
116
199
|
evaluated.to_mesh_clear()
|
|
200
|
+
if calibration is not None and entry['available']:
|
|
201
|
+
entry['cameraProjection'] = project_geometry(obj, asset_ids is not None and entity_id in asset_ids, graph, calibration)
|
|
117
202
|
objects.append(entry)
|
|
118
203
|
report = {'schemaVersion': 'deepblend.geometry-inspection/v1', 'checkpointSha256': expected,
|
|
119
204
|
'checkpointUnchanged': True, 'frame': measured_frame, 'blenderVersion': bpy.app.version_string,
|
|
@@ -125,6 +210,10 @@ def inspect_geometry(request, options):
|
|
|
125
210
|
if object_axes:
|
|
126
211
|
report['schemaVersion'] = 'deepblend.geometry-inspection/v3'
|
|
127
212
|
report['objectAxisBasis'] = 'evaluated-object-axes-scaled'
|
|
213
|
+
if calibration is not None:
|
|
214
|
+
report['camera'] = calibration[2]
|
|
215
|
+
if asset_ids is not None or calibration is not None:
|
|
216
|
+
report['schemaVersion'] = 'deepblend.geometry-inspection/v4'
|
|
128
217
|
if file_digest(checkpoint) != expected:
|
|
129
218
|
raise ActionError('SCENE_VALIDATION_FAILED', 'The checkpoint changed during geometry inspection.')
|
|
130
219
|
return report, [], []
|
|
@@ -51,7 +51,44 @@ def _inside(target, point):
|
|
|
51
51
|
return votes >= 2
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def
|
|
54
|
+
def _world_matrices(objects):
|
|
55
|
+
"""Evaluate reference poses without retaining any visibility changes.
|
|
56
|
+
|
|
57
|
+
Viewport-disabled objects can keep an identity/stale matrix_world after
|
|
58
|
+
their local transform is authored. Scatter has no native modifier
|
|
59
|
+
dependency to make Blender evaluate those hidden reference objects.
|
|
60
|
+
Snapshot their current poses while references and ancestors are enabled;
|
|
61
|
+
keep render visibility untouched and restore every viewport flag.
|
|
62
|
+
"""
|
|
63
|
+
objects = list(dict.fromkeys(objects))
|
|
64
|
+
references = set(objects)
|
|
65
|
+
for obj in objects:
|
|
66
|
+
parent = obj.parent
|
|
67
|
+
while parent is not None and parent not in references:
|
|
68
|
+
references.add(parent)
|
|
69
|
+
parent = parent.parent
|
|
70
|
+
layer = bpy.context.view_layer
|
|
71
|
+
if any(obj.name not in layer.objects for obj in references):
|
|
72
|
+
_refuse('reference transform is outside the active view layer')
|
|
73
|
+
states = [(obj, obj.hide_viewport, obj.hide_get(view_layer=layer)) for obj in references]
|
|
74
|
+
try:
|
|
75
|
+
for obj, hidden, local_hidden in states:
|
|
76
|
+
if hidden:
|
|
77
|
+
obj.hide_viewport = False
|
|
78
|
+
if local_hidden:
|
|
79
|
+
obj.hide_set(False, view_layer=layer)
|
|
80
|
+
layer.update()
|
|
81
|
+
return {obj: obj.matrix_world.copy() for obj in objects}
|
|
82
|
+
finally:
|
|
83
|
+
for obj, hidden, local_hidden in states:
|
|
84
|
+
if obj.hide_get(view_layer=layer) != local_hidden:
|
|
85
|
+
obj.hide_set(local_hidden, view_layer=layer)
|
|
86
|
+
if obj.hide_viewport != hidden:
|
|
87
|
+
obj.hide_viewport = hidden
|
|
88
|
+
layer.update()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _target(entity_id, obj, matrix):
|
|
55
92
|
mesh = obj.data
|
|
56
93
|
bm = bmesh.new()
|
|
57
94
|
try:
|
|
@@ -60,7 +97,7 @@ def _target(entity_id, obj):
|
|
|
60
97
|
_refuse('target "%s" needs a closed, non-degenerate solid mesh' % entity_id)
|
|
61
98
|
finally:
|
|
62
99
|
bm.free()
|
|
63
|
-
points = [
|
|
100
|
+
points = [matrix @ vertex.co for vertex in mesh.vertices]
|
|
64
101
|
if any(not all(math.isfinite(value) for value in point) for point in points):
|
|
65
102
|
_refuse('target has non-finite world coordinates')
|
|
66
103
|
mesh.calc_loop_triangles()
|
|
@@ -104,7 +141,8 @@ def apply_surface_scatter(obj, targets, entry):
|
|
|
104
141
|
_refuse('targets exceed the 131072-polygon surface budget')
|
|
105
142
|
if sum(max(0, len(polygon.vertices) - 2) for _, target in targets for polygon in target.data.polygons) > MAX_TARGET_TRIANGLES:
|
|
106
143
|
_refuse('targets exceed the 262144-triangle surface budget before triangulation')
|
|
107
|
-
|
|
144
|
+
matrices = _world_matrices([obj] + [target for _, target in targets])
|
|
145
|
+
target_records = [_target(key, target, matrices[target]) for key, target in targets]
|
|
108
146
|
if sum(len(target['triangles']) for target in target_records) > MAX_TARGET_TRIANGLES:
|
|
109
147
|
_refuse('targets exceed the 262144-triangle surface budget')
|
|
110
148
|
weighted, areas, total = [], [], 0.0
|
|
@@ -131,7 +169,7 @@ def apply_surface_scatter(obj, targets, entry):
|
|
|
131
169
|
material_indices = [polygon.material_index for polygon in source.polygons]
|
|
132
170
|
materials = list(source.materials)
|
|
133
171
|
uv_maps = [(layer.name, [(item.uv.x, item.uv.y) for item in layer.data]) for layer in source.uv_layers]
|
|
134
|
-
matrix = obj
|
|
172
|
+
matrix = matrices[obj]
|
|
135
173
|
try:
|
|
136
174
|
inverse = matrix.inverted()
|
|
137
175
|
except ValueError:
|
|
@@ -39,6 +39,7 @@ from mathutils import Vector
|
|
|
39
39
|
from deepblend_util import ActionError, Guard, as_text, error_text, report_progress, warning
|
|
40
40
|
from deepblend_asset_bundle import verify_asset_bundle, verify_unbundled_gltf_asset, verify_unbundled_obj_asset
|
|
41
41
|
from deepblend_geometry import create_lathe, create_curve, apply_model_modifiers
|
|
42
|
+
from deepblend_image_headers import MAX_IMAGE_DECODE_BYTES
|
|
42
43
|
from deepblend_images import build_image_maps, build_environment, pack_imported_material_images, check_scene_image_budget
|
|
43
44
|
from deepblend_parts import isolated_import_names, stamp_imported_parts, apply_material_bindings
|
|
44
45
|
from deepblend_anisotropy import build_anisotropy, validate_anisotropy_material, validate_anisotropy_usage, validate_native_material_usage
|
|
@@ -2000,7 +2001,11 @@ def build_scene(spec, options, guard):
|
|
|
2000
2001
|
verify_unbundled_gltf_asset(options.get("project_root") or "", asset)
|
|
2001
2002
|
verify_unbundled_obj_asset(options.get("project_root") or "", asset)
|
|
2002
2003
|
|
|
2003
|
-
check_scene_image_budget(spec, options.get('project_root'))
|
|
2004
|
+
image_budget = check_scene_image_budget(spec, options.get('project_root'))
|
|
2005
|
+
if image_budget['limitBytes'] != MAX_IMAGE_DECODE_BYTES:
|
|
2006
|
+
guard.note('SCENE_COMPILER_DECISION', 'using an explicit operator scene image budget',
|
|
2007
|
+
{'kind': 'scene-image-budget', **image_budget,
|
|
2008
|
+
'allocationPolicy': 'conservative-per-imported-instance'})
|
|
2004
2009
|
report_progress("reset_scene", 5)
|
|
2005
2010
|
scene = reset_scene()
|
|
2006
2011
|
|