@nodetool-ai/blender-nodes 0.8.1

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 (83) hide show
  1. package/README.md +36 -0
  2. package/blender_ops/depth.py +133 -0
  3. package/blender_ops/errors.py +46 -0
  4. package/blender_ops/exr.py +205 -0
  5. package/blender_ops/framing.py +75 -0
  6. package/blender_ops/ops/__init__.py +24 -0
  7. package/blender_ops/ops/common.py +310 -0
  8. package/blender_ops/ops/export_model.py +95 -0
  9. package/blender_ops/ops/prepare_for_engine.py +352 -0
  10. package/blender_ops/ops/render_animation.py +382 -0
  11. package/blender_ops/ops/render_image.py +41 -0
  12. package/blender_ops/ops/render_passes.py +363 -0
  13. package/blender_ops/run_job.py +110 -0
  14. package/blender_ops/tests/test_camera_lens.py +107 -0
  15. package/blender_ops/tests/test_depth.py +158 -0
  16. package/blender_ops/tests/test_exr.py +218 -0
  17. package/blender_ops/tests/test_framing.py +50 -0
  18. package/dist/.nodetool-build-stamp +1 -0
  19. package/dist/bake.d.ts +107 -0
  20. package/dist/bake.d.ts.map +1 -0
  21. package/dist/bake.js +209 -0
  22. package/dist/bake.js.map +1 -0
  23. package/dist/blender-binary.d.ts +38 -0
  24. package/dist/blender-binary.d.ts.map +1 -0
  25. package/dist/blender-binary.js +169 -0
  26. package/dist/blender-binary.js.map +1 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +40 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/job.d.ts +163 -0
  32. package/dist/job.d.ts.map +1 -0
  33. package/dist/job.js +67 -0
  34. package/dist/job.js.map +1 -0
  35. package/dist/nodes/bake-timeline-clip.d.ts +48 -0
  36. package/dist/nodes/bake-timeline-clip.d.ts.map +1 -0
  37. package/dist/nodes/bake-timeline-clip.js +152 -0
  38. package/dist/nodes/bake-timeline-clip.js.map +1 -0
  39. package/dist/nodes/blender-error.d.ts +21 -0
  40. package/dist/nodes/blender-error.d.ts.map +1 -0
  41. package/dist/nodes/blender-error.js +38 -0
  42. package/dist/nodes/blender-error.js.map +1 -0
  43. package/dist/nodes/defaults.d.ts +18 -0
  44. package/dist/nodes/defaults.d.ts.map +1 -0
  45. package/dist/nodes/defaults.js +18 -0
  46. package/dist/nodes/defaults.js.map +1 -0
  47. package/dist/nodes/export-model.d.ts +49 -0
  48. package/dist/nodes/export-model.d.ts.map +1 -0
  49. package/dist/nodes/export-model.js +127 -0
  50. package/dist/nodes/export-model.js.map +1 -0
  51. package/dist/nodes/prepare-for-engine.d.ts +52 -0
  52. package/dist/nodes/prepare-for-engine.d.ts.map +1 -0
  53. package/dist/nodes/prepare-for-engine.js +127 -0
  54. package/dist/nodes/prepare-for-engine.js.map +1 -0
  55. package/dist/nodes/progress.d.ts +11 -0
  56. package/dist/nodes/progress.d.ts.map +1 -0
  57. package/dist/nodes/progress.js +21 -0
  58. package/dist/nodes/progress.js.map +1 -0
  59. package/dist/nodes/render-animation.d.ts +52 -0
  60. package/dist/nodes/render-animation.d.ts.map +1 -0
  61. package/dist/nodes/render-animation.js +136 -0
  62. package/dist/nodes/render-animation.js.map +1 -0
  63. package/dist/nodes/render-base.d.ts +35 -0
  64. package/dist/nodes/render-base.d.ts.map +1 -0
  65. package/dist/nodes/render-base.js +95 -0
  66. package/dist/nodes/render-base.js.map +1 -0
  67. package/dist/nodes/render-image.d.ts +41 -0
  68. package/dist/nodes/render-image.d.ts.map +1 -0
  69. package/dist/nodes/render-image.js +98 -0
  70. package/dist/nodes/render-image.js.map +1 -0
  71. package/dist/nodes/render-passes.d.ts +54 -0
  72. package/dist/nodes/render-passes.d.ts.map +1 -0
  73. package/dist/nodes/render-passes.js +167 -0
  74. package/dist/nodes/render-passes.js.map +1 -0
  75. package/dist/run-job.d.ts +34 -0
  76. package/dist/run-job.d.ts.map +1 -0
  77. package/dist/run-job.js +137 -0
  78. package/dist/run-job.js.map +1 -0
  79. package/dist/runner.d.ts +124 -0
  80. package/dist/runner.d.ts.map +1 -0
  81. package/dist/runner.js +521 -0
  82. package/dist/runner.js.map +1 -0
  83. package/package.json +73 -0
@@ -0,0 +1,352 @@
1
+ """The `prepare_for_engine` op: decimate, unwrap, bake, and export GLBs.
2
+
3
+ Pipeline over the imported glTF scene: every mesh is decimated toward
4
+ `target_faces`, optionally UV-unwrapped (`smart_project`), optionally baked
5
+ (`ao`, `normal`, or both) into the materials at `bake_resolution`, then the
6
+ prepared model is exported as GLB along with `lod_count` LODs at halving
7
+ face targets (`target/2`, `target/4`, ...). Every output is a GLB the glTF
8
+ exporter wrote, so `validateModel3D` reads them back.
9
+
10
+ Baking runs on Cycles (the bake operator's engine) with low samples; no
11
+ render happens, so the still/animation engine params do not exist here.
12
+ """
13
+
14
+ import os
15
+ import time
16
+
17
+ import bpy
18
+
19
+ from errors import BadJob, BakeFailed, ExportFailed, RenderFailed
20
+ from ops.common import import_model
21
+
22
+
23
+ def _face_count(meshes):
24
+ return sum(len(obj.data.polygons) for obj in meshes)
25
+
26
+
27
+ def _set_active(obj):
28
+ bpy.ops.object.select_all(action="DESELECT")
29
+ obj.select_set(True)
30
+ bpy.context.view_layer.objects.active = obj
31
+
32
+
33
+ def _decimate(meshes, target_faces):
34
+ """Collapse every mesh toward its face target. Skips when already under."""
35
+ for obj in meshes:
36
+ faces = len(obj.data.polygons)
37
+ if faces == 0:
38
+ continue
39
+ ratio = target_faces / faces
40
+ if ratio >= 1.0:
41
+ continue
42
+ _set_active(obj)
43
+ modifier = obj.modifiers.new("NodeTool_Decimate", "DECIMATE")
44
+ modifier.ratio = max(ratio, 1.0 / faces)
45
+ try:
46
+ bpy.ops.object.modifier_apply(modifier=modifier.name)
47
+ except Exception as exc:
48
+ raise RenderFailed(
49
+ "decimate failed on %r: %s" % (obj.name, exc)
50
+ )
51
+
52
+
53
+ def _decimate_lod(meshes):
54
+ """Halve each mesh so the aggregate LOD is half the prepared scene."""
55
+ for obj in meshes:
56
+ _decimate([obj], max(1, len(obj.data.polygons) // 2))
57
+
58
+
59
+ def _smart_project(obj):
60
+ _set_active(obj)
61
+ bpy.ops.object.mode_set(mode="EDIT")
62
+ try:
63
+ bpy.ops.mesh.select_all(action="SELECT")
64
+ bpy.ops.uv.smart_project(angle_limit=66, island_margin=0.02)
65
+ finally:
66
+ bpy.ops.object.mode_set(mode="OBJECT")
67
+
68
+
69
+ def _unwrap(meshes):
70
+ for obj in meshes:
71
+ _smart_project(obj)
72
+
73
+
74
+ def _has_uvs(obj):
75
+ return len(obj.data.uv_layers) > 0
76
+
77
+
78
+ def _principled(material):
79
+ for node in material.node_tree.nodes:
80
+ if node.type == "BSDF_PRINCIPLED":
81
+ return node
82
+ return None
83
+
84
+
85
+ def _unique_materials(obj):
86
+ """Every material on the object's slots, once each, in slot order."""
87
+ materials = []
88
+ for slot in obj.material_slots:
89
+ if slot.material is not None and slot.material not in materials:
90
+ materials.append(slot.material)
91
+ return materials
92
+
93
+
94
+ def _bake_image(meshes, bake, bake_resolution):
95
+ """One bake image per material per kind. Materials go single-user first
96
+ so two meshes never share one image node. Every material gets its own
97
+ selected image node: wiring only the active material leaves the other
98
+ slots without a bake target, and the mesh exports half-baked.
99
+ Returns `images[kind][obj.name]`, a list of `(material, image)` pairs."""
100
+ images = {}
101
+ for obj in meshes:
102
+ for slot in obj.material_slots:
103
+ if slot.material is not None:
104
+ slot.material = slot.material.copy()
105
+ materials = _unique_materials(obj)
106
+ if not materials:
107
+ raise BakeFailed(
108
+ "cannot bake: %r has no material" % (obj.name,)
109
+ )
110
+ kinds = ("ao", "normal") if bake == "both" else (bake,)
111
+ for material in materials:
112
+ if not material.use_nodes:
113
+ raise BakeFailed(
114
+ "cannot bake: material %r on %r has no nodes"
115
+ % (material.name, obj.name)
116
+ )
117
+ for kind in kinds:
118
+ image = bpy.data.images.new(
119
+ "NodeTool_Bake_%s_%s_%s" % (kind, obj.name, material.name),
120
+ width=bake_resolution,
121
+ height=bake_resolution,
122
+ )
123
+ nodes = material.node_tree.nodes
124
+ tex = nodes.new("ShaderNodeTexImage")
125
+ tex.image = image
126
+ # The bake operator writes the selected image nodes. Compare
127
+ # with `==`, never `is`: every collection access hands back a
128
+ # new Python proxy, so `is` never matches and the loop
129
+ # deselects everything — the bake then cancels with "No
130
+ # active and selected image texture node found" and packs a
131
+ # black default (measured 5.2.1).
132
+ for node in nodes:
133
+ node.select = node == tex
134
+ nodes.active = tex
135
+ images.setdefault(kind, {}).setdefault(obj.name, []).append(
136
+ (material, image)
137
+ )
138
+ return images
139
+
140
+
141
+ def _image_for(pairs, material):
142
+ for candidate, image in pairs:
143
+ if candidate is material:
144
+ return image
145
+ return None
146
+
147
+
148
+ def _wire_baked(meshes, images):
149
+ """Normal maps ride a Normal Map node into Principled; AO multiplies
150
+ into Base Color. Both shapes are ones the glTF exporter reads. Every
151
+ material is wired, not just the active one: `_bake_image` bakes one
152
+ image per material, and an unwired one would export without its bake."""
153
+ for obj in meshes:
154
+ for material in _unique_materials(obj):
155
+ nodes = material.node_tree.nodes
156
+ links = material.node_tree.links
157
+ principled = _principled(material)
158
+ if principled is None:
159
+ continue
160
+ if "normal" in images:
161
+ baked = _image_for(images["normal"].get(obj.name, []), material)
162
+ tex = next(
163
+ (
164
+ node
165
+ for node in nodes
166
+ if node.type == "TEX_IMAGE" and node.image is baked
167
+ ),
168
+ None,
169
+ )
170
+ if tex is not None:
171
+ normal_map = nodes.new("ShaderNodeNormalMap")
172
+ normal_map.space = "TANGENT"
173
+ links.new(tex.outputs["Color"], normal_map.inputs["Color"])
174
+ normal_socket = principled.inputs.get("Normal")
175
+ if normal_socket is not None:
176
+ links.new(normal_map.outputs["Normal"], normal_socket)
177
+ if "ao" in images:
178
+ baked = _image_for(images["ao"].get(obj.name, []), material)
179
+ tex = next(
180
+ (
181
+ node
182
+ for node in nodes
183
+ if node.type == "TEX_IMAGE" and node.image is baked
184
+ ),
185
+ None,
186
+ )
187
+ if tex is not None:
188
+ base = principled.inputs.get("Base Color")
189
+ if base is not None:
190
+ mix = nodes.new("ShaderNodeMixRGB")
191
+ mix.blend_type = "MULTIPLY"
192
+ mix.inputs["Fac"].default_value = 1.0
193
+ links.new(tex.outputs["Color"], mix.inputs["Color2"])
194
+ if base.is_linked:
195
+ link = base.links[0]
196
+ links.new(link.from_socket, mix.inputs["Color1"])
197
+ links.remove(link)
198
+ else:
199
+ mix.inputs["Color1"].default_value = tuple(base.default_value)
200
+ links.new(mix.outputs["Color"], base)
201
+
202
+
203
+ def _select_kind_nodes(meshes, images, kind):
204
+ """Select only `kind`'s bake nodes, in every mesh material, and make each
205
+ one the active node of its tree. `_bake_image` builds one node per kind
206
+ and leaves the last kind's nodes selected, so without this an `ao` bake
207
+ lands in the `normal` image (and the AO map exports black) while the
208
+ normal bake overwrites it. Image and material data blocks compare by
209
+ identity; nodes do not (see `_bake_image`)."""
210
+ for obj in meshes:
211
+ for material in _unique_materials(obj):
212
+ target = _image_for(images[kind].get(obj.name, []), material)
213
+ nodes = material.node_tree.nodes
214
+ for node in nodes:
215
+ if node.type == "TEX_IMAGE" and node.image is not None:
216
+ node.select = target is not None and node.image == target
217
+ if target is not None:
218
+ tex = next(
219
+ (
220
+ node
221
+ for node in nodes
222
+ if node.type == "TEX_IMAGE"
223
+ and node.image is not None
224
+ and node.image == target
225
+ ),
226
+ None,
227
+ )
228
+ if tex is not None:
229
+ nodes.active = tex
230
+
231
+
232
+ def _bake(meshes, bake, bake_resolution):
233
+ scene = bpy.context.scene
234
+ scene.render.engine = "CYCLES"
235
+ scene.cycles.device = "CPU"
236
+ scene.cycles.samples = 16
237
+ images = _bake_image(meshes, bake, bake_resolution)
238
+ kinds = ("ao", "normal") if bake == "both" else (bake,)
239
+ for obj in meshes:
240
+ obj.select_set(True)
241
+ for kind in kinds:
242
+ _select_kind_nodes(meshes, images, kind)
243
+ bpy.context.view_layer.objects.active = meshes[0]
244
+ try:
245
+ if kind == "ao":
246
+ result = bpy.ops.object.bake(type="AO", margin=16)
247
+ else:
248
+ result = bpy.ops.object.bake(type="NORMAL", margin=16)
249
+ except Exception as exc:
250
+ raise BakeFailed("bake %r failed: %s" % (kind, exc))
251
+ # A cancelled bake writes nothing and leaves the black default in
252
+ # place, which `pack()` would then freeze into the export — the same
253
+ # way `_export_glb` treats a rejected export, a non-FINISHED bake is
254
+ # a failure, not a silent no-op.
255
+ if "FINISHED" not in result:
256
+ raise BakeFailed(
257
+ "bake %r was cancelled: no selected image node held a bake "
258
+ "target (operator returned %r)" % (kind, result)
259
+ )
260
+ for kind_images in images.values():
261
+ for pairs in kind_images.values():
262
+ for _material, image in pairs:
263
+ image.pack()
264
+ _wire_baked(meshes, images)
265
+
266
+
267
+ def _export_glb(path, selected):
268
+ try:
269
+ result = bpy.ops.export_scene.gltf(
270
+ filepath=path,
271
+ export_format="GLB",
272
+ use_selection=bool(selected),
273
+ )
274
+ except Exception as exc:
275
+ raise ExportFailed("GLB export failed: %s" % (exc,))
276
+ if "FINISHED" not in result:
277
+ raise ExportFailed("glTF exporter rejected the scene")
278
+ if not os.path.exists(path):
279
+ raise ExportFailed("GLB export finished but %r is missing" % (path,))
280
+
281
+
282
+ def _duplicate(meshes):
283
+ bpy.ops.object.select_all(action="DESELECT")
284
+ for obj in meshes:
285
+ obj.select_set(True)
286
+ bpy.context.view_layer.objects.active = meshes[0]
287
+ bpy.ops.object.duplicate()
288
+ return list(bpy.context.selected_objects)
289
+
290
+
291
+ def _hide(meshes):
292
+ for obj in meshes:
293
+ obj.hide_viewport = True
294
+ obj.hide_render = True
295
+ obj.select_set(False)
296
+
297
+
298
+ def run(job, workdir):
299
+ """Run `prepare_for_engine`. Returns `(produced, stats)`."""
300
+ params = job["job"]["params"]
301
+ outputs = job["outputs"]
302
+ if "model" not in outputs:
303
+ raise BadJob("prepare_for_engine declares no 'model' output")
304
+ target_faces = max(1, int(params.get("target_faces", 5000)))
305
+ unwrap = bool(params.get("unwrap", True))
306
+ bake = params.get("bake", "none")
307
+ if bake not in ("none", "ao", "normal", "both"):
308
+ raise BadJob("unknown bake mode %r" % (bake,))
309
+ bake_resolution = max(16, int(params.get("bake_resolution", 1024)))
310
+ lod_count = max(0, int(params.get("lod_count", 0)))
311
+ for index in range(1, lod_count + 1):
312
+ if "lod_%d" % (index,) not in outputs:
313
+ raise BadJob(
314
+ "prepare_for_engine declares no 'lod_%d' output" % (index,)
315
+ )
316
+
317
+ # The empty scene gates as `no_geometry` inside `import_model`, before
318
+ # any mesh work is spent.
319
+ _scene, meshes = import_model(job, workdir)
320
+
321
+ started = time.monotonic()
322
+ _decimate(meshes, target_faces)
323
+ if unwrap:
324
+ _unwrap(meshes)
325
+ if bake != "none":
326
+ for obj in meshes:
327
+ if not _has_uvs(obj):
328
+ _smart_project(obj)
329
+ _bake(meshes, bake, bake_resolution)
330
+
331
+ produced = []
332
+ _export_glb(os.path.join(workdir, outputs["model"]), selected=False)
333
+ produced.append("model")
334
+
335
+ current = meshes
336
+ for index in range(1, lod_count + 1):
337
+ lod = _duplicate(current)
338
+ _hide(current)
339
+ _decimate_lod(lod)
340
+ _set_active(lod[0])
341
+ for obj in lod:
342
+ obj.select_set(True)
343
+ _export_glb(os.path.join(workdir, outputs["lod_%d" % (index,)]), selected=True)
344
+ produced.append("lod_%d" % (index,))
345
+ current = lod
346
+
347
+ stats = {
348
+ "blender_version": bpy.app.version_string,
349
+ "render_seconds": time.monotonic() - started,
350
+ "objects": len(meshes),
351
+ }
352
+ return produced, stats