@deepblend/dsh-blender-provider-local 0.2.19 → 0.3.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 +50 -35
- package/package.json +2 -2
- package/python/bootstrap.py +4 -2
- package/python/deepblend_anisotropy.py +110 -0
- package/python/deepblend_asset_bundle.py +193 -0
- package/python/deepblend_avif_headers.py +222 -0
- package/python/deepblend_bridge.py +46 -4
- package/python/deepblend_capabilities.py +7 -0
- package/python/deepblend_frames.py +11 -1
- package/python/deepblend_geometry.py +210 -0
- package/python/deepblend_gltf_images.py +183 -0
- package/python/deepblend_gltf_import.py +95 -0
- package/python/deepblend_gltf_roles.py +56 -0
- package/python/deepblend_image_headers.py +294 -0
- package/python/deepblend_image_roles.py +2 -0
- package/python/deepblend_images.py +203 -0
- package/python/deepblend_imported_image_headers.py +364 -0
- package/python/deepblend_mesh_checks.py +145 -0
- package/python/deepblend_obj_images.py +54 -0
- package/python/deepblend_obj_import.py +58 -0
- package/python/deepblend_obj_resources.py +79 -0
- package/python/deepblend_parts.py +106 -0
- package/python/deepblend_render.py +53 -0
- package/python/deepblend_scene.py +447 -51
- package/python/deepblend_vessel.py +192 -0
- package/python/deepblend_vessel_math.py +116 -0
- package/python/deepblend_vessel_parameters.py +65 -0
- package/python/deepblend_views.py +18 -19
- package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""AVIF item locations and AV1 size metadata, without decoding pixel payloads."""
|
|
2
|
+
from deepblend_image_headers import MAX_IMAGE_PARTS, dimensions
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Fields:
|
|
6
|
+
def __init__(self, reader, start, end):
|
|
7
|
+
self.reader, self.pos, self.end = reader, start, end
|
|
8
|
+
|
|
9
|
+
def take(self, count):
|
|
10
|
+
if count < 0 or self.pos + count > self.end:
|
|
11
|
+
raise ValueError('image item fields escape their box')
|
|
12
|
+
value = self.reader.read(self.pos, count)
|
|
13
|
+
self.pos += count
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
def number(self, count):
|
|
17
|
+
return int.from_bytes(self.take(count), 'big')
|
|
18
|
+
|
|
19
|
+
def done(self):
|
|
20
|
+
if self.pos != self.end:
|
|
21
|
+
raise ValueError('invalid image item field length')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def locations(reader, start, end):
|
|
25
|
+
fields = Fields(reader, start, end)
|
|
26
|
+
header = fields.take(4)
|
|
27
|
+
version = header[0]
|
|
28
|
+
if version not in (0, 1, 2) or any(header[1:]):
|
|
29
|
+
raise ValueError('unsupported image location version')
|
|
30
|
+
sizes = fields.take(2)
|
|
31
|
+
offset_size, length_size = sizes[0] >> 4, sizes[0] & 15
|
|
32
|
+
base_size, index_size = sizes[1] >> 4, sizes[1] & 15
|
|
33
|
+
if any(size > 8 for size in (offset_size, length_size, base_size, index_size)):
|
|
34
|
+
raise ValueError('invalid image location field width')
|
|
35
|
+
count = fields.number(2 if version < 2 else 4)
|
|
36
|
+
if count > MAX_IMAGE_PARTS:
|
|
37
|
+
raise ValueError('too many image item locations')
|
|
38
|
+
items = {}
|
|
39
|
+
for _ in range(count):
|
|
40
|
+
item = fields.number(2 if version < 2 else 4)
|
|
41
|
+
method = fields.number(2) if version else 0
|
|
42
|
+
reference, base, number = fields.number(2), fields.number(base_size), fields.number(2)
|
|
43
|
+
if item in items or method not in (0, 1) or reference or number > MAX_IMAGE_PARTS:
|
|
44
|
+
raise ValueError('unsupported or duplicate image item location')
|
|
45
|
+
extents = []
|
|
46
|
+
for _ in range(number):
|
|
47
|
+
if version and index_size:
|
|
48
|
+
fields.number(index_size)
|
|
49
|
+
offset, length = fields.number(offset_size), fields.number(length_size)
|
|
50
|
+
extents.append((base + offset, length))
|
|
51
|
+
items[item] = (method, extents)
|
|
52
|
+
fields.done()
|
|
53
|
+
return items
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def item_info(reader, start, end, boxes):
|
|
57
|
+
fields = Fields(reader, start, end)
|
|
58
|
+
header = fields.take(4)
|
|
59
|
+
if header[0] not in (0, 1) or any(header[1:]):
|
|
60
|
+
raise ValueError('unsupported image item information version')
|
|
61
|
+
count = fields.number(2 if header[0] == 0 else 4)
|
|
62
|
+
if count > MAX_IMAGE_PARTS:
|
|
63
|
+
raise ValueError('too many image item definitions')
|
|
64
|
+
items = {}
|
|
65
|
+
for kind, pos, stop in boxes(reader, fields.pos, end):
|
|
66
|
+
if kind != b'infe':
|
|
67
|
+
raise ValueError('invalid image item definition')
|
|
68
|
+
entry = Fields(reader, pos, stop)
|
|
69
|
+
version = entry.take(4)[0]
|
|
70
|
+
if version not in (2, 3):
|
|
71
|
+
raise ValueError('unsupported image item definition version')
|
|
72
|
+
item, protection = entry.number(2 if version == 2 else 4), entry.number(2)
|
|
73
|
+
if item in items or protection:
|
|
74
|
+
raise ValueError('protected or duplicate image item')
|
|
75
|
+
items[item] = entry.take(4)
|
|
76
|
+
if len(items) != count:
|
|
77
|
+
raise ValueError('image item count differs from its definitions')
|
|
78
|
+
return items
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ItemReader:
|
|
82
|
+
def __init__(self, reader, location, idat):
|
|
83
|
+
self.reader, self.extents = reader, []
|
|
84
|
+
method, extents = location
|
|
85
|
+
if method == 1 and idat is None:
|
|
86
|
+
raise ValueError('image item requires an inline data box')
|
|
87
|
+
base, limit = idat if method == 1 else (0, reader.size)
|
|
88
|
+
for offset, length in extents:
|
|
89
|
+
if offset < 0 or length <= 0 or base + offset + length > limit:
|
|
90
|
+
raise ValueError('image item payload escapes its data bounds')
|
|
91
|
+
self.extents.append((base + offset, length))
|
|
92
|
+
self.size = sum(length for offset, length in self.extents)
|
|
93
|
+
|
|
94
|
+
def read(self, offset, count):
|
|
95
|
+
if offset < 0 or count < 0 or offset + count > self.size:
|
|
96
|
+
raise ValueError('truncated coded image metadata')
|
|
97
|
+
chunks = []
|
|
98
|
+
for start, length in self.extents:
|
|
99
|
+
if offset >= length:
|
|
100
|
+
offset -= length
|
|
101
|
+
continue
|
|
102
|
+
number = min(count, length - offset)
|
|
103
|
+
chunks.append(self.reader.read(start + offset, number))
|
|
104
|
+
count -= number
|
|
105
|
+
if count == 0:
|
|
106
|
+
break
|
|
107
|
+
offset = 0
|
|
108
|
+
return b''.join(chunks)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Bits:
|
|
112
|
+
def __init__(self, data):
|
|
113
|
+
self.data, self.pos = data, 0
|
|
114
|
+
|
|
115
|
+
def take(self, count):
|
|
116
|
+
if self.pos + count > len(self.data) * 8:
|
|
117
|
+
raise ValueError('truncated AV1 sequence header')
|
|
118
|
+
value = 0
|
|
119
|
+
for _ in range(count):
|
|
120
|
+
value = (value << 1) | ((self.data[self.pos // 8] >> (7 - self.pos % 8)) & 1)
|
|
121
|
+
self.pos += 1
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
def uvlc(self):
|
|
125
|
+
for count in range(32):
|
|
126
|
+
if self.take(1):
|
|
127
|
+
self.take(count)
|
|
128
|
+
return
|
|
129
|
+
# AV1's saturated value is encoded by 32 leading zero bits.
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sequence_size(data):
|
|
133
|
+
"""Read AV1 sequence maxima; they bound even a smaller selected layer."""
|
|
134
|
+
bits = Bits(data)
|
|
135
|
+
profile, still, reduced = bits.take(3), bits.take(1), bits.take(1)
|
|
136
|
+
if profile > 2 or (reduced and not still):
|
|
137
|
+
raise ValueError('invalid AV1 sequence profile')
|
|
138
|
+
if reduced:
|
|
139
|
+
bits.take(5)
|
|
140
|
+
else:
|
|
141
|
+
decoder, delay_bits = False, 0
|
|
142
|
+
if bits.take(1):
|
|
143
|
+
bits.take(64)
|
|
144
|
+
if bits.take(1):
|
|
145
|
+
bits.uvlc()
|
|
146
|
+
decoder = bool(bits.take(1))
|
|
147
|
+
if decoder:
|
|
148
|
+
delay_bits = bits.take(5) + 1
|
|
149
|
+
bits.take(42)
|
|
150
|
+
display = bits.take(1)
|
|
151
|
+
for _ in range(bits.take(5) + 1):
|
|
152
|
+
bits.take(12)
|
|
153
|
+
if bits.take(5) > 7:
|
|
154
|
+
bits.take(1)
|
|
155
|
+
if decoder and bits.take(1):
|
|
156
|
+
bits.take(2 * delay_bits + 1)
|
|
157
|
+
if display and bits.take(1):
|
|
158
|
+
bits.take(4)
|
|
159
|
+
width_bits, height_bits = bits.take(4) + 1, bits.take(4) + 1
|
|
160
|
+
width, height = bits.take(width_bits) + 1, bits.take(height_bits) + 1
|
|
161
|
+
dimensions(width, height)
|
|
162
|
+
return width, height
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def coded_size(item):
|
|
166
|
+
offset, sizes = 0, []
|
|
167
|
+
for _ in range(MAX_IMAGE_PARTS):
|
|
168
|
+
if offset == item.size:
|
|
169
|
+
if not sizes:
|
|
170
|
+
raise ValueError('AV1 image requires a coded sequence header')
|
|
171
|
+
return max(w for w, h in sizes), max(h for w, h in sizes)
|
|
172
|
+
header = item.read(offset, 1)[0]
|
|
173
|
+
offset += 1
|
|
174
|
+
if header & 0x81:
|
|
175
|
+
raise ValueError('invalid AV1 OBU header')
|
|
176
|
+
kind = (header >> 3) & 15
|
|
177
|
+
if header & 4:
|
|
178
|
+
if item.read(offset, 1)[0] & 7:
|
|
179
|
+
raise ValueError('invalid AV1 OBU extension')
|
|
180
|
+
offset += 1
|
|
181
|
+
length = item.size - offset
|
|
182
|
+
if header & 2:
|
|
183
|
+
length = 0
|
|
184
|
+
for index in range(8):
|
|
185
|
+
value = item.read(offset, 1)[0]
|
|
186
|
+
offset += 1
|
|
187
|
+
length |= (value & 127) << (index * 7)
|
|
188
|
+
if not value & 128:
|
|
189
|
+
break
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError('invalid AV1 OBU length')
|
|
192
|
+
if offset + length > item.size:
|
|
193
|
+
raise ValueError('AV1 OBU escapes its image item')
|
|
194
|
+
if kind == 1:
|
|
195
|
+
# Only this metadata OBU is read; encoded frames and tiles are sought past.
|
|
196
|
+
sizes.append(sequence_size(item.read(offset, length)))
|
|
197
|
+
offset += length
|
|
198
|
+
raise ValueError('AV1 image exceeds its metadata OBU limit')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def decoded_item_size(reader, item, item_type, location, idat, extent, dependencies):
|
|
202
|
+
if location is None:
|
|
203
|
+
raise ValueError('decoded image item has no payload location')
|
|
204
|
+
source = ItemReader(reader, location, idat)
|
|
205
|
+
if item_type == b'av01':
|
|
206
|
+
width, height = coded_size(source)
|
|
207
|
+
elif item_type == b'grid':
|
|
208
|
+
header = source.read(0, 4)
|
|
209
|
+
if header[0] or header[1] & ~1 or not dependencies:
|
|
210
|
+
raise ValueError('invalid AVIF grid image')
|
|
211
|
+
size = 4 if header[1] & 1 else 2
|
|
212
|
+
data = source.read(4, size * 2)
|
|
213
|
+
width, height = int.from_bytes(data[:size], 'big'), int.from_bytes(data[size:], 'big')
|
|
214
|
+
if (header[2] + 1) * (header[3] + 1) != len(dependencies):
|
|
215
|
+
raise ValueError('AVIF grid tile count differs from its dependencies')
|
|
216
|
+
elif item_type == b'iden' and len(dependencies) == 1:
|
|
217
|
+
width, height = extent
|
|
218
|
+
else:
|
|
219
|
+
raise ValueError('unsupported decoded AVIF image item type')
|
|
220
|
+
# Container properties may describe a selected layer rather than sequence maxima.
|
|
221
|
+
dimensions(*extent)
|
|
222
|
+
return dimensions(max(width, extent[0]), max(height, extent[1]))
|
|
@@ -30,6 +30,7 @@ Owner: DeepBlend Studio — SPEC §20 M6
|
|
|
30
30
|
|
|
31
31
|
import json
|
|
32
32
|
import os
|
|
33
|
+
import queue
|
|
33
34
|
import socket
|
|
34
35
|
import sys
|
|
35
36
|
import threading
|
|
@@ -132,6 +133,9 @@ class _Bridge:
|
|
|
132
133
|
self.last_action = None
|
|
133
134
|
self.last_error = None
|
|
134
135
|
self.connected = False
|
|
136
|
+
self.pending = queue.Queue()
|
|
137
|
+
self.connection = None
|
|
138
|
+
self.timer = self._pump
|
|
135
139
|
|
|
136
140
|
# -- lifecycle ---------------------------------------------------------
|
|
137
141
|
|
|
@@ -164,6 +168,29 @@ class _Bridge:
|
|
|
164
168
|
self.server.listen(4)
|
|
165
169
|
self.thread = threading.Thread(target=self._serve, name="deepblend-bridge", daemon=True)
|
|
166
170
|
self.thread.start()
|
|
171
|
+
if not bpy.app.background:
|
|
172
|
+
bpy.app.timers.register(self.timer, first_interval=0.01, persistent=True)
|
|
173
|
+
|
|
174
|
+
def _dispatch_pending(self, timeout=0):
|
|
175
|
+
"""Run Blender API calls on the main thread; the socket thread only does I/O."""
|
|
176
|
+
try:
|
|
177
|
+
job = self.pending.get(timeout=timeout)
|
|
178
|
+
except queue.Empty:
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
job["answer"] = bootstrap._session_dispatch(job["request"], self.options)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
job["answer"] = bootstrap.build_envelope(None, None, None, {
|
|
184
|
+
"code": "BLENDER_SCRIPT_ERROR", "message": bootstrap.error_text(exc),
|
|
185
|
+
}, [], [])
|
|
186
|
+
finally:
|
|
187
|
+
job["done"].set()
|
|
188
|
+
|
|
189
|
+
def _pump(self):
|
|
190
|
+
if self.server is None:
|
|
191
|
+
return None
|
|
192
|
+
self._dispatch_pending()
|
|
193
|
+
return 0.01
|
|
167
194
|
|
|
168
195
|
@staticmethod
|
|
169
196
|
def derive_workspace(open_file):
|
|
@@ -204,6 +231,13 @@ class _Bridge:
|
|
|
204
231
|
pass
|
|
205
232
|
|
|
206
233
|
def stop(self):
|
|
234
|
+
if not bpy.app.background and bpy.app.timers.is_registered(self.timer):
|
|
235
|
+
bpy.app.timers.unregister(self.timer)
|
|
236
|
+
if self.connection is not None:
|
|
237
|
+
try:
|
|
238
|
+
self.connection.shutdown(socket.SHUT_RDWR)
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
207
241
|
try:
|
|
208
242
|
if self.server is not None:
|
|
209
243
|
self.server.close()
|
|
@@ -225,12 +259,14 @@ class _Bridge:
|
|
|
225
259
|
except Exception:
|
|
226
260
|
return
|
|
227
261
|
self.connected = True
|
|
262
|
+
self.connection = connection
|
|
228
263
|
try:
|
|
229
264
|
self._converse(connection)
|
|
230
265
|
except Exception as exc:
|
|
231
266
|
self.last_error = bootstrap.error_text(exc)
|
|
232
267
|
finally:
|
|
233
268
|
self.connected = False
|
|
269
|
+
self.connection = None
|
|
234
270
|
try:
|
|
235
271
|
connection.close()
|
|
236
272
|
except Exception:
|
|
@@ -274,7 +310,12 @@ class _Bridge:
|
|
|
274
310
|
return
|
|
275
311
|
self.requests += 1
|
|
276
312
|
self.last_action = request.get("action") if isinstance(request, dict) else None
|
|
277
|
-
|
|
313
|
+
job = {"request": request, "done": threading.Event()}
|
|
314
|
+
self.pending.put(job)
|
|
315
|
+
while not job["done"].wait(0.1):
|
|
316
|
+
if self.server is None:
|
|
317
|
+
return
|
|
318
|
+
answer = job["answer"]
|
|
278
319
|
answer["kind"] = "result"
|
|
279
320
|
connection.sendall((json.dumps(answer, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
280
321
|
|
|
@@ -407,9 +448,10 @@ def main():
|
|
|
407
448
|
return 2
|
|
408
449
|
print(json.dumps({"kind": "ready", "pid": os.getpid(), "socket": socket_path, "workspace": workspace}), flush=True)
|
|
409
450
|
try:
|
|
410
|
-
#
|
|
411
|
-
#
|
|
412
|
-
_BRIDGE.thread.
|
|
451
|
+
# Headless Blender has no UI timer loop. Pump on this main thread instead
|
|
452
|
+
# of joining the socket worker, which would run bpy from the wrong thread.
|
|
453
|
+
while _BRIDGE.thread.is_alive():
|
|
454
|
+
_BRIDGE._dispatch_pending(timeout=0.1)
|
|
413
455
|
except KeyboardInterrupt:
|
|
414
456
|
pass
|
|
415
457
|
finally:
|
|
@@ -366,6 +366,7 @@ def smoke_render(scene, engine):
|
|
|
366
366
|
saved["resolution_percentage"] = render.resolution_percentage
|
|
367
367
|
saved["file_format"] = render.image_settings.file_format
|
|
368
368
|
saved["cycles_samples"] = getattr(scene.cycles, "samples", None)
|
|
369
|
+
saved["cycles_device"] = getattr(scene.cycles, "device", None)
|
|
369
370
|
saved["cycles_use_denoising"] = getattr(scene.cycles, "use_denoising", None)
|
|
370
371
|
saved["eevee_samples"] = getattr(scene.eevee, "taa_render_samples", None)
|
|
371
372
|
|
|
@@ -376,6 +377,10 @@ def smoke_render(scene, engine):
|
|
|
376
377
|
render.resolution_percentage = 100
|
|
377
378
|
render.image_settings.file_format = "PNG"
|
|
378
379
|
if engine == "CYCLES":
|
|
380
|
+
# This measures engine availability. GPU devices are enumerated
|
|
381
|
+
# separately; inheriting an open project's GPU setting can trigger
|
|
382
|
+
# minutes of Metal kernel compilation during a capability query.
|
|
383
|
+
scene.cycles.device = "CPU"
|
|
379
384
|
# 1 sample and no denoise keeps the probe effectively instantaneous.
|
|
380
385
|
try:
|
|
381
386
|
scene.cycles.samples = 1
|
|
@@ -429,6 +434,8 @@ def smoke_render(scene, engine):
|
|
|
429
434
|
render.image_settings.file_format = saved["file_format"]
|
|
430
435
|
if saved.get("cycles_samples") is not None:
|
|
431
436
|
scene.cycles.samples = saved["cycles_samples"]
|
|
437
|
+
if saved.get("cycles_device") is not None:
|
|
438
|
+
scene.cycles.device = saved["cycles_device"]
|
|
432
439
|
if saved.get("cycles_use_denoising") is not None:
|
|
433
440
|
scene.cycles.use_denoising = saved["cycles_use_denoising"]
|
|
434
441
|
if saved.get("eevee_samples") is not None:
|
|
@@ -45,6 +45,7 @@ than trusting that.
|
|
|
45
45
|
Runs inside Blender's embedded interpreter.
|
|
46
46
|
"""
|
|
47
47
|
|
|
48
|
+
import hashlib
|
|
48
49
|
import json
|
|
49
50
|
import os
|
|
50
51
|
import time
|
|
@@ -299,6 +300,10 @@ def render_frames(plan, guard):
|
|
|
299
300
|
profile = plan.get("profile") or {}
|
|
300
301
|
report_progress("configure_render", 4)
|
|
301
302
|
overrides, actual = apply_profile(scene, profile, guard)
|
|
303
|
+
# Persist the read-back settings before any frame can be accepted. A process
|
|
304
|
+
# killed before the final result still leaves its completed frames attributable.
|
|
305
|
+
attempt_token = plan.get("attemptToken")
|
|
306
|
+
append_event(events_path, {"type": "render_config", "attemptToken": attempt_token, "renderConfig": actual})
|
|
302
307
|
expected_width = int(actual["resolution"][0])
|
|
303
308
|
expected_height = int(actual["resolution"][1])
|
|
304
309
|
|
|
@@ -360,7 +365,12 @@ def render_frames(plan, guard):
|
|
|
360
365
|
total_bytes += verdict["bytes"]
|
|
361
366
|
# Written AFTER the file is verified on disk, so the journal never claims
|
|
362
367
|
# a frame the ledger would call missing.
|
|
363
|
-
|
|
368
|
+
digest = hashlib.sha256()
|
|
369
|
+
with open(path, "rb") as stream:
|
|
370
|
+
for chunk in iter(lambda: stream.read(256 * 1024), b""):
|
|
371
|
+
digest.update(chunk)
|
|
372
|
+
append_event(events_path, {"type": "frame", "frame": frame, "bytes": verdict["bytes"], "ms": elapsed_ms,
|
|
373
|
+
"attemptToken": attempt_token, "sha256": digest.hexdigest()})
|
|
364
374
|
|
|
365
375
|
report_progress("frames_done", 97, {"rendered": len(written)})
|
|
366
376
|
append_event(events_path, {"type": "frames_done", "rendered": written})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Deterministic meshes for bounded SceneSpec modeling operations."""
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
import bpy
|
|
5
|
+
from deepblend_util import ActionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_model_modifiers(entities, entity_meshes):
|
|
9
|
+
"""Apply dependency-ordered, bounded mesh operations to actual geometry."""
|
|
10
|
+
by_id = {entity["id"]: entity for entity in entities}
|
|
11
|
+
done, active = set(), set()
|
|
12
|
+
bpy.context.view_layer.update()
|
|
13
|
+
|
|
14
|
+
def single_mesh(entity_id):
|
|
15
|
+
meshes = entity_meshes.get(entity_id, [])
|
|
16
|
+
if len(meshes) != 1:
|
|
17
|
+
raise ActionError("SCENE_SPEC_INVALID", 'modifier entity "%s" needs exactly one mesh' % entity_id)
|
|
18
|
+
return meshes[0]
|
|
19
|
+
|
|
20
|
+
def visit(entity_id):
|
|
21
|
+
if entity_id in done:
|
|
22
|
+
return
|
|
23
|
+
if entity_id in active:
|
|
24
|
+
raise ActionError("SCENE_SPEC_INVALID", "boolean dependency cycle: " + entity_id)
|
|
25
|
+
active.add(entity_id)
|
|
26
|
+
entity = by_id[entity_id]
|
|
27
|
+
for entry in entity.get("modifiers", []):
|
|
28
|
+
kind = entry["type"]
|
|
29
|
+
obj = single_mesh(entity_id)
|
|
30
|
+
if kind == "boolean":
|
|
31
|
+
target = entry["targetEntityId"]
|
|
32
|
+
if target not in by_id:
|
|
33
|
+
raise ActionError("SCENE_SPEC_INVALID", "missing boolean operand: " + target)
|
|
34
|
+
visit(target)
|
|
35
|
+
operand = single_mesh(target)
|
|
36
|
+
estimate = len(obj.data.polygons) * (entry.get("count", 1) if kind == "array" else 1)
|
|
37
|
+
if estimate > 500_000:
|
|
38
|
+
raise ActionError("SCENE_SPEC_INVALID", "modifier exceeds the 500000 polygon per-entity budget")
|
|
39
|
+
modifier = obj.modifiers.new("db_model_" + kind, kind.upper())
|
|
40
|
+
if kind == "solidify":
|
|
41
|
+
modifier.thickness = float(entry["thickness"])
|
|
42
|
+
modifier.offset = float(entry.get("offset", -1))
|
|
43
|
+
modifier.use_even_offset = True
|
|
44
|
+
elif kind == "mirror":
|
|
45
|
+
modifier.use_axis = tuple(axis == entry["axis"] for axis in ("x", "y", "z"))
|
|
46
|
+
modifier.use_mirror_merge = bool(entry.get("merge", True))
|
|
47
|
+
elif kind == "array":
|
|
48
|
+
modifier.count = int(entry["count"])
|
|
49
|
+
modifier.use_relative_offset = False
|
|
50
|
+
modifier.use_constant_offset = True
|
|
51
|
+
modifier.constant_offset_displace = entry["offset"]
|
|
52
|
+
elif kind == "boolean":
|
|
53
|
+
modifier.operation = entry["operation"].upper()
|
|
54
|
+
modifier.solver = "EXACT"
|
|
55
|
+
modifier.object = operand
|
|
56
|
+
elif kind == "bevel":
|
|
57
|
+
modifier.width = float(entry["width"])
|
|
58
|
+
modifier.segments = int(entry.get("segments", 4))
|
|
59
|
+
modifier.limit_method = "ANGLE"
|
|
60
|
+
modifier.angle_limit = math.radians(float(entry.get("angle", 30)))
|
|
61
|
+
modifier.use_clamp_overlap = True
|
|
62
|
+
miter_inner = entry.get("miterInner", "arc")
|
|
63
|
+
if miter_inner not in ("arc", "sharp"):
|
|
64
|
+
raise ActionError("SCENE_SPEC_INVALID", "bevel miterInner must be arc or sharp")
|
|
65
|
+
modifier.miter_inner = {"arc": "MITER_ARC", "sharp": "MITER_SHARP"}[miter_inner]
|
|
66
|
+
modifier.spread = float(entry["width"])
|
|
67
|
+
for selected in bpy.context.selected_objects:
|
|
68
|
+
selected.select_set(False)
|
|
69
|
+
# Hidden operands still need their own modifier stack evaluated.
|
|
70
|
+
hidden = obj.hide_viewport
|
|
71
|
+
obj.hide_viewport = False
|
|
72
|
+
obj.select_set(True)
|
|
73
|
+
bpy.context.view_layer.objects.active = obj
|
|
74
|
+
try:
|
|
75
|
+
result = bpy.ops.object.modifier_apply(modifier=modifier.name)
|
|
76
|
+
finally:
|
|
77
|
+
obj.hide_viewport = hidden
|
|
78
|
+
if "FINISHED" not in result or not obj.data.polygons:
|
|
79
|
+
raise ActionError("SCENE_SPEC_INVALID", 'modifier "%s" on "%s" produced no mesh' % (kind, entity_id))
|
|
80
|
+
if len(obj.data.polygons) > 500_000:
|
|
81
|
+
raise ActionError("SCENE_SPEC_INVALID", "modifier result exceeds the polygon budget")
|
|
82
|
+
bpy.context.view_layer.update()
|
|
83
|
+
active.remove(entity_id)
|
|
84
|
+
done.add(entity_id)
|
|
85
|
+
|
|
86
|
+
for entity in entities:
|
|
87
|
+
visit(entity["id"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_curve(name, spec):
|
|
91
|
+
"""Sweep a round section along a polyline or automatic Bezier spline."""
|
|
92
|
+
data = bpy.data.curves.new(name + "_path", "CURVE")
|
|
93
|
+
data.dimensions = "3D"
|
|
94
|
+
data.resolution_u = int(spec.get("curveResolution", 16))
|
|
95
|
+
data.render_resolution_u = data.resolution_u
|
|
96
|
+
data.bevel_depth = float(spec.get("radius", 0.01))
|
|
97
|
+
data.bevel_resolution = int(spec.get("bevelResolution", 8))
|
|
98
|
+
data.use_fill_caps = bool(spec.get("capEnds", True))
|
|
99
|
+
bezier = spec.get("pathInterpolation", "poly") == "bezier"
|
|
100
|
+
spline = data.splines.new("BEZIER" if bezier else "POLY")
|
|
101
|
+
points = spline.bezier_points if bezier else spline.points
|
|
102
|
+
points.add(len(spec["path"]) - 1)
|
|
103
|
+
for point, coordinate in zip(points, spec["path"]):
|
|
104
|
+
point.co = coordinate if bezier else (*coordinate, 1)
|
|
105
|
+
if bezier:
|
|
106
|
+
point.handle_left_type = "AUTO"
|
|
107
|
+
point.handle_right_type = "AUTO"
|
|
108
|
+
spline.use_cyclic_u = bool(spec.get("pathClosed", False))
|
|
109
|
+
obj = bpy.data.objects.new(name, data)
|
|
110
|
+
bpy.context.scene.collection.objects.link(obj)
|
|
111
|
+
for selected in bpy.context.selected_objects:
|
|
112
|
+
selected.select_set(False)
|
|
113
|
+
obj.select_set(True)
|
|
114
|
+
bpy.context.view_layer.objects.active = obj
|
|
115
|
+
result = bpy.ops.object.convert(target="MESH")
|
|
116
|
+
if "FINISHED" not in result:
|
|
117
|
+
raise RuntimeError("curve conversion did not produce a mesh")
|
|
118
|
+
obj = bpy.context.object
|
|
119
|
+
# Curve conversion duplicates the end-cap rim vertices. Weld those rims
|
|
120
|
+
# into the side wall; per-corner UVs remain distinct across texture seams.
|
|
121
|
+
import bmesh
|
|
122
|
+
mesh = bmesh.new()
|
|
123
|
+
try:
|
|
124
|
+
mesh.from_mesh(obj.data)
|
|
125
|
+
bmesh.ops.remove_doubles(mesh, verts=list(mesh.verts), dist=max(float(spec.get("radius", 0.01)) * 1e-7, 1e-12))
|
|
126
|
+
bmesh.ops.recalc_face_normals(mesh, faces=list(mesh.faces))
|
|
127
|
+
mesh.to_mesh(obj.data)
|
|
128
|
+
finally:
|
|
129
|
+
mesh.free()
|
|
130
|
+
return obj
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_lathe(name, spec):
|
|
134
|
+
"""Revolve an ordered [radius, height] boundary about local Z.
|
|
135
|
+
|
|
136
|
+
Axis points become one vertex, so caps have no coincident pole rings.
|
|
137
|
+
UV U runs around the circumference; V follows profile arc length.
|
|
138
|
+
"""
|
|
139
|
+
import bmesh
|
|
140
|
+
|
|
141
|
+
profile = spec["profile"]
|
|
142
|
+
segments = int(spec.get("segments", 96))
|
|
143
|
+
closed = bool(spec.get("closedProfile", False))
|
|
144
|
+
vertices, rings, faces, face_uvs = [], [], [], []
|
|
145
|
+
distances = [0.0]
|
|
146
|
+
for a, b in zip(profile, profile[1:]):
|
|
147
|
+
distances.append(distances[-1] + math.dist(a, b))
|
|
148
|
+
total = distances[-1] + (math.dist(profile[-1], profile[0]) if closed else 0)
|
|
149
|
+
for radius, height in profile:
|
|
150
|
+
ring = []
|
|
151
|
+
for index in range(1 if radius == 0 else segments):
|
|
152
|
+
angle = math.tau * index / segments
|
|
153
|
+
ring.append(len(vertices))
|
|
154
|
+
vertices.append((radius * math.cos(angle), radius * math.sin(angle), height))
|
|
155
|
+
rings.append(ring)
|
|
156
|
+
|
|
157
|
+
def add_face(indices, uvs):
|
|
158
|
+
# A pole appears twice in a quad. Collapse it to a triangle while
|
|
159
|
+
# retaining the texture seam as per-corner UVs.
|
|
160
|
+
unique = []
|
|
161
|
+
corners = []
|
|
162
|
+
for index, uv in zip(indices, uvs):
|
|
163
|
+
if index not in unique:
|
|
164
|
+
unique.append(index)
|
|
165
|
+
corners.append(uv)
|
|
166
|
+
if len(unique) >= 3:
|
|
167
|
+
faces.append(unique)
|
|
168
|
+
face_uvs.append(corners)
|
|
169
|
+
|
|
170
|
+
for row in range(len(profile) if closed else len(profile) - 1):
|
|
171
|
+
following = (row + 1) % len(profile)
|
|
172
|
+
a, b = rings[row], rings[following]
|
|
173
|
+
va = distances[row] / total
|
|
174
|
+
vb = distances[following] / total if following else 1.0
|
|
175
|
+
for column in range(segments):
|
|
176
|
+
next_column = (column + 1) % segments
|
|
177
|
+
ua, ub = column / segments, (column + 1) / segments
|
|
178
|
+
add_face([a[column % len(a)], a[next_column % len(a)],
|
|
179
|
+
b[next_column % len(b)], b[column % len(b)]],
|
|
180
|
+
[(ua, va), (ub, va), (ub, vb), (ua, vb)])
|
|
181
|
+
|
|
182
|
+
if not closed and spec.get("capEnds", True):
|
|
183
|
+
for row, reverse in [(0, True), (len(rings) - 1, False)]:
|
|
184
|
+
ring = rings[row]
|
|
185
|
+
if len(ring) == 1:
|
|
186
|
+
continue
|
|
187
|
+
ordered = list(reversed(ring)) if reverse else list(ring)
|
|
188
|
+
radius = profile[row][0]
|
|
189
|
+
add_face(ordered, [(vertices[index][0] / (2 * radius) + 0.5,
|
|
190
|
+
vertices[index][1] / (2 * radius) + 0.5) for index in ordered])
|
|
191
|
+
|
|
192
|
+
mesh = bpy.data.meshes.new(name + "_mesh")
|
|
193
|
+
mesh.from_pydata(vertices, [], faces)
|
|
194
|
+
mesh.update()
|
|
195
|
+
uv = mesh.uv_layers.new(name="UVMap")
|
|
196
|
+
for polygon, corners in zip(mesh.polygons, face_uvs):
|
|
197
|
+
for loop_index, coordinate in zip(polygon.loop_indices, corners):
|
|
198
|
+
uv.data[loop_index].uv = coordinate
|
|
199
|
+
bm = bmesh.new()
|
|
200
|
+
try:
|
|
201
|
+
bm.from_mesh(mesh)
|
|
202
|
+
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
|
|
203
|
+
bm.to_mesh(mesh)
|
|
204
|
+
finally:
|
|
205
|
+
bm.free()
|
|
206
|
+
obj = bpy.data.objects.new(name, mesh)
|
|
207
|
+
bpy.context.scene.collection.objects.link(obj)
|
|
208
|
+
bpy.context.view_layer.objects.active = obj
|
|
209
|
+
obj.select_set(True)
|
|
210
|
+
return obj
|