@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.
- package/README.md +36 -0
- package/blender_ops/depth.py +133 -0
- package/blender_ops/errors.py +46 -0
- package/blender_ops/exr.py +205 -0
- package/blender_ops/framing.py +75 -0
- package/blender_ops/ops/__init__.py +24 -0
- package/blender_ops/ops/common.py +310 -0
- package/blender_ops/ops/export_model.py +95 -0
- package/blender_ops/ops/prepare_for_engine.py +352 -0
- package/blender_ops/ops/render_animation.py +382 -0
- package/blender_ops/ops/render_image.py +41 -0
- package/blender_ops/ops/render_passes.py +363 -0
- package/blender_ops/run_job.py +110 -0
- package/blender_ops/tests/test_camera_lens.py +107 -0
- package/blender_ops/tests/test_depth.py +158 -0
- package/blender_ops/tests/test_exr.py +218 -0
- package/blender_ops/tests/test_framing.py +50 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/bake.d.ts +107 -0
- package/dist/bake.d.ts.map +1 -0
- package/dist/bake.js +209 -0
- package/dist/bake.js.map +1 -0
- package/dist/blender-binary.d.ts +38 -0
- package/dist/blender-binary.d.ts.map +1 -0
- package/dist/blender-binary.js +169 -0
- package/dist/blender-binary.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +163 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +67 -0
- package/dist/job.js.map +1 -0
- package/dist/nodes/bake-timeline-clip.d.ts +48 -0
- package/dist/nodes/bake-timeline-clip.d.ts.map +1 -0
- package/dist/nodes/bake-timeline-clip.js +152 -0
- package/dist/nodes/bake-timeline-clip.js.map +1 -0
- package/dist/nodes/blender-error.d.ts +21 -0
- package/dist/nodes/blender-error.d.ts.map +1 -0
- package/dist/nodes/blender-error.js +38 -0
- package/dist/nodes/blender-error.js.map +1 -0
- package/dist/nodes/defaults.d.ts +18 -0
- package/dist/nodes/defaults.d.ts.map +1 -0
- package/dist/nodes/defaults.js +18 -0
- package/dist/nodes/defaults.js.map +1 -0
- package/dist/nodes/export-model.d.ts +49 -0
- package/dist/nodes/export-model.d.ts.map +1 -0
- package/dist/nodes/export-model.js +127 -0
- package/dist/nodes/export-model.js.map +1 -0
- package/dist/nodes/prepare-for-engine.d.ts +52 -0
- package/dist/nodes/prepare-for-engine.d.ts.map +1 -0
- package/dist/nodes/prepare-for-engine.js +127 -0
- package/dist/nodes/prepare-for-engine.js.map +1 -0
- package/dist/nodes/progress.d.ts +11 -0
- package/dist/nodes/progress.d.ts.map +1 -0
- package/dist/nodes/progress.js +21 -0
- package/dist/nodes/progress.js.map +1 -0
- package/dist/nodes/render-animation.d.ts +52 -0
- package/dist/nodes/render-animation.d.ts.map +1 -0
- package/dist/nodes/render-animation.js +136 -0
- package/dist/nodes/render-animation.js.map +1 -0
- package/dist/nodes/render-base.d.ts +35 -0
- package/dist/nodes/render-base.d.ts.map +1 -0
- package/dist/nodes/render-base.js +95 -0
- package/dist/nodes/render-base.js.map +1 -0
- package/dist/nodes/render-image.d.ts +41 -0
- package/dist/nodes/render-image.d.ts.map +1 -0
- package/dist/nodes/render-image.js +98 -0
- package/dist/nodes/render-image.js.map +1 -0
- package/dist/nodes/render-passes.d.ts +54 -0
- package/dist/nodes/render-passes.d.ts.map +1 -0
- package/dist/nodes/render-passes.js +167 -0
- package/dist/nodes/render-passes.js.map +1 -0
- package/dist/run-job.d.ts +34 -0
- package/dist/run-job.d.ts.map +1 -0
- package/dist/run-job.js +137 -0
- package/dist/run-job.js.map +1 -0
- package/dist/runner.d.ts +124 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +521 -0
- package/dist/runner.js.map +1 -0
- 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
|