@deepblend/dsh-blender-provider-local 0.2.20 → 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.
Files changed (43) hide show
  1. package/lib/index.js +50 -35
  2. package/package.json +2 -2
  3. package/python/bootstrap.py +4 -2
  4. package/python/deepblend_anisotropy.py +110 -0
  5. package/python/deepblend_asset_bundle.py +193 -0
  6. package/python/deepblend_avif_headers.py +222 -0
  7. package/python/deepblend_bridge.py +46 -4
  8. package/python/deepblend_capabilities.py +7 -0
  9. package/python/deepblend_frames.py +11 -1
  10. package/python/deepblend_geometry.py +210 -0
  11. package/python/deepblend_gltf_images.py +183 -0
  12. package/python/deepblend_gltf_import.py +95 -0
  13. package/python/deepblend_gltf_roles.py +56 -0
  14. package/python/deepblend_image_headers.py +294 -0
  15. package/python/deepblend_image_roles.py +2 -0
  16. package/python/deepblend_images.py +203 -0
  17. package/python/deepblend_imported_image_headers.py +364 -0
  18. package/python/deepblend_mesh_checks.py +145 -0
  19. package/python/deepblend_obj_images.py +54 -0
  20. package/python/deepblend_obj_import.py +58 -0
  21. package/python/deepblend_obj_resources.py +79 -0
  22. package/python/deepblend_parts.py +106 -0
  23. package/python/deepblend_render.py +53 -0
  24. package/python/deepblend_scene.py +307 -49
  25. package/python/deepblend_vessel.py +192 -0
  26. package/python/deepblend_vessel_math.py +116 -0
  27. package/python/deepblend_vessel_parameters.py +65 -0
  28. package/python/deepblend_views.py +18 -19
  29. package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
  30. package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
  31. package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
  32. package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
  33. package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
  34. package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
  35. package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
  36. package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
  37. package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
  38. package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
  39. package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
  40. package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
  41. package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
  42. package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
  43. 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
- answer = bootstrap._session_dispatch(request, self.options)
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
- # The serve thread is a daemon, so this loop is what keeps the process alive. A SIGTERM (or
411
- # the product closing the socket and asking for shutdown) ends it.
412
- _BRIDGE.thread.join()
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
- append_event(events_path, {"type": "frame", "frame": frame, "bytes": verdict["bytes"], "ms": elapsed_ms})
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