@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,310 @@
|
|
|
1
|
+
"""Shared render-op setup: import, camera, lights, world, engine.
|
|
2
|
+
|
|
3
|
+
Factored out of `render_image.py` so `render_passes` and `render_animation`
|
|
4
|
+
run the same scene setup (D5). Behavior is unchanged: factory settings with
|
|
5
|
+
`use_empty=True`, glTF import, then the camera `camera_mode` selects (D4).
|
|
6
|
+
Lights come from the scene when it has any, else the `lighting` preset.
|
|
7
|
+
|
|
8
|
+
`setup_render_scene(job, workdir)` runs the full setup and returns
|
|
9
|
+
`(scene, meshes, center, radius, camera_obj)`. Ops that need per-frame work
|
|
10
|
+
(render_animation's orbit sweep) use the smaller pieces directly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import bpy
|
|
16
|
+
from mathutils import Vector
|
|
17
|
+
|
|
18
|
+
from errors import BadJob, ImportFailed, NoCamera, NoGeometry, RenderFailed
|
|
19
|
+
from framing import apply_camera_lens, compute_framing, orbit_offset
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ORBIT_CAMERA_NAME = "NodeTool_Orbit"
|
|
23
|
+
|
|
24
|
+
CYCLES_SEED = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _srgb_to_linear(channel):
|
|
28
|
+
"""One sRGB channel in `[0, 1]` to scene-linear (the sRGB EOTF)."""
|
|
29
|
+
if channel <= 0.04045:
|
|
30
|
+
return channel / 12.92
|
|
31
|
+
return ((channel + 0.055) / 1.055) ** 2.4
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hex_to_rgb(value):
|
|
35
|
+
# A hex color is sRGB; the Background node takes scene-linear values
|
|
36
|
+
# under the Standard view transform, so each channel goes through the
|
|
37
|
+
# sRGB EOTF. Writing sRGB straight in renders bright: measured,
|
|
38
|
+
# `#808080` came out as (188, 188, 188) instead of (128, 128, 128).
|
|
39
|
+
text = value.strip().lstrip("#")
|
|
40
|
+
if len(text) == 3:
|
|
41
|
+
text = "".join(ch * 2 for ch in text)
|
|
42
|
+
if len(text) != 6:
|
|
43
|
+
raise BadJob("background_color %r is not a hex color" % (value,))
|
|
44
|
+
try:
|
|
45
|
+
channels = [_srgb_to_linear(int(text[i : i + 2], 16) / 255) for i in (0, 2, 4)]
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise BadJob("background_color %r is not a hex color" % (value,))
|
|
48
|
+
return tuple(channels) + (1.0,)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mesh_objects(scene):
|
|
52
|
+
return [
|
|
53
|
+
obj
|
|
54
|
+
for obj in scene.objects
|
|
55
|
+
if obj.type == "MESH" and len(obj.data.polygons) > 0
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ensure_materials(meshes):
|
|
60
|
+
"""Give material-less meshes a neutral white material.
|
|
61
|
+
|
|
62
|
+
The three.js preview renders mesh data with a default material when the
|
|
63
|
+
glTF carries none; EEVEE/Cycles leave those faces unlit (effectively
|
|
64
|
+
invisible), so the op does the same fallback before any render time.
|
|
65
|
+
"""
|
|
66
|
+
fallback = None
|
|
67
|
+
for obj in meshes:
|
|
68
|
+
slots = obj.material_slots
|
|
69
|
+
if slots and all(slot.material is not None for slot in slots):
|
|
70
|
+
continue
|
|
71
|
+
if fallback is None:
|
|
72
|
+
fallback = bpy.data.materials.new("NodeTool_Default")
|
|
73
|
+
fallback.use_nodes = True
|
|
74
|
+
principled = fallback.node_tree.nodes.get("Principled BSDF")
|
|
75
|
+
if principled is not None:
|
|
76
|
+
principled.inputs["Base Color"].default_value = (0.9, 0.9, 0.9, 1.0)
|
|
77
|
+
principled.inputs["Roughness"].default_value = 0.8
|
|
78
|
+
if not slots:
|
|
79
|
+
obj.data.materials.append(fallback)
|
|
80
|
+
else:
|
|
81
|
+
for slot in slots:
|
|
82
|
+
if slot.material is None:
|
|
83
|
+
slot.material = fallback
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def scene_bounds(meshes):
|
|
87
|
+
corners = []
|
|
88
|
+
for obj in meshes:
|
|
89
|
+
for corner in obj.bound_box:
|
|
90
|
+
corners.append(obj.matrix_world @ Vector(corner))
|
|
91
|
+
if not corners:
|
|
92
|
+
raise NoGeometry("the model contains no visible geometry")
|
|
93
|
+
lo = Vector(
|
|
94
|
+
(
|
|
95
|
+
min(c.x for c in corners),
|
|
96
|
+
min(c.y for c in corners),
|
|
97
|
+
min(c.z for c in corners),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
hi = Vector(
|
|
101
|
+
(
|
|
102
|
+
max(c.x for c in corners),
|
|
103
|
+
max(c.y for c in corners),
|
|
104
|
+
max(c.z for c in corners),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
center = (lo + hi) / 2
|
|
108
|
+
radius = max((c - center).length for c in corners)
|
|
109
|
+
return center, radius
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def scene_cameras(scene):
|
|
113
|
+
# Importer order, not alphabetical: D4 means the first camera in the
|
|
114
|
+
# document, and `scene.objects` lists cameras in creation order (the
|
|
115
|
+
# glTF importer creates one object per node in document order). Sorting
|
|
116
|
+
# by name rendered `Shot01` for a document that opens on `Shot02`.
|
|
117
|
+
return [obj for obj in scene.objects if obj.type == "CAMERA"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def orbit_location(center, radius, params, aspect):
|
|
121
|
+
"""World-space location of the orbit camera for the current props."""
|
|
122
|
+
framing = compute_framing(radius, params["fov"], aspect, params["zoom"])
|
|
123
|
+
offset = orbit_offset(params["azimuth"], params["elevation"], framing["distance"])
|
|
124
|
+
# Rigid three.js (Y up) to Blender (Z up) rotation: (x, y, z) -> (x, -z, y).
|
|
125
|
+
# Up stays up, azimuth 0 sits on -Y; see the framing module docstring.
|
|
126
|
+
return center + Vector((offset["x"], -offset["z"], offset["y"])), framing
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_orbit_camera(scene, center, radius, params, aspect):
|
|
130
|
+
location, framing = orbit_location(center, radius, params, aspect)
|
|
131
|
+
data = bpy.data.cameras.new(ORBIT_CAMERA_NAME)
|
|
132
|
+
apply_camera_lens(data, params["fov"], framing)
|
|
133
|
+
obj = bpy.data.objects.new(ORBIT_CAMERA_NAME, data)
|
|
134
|
+
scene.collection.objects.link(obj)
|
|
135
|
+
obj.location = location
|
|
136
|
+
aim_camera(obj, center)
|
|
137
|
+
return obj
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def aim_camera(obj, center):
|
|
141
|
+
direction = center - obj.location
|
|
142
|
+
if direction.length < 1e-9:
|
|
143
|
+
raise RenderFailed("orbit camera coincides with the scene center")
|
|
144
|
+
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def select_camera(scene, center, radius, params, aspect):
|
|
148
|
+
mode = params.get("camera_mode", "auto")
|
|
149
|
+
if mode == "orbit":
|
|
150
|
+
return make_orbit_camera(scene, center, radius, params, aspect)
|
|
151
|
+
cameras = scene_cameras(scene)
|
|
152
|
+
if mode == "scene":
|
|
153
|
+
if not cameras:
|
|
154
|
+
raise NoCamera(
|
|
155
|
+
"camera_mode is 'scene' but the model has no camera; "
|
|
156
|
+
"use 'auto' or 'orbit' instead"
|
|
157
|
+
)
|
|
158
|
+
return cameras[0]
|
|
159
|
+
if mode == "auto":
|
|
160
|
+
if cameras:
|
|
161
|
+
return cameras[0]
|
|
162
|
+
return make_orbit_camera(scene, center, radius, params, aspect)
|
|
163
|
+
raise BadJob("unknown camera_mode %r" % (mode,))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def has_scene_lights(scene):
|
|
167
|
+
return any(obj.type == "LIGHT" for obj in scene.objects)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def scene_animations(scene):
|
|
171
|
+
"""Objects carrying an imported glTF animation action."""
|
|
172
|
+
found = []
|
|
173
|
+
for obj in scene.objects:
|
|
174
|
+
anim = obj.animation_data
|
|
175
|
+
if anim is not None and anim.action is not None:
|
|
176
|
+
found.append(obj)
|
|
177
|
+
return found
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _add_sun(name, direction, energy):
|
|
181
|
+
# `direction` points from the scene toward the light (camera side, like
|
|
182
|
+
# the three.js key/fill/rim offsets). A sun's rays travel along its
|
|
183
|
+
# local -Z, so +Z aligns with `direction` and the rays fly back into the
|
|
184
|
+
# scene, lighting the faces the camera sees.
|
|
185
|
+
data = bpy.data.lights.new(name, "SUN")
|
|
186
|
+
data.energy = energy
|
|
187
|
+
obj = bpy.data.objects.new(name, data)
|
|
188
|
+
bpy.context.scene.collection.objects.link(obj)
|
|
189
|
+
obj.rotation_euler = direction.to_track_quat("Z", "Y").to_euler()
|
|
190
|
+
return obj
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def apply_lighting_preset(scene, camera_obj, center, params):
|
|
194
|
+
"""Camera-relative key/fill/rim suns mirroring `addLights` in three.js."""
|
|
195
|
+
to_camera = (camera_obj.location - center).normalized()
|
|
196
|
+
up = Vector((0, 0, 1))
|
|
197
|
+
side = up.cross(to_camera)
|
|
198
|
+
if side.length < 1e-6:
|
|
199
|
+
side = Vector((1, 0, 0))
|
|
200
|
+
side = side.normalized()
|
|
201
|
+
intensity = params["light_intensity"]
|
|
202
|
+
preset = params["lighting"]
|
|
203
|
+
|
|
204
|
+
def place(name, color, energy, direction):
|
|
205
|
+
light = _add_sun(name, direction, energy * intensity)
|
|
206
|
+
light.data.color = color
|
|
207
|
+
return light
|
|
208
|
+
|
|
209
|
+
if preset == "studio":
|
|
210
|
+
key = to_camera + side * 0.8 + up * 0.9
|
|
211
|
+
fill = to_camera - side * 1.1 + up * 0.2
|
|
212
|
+
rim = -to_camera + up * 1.2
|
|
213
|
+
place("NodeTool_Key", (1, 1, 1), 3.0, key.normalized())
|
|
214
|
+
place("NodeTool_Fill", (1, 1, 1), 1.0, fill.normalized())
|
|
215
|
+
place("NodeTool_Rim", (1, 1, 1), 2.0, rim.normalized())
|
|
216
|
+
elif preset == "soft":
|
|
217
|
+
place("NodeTool_Key", (1, 1, 1), 1.5, (to_camera + up).normalized())
|
|
218
|
+
elif preset == "flat":
|
|
219
|
+
pass
|
|
220
|
+
else:
|
|
221
|
+
raise BadJob("unknown lighting preset %r" % (preset,))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def apply_world(background_color, transparent):
|
|
225
|
+
world = bpy.data.worlds.new("NodeTool_World")
|
|
226
|
+
world.use_nodes = True
|
|
227
|
+
background = world.node_tree.nodes.get("Background")
|
|
228
|
+
if background is not None:
|
|
229
|
+
background.inputs["Color"].default_value = _hex_to_rgb(background_color)
|
|
230
|
+
background.inputs["Strength"].default_value = 1.0
|
|
231
|
+
bpy.context.scene.world = world
|
|
232
|
+
bpy.context.scene.render.film_transparent = bool(transparent)
|
|
233
|
+
# `Standard` avoids the default filmic-style desaturation (which renders
|
|
234
|
+
# #ffffff as mid gray), but it is still a display transform over
|
|
235
|
+
# scene-linear values — not a passthrough. The hex therefore reaches the
|
|
236
|
+
# Background node linearized (see `_hex_to_rgb`); without that step the
|
|
237
|
+
# rendered background reads bright.
|
|
238
|
+
bpy.context.scene.view_settings.view_transform = "Standard"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def apply_engine(params):
|
|
242
|
+
engine = params["engine"]
|
|
243
|
+
scene = bpy.context.scene
|
|
244
|
+
if engine == "eevee":
|
|
245
|
+
scene.render.engine = "BLENDER_EEVEE"
|
|
246
|
+
scene.eevee.taa_render_samples = max(1, int(params["samples"]))
|
|
247
|
+
elif engine == "cycles":
|
|
248
|
+
scene.render.engine = "CYCLES"
|
|
249
|
+
scene.cycles.device = "CPU"
|
|
250
|
+
scene.cycles.samples = max(1, int(params["samples"]))
|
|
251
|
+
scene.cycles.use_denoising = bool(params["denoise"])
|
|
252
|
+
scene.cycles.seed = CYCLES_SEED
|
|
253
|
+
else:
|
|
254
|
+
raise BadJob("unknown engine %r" % (engine,))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def apply_resolution(params):
|
|
258
|
+
scene = bpy.context.scene
|
|
259
|
+
width = max(1, int(params["width"]))
|
|
260
|
+
height = max(1, int(params["height"]))
|
|
261
|
+
scene.render.resolution_x = width
|
|
262
|
+
scene.render.resolution_y = height
|
|
263
|
+
scene.render.resolution_percentage = max(1, int(params["resolution_percentage"]))
|
|
264
|
+
return width, height
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def import_model(job, workdir, fps=None):
|
|
268
|
+
"""Factory reset, glTF import, and the `no_geometry` gate.
|
|
269
|
+
|
|
270
|
+
`fps` is applied before the import when given: the importer maps a glTF
|
|
271
|
+
animation channel timestamp `t` seconds onto frame `round(t * fps)` (D4),
|
|
272
|
+
so the scene fps must already hold at import time.
|
|
273
|
+
"""
|
|
274
|
+
model_path = os.path.join(workdir, job["inputs"]["model"])
|
|
275
|
+
if not os.path.exists(model_path):
|
|
276
|
+
raise BadJob("model input %r is missing" % (job["inputs"]["model"],))
|
|
277
|
+
|
|
278
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
279
|
+
if fps is not None:
|
|
280
|
+
bpy.context.scene.render.fps = max(1, int(fps))
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
result = bpy.ops.import_scene.gltf(filepath=model_path)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
raise ImportFailed("glTF import failed: %s" % (exc,))
|
|
286
|
+
if "FINISHED" not in result:
|
|
287
|
+
raise ImportFailed("glTF importer rejected %r" % (job["inputs"]["model"],))
|
|
288
|
+
|
|
289
|
+
scene = bpy.context.scene
|
|
290
|
+
meshes = mesh_objects(scene)
|
|
291
|
+
if not meshes:
|
|
292
|
+
raise NoGeometry("the model contains no visible geometry")
|
|
293
|
+
ensure_materials(meshes)
|
|
294
|
+
return scene, meshes
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def setup_render_scene(job, workdir, fps=None):
|
|
298
|
+
"""Full still-style setup. Returns `(scene, meshes, center, radius, camera)`."""
|
|
299
|
+
params = job["job"]["params"]
|
|
300
|
+
scene, meshes = import_model(job, workdir, fps=fps)
|
|
301
|
+
width, height = apply_resolution(params)
|
|
302
|
+
aspect = width / height
|
|
303
|
+
center, radius = scene_bounds(meshes)
|
|
304
|
+
camera_obj = select_camera(scene, center, radius, params, aspect)
|
|
305
|
+
scene.camera = camera_obj
|
|
306
|
+
if not has_scene_lights(scene):
|
|
307
|
+
apply_lighting_preset(scene, camera_obj, center, params)
|
|
308
|
+
apply_world(params["background_color"], params["transparent"])
|
|
309
|
+
apply_engine(params)
|
|
310
|
+
return scene, meshes, center, radius, camera_obj
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The `export_model` op: glTF bytes in, one exported file out (D8).
|
|
2
|
+
|
|
3
|
+
`params["format"]` is `fbx`, `obj`, or `usd`. The node persists the bytes
|
|
4
|
+
through `context.createAsset` and returns an `AssetRef` — never a
|
|
5
|
+
`Model3DRef`, which keeps meaning glTF everywhere. GLB is deliberately not
|
|
6
|
+
a format here: `prepare_for_engine` and `FormatConverter` already produce a
|
|
7
|
+
`Model3DRef` for it.
|
|
8
|
+
|
|
9
|
+
The operators are the ones Blender 5.x ships: the FBX IO add-on (enabled
|
|
10
|
+
under `--factory-startup`) and the core `wm.obj_export` / `wm.usd_export`
|
|
11
|
+
operators, which need no add-on. A call that raises (a failing poll, a
|
|
12
|
+
rejected scene) surfaces as `export_failed` with the operator's message.
|
|
13
|
+
|
|
14
|
+
OBJ export is geometry-only (`export_materials=False`): the operator would
|
|
15
|
+
otherwise write a `model.mtl` the job never declares, and the `.obj` would
|
|
16
|
+
keep a dangling `mtllib` line pointing at a file that dies with the scratch
|
|
17
|
+
directory.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import bpy
|
|
24
|
+
|
|
25
|
+
from errors import BadJob, ExportFailed
|
|
26
|
+
from ops.common import import_model
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_FORMAT_OPS = {
|
|
30
|
+
"fbx": "export_scene.fbx",
|
|
31
|
+
"obj": "wm.obj_export",
|
|
32
|
+
"usd": "wm.usd_export",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _exporter(format):
|
|
37
|
+
try:
|
|
38
|
+
dotted = _FORMAT_OPS[format]
|
|
39
|
+
except KeyError:
|
|
40
|
+
raise BadJob(
|
|
41
|
+
"unknown export format %r: choose from %s" % (format, sorted(_FORMAT_OPS))
|
|
42
|
+
)
|
|
43
|
+
group, name = dotted.split(".")
|
|
44
|
+
return getattr(getattr(bpy.ops, group), name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _export_kwargs(format, out_path):
|
|
48
|
+
# OBJ export is geometry-only by decision: `wm.obj_export` writes
|
|
49
|
+
# `model.mtl` next to `model.obj`, but the job declares (and the node
|
|
50
|
+
# returns) only `file`, so the material file would vanish with the
|
|
51
|
+
# scratch directory while the `.obj` kept a dangling `mtllib` line.
|
|
52
|
+
# Disabling materials keeps the contract to one file.
|
|
53
|
+
if format == "obj":
|
|
54
|
+
return {"filepath": out_path, "export_materials": False}
|
|
55
|
+
return {"filepath": out_path}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(job, workdir):
|
|
59
|
+
"""Run `export_model`. Returns `(produced, stats)`."""
|
|
60
|
+
params = job["job"]["params"]
|
|
61
|
+
outputs = job["outputs"]
|
|
62
|
+
if "file" not in outputs:
|
|
63
|
+
raise BadJob("export_model declares no 'file' output")
|
|
64
|
+
format = params.get("format")
|
|
65
|
+
if format not in _FORMAT_OPS:
|
|
66
|
+
raise BadJob(
|
|
67
|
+
"unknown export format %r: choose from %s" % (format, sorted(_FORMAT_OPS))
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# The empty scene gates as `no_geometry` inside `import_model`, before
|
|
71
|
+
# any export time is spent.
|
|
72
|
+
_scene, meshes = import_model(job, workdir)
|
|
73
|
+
|
|
74
|
+
out_path = os.path.join(workdir, outputs["file"])
|
|
75
|
+
export = _exporter(format)
|
|
76
|
+
|
|
77
|
+
started = time.monotonic()
|
|
78
|
+
try:
|
|
79
|
+
result = export(**_export_kwargs(format, out_path))
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise ExportFailed("%s export failed: %s" % (format, exc))
|
|
82
|
+
render_seconds = time.monotonic() - started
|
|
83
|
+
|
|
84
|
+
if "FINISHED" not in result:
|
|
85
|
+
raise ExportFailed("%s exporter rejected the scene" % (format,))
|
|
86
|
+
if not os.path.exists(out_path):
|
|
87
|
+
raise ExportFailed(
|
|
88
|
+
"%s export finished but %r is missing" % (format, outputs["file"])
|
|
89
|
+
)
|
|
90
|
+
stats = {
|
|
91
|
+
"blender_version": bpy.app.version_string,
|
|
92
|
+
"render_seconds": render_seconds,
|
|
93
|
+
"objects": len(meshes),
|
|
94
|
+
}
|
|
95
|
+
return ["file"], stats
|