Skip to content

Blender Cycles backend

quiltwright.cycles

Blender Cycles Quilt Renderer

Drives Blender <https://www.blender.org/>_'s Cycles path tracer to produce quilts for Looking Glass holographic displays. This is the hardware-ray-tracing sibling of :mod:quiltwright.povray: on Apple Silicon, Cycles' Metal backend runs ray/triangle intersection on the GPU's dedicated ray-tracing cores (M3 and later; earlier chips run the same Metal path in GPU software), and the equivalent applies on NVIDIA (OptiX), AMD (HIP) and Intel (oneAPI) hardware elsewhere. POV-Ray can never use any of that -- it is a CPU tracer with its own primitive intersectors -- so scenes that exist as meshes rather than POV-Ray SDL come here instead.

The structural win over the POV-Ray backend is bigger than the hardware: POV-Ray re-parses the scene and rebuilds its data structures once per view -- 48 times for a Portrait quilt -- while this backend runs one Blender process for the whole sweep. The scene imports once, Cycles builds its BVH once (use_persistent_data), and only the camera moves between views.

Off-axis projection. The same shear as every other quiltwright backend, expressed through Blender's camera shift. For an eye offset s along the camera's unit right vector, with focal distance Z, vertical field of view fov and view aspect a, the eye translates by s (the aim point riding along, so the view direction never rotates) and the frustum is sheared back with

.. code-block:: text

shift_x = -s / (2 * Z * tan(fov/2) * a)

which is the identical quantity VTK's SetWindowCenter receives in :func:quiltwright.lfd._apply_off_axis_view, in Blender's units (fractions of the frame width, under a horizontal sensor fit) instead of VTK's half-widths. The original look-at point stays pinned to the centre of every view; that point is the holographic focal plane.

Scene sources. A .blend file renders with its own materials, lights and world; everything Blender can import -- glTF/GLB, OBJ, STL, PLY, USD, FBX, Alembic -- is loaded into an empty scene. Imported meshes usually arrive without lights, which in a path tracer means a black frame, so by default a neutral world and a sun are added when the scene has no light of its own. The lighting parameter picks the rig: a neutral world-plus-sun ("soft", the default), a camera-relative three-point studio rig ("studio"), Blender's physical sky ("sky"), an equirectangular .hdr/.exr environment map, or None to add nothing.

A .blend may also supply its own camera: pass camera=None and the file's active camera becomes the centre view. The focal plane then comes from the camera's depth-of-field focus distance (or focus object) -- that is Blender's native "this distance matters" annotation, and setting it does not blur anything unless DoF is actually enabled.

Requirements -- a blender binary (macOS: brew install --cask blender; the standard /Applications install is found automatically), plus pillow for quilt assembly. Blender 4.x or later.

Typical usage::

from quiltwright.quilt import QUILT_PRESETS, save_quilt
from quiltwright.cycles import CyclesCamera, render_cycles_quilt

camera = CyclesCamera(location=(0, -35, 8), look_at=(0, 0, 5), fov=14)
spec = QUILT_PRESETS["portrait"]
quilt = render_cycles_quilt("protein.glb", spec, camera, samples=128)
save_quilt(quilt, "protein", spec)   # -> protein_qs8x6a0.75.png

Part of Quiltwright -- https://github.com/Flux-Frontiers/quiltwright Author: Eric G. Suchanek, PhD

CyclesCamera(location, look_at, up=(0.0, 0.0, 1.0), fov=14.0) dataclass

A Blender camera in look-at form, plus the quilt's focal geometry.

The look_at point defines the holographic focal plane, so aim it at whatever should sit on the surface of the glass. Geometry closer to the camera floats out of the display; geometry beyond it recedes.

Coordinates are Blender's own: right-handed, Z up. This is the same convention most mesh interchange formats and :mod:pyvista use, so no conversion applies -- unlike :class:~quiltwright.povray.PovCamera, which speaks POV-Ray's left-handed world.

The fov/focal_distance pairing matches PovCamera, so :func:~quiltwright.povray.depth_budget and :func:~quiltwright.povray.format_depth_budget accept either camera: run the depth budget before committing Cycles to a 48-view render, exactly as you would for POV-Ray.

Parameters:

Name Type Description Default
location tuple[float, float, float]

Eye position (x, y, z).

required
look_at tuple[float, float, float]

Point the camera is aimed at. Becomes the focal plane.

required
up tuple[float, float, float]

Up-hint used to build the camera basis. Must not be parallel to the view direction. Defaults to +z, Blender's world up.

(0.0, 0.0, 1.0)
fov float

Vertical field of view in degrees. Looking Glass recommends ~14° for object-centric content; see :class:~quiltwright.povray.PovCamera for why interiors should keep their native FOV instead.

14.0

focal_distance property

Distance from the eye to the focal plane, in scene units.

aimed(location, aim, *, fov, focal_distance=None, lateral_shift=0.0, up=(0.0, 0.0, 1.0)) classmethod

Adopt a scene's own viewpoint, re-aimed and re-centred for a sweep.

The mesh-world twin of :meth:.PovCamera.aimed, with the same contract: the focal plane moves to focal_distance along the original aim ray and the eye slides lateral_shift along the camera's right vector, without touching the view direction or the lens. Pair with :class:~quiltwright.povray.Clearance for enclosed scenes.

Parameters:

Name Type Description Default
location Sequence[float]

The scene's eye position.

required
aim Sequence[float]

The scene's aim point. Used for direction only unless focal_distance is None.

required
fov float

Vertical field of view in degrees.

required
focal_distance float | None

Distance along the aim ray to place the focal plane. Defaults to the scene's own aim distance.

None
lateral_shift float

Distance to slide the eye along the camera's right vector before re-aiming.

0.0
up tuple[float, float, float]

Up-hint, as on :class:CyclesCamera.

(0.0, 0.0, 1.0)

Returns:

Type Description
CyclesCamera

The centre-view camera.

Raises:

Type Description
ValueError

If the camera is degenerate (see :meth:basis) or focal_distance is not positive.

Source code in src/quiltwright/cycles.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
@classmethod
def aimed(
    cls,
    location: Sequence[float],
    aim: Sequence[float],
    *,
    fov: float,
    focal_distance: float | None = None,
    lateral_shift: float = 0.0,
    up: tuple[float, float, float] = (0.0, 0.0, 1.0),
) -> CyclesCamera:
    """Adopt a scene's own viewpoint, re-aimed and re-centred for a sweep.

    The mesh-world twin of :meth:`.PovCamera.aimed`, with the same
    contract: the focal plane moves to *focal_distance* along the
    original aim ray and the eye slides *lateral_shift* along the
    camera's right vector, without touching the view direction or the
    lens.  Pair with :class:`~quiltwright.povray.Clearance` for enclosed
    scenes.

    :param location: The scene's eye position.
    :param aim: The scene's aim point.  Used for direction only unless
        *focal_distance* is ``None``.
    :param fov: Vertical field of view in degrees.
    :param focal_distance: Distance along the aim ray to place the focal
        plane.  Defaults to the scene's own aim distance.
    :param lateral_shift: Distance to slide the eye along the camera's
        right vector before re-aiming.
    :param up: Up-hint, as on :class:`CyclesCamera`.
    :return: The centre-view camera.
    :raises ValueError: If the camera is degenerate (see :meth:`basis`)
        or *focal_distance* is not positive.
    """
    base = cls(location=_triple(location), look_at=_triple(aim), up=up, fov=fov)
    forward, right, _ = base.basis()
    distance = base.focal_distance if focal_distance is None else float(focal_distance)
    if distance <= 0:
        raise ValueError(f"focal_distance must be positive, got {distance}")
    eye = np.asarray(base.location, dtype="d") + right * float(lateral_shift)
    return cls(
        location=_triple(eye),
        look_at=_triple(eye + forward * distance),
        up=up,
        fov=fov,
    )

basis()

Orthonormal camera basis (forward, right, up).

Right-handed -- right = forward x up_hint -- so a camera looking down +y with +z up gets right = +x. The driver rebuilds the same basis inside Blender; the end-to-end test pins the two against each other via the focal-plane invariant.

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

Three unit vectors as (3,) arrays.

Raises:

Type Description
ValueError

If the camera is degenerate (zero-length view direction, or up parallel to it).

Source code in src/quiltwright/cycles.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def basis(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Orthonormal camera basis ``(forward, right, up)``.

    Right-handed -- ``right = forward x up_hint`` -- so a camera looking
    down ``+y`` with ``+z`` up gets ``right = +x``.  The driver rebuilds
    the same basis inside Blender; the end-to-end test pins the two
    against each other via the focal-plane invariant.

    :return: Three unit vectors as ``(3,)`` arrays.
    :raises ValueError: If the camera is degenerate (zero-length view
        direction, or *up* parallel to it).
    """
    loc = np.asarray(self.location, dtype="d")
    forward = np.asarray(self.look_at, dtype="d") - loc
    norm = np.linalg.norm(forward)
    if norm == 0:
        raise ValueError("CyclesCamera.location and look_at are identical")
    forward = forward / norm

    right = np.cross(forward, np.asarray(self.up, dtype="d"))
    norm = np.linalg.norm(right)
    if norm < 1e-12:
        raise ValueError(
            f"CyclesCamera.up {self.up} is parallel to the view direction; "
            "pick a different up-hint"
        )
    right = right / norm
    return forward, right, np.cross(right, forward)

autoframe_camera(scene, *, fov=14.0, view_direction=(0.0, -1.0, 0.0), up=(0.0, 0.0, 1.0), margin=FRAME_MARGIN, binary=None)

Probe a mesh file's bounds and return a :class:CyclesCamera framing it.

:func:mesh_bounds then :func:frame_camera -- the one call an unfamiliar mesh file needs before :func:render_cycles_quilt, since the file carries no camera of its own. The returned camera's look_at is the bounds centre (the focal plane); run :func:~quiltwright.povray.format_depth_budget on it before committing to a full sweep, exactly as for any other :class:CyclesCamera.

Parameters:

Name Type Description Default
scene str | Path

Path to an importable mesh format in :data:SCENE_FORMATS (not .blend); see :func:mesh_bounds.

required
fov float

Vertical field of view in degrees.

14.0
view_direction Sequence[float]

Direction from the bounds centre to the eye; see :func:frame_camera.

(0.0, -1.0, 0.0)
up tuple[float, float, float]

Up-hint for the camera.

(0.0, 0.0, 1.0)
margin float

Framing headroom; see :func:frame_camera.

FRAME_MARGIN
binary str | None

Blender executable; see :func:render_cycles_quilt.

None

Returns:

Type Description
CyclesCamera

The centre-view :class:CyclesCamera.

Raises:

Type Description
RuntimeError

If the bounds probe fails; see :func:mesh_bounds.

Source code in src/quiltwright/cycles.py
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
def autoframe_camera(
    scene: str | Path,
    *,
    fov: float = 14.0,
    view_direction: Sequence[float] = (0.0, -1.0, 0.0),
    up: tuple[float, float, float] = (0.0, 0.0, 1.0),
    margin: float = FRAME_MARGIN,
    binary: str | None = None,
) -> CyclesCamera:
    """Probe a mesh file's bounds and return a :class:`CyclesCamera` framing it.

    :func:`mesh_bounds` then :func:`frame_camera` -- the one call an
    unfamiliar mesh file needs before :func:`render_cycles_quilt`, since the
    file carries no camera of its own.  The returned camera's ``look_at`` is
    the bounds centre (the focal plane); run
    :func:`~quiltwright.povray.format_depth_budget` on it before committing to
    a full sweep, exactly as for any other :class:`CyclesCamera`.

    :param scene: Path to an importable mesh format in :data:`SCENE_FORMATS`
        (not ``.blend``); see :func:`mesh_bounds`.
    :param fov: Vertical field of view in degrees.
    :param view_direction: Direction from the bounds centre to the eye; see
        :func:`frame_camera`.
    :param up: Up-hint for the camera.
    :param margin: Framing headroom; see :func:`frame_camera`.
    :param binary: Blender executable; see :func:`render_cycles_quilt`.
    :return: The centre-view :class:`CyclesCamera`.
    :raises RuntimeError: If the bounds probe fails; see :func:`mesh_bounds`.
    """
    lo, hi = mesh_bounds(scene, binary=binary)
    return frame_camera(lo, hi, fov=fov, view_direction=view_direction, up=up, margin=margin)

cycles_camera_from_plotter(plotter, *, fov=14.0, zoom=None)

Build the :class:CyclesCamera matching a plotter's current view.

The plotter's camera defines the centre view and its focal point becomes the holographic focal plane, exactly as in :func:~quiltwright.lfd.render_quilt -- including its FOV convention: the lens is narrowed to fov and the camera dollied back so the focal plane stays the same size in frame. The result is expressed in Blender's world under the glTF contract above, so it pairs with :func:export_plotter_gltf and nothing else.

The plotter is read, never mutated: unlike render_quilt, whose sweep must drive the live VTK camera, this backend renders from a copy of the view, so the plotter remains exactly as composed.

Parameters:

Name Type Description Default
plotter

A pv.Plotter (or anything with a vtkCamera-shaped .camera) with the view positioned, e.g. via plotter.camera_position or plotter.reset_camera().

required
fov float | None

Vertical field of view in degrees for the quilt cameras; the eye dollies back to compensate. None keeps the plotter's own FOV and distance.

14.0
zoom float | None

Optional dolly factor applied after framing; values > 1 make the subject fill more of each tile, which is what drives perceived depth.

None

Returns:

Type Description

The centre-view :class:CyclesCamera.

Source code in src/quiltwright/cycles.py
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
def cycles_camera_from_plotter(plotter, *, fov: float | None = 14.0, zoom: float | None = None):
    """Build the :class:`CyclesCamera` matching a plotter's current view.

    The plotter's camera defines the centre view and its focal point becomes
    the holographic focal plane, exactly as in
    :func:`~quiltwright.lfd.render_quilt` -- including its FOV convention:
    the lens is narrowed to *fov* and the camera dollied back so the focal
    plane stays the same size in frame.  The result is expressed in
    Blender's world under the glTF contract above, so it pairs with
    :func:`export_plotter_gltf` and nothing else.

    The plotter is read, never mutated: unlike ``render_quilt``, whose
    sweep must drive the live VTK camera, this backend renders from a copy
    of the view, so the plotter remains exactly as composed.

    :param plotter: A ``pv.Plotter`` (or anything with a vtkCamera-shaped
        ``.camera``) with the view positioned, e.g. via
        ``plotter.camera_position`` or ``plotter.reset_camera()``.
    :param fov: Vertical field of view in degrees for the quilt cameras;
        the eye dollies back to compensate.  ``None`` keeps the plotter's
        own FOV and distance.
    :param zoom: Optional dolly factor applied after framing; values > 1
        make the subject fill more of each tile, which is what drives
        perceived depth.
    :return: The centre-view :class:`CyclesCamera`.
    """
    from quiltwright.lfd import camera_frame

    pos, focal, _, true_up, distance = camera_frame(plotter.camera)
    forward = (focal - pos) / distance
    if fov is None:
        fov = float(plotter.camera.view_angle)
    else:
        half_height = distance * math.tan(math.radians(plotter.camera.view_angle) / 2.0)
        distance = half_height / math.tan(math.radians(fov) / 2.0)
    if zoom is not None and zoom != 1.0:
        distance /= zoom
    eye = focal - forward * distance
    return CyclesCamera(
        location=_to_blender(eye),
        look_at=_to_blender(focal),
        up=_to_blender(true_up),
        fov=float(fov),
    )

export_plotter_gltf(plotter, path)

Export a composed PyVista plotter scene as glTF for this backend.

A thin wrapper over plotter.export_gltf that pins the export settings the coordinate contract above depends on -- most importantly rotate_scene=False. Export works headless: no OpenGL context or prior render is required, so it runs where plotter.show() cannot.

Parameters:

Name Type Description Default
plotter

A pv.Plotter with the scene composed.

required
path str | Path

Destination .gltf path. Buffers and baked colour textures are inlined, so the one file is the whole scene.

required

Returns:

Type Description
Path

path, as a :class:~pathlib.Path.

Source code in src/quiltwright/cycles.py
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
def export_plotter_gltf(plotter, path: str | Path) -> Path:
    """Export a composed PyVista plotter scene as glTF for this backend.

    A thin wrapper over ``plotter.export_gltf`` that pins the export
    settings the coordinate contract above depends on -- most importantly
    ``rotate_scene=False``.  Export works headless: no OpenGL context or
    prior render is required, so it runs where ``plotter.show()`` cannot.

    :param plotter: A ``pv.Plotter`` with the scene composed.
    :param path: Destination ``.gltf`` path.  Buffers and baked colour
        textures are inlined, so the one file is the whole scene.
    :return: *path*, as a :class:`~pathlib.Path`.
    """
    out = Path(path).expanduser()
    plotter.export_gltf(str(out), inline_data=True, rotate_scene=False, save_normals=True)
    return out

frame_camera(lo, hi, *, fov=14.0, view_direction=(0.0, -1.0, 0.0), up=(0.0, 0.0, 1.0), margin=FRAME_MARGIN)

A :class:CyclesCamera framing an axis-aligned box, aimed at its centre.

Places the eye along view_direction from the box centre at the distance that makes the box's enclosing sphere fill fov (with margin headroom), and aims back at the centre -- which becomes the holographic focal plane. Uses the exact spherical relation sin(fov/2) = r / d rather than the small-angle tangent, since object-centric FOVs (~14-30 deg) are not small angles; the enclosing sphere (half the box diagonal) is used so the whole box stays framed from any view_direction.

Parameters:

Name Type Description Default
lo Sequence[float] | ndarray

Box minimum corner (x, y, z) -- e.g. from :func:mesh_bounds.

required
hi Sequence[float] | ndarray

Box maximum corner.

required
fov float

Vertical field of view in degrees.

14.0
view_direction Sequence[float] | ndarray

Direction from the box centre to the eye; need not be normalised. The default (0, -1, 0) is a front-on view looking along +y, with +z up.

(0.0, -1.0, 0.0)
up tuple[float, float, float]

Up-hint passed to the camera; must not be parallel to view_direction.

(0.0, 0.0, 1.0)
margin float

Framing headroom beyond a tight fit -- 1.0 is exactly tight, the default leaves a little air.

FRAME_MARGIN

Returns:

Type Description
CyclesCamera

The centre-view :class:CyclesCamera.

Raises:

Type Description
ValueError

If the box is degenerate (zero diagonal) or view_direction is the zero vector.

Source code in src/quiltwright/cycles.py
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
def frame_camera(
    lo: Sequence[float] | np.ndarray,
    hi: Sequence[float] | np.ndarray,
    *,
    fov: float = 14.0,
    view_direction: Sequence[float] | np.ndarray = (0.0, -1.0, 0.0),
    up: tuple[float, float, float] = (0.0, 0.0, 1.0),
    margin: float = FRAME_MARGIN,
) -> CyclesCamera:
    """A :class:`CyclesCamera` framing an axis-aligned box, aimed at its centre.

    Places the eye along *view_direction* from the box centre at the distance
    that makes the box's enclosing sphere fill *fov* (with *margin*
    headroom), and aims back at the centre -- which becomes the holographic
    focal plane.  Uses the exact spherical relation ``sin(fov/2) = r / d``
    rather than the small-angle tangent, since object-centric FOVs (~14-30
    deg) are not small angles; the enclosing *sphere* (half the box diagonal)
    is used so the whole box stays framed from any *view_direction*.

    :param lo: Box minimum corner ``(x, y, z)`` -- e.g. from
        :func:`mesh_bounds`.
    :param hi: Box maximum corner.
    :param fov: Vertical field of view in degrees.
    :param view_direction: Direction from the box centre to the eye; need not
        be normalised.  The default ``(0, -1, 0)`` is a front-on view looking
        along +y, with +z up.
    :param up: Up-hint passed to the camera; must not be parallel to
        *view_direction*.
    :param margin: Framing headroom beyond a tight fit -- 1.0 is exactly
        tight, the default leaves a little air.
    :return: The centre-view :class:`CyclesCamera`.
    :raises ValueError: If the box is degenerate (zero diagonal) or
        *view_direction* is the zero vector.
    """
    lo_a = np.asarray(lo, dtype="d")
    hi_a = np.asarray(hi, dtype="d")
    centre = (lo_a + hi_a) / 2.0
    radius = float(np.linalg.norm(hi_a - lo_a)) / 2.0
    if radius == 0.0:
        raise ValueError("degenerate bounds: lo == hi")
    direction = np.asarray(view_direction, dtype="d")
    dnorm = np.linalg.norm(direction)
    if dnorm == 0.0:
        raise ValueError("view_direction must be a non-zero vector")
    direction = direction / dnorm
    distance = radius / math.sin(math.radians(fov) / 2.0) * margin
    eye = centre + direction * distance
    return CyclesCamera(location=_triple(eye), look_at=_triple(centre), up=up, fov=float(fov))

mesh_bounds(scene, *, binary=None, extra_args=())

Import a mesh file headlessly and return its world-space bounds.

Runs one background Blender that imports scene through the same importer :func:render_cycles_quilt uses -- so the returned box is exactly what the render will see, with the file's transforms and axis conversion applied -- and reports the combined bounding box of every mesh object. Pair with :func:frame_camera (or call :func:autoframe_camera, which does both) to give an imported mesh the camera it doesn't carry.

Parameters:

Name Type Description Default
scene str | Path

Path to an importable mesh format in :data:SCENE_FORMATS. Not .blend -- a .blend carries its own camera, so frame it with camera=None instead of this.

required
binary str | None

Blender executable; see :func:render_cycles_quilt.

None
extra_args Sequence[str]

Extra Blender command-line arguments.

()

Returns:

Type Description
tuple[ndarray, ndarray]

(lo, hi), each a (3,) float array of the world-space minimum and maximum corner.

Raises:

Type Description
FileNotFoundError

If the scene file does not exist.

ValueError

If the format is unsupported, or is .blend.

RuntimeError

If Blender fails, or the scene has no mesh object.

Source code in src/quiltwright/cycles.py
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
def mesh_bounds(
    scene: str | Path,
    *,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
) -> tuple[np.ndarray, np.ndarray]:
    """Import a mesh file headlessly and return its world-space bounds.

    Runs one background Blender that imports *scene* through the same
    importer :func:`render_cycles_quilt` uses -- so the returned box is
    exactly what the render will see, with the file's transforms and
    axis conversion applied -- and reports the combined bounding box of every
    mesh object.  Pair with :func:`frame_camera` (or call
    :func:`autoframe_camera`, which does both) to give an imported mesh the
    camera it doesn't carry.

    :param scene: Path to an importable mesh format in :data:`SCENE_FORMATS`.
        Not ``.blend`` -- a ``.blend`` carries its own camera, so frame it
        with ``camera=None`` instead of this.
    :param binary: Blender executable; see :func:`render_cycles_quilt`.
    :param extra_args: Extra Blender command-line arguments.
    :return: ``(lo, hi)``, each a ``(3,)`` float array of the world-space
        minimum and maximum corner.
    :raises FileNotFoundError: If the scene file does not exist.
    :raises ValueError: If the format is unsupported, or is ``.blend``.
    :raises RuntimeError: If Blender fails, or the scene has no mesh object.
    """
    blender = _find_blender(binary)
    scene_path = Path(scene).expanduser().resolve()
    if not scene_path.is_file():
        raise FileNotFoundError(f"scene not found: {scene_path}")
    kind = _scene_format(scene_path)
    if kind == "blend":
        raise ValueError(
            "mesh_bounds is for importable mesh files; a .blend carries its own "
            "camera -- render it with camera=None"
        )

    with tempfile.TemporaryDirectory(prefix="cycles_bbox_") as tmp:
        workdir = Path(tmp)
        job_file = workdir / "bbox_job.json"
        job_file.write_text(json.dumps({"scene": str(scene_path), "format": kind}))
        driver = workdir / "bbox.py"
        driver.write_text(_BBOX_DRIVER)
        cmd = [
            blender,
            "--background",
            "--factory-startup",
            *extra_args,
            "--python",
            str(driver),
            "--",
            str(job_file),
        ]
        proc = subprocess.run(cmd, capture_output=True, text=True, cwd=workdir)

        bbox: dict | None = None
        error: str | None = None
        for line in proc.stdout.splitlines():
            if line.startswith("QW_BBOX "):
                bbox = json.loads(line[len("QW_BBOX ") :])
            elif line.startswith("QW_ERROR: "):
                error = line[len("QW_ERROR: ") :]
        if error is not None:
            raise RuntimeError(f"bounding-box probe failed: {error}")
        if bbox is None:
            tail = (proc.stdout[-1500:] + "\n" + proc.stderr[-1500:]).strip()
            raise RuntimeError(f"bounding-box probe produced no bounds:\n{tail}")
        return np.asarray(bbox["lo"], dtype="d"), np.asarray(bbox["hi"], dtype="d")

render_cycles_quilt(scene, spec, camera, *, view_cone=None, samples=64, denoise=True, view_transform='Standard', device='auto', lighting='soft', threads=None, binary=None, extra_args=(), keep_views=None, progress=True)

Render a mesh scene with Cycles into a Looking Glass quilt.

Sweeps camera horizontally across the display's view cone using off-axis projections (see the module docstring), path-traces one image per view, and tiles them with :func:~quiltwright.lfd.assemble_quilt.

The whole sweep runs in one Blender process: the scene imports once and Cycles keeps its BVH across views (use_persistent_data), so cost per view is dominated by actual ray tracing -- on Apple Silicon, Metal hardware ray tracing when a GPU device is available.

Parameters:

Name Type Description Default
scene str | Path

Path to the scene -- .blend, or any importable mesh format in :data:SCENE_FORMATS. Not modified.

required
spec QuiltSpec

Quilt specification (grid, size, aspect, cone).

required
camera CyclesCamera | None

Base camera; its look_at becomes the focal plane. None adopts a .blend's own active camera, taking the focal plane from its DoF focus distance (see the module docstring).

required
view_cone float | None

Override the spec's view cone in degrees.

None
samples int

Cycles samples per pixel. 64 previews cleanly with denoising; 128-256 for finals.

64
denoise bool

Run Cycles' denoiser on each view.

True
view_transform str

Color management applied to the render -- an OCIO view transform name Blender recognises ("Standard", "AgX", "Filmic", ...). "Standard" is Blender's raw display-referred output and reads closest to POV-Ray's; Blender's own interactive default since 4.0 is "AgX", whose filmic highlight compression desaturates and flattens a render next to POV-Ray's or a reference photo -- deliberately not the default here.

'Standard'
device str

"auto" prefers a GPU (Metal first) and falls back to CPU; "gpu" errors if no GPU compute device exists; "cpu" forces CPU rendering.

'auto'
lighting str | Path | None

How to light an imported scene that has no lights of its own -- without this a path tracer renders an unlit import black. "soft" (default) is a neutral world plus a sun; "studio" a camera-relative three-point rig over a dark world; "sky" Blender's physical sky; a .hdr/.exr path an HDRI environment world. None adds nothing. Rigs scale with the focal distance, never touch a .blend, and defer to any light the import carries.

'soft'
threads int | None

Blender -t thread count. None applies the same courtesy cap as the POV-Ray backend (cpu_count - 2); 0 lets Blender take every core. GPU rendering is unaffected.

None
binary str | None

Blender executable; defaults to BLENDER_BINARY, blender on PATH, or the macOS application bundle.

None
extra_args Sequence[str]

Extra Blender command-line arguments, e.g. a ["--log", "..."] debug flag.

()
keep_views str | Path | None

Directory to retain the per-view PNGs and the job description in, for inspection. Discarded if None.

None
progress bool

Print a progress line while rendering.

True

Returns:

Type Description
ndarray

uint8 RGB array of shape (quilt_height, quilt_width, 3).

Source code in src/quiltwright/cycles.py
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
def render_cycles_quilt(
    scene: str | Path,
    spec: QuiltSpec,
    camera: CyclesCamera | None,
    *,
    view_cone: float | None = None,
    samples: int = 64,
    denoise: bool = True,
    view_transform: str = "Standard",
    device: str = "auto",
    lighting: str | Path | None = "soft",
    threads: int | None = None,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
    keep_views: str | Path | None = None,
    progress: bool = True,
) -> np.ndarray:
    """Render a mesh scene with Cycles into a Looking Glass quilt.

    Sweeps *camera* horizontally across the display's view cone using
    off-axis projections (see the module docstring), path-traces one image
    per view, and tiles them with :func:`~quiltwright.lfd.assemble_quilt`.

    The whole sweep runs in **one** Blender process: the scene imports once
    and Cycles keeps its BVH across views (``use_persistent_data``), so cost
    per view is dominated by actual ray tracing -- on Apple Silicon, Metal
    hardware ray tracing when a GPU device is available.

    :param scene: Path to the scene -- ``.blend``, or any importable mesh
        format in :data:`SCENE_FORMATS`.  Not modified.
    :param spec: Quilt specification (grid, size, aspect, cone).
    :param camera: Base camera; its ``look_at`` becomes the focal plane.
        ``None`` adopts a ``.blend``'s own active camera, taking the focal
        plane from its DoF focus distance (see the module docstring).
    :param view_cone: Override the spec's view cone in degrees.
    :param samples: Cycles samples per pixel.  64 previews cleanly with
        denoising; 128-256 for finals.
    :param denoise: Run Cycles' denoiser on each view.
    :param view_transform: Color management applied to the render -- an OCIO
        view transform name Blender recognises (``"Standard"``,
        ``"AgX"``, ``"Filmic"``, ...).  ``"Standard"`` is Blender's raw
        display-referred output and reads closest to POV-Ray's; Blender's
        own interactive default since 4.0 is ``"AgX"``, whose filmic
        highlight compression desaturates and flattens a render next to
        POV-Ray's or a reference photo -- deliberately not the default here.
    :param device: ``"auto"`` prefers a GPU (Metal first) and falls back to
        CPU; ``"gpu"`` errors if no GPU compute device exists; ``"cpu"``
        forces CPU rendering.
    :param lighting: How to light an *imported* scene that has no lights of
        its own -- without this a path tracer renders an unlit import black.
        ``"soft"`` (default) is a neutral world plus a sun; ``"studio"`` a
        camera-relative three-point rig over a dark world; ``"sky"``
        Blender's physical sky; a ``.hdr``/``.exr`` path an HDRI
        environment world.  ``None`` adds nothing.  Rigs scale with the
        focal distance, never touch a ``.blend``, and defer to any light
        the import carries.
    :param threads: Blender ``-t`` thread count.  ``None`` applies the same
        courtesy cap as the POV-Ray backend (``cpu_count - 2``); ``0`` lets
        Blender take every core.  GPU rendering is unaffected.
    :param binary: Blender executable; defaults to ``BLENDER_BINARY``,
        ``blender`` on ``PATH``, or the macOS application bundle.
    :param extra_args: Extra Blender command-line arguments, e.g. a
        ``["--log", "..."]`` debug flag.
    :param keep_views: Directory to retain the per-view PNGs and the job
        description in, for inspection.  Discarded if ``None``.
    :param progress: Print a progress line while rendering.
    :return: ``uint8`` RGB array of shape ``(quilt_height, quilt_width, 3)``.
    """
    from PIL import Image

    blender, scene_path, kind, spec = _prepare(scene, spec, camera, view_cone, binary)

    # Match render_quilt/render_pov_quilt: capture at the declared view
    # aspect so the frustum is undistorted, then let assemble_quilt resample
    # into the tile (anamorphic presets, e.g. the 27" quilts).
    render_h = spec.tile_height
    render_w = round(render_h * spec.aspect)

    job = {
        "scene": str(scene_path),
        "format": kind,
        "width": render_w,
        "height": render_h,
        "angles": _view_angles(spec),
        "camera": _camera_job(camera),
        "samples": int(samples),
        "denoise": bool(denoise),
        "view_transform": view_transform,
        "device": device,
        "lighting": _lighting_job(lighting),
    }

    with tempfile.TemporaryDirectory(prefix="cycles_quilt_") as tmp:
        workdir = Path(tmp)
        views = _run_blender(blender, workdir, job, extra_args, threads, progress)
        quilt = assemble_quilt((np.asarray(Image.open(png).convert("RGB")) for png in views), spec)
        if keep_views is not None:
            out = Path(keep_views).expanduser()
            out.mkdir(parents=True, exist_ok=True)
            shutil.copy2(workdir / "job.json", out / "job.json")
            for png in views:
                shutil.copy2(png, out / png.name)
    return quilt

render_cycles_quilt_from_plotter(plotter, spec, *, fov=14.0, zoom=None, gltf=None, **kwargs)

Render a PyVista plotter's scene into a quilt with Cycles.

The hardware-ray-traced sibling of :func:~quiltwright.lfd.render_quilt: same plotter in, same quilt out, but the views are path-traced by Cycles -- with GPU ray tracing where the hardware offers it -- instead of rasterised by VTK. Compose the scene and position the camera exactly as you would for render_quilt, then swap the call.

The scene is exported to glTF once (see :func:export_plotter_gltf) and the whole sweep renders in one Blender process. The export carries no lights, so the backend's lighting rigs supply them -- the "soft" default for a neutral look, lighting="studio" for a three-point product shot, "sky" or an HDRI path for environments; pass materials-appropriate samples for finals.

Parameters:

Name Type Description Default
plotter

An off-screen pv.Plotter with the scene composed. Read, never mutated -- and never rendered, so this works on machines whose GL stack cannot even take a screenshot.

required
spec QuiltSpec

Quilt specification (grid, size, aspect, cone).

required
fov float | None

Vertical field of view in degrees, with the render_quilt dolly-back convention; None keeps the plotter's own.

14.0
zoom float | None

Optional dolly factor applied after framing, as on :func:~quiltwright.lfd.render_quilt.

None
gltf str | Path | None

Also write the exported scene here, for inspection or reuse. Exported to a temporary file if None.

None
kwargs

Forwarded to :func:render_cycles_quilt -- samples, denoise, device, threads, view_cone, binary, keep_views, progress and the rest.

{}

Returns:

Type Description
ndarray

uint8 RGB array of shape (quilt_height, quilt_width, 3).

Source code in src/quiltwright/cycles.py
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
def render_cycles_quilt_from_plotter(
    plotter,
    spec: QuiltSpec,
    *,
    fov: float | None = 14.0,
    zoom: float | None = None,
    gltf: str | Path | None = None,
    **kwargs,
) -> np.ndarray:
    """Render a PyVista plotter's scene into a quilt with Cycles.

    The hardware-ray-traced sibling of :func:`~quiltwright.lfd.render_quilt`:
    same plotter in, same quilt out, but the views are path-traced by Cycles
    -- with GPU ray tracing where the hardware offers it -- instead of
    rasterised by VTK.  Compose the scene and position the camera exactly as
    you would for ``render_quilt``, then swap the call.

    The scene is exported to glTF once (see :func:`export_plotter_gltf`) and
    the whole sweep renders in one Blender process.  The export carries no
    lights, so the backend's ``lighting`` rigs supply them -- the ``"soft"``
    default for a neutral look, ``lighting="studio"`` for a three-point
    product shot, ``"sky"`` or an HDRI path for environments; pass
    materials-appropriate ``samples`` for finals.

    :param plotter: An *off-screen* ``pv.Plotter`` with the scene composed.
        Read, never mutated -- and never rendered, so this works on
        machines whose GL stack cannot even take a screenshot.
    :param spec: Quilt specification (grid, size, aspect, cone).
    :param fov: Vertical field of view in degrees, with the
        ``render_quilt`` dolly-back convention; ``None`` keeps the
        plotter's own.
    :param zoom: Optional dolly factor applied after framing, as on
        :func:`~quiltwright.lfd.render_quilt`.
    :param gltf: Also write the exported scene here, for inspection or
        reuse.  Exported to a temporary file if ``None``.
    :param kwargs: Forwarded to :func:`render_cycles_quilt` -- ``samples``,
        ``denoise``, ``device``, ``threads``, ``view_cone``, ``binary``,
        ``keep_views``, ``progress`` and the rest.
    :return: ``uint8`` RGB array of shape ``(quilt_height, quilt_width, 3)``.
    """
    if not plotter.camera.is_set:
        # Mirror render_quilt's first-render behaviour, without rendering.
        plotter.camera_position = plotter.renderer.get_default_cam_pos()
        plotter.reset_camera()
    camera = cycles_camera_from_plotter(plotter, fov=fov, zoom=zoom)

    if gltf is not None:
        scene = export_plotter_gltf(plotter, gltf)
        return render_cycles_quilt(scene, spec, camera, **kwargs)
    with tempfile.TemporaryDirectory(prefix="cycles_gltf_") as tmp:
        scene = export_plotter_gltf(plotter, Path(tmp) / "scene.gltf")
        return render_cycles_quilt(scene, spec, camera, **kwargs)

render_cycles_views(scene, spec, camera, out_dir, *, view_cone=None, samples=64, denoise=True, view_transform='Standard', device='auto', lighting='soft', threads=None, binary=None, extra_args=(), keep_job=False, progress=True)

Render a mesh scene as a sweep of separate view images.

Identical camera geometry to :func:render_cycles_quilt -- the same off-axis sheared frustum, the same focal plane -- but the frames are written out individually instead of being tiled into a quilt, for consumers like hologram printers and lenticular interlacers. Pair with :func:~quiltwright.lfd.sweep_spec for view counts no quilt grid can express.

Parameters:

Name Type Description Default
scene str | Path

Path to the scene, as for :func:render_cycles_quilt.

required
spec QuiltSpec

Sweep or quilt specification supplying view count, view cone, and per-view pixel size.

required
camera CyclesCamera | None

Base camera, or None for a .blend's own.

required
out_dir str | Path

Directory to write the frames into; created if absent.

required
view_cone float | None

Override the spec's view cone in degrees.

None
samples int

Cycles samples per pixel.

64
denoise bool

Run Cycles' denoiser on each view.

True
view_transform str

Color management; see :func:render_cycles_quilt.

'Standard'
device str

"auto", "gpu" or "cpu", as for :func:render_cycles_quilt.

'auto'
lighting str | Path | None

Rig for an unlit imported scene -- "soft", "studio", "sky", an HDRI path, or None; see :func:render_cycles_quilt.

'soft'
threads int | None

Blender -t thread count; see :func:render_cycles_quilt.

None
binary str | None

Blender executable override.

None
extra_args Sequence[str]

Extra Blender command-line arguments.

()
keep_job bool

Also write the job.json driver input alongside the frames, for inspection.

False
progress bool

Print a progress line while rendering.

True

Returns:

Type Description
list[Path]

Paths to the written frames, in view order -- view 0 leftmost.

Source code in src/quiltwright/cycles.py
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
def render_cycles_views(
    scene: str | Path,
    spec: QuiltSpec,
    camera: CyclesCamera | None,
    out_dir: str | Path,
    *,
    view_cone: float | None = None,
    samples: int = 64,
    denoise: bool = True,
    view_transform: str = "Standard",
    device: str = "auto",
    lighting: str | Path | None = "soft",
    threads: int | None = None,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
    keep_job: bool = False,
    progress: bool = True,
) -> list[Path]:
    """Render a mesh scene as a sweep of separate view images.

    Identical camera geometry to :func:`render_cycles_quilt` -- the same
    off-axis sheared frustum, the same focal plane -- but the frames are
    written out individually instead of being tiled into a quilt, for
    consumers like hologram printers and lenticular interlacers.  Pair with
    :func:`~quiltwright.lfd.sweep_spec` for view counts no quilt grid can
    express.

    :param scene: Path to the scene, as for :func:`render_cycles_quilt`.
    :param spec: Sweep or quilt specification supplying view count, view
        cone, and per-view pixel size.
    :param camera: Base camera, or ``None`` for a ``.blend``'s own.
    :param out_dir: Directory to write the frames into; created if absent.
    :param view_cone: Override the spec's view cone in degrees.
    :param samples: Cycles samples per pixel.
    :param denoise: Run Cycles' denoiser on each view.
    :param view_transform: Color management; see :func:`render_cycles_quilt`.
    :param device: ``"auto"``, ``"gpu"`` or ``"cpu"``, as for
        :func:`render_cycles_quilt`.
    :param lighting: Rig for an unlit *imported* scene -- ``"soft"``,
        ``"studio"``, ``"sky"``, an HDRI path, or ``None``; see
        :func:`render_cycles_quilt`.
    :param threads: Blender ``-t`` thread count; see
        :func:`render_cycles_quilt`.
    :param binary: Blender executable override.
    :param extra_args: Extra Blender command-line arguments.
    :param keep_job: Also write the ``job.json`` driver input alongside the
        frames, for inspection.
    :param progress: Print a progress line while rendering.
    :return: Paths to the written frames, in view order -- view 0 leftmost.
    """
    blender, scene_path, kind, spec = _prepare(scene, spec, camera, view_cone, binary)

    render_h = spec.tile_height
    render_w = round(render_h * spec.aspect)

    job = {
        "scene": str(scene_path),
        "format": kind,
        "width": render_w,
        "height": render_h,
        "angles": _view_angles(spec),
        "camera": _camera_job(camera),
        "samples": int(samples),
        "denoise": bool(denoise),
        "view_transform": view_transform,
        "device": device,
        "lighting": _lighting_job(lighting),
    }

    with tempfile.TemporaryDirectory(prefix="cycles_sweep_") as tmp:
        workdir = Path(tmp)
        views = _run_blender(blender, workdir, job, extra_args, threads, progress)
        out = Path(out_dir).expanduser()
        out.mkdir(parents=True, exist_ok=True)
        if keep_job:
            shutil.copy2(workdir / "job.json", out / "job.json")
        return [Path(shutil.copy2(png, out / png.name)) for png in views]

view_shift_x(offset, focal_distance, fov, aspect)

Blender camera shift_x for one quilt view, under a horizontal sensor fit.

The off-axis shear in Blender's units: the eye has translated offset along the right vector, and this shift slides the frustum window back so the original look-at point stays centred. It is the same quantity VTK's SetWindowCenter receives in the PyVista backend, converted from half-widths to Blender's fractions of the frame width (a factor of 2).

Parameters:

Name Type Description Default
offset float

Lateral eye offset along the camera's right vector, in scene units, from :func:~quiltwright.lfd.view_offsets.

required
focal_distance float

Camera-to-focal-plane distance, in scene units.

required
fov float

Vertical field of view in degrees.

required
aspect float

Width / height of the rendered view.

required

Returns:

Type Description
float

The shift_x value for this view.

Source code in src/quiltwright/cycles.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def view_shift_x(offset: float, focal_distance: float, fov: float, aspect: float) -> float:
    """Blender camera ``shift_x`` for one quilt view, under a horizontal
    sensor fit.

    The off-axis shear in Blender's units: the eye has translated *offset*
    along the right vector, and this shift slides the frustum window back so
    the original look-at point stays centred.  It is the same quantity VTK's
    ``SetWindowCenter`` receives in the PyVista backend, converted from
    half-widths to Blender's fractions of the frame width (a factor of 2).

    :param offset: Lateral eye offset along the camera's right vector, in
        scene units, from :func:`~quiltwright.lfd.view_offsets`.
    :param focal_distance: Camera-to-focal-plane distance, in scene units.
    :param fov: Vertical field of view in degrees.
    :param aspect: Width / height of the rendered view.
    :return: The ``shift_x`` value for this view.
    """
    return window_shear(offset, focal_distance, fov, aspect) / 2.0