@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
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @nodetool-ai/blender-nodes
2
+
3
+ Headless Blender nodes for NodeTool: render glTF scenes to images, image
4
+ sequences and render passes, and prepare meshes for a game engine.
5
+
6
+ The nodes shell out to a Blender binary, resolved by `blender-binary.ts` from
7
+ `BLENDER_PATH` or the platform's usual install locations. Without a
8
+ Blender install the nodes fail their preflight with the path they looked for —
9
+ they never silently produce an empty render.
10
+
11
+ ## Nodes
12
+
13
+ | Node | Does |
14
+ |---|---|
15
+ | `RenderImage` | One frame of a glTF scene |
16
+ | `RenderAnimation` | A frame range, returned as a video |
17
+ | `RenderPasses` | Per-pass output (depth, normal, cryptomatte, …) |
18
+ | `PrepareForEngine` | Mesh cleanup, decimation and export for engine import |
19
+ | `ExportModel` | Convert a scene between the formats Blender writes |
20
+
21
+ ## Usage
22
+
23
+ Install the package alongside the runtime and it registers with the node
24
+ registry like any other node package:
25
+
26
+ ```bash
27
+ npm install @nodetool-ai/blender-nodes
28
+ ```
29
+
30
+ Run a single node without a graph:
31
+
32
+ ```bash
33
+ nodetool node run nodetool.blender.RenderImage --props '{"scene": "..."}'
34
+ ```
35
+
36
+ See [AGENTS.md](../../AGENTS.md) for the repo-wide build and test commands.
@@ -0,0 +1,133 @@
1
+ """Depth normalization for the `render_passes` op (D4).
2
+
3
+ Pure Python, no `bpy` import, so `tests/test_depth.py` runs under system
4
+ Python as well as Blender's own interpreter.
5
+
6
+ Contracts:
7
+
8
+ - `depth`: linear distance along the camera view axis, in scene units, from
9
+ the Z pass. `png16` (default) normalizes to `[0, 65535]` between
10
+ `depth_near` and `depth_far` — the min and max finite depth over the
11
+ foreground (masked) pixels — with background `65535`. `exr` keeps the raw
12
+ float with background `+inf`, rewritten from the `1e10` sentinel the
13
+ staged file carries.
14
+ """
15
+
16
+ import struct
17
+ import zlib
18
+
19
+ #: 16-bit value written for background (masked-out or non-finite) pixels.
20
+ BACKGROUND16 = 65535
21
+
22
+ _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
23
+
24
+
25
+ def _isfinite(value):
26
+ return value != float("inf") and value != float("-inf") and value == value
27
+
28
+
29
+ def depth_range(depths, foreground):
30
+ """Min/max finite depth over the foreground pixels.
31
+
32
+ `depths` is the Z channel in row-major order, `foreground` a parallel
33
+ mask (nonzero is foreground). Returns `(near, far)`. Raises
34
+ `ValueError` when no foreground pixel carries a finite depth.
35
+ """
36
+ near = float("inf")
37
+ far = float("-inf")
38
+ for depth, mask in zip(depths, foreground):
39
+ if not mask:
40
+ continue
41
+ if not _isfinite(depth):
42
+ continue
43
+ if depth < near:
44
+ near = depth
45
+ if depth > far:
46
+ far = depth
47
+ if near == float("inf"):
48
+ raise ValueError("no foreground pixel carries a finite depth")
49
+ return near, far
50
+
51
+
52
+ def normalize_to_u16(depths, foreground, near, far):
53
+ """Map depths to `[0, 65535]`: foreground `near -> 0`, `far -> 65535`.
54
+
55
+ Background (unmasked) and non-finite pixels map to `65535`. A degenerate
56
+ range (`far <= near`, e.g. one fronto-parallel plane) maps finite
57
+ foreground to `0` instead of dividing by zero.
58
+ """
59
+ span = far - near
60
+ values = []
61
+ for depth, mask in zip(depths, foreground):
62
+ if not mask or not _isfinite(depth):
63
+ values.append(BACKGROUND16)
64
+ elif span <= 0:
65
+ values.append(0)
66
+ else:
67
+ scaled = (depth - near) / span
68
+ if scaled < 0:
69
+ scaled = 0
70
+ elif scaled > 1:
71
+ scaled = 1
72
+ values.append(int(round(scaled * BACKGROUND16)))
73
+ return values
74
+
75
+
76
+ def _chunk(kind, payload):
77
+ return (
78
+ struct.pack(">I", len(payload))
79
+ + kind
80
+ + payload
81
+ + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
82
+ )
83
+
84
+
85
+ def _check_fills(values, width, height, channels, label):
86
+ if len(values) != width * height * channels:
87
+ raise ValueError(
88
+ "%s values %d do not fill %dx%dx%d"
89
+ % (label, len(values), width, height, channels)
90
+ )
91
+
92
+
93
+ def _write_png(path, width, height, bit_depth, color_type, raw):
94
+ ihdr = struct.pack(">IIBBBBB", width, height, bit_depth, color_type, 0, 0, 0)
95
+ with open(path, "wb") as handle:
96
+ handle.write(_PNG_SIGNATURE)
97
+ handle.write(_chunk(b"IHDR", ihdr))
98
+ handle.write(_chunk(b"IDAT", zlib.compress(bytes(raw))))
99
+ handle.write(_chunk(b"IEND", b""))
100
+
101
+
102
+ def write_gray16_png(path, width, height, values):
103
+ """Write 16-bit grayscale PNG: big-endian samples, filter 0 per row."""
104
+ _check_fills(values, width, height, 1, "depth")
105
+ raw = bytearray()
106
+ for row in range(height):
107
+ raw.append(0)
108
+ for col in range(width):
109
+ raw += struct.pack(">H", values[row * width + col])
110
+ _write_png(path, width, height, 16, 0, raw)
111
+
112
+
113
+ def write_gray8_png(path, width, height, values):
114
+ """Write 8-bit grayscale PNG (the mask pass): filter 0 per row."""
115
+ _check_fills(values, width, height, 1, "mask")
116
+ raw = bytearray()
117
+ for row in range(height):
118
+ raw.append(0)
119
+ for col in range(width):
120
+ raw.append(values[row * width + col])
121
+ _write_png(path, width, height, 8, 0, raw)
122
+
123
+
124
+ def write_rgb8_png(path, width, height, values):
125
+ """Write 8-bit RGB PNG (the normal pass): filter 0 per row."""
126
+ _check_fills(values, width, height, 3, "normal")
127
+ raw = bytearray()
128
+ for row in range(height):
129
+ raw.append(0)
130
+ for col in range(width):
131
+ base = (row * width + col) * 3
132
+ raw += bytes(values[base : base + 3])
133
+ _write_png(path, width, height, 8, 2, raw)
@@ -0,0 +1,46 @@
1
+ """Error classes shared by `run_job.py` and the op modules.
2
+
3
+ An op failure carries its `result.json` error code on the class, so
4
+ `run_job.py` turns it into `ok: false` with the code from the class and the
5
+ message from the exception. Imported before `sys.excepthook` is installed:
6
+ this module imports nothing, so the import itself cannot fail.
7
+ """
8
+
9
+
10
+ class BlenderOpError(Exception):
11
+ """An op failure with a `result.json` error code on the class."""
12
+
13
+ code = "render_failed"
14
+
15
+ def __init__(self, message="", code=None):
16
+ super().__init__(message)
17
+ if code is not None:
18
+ self.code = code
19
+
20
+
21
+ class BadJob(BlenderOpError):
22
+ code = "bad_job"
23
+
24
+
25
+ class ImportFailed(BlenderOpError):
26
+ code = "import_failed"
27
+
28
+
29
+ class NoGeometry(BlenderOpError):
30
+ code = "no_geometry"
31
+
32
+
33
+ class NoCamera(BlenderOpError):
34
+ code = "no_camera"
35
+
36
+
37
+ class RenderFailed(BlenderOpError):
38
+ code = "render_failed"
39
+
40
+
41
+ class ExportFailed(BlenderOpError):
42
+ code = "export_failed"
43
+
44
+
45
+ class BakeFailed(BlenderOpError):
46
+ code = "bake_failed"
@@ -0,0 +1,205 @@
1
+ """Minimal OpenEXR reader for the compositor's staged passes.
2
+
3
+ Blender's File Output node writes multilayer scanline EXR that this
4
+ Blender's own `bpy.data.images.load` surfaces as a 0x0 image, so the op
5
+ reads the bytes directly. Only the shape Blender writes under
6
+ `--factory-startup` is supported, and the reader refuses anything else
7
+ rather than guessing:
8
+
9
+ - single-part scanline image, increasing line order,
10
+ - `NO_COMPRESSION` (Blender's default for file outputs),
11
+ - `FLOAT` channels (depth and normal passes are float),
12
+ - one chunk per scanline holding every channel in file order.
13
+
14
+ Pure Python, no `bpy`: `tests/test_exr.py` (inside `test_depth.py`) covers
15
+ it with a synthetic file, and the T4 passes suite covers it against Blender.
16
+ """
17
+
18
+ import struct
19
+
20
+ _MAGIC = b"v/1\x01"
21
+
22
+ _NO_COMPRESSION = 0
23
+
24
+ _FLOAT = 2
25
+ _HALF = 1
26
+
27
+ _TYPE_SIZES = {_FLOAT: 4, _HALF: 2}
28
+
29
+
30
+ class ExrError(ValueError):
31
+ """The file is not the scanline EXR shape this reader supports."""
32
+
33
+
34
+ def _read_cstring(blob, pos):
35
+ end = blob.index(b"\x00", pos)
36
+ return blob[pos:end].decode("utf-8"), end + 1
37
+
38
+
39
+ def _parse_header(blob):
40
+ if blob[:4] != _MAGIC:
41
+ raise ExrError("not an EXR file")
42
+ (version,) = struct.unpack("<I", blob[4:8])
43
+ if version & 0x200:
44
+ raise ExrError("tiled EXR is not supported")
45
+ if version & 0x800:
46
+ raise ExrError("multipart EXR is not supported")
47
+ if version & 0x400:
48
+ raise ExrError("long-name EXR is not supported")
49
+ pos = 8
50
+ attrs = {}
51
+ while True:
52
+ name, pos = _read_cstring(blob, pos)
53
+ if name == "":
54
+ break
55
+ _type, pos = _read_cstring(blob, pos)
56
+ (size,) = struct.unpack("<i", blob[pos : pos + 4])
57
+ pos += 4
58
+ attrs[name] = blob[pos : pos + size]
59
+ pos += size
60
+ return attrs, pos
61
+
62
+
63
+ def _parse_channels(raw):
64
+ channels = []
65
+ pos = 0
66
+ while pos < len(raw):
67
+ name, pos = _read_cstring(raw, pos)
68
+ if name == "":
69
+ break
70
+ pixel_type = struct.unpack("<i", raw[pos : pos + 4])[0]
71
+ x_sampling = struct.unpack("<i", raw[pos + 8 : pos + 12])[0]
72
+ y_sampling = struct.unpack("<i", raw[pos + 12 : pos + 16])[0]
73
+ pos += 16
74
+ channels.append((name, pixel_type, x_sampling, y_sampling))
75
+ return channels
76
+
77
+
78
+ def read_exr_rgba(path):
79
+ """Read R, G, B channels as float lists in row-major order.
80
+
81
+ Returns `(width, height, r, g, b)`. Channel lookup is by suffix: the
82
+ compositor writes `*.R`, `*.G`, `*.B` (multilayer prefixes the socket
83
+ layer, e.g. `Image.R`). Raises `ExrError` on any other shape.
84
+ """
85
+ with open(path, "rb") as handle:
86
+ blob = handle.read()
87
+ attrs, pos = _parse_header(blob)
88
+ (compression,) = struct.unpack("B", attrs["compression"][:1])
89
+ if compression != _NO_COMPRESSION:
90
+ raise ExrError("compressed EXR (codec %d) is not supported" % (compression,))
91
+ (x0, y0, x1, y1) = struct.unpack("<iiii", attrs["dataWindow"])
92
+ width, height = x1 - x0 + 1, y1 - y0 + 1
93
+ channels = _parse_header_channels(attrs)
94
+ if any(s != (1, 1) for _, _, s in channels):
95
+ raise ExrError("subsampled EXR channels are not supported")
96
+ for _, pixel_type, _ in channels:
97
+ if pixel_type not in _TYPE_SIZES:
98
+ raise ExrError("non-float EXR channel is not supported")
99
+
100
+ offsets = struct.unpack("<%dq" % (height,), blob[pos : pos + 8 * height])
101
+ found = {}
102
+ for row, offset in enumerate(offsets):
103
+ (y,) = struct.unpack("<i", blob[offset : offset + 4])
104
+ if y != y0 + row:
105
+ raise ExrError("scanline %d out of order" % (row,))
106
+ (size,) = struct.unpack("<i", blob[offset + 4 : offset + 8])
107
+ cursor = offset + 8
108
+ for name, pixel_type, _ in channels:
109
+ count = width * _TYPE_SIZES[pixel_type]
110
+ chunk = blob[cursor : cursor + count]
111
+ if len(chunk) != count:
112
+ raise ExrError("scanline %d is truncated" % (row,))
113
+ cursor += count
114
+ values = _decode(chunk, pixel_type)
115
+ found.setdefault(name, []).extend(values)
116
+ if cursor != offset + 8 + size:
117
+ raise ExrError("scanline %d size mismatch" % (row,))
118
+
119
+ def channel(suffix):
120
+ for name, values in found.items():
121
+ if name == suffix or name.endswith("." + suffix):
122
+ return values
123
+ raise ExrError("EXR has no .%s channel" % (suffix,))
124
+
125
+ return width, height, channel("R"), channel("G"), channel("B")
126
+
127
+
128
+ def _parse_header_channels(attrs):
129
+ raw = attrs.get("channels")
130
+ if raw is None:
131
+ raise ExrError("EXR has no channel list")
132
+ channels = [
133
+ (name, pixel_type, (xs, ys))
134
+ for name, pixel_type, xs, ys in _parse_channels(raw)
135
+ ]
136
+ if not channels:
137
+ raise ExrError("EXR has no channels")
138
+ return channels
139
+
140
+
141
+ def _is_color_channel(name):
142
+ return name in ("R", "G", "B") or name.endswith((".R", ".G", ".B"))
143
+
144
+
145
+ def rewrite_background_to_inf(path, foreground):
146
+ """Set every non-foreground sample of a staged depth EXR to `+inf`.
147
+
148
+ The compositor stages the raw Z pass, whose off-geometry pixels carry
149
+ the no-hit sentinel (`1e10`, measured on Blender 5.2.1 on both EEVEE
150
+ and Cycles) instead of the `+inf` the `render_passes` contract
151
+ promises for `depth_format: "exr"`. `foreground` is the row-major
152
+ mask the op resolved from this same file (`True` is foreground):
153
+ every other sample becomes `+inf` in each R/G/B-suffixed float
154
+ channel, foreground samples are untouched. Refuses files outside
155
+ the shape `read_exr_rgba` supports.
156
+ """
157
+ with open(path, "rb") as handle:
158
+ blob = bytearray(handle.read())
159
+ attrs, pos = _parse_header(blob)
160
+ (compression,) = struct.unpack("B", attrs["compression"][:1])
161
+ if compression != _NO_COMPRESSION:
162
+ raise ExrError("compressed EXR (codec %d) is not supported" % (compression,))
163
+ (x0, y0, x1, y1) = struct.unpack("<iiii", attrs["dataWindow"])
164
+ width, height = x1 - x0 + 1, y1 - y0 + 1
165
+ if len(foreground) != width * height:
166
+ raise ValueError(
167
+ "foreground mask %d does not fill %dx%d"
168
+ % (len(foreground), width, height)
169
+ )
170
+ channels = _parse_header_channels(attrs)
171
+ if any(s != (1, 1) for _, _, s in channels):
172
+ raise ExrError("subsampled EXR channels are not supported")
173
+ for _, pixel_type, _ in channels:
174
+ if pixel_type not in _TYPE_SIZES:
175
+ raise ExrError("non-float EXR channel is not supported")
176
+
177
+ offsets = struct.unpack("<%dq" % (height,), blob[pos : pos + 8 * height])
178
+ for row, offset in enumerate(offsets):
179
+ (y,) = struct.unpack("<i", blob[offset : offset + 4])
180
+ if y != y0 + row:
181
+ raise ExrError("scanline %d out of order" % (row,))
182
+ (size,) = struct.unpack("<i", blob[offset + 4 : offset + 8])
183
+ cursor = offset + 8
184
+ for name, pixel_type, _ in channels:
185
+ sample = _TYPE_SIZES[pixel_type]
186
+ if _is_color_channel(name):
187
+ if pixel_type == _FLOAT:
188
+ inf = struct.pack("<f", float("inf"))
189
+ else:
190
+ inf = struct.pack("<e", float("inf"))
191
+ for col in range(width):
192
+ if not foreground[row * width + col]:
193
+ start = cursor + col * sample
194
+ blob[start : start + sample] = inf
195
+ cursor += width * sample
196
+ if cursor != offset + 8 + size:
197
+ raise ExrError("scanline %d size mismatch" % (row,))
198
+ with open(path, "wb") as handle:
199
+ handle.write(bytes(blob))
200
+
201
+
202
+ def _decode(chunk, pixel_type):
203
+ if pixel_type == _FLOAT:
204
+ return list(struct.unpack("<%df" % (len(chunk) // 4,), chunk))
205
+ return list(struct.unpack("<%de" % (len(chunk) // 2,), chunk))
@@ -0,0 +1,75 @@
1
+ """Auto-framing math for the Blender orbit camera.
2
+
3
+ A line-for-line port of `computeFraming` and `orbitOffset` from
4
+ `packages/video-nodes/src/nodes/model3d/render3d-core.ts`: same math, same
5
+ names (snake_cased). The two sides are pinned to the same golden camera
6
+ position by `tests/framing.test.ts` (TypeScript) and
7
+ `blender_ops/tests/test_framing.py` (this interpreter).
8
+
9
+ The offsets are in the three.js convention (Y up). `ops/render_image.py`
10
+ rotates them rigidly into Blender space when placing the camera; the
11
+ functions here stay identical so the cross-language pin holds.
12
+
13
+ `apply_camera_lens` is the one function here that is not part of that port:
14
+ it writes the framing onto a camera datablock, and lives beside the math so
15
+ that what `fov` means (the vertical axis) is decided in one place. It touches
16
+ no bpy API, so `tests/test_camera_lens.py` exercises it without Blender.
17
+ """
18
+
19
+ import math
20
+
21
+
22
+ def compute_framing(radius, fov_deg, aspect, zoom):
23
+ """Distance fitting a bounding sphere of `radius` into the frame.
24
+
25
+ `zoom` > 1 moves closer. Returns a dict with `distance`, `near`, `far`.
26
+ """
27
+ safe_radius = max(radius, 1e-6)
28
+ v_fov = (max(fov_deg, 1) * math.pi) / 180
29
+ h_fov = 2 * math.atan(math.tan(v_fov / 2) * max(aspect, 1e-6))
30
+ fit_v = safe_radius / math.sin(v_fov / 2)
31
+ fit_h = safe_radius / math.sin(h_fov / 2)
32
+ distance = max(fit_v, fit_h) / max(zoom, 1e-3)
33
+ return {
34
+ "distance": distance,
35
+ "near": max(distance - safe_radius * 4, distance / 100, 1e-4),
36
+ "far": distance + safe_radius * 10,
37
+ }
38
+
39
+
40
+ def orbit_offset(azimuth_deg, elevation_deg, distance):
41
+ """Spherical-to-cartesian offset for the orbit camera (Y up)."""
42
+ az = (azimuth_deg * math.pi) / 180
43
+ el = (min(max(elevation_deg, -89.9), 89.9) * math.pi) / 180
44
+ return {
45
+ "x": distance * math.cos(el) * math.sin(az),
46
+ "y": distance * math.sin(el),
47
+ "z": distance * math.cos(el) * math.cos(az),
48
+ }
49
+
50
+
51
+ def apply_camera_lens(data, fov_deg, framing):
52
+ """Give a camera datablock the vertical fov `fov_deg` and `framing`'s clips.
53
+
54
+ `fov` means the *vertical* field of view on both sides of this stack:
55
+ `compute_framing` fits the bounding sphere against `v_fov`, and the
56
+ three.js preview sets `PerspectiveCamera.fov`, which is vertical too. So
57
+ the fit is `VERTICAL` and the angle is written through `angle_y`, which
58
+ always resolves against `sensor_height`.
59
+
60
+ Bare `angle` would not: it resolves against whichever axis `sensor_fit`
61
+ names, so writing it before the fit is set means the 36mm sensor *width*
62
+ and leaves a 35 degree request as a 23.7 degree camera — a 1.52x tighter
63
+ crop that no longer fits the sphere `compute_framing` measured. Two
64
+ callers wrote `angle` in opposite orders and rendered the same model at
65
+ two sizes; `angle_y` makes the order irrelevant.
66
+
67
+ Takes the datablock instead of importing bpy, so the rule is testable
68
+ outside Blender (`tests/test_camera_lens.py`).
69
+ """
70
+ data.sensor_fit = "VERTICAL"
71
+ # The same clamp `compute_framing` applies, so the lens and the distance
72
+ # it computed always describe one camera.
73
+ data.angle_y = math.radians(max(fov_deg, 1))
74
+ data.clip_start = framing["near"]
75
+ data.clip_end = framing["far"]
@@ -0,0 +1,24 @@
1
+ """Blender op modules. Stage 3 adds `prepare_for_engine` and `export_model`.
2
+
3
+ Each op exposes `run(job, workdir) -> (produced, stats)` where `job` is the
4
+ parsed `job.json` dict, `workdir` the scratch directory holding the inputs,
5
+ `produced` the logical output names written, and `stats` the fields for
6
+ `result.json`. An op raises `BlenderOpError` (or a subclass) on failure;
7
+ `run_job.py` turns it into `ok: false` with the code from the class.
8
+ """
9
+
10
+ from ops.export_model import run as export_model
11
+ from ops.prepare_for_engine import run as prepare_for_engine
12
+ from ops.render_animation import run as render_animation
13
+ from ops.render_image import run as render_image
14
+ from ops.render_passes import run as render_passes
15
+
16
+ DISPATCH = {
17
+ "render_image": render_image,
18
+ "render_passes": render_passes,
19
+ "render_animation": render_animation,
20
+ "prepare_for_engine": prepare_for_engine,
21
+ "export_model": export_model,
22
+ }
23
+
24
+ __all__ = ["DISPATCH"]