Skip to content

PyVista backend (lfd)

quiltwright.lfd

Looking Glass Quilt Renderer

Renders any PyVista scene into a quilt -- the tiled multi-view image format used by Looking Glass holographic light-field displays.

A quilt packs N renders of the same scene, captured from camera positions swept horizontally across a viewing cone, into a single image. Views are tiled left-to-right, bottom-to-top: view 0 (leftmost camera) sits at the bottom-left tile and view N-1 (rightmost camera) at the top-right. Looking Glass software (Bridge, Studio) detects quilt settings from the filename suffix _qs<cols>x<rows>a<aspect>.png, so files saved through :func:save_quilt are recognised automatically.

Each view uses an off-axis (asymmetric-frustum) projection rather than a "toe-in" rotation: the camera translates along its horizontal axis while the frustum is sheared back toward the focal plane. This keeps the focal plane identical across views -- the geometric requirement for the display's lenticular optics to fuse the views into a stable hologram. Content at the focal plane appears at the physical screen surface; content nearer/farther floats in front of / behind the glass.

Quilt geometry (:class:QuiltSpec, :data:QUILT_PRESETS, :func:assemble_quilt, :func:save_quilt) lives in :mod:quiltwright.quilt. Bridge control (:func:cast_quilt) lives in :mod:quiltwright.bridge. Both are re-exported from here so existing from quiltwright.lfd import ... callers keep working.

Optional dependencies -- install the viz extras group::

poetry install --with viz   # pyvista, pillow, scipy, ...

Typical usage::

import pyvista as pv
from quiltwright.lfd import QUILT_PRESETS, render_quilt, save_quilt

p = pv.Plotter(off_screen=True)
p.add_mesh(pv.ParametricTorus())
spec = QUILT_PRESETS["portrait"]
quilt = render_quilt(p, spec)
save_quilt(quilt, "torus", spec)        # -> torus_qs8x6a0.75.png
p.close()

The saved quilt can be displayed on the device by dragging it into Looking Glass Studio, or cast directly from Python via :func:cast_quilt if Looking Glass Bridge is running on the machine driving the display.

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

QuiltSpec(columns, rows, quilt_width, quilt_height, aspect, view_cone=35.0) dataclass

Geometry of a quilt: tiling grid, total pixel size, and view cone.

Parameters:

Name Type Description Default
columns int

Number of view tiles per quilt row.

required
rows int

Number of view tiles per quilt column.

required
quilt_width int

Total quilt width in pixels.

required
quilt_height int

Total quilt height in pixels.

required
aspect float

Aspect ratio (width / height) of a single view, which matches the target display's aspect. Embedded in the quilt filename so Looking Glass software can configure playback correctly.

required
view_cone float

Total horizontal sweep of the camera in degrees. Looking Glass documents 35° as the standard rendering cone (the physical display cone is wider, ~40-58° depending on model; rendering slightly narrower adds apparent depth).

35.0

n_views property

Total number of views in the quilt.

tile_height property

Height of a single view tile in pixels.

tile_width property

Width of a single view tile in pixels.

filename(stem, ext='png')

Quilt filename with the metadata suffix Looking Glass software parses.

Parameters:

Name Type Description Default
stem str

Base name without extension (e.g. "helix_density").

required
ext str

File extension without the dot.

'png'

Returns:

Type Description
str

e.g. "helix_density_qs8x6a0.75.png".

Source code in src/quiltwright/quilt.py
 95
 96
 97
 98
 99
100
101
102
def filename(self, stem: str, ext: str = "png") -> str:
    """Quilt filename with the metadata suffix Looking Glass software parses.

    :param stem: Base name without extension (e.g. ``"helix_density"``).
    :param ext: File extension without the dot.
    :return: e.g. ``"helix_density_qs8x6a0.75.png"``.
    """
    return f"{stem}_qs{self.columns}x{self.rows}a{self.aspect:g}.{ext}"

scaled(factor)

Same view grid at a fraction of the pixel size.

Casting at full preset size is rarely what you want: rendering costs about a second, but the wait is Bridge loading the resulting PNG, and that scales with its area. Halving the linear size quarters it.

The scaled dimensions are rounded down to a multiple of the tile grid, which is the part that is easy to get wrong: scale naively and the quilt no longer divides evenly into tiles, so every view lands on a fractional pixel boundary and the whole light field smears.

Parameters:

Name Type Description Default
factor float

Linear scale factor, e.g. 0.5 for quarter the pixels.

required

Returns:

Type Description
QuiltSpec

A new :class:QuiltSpec at the scaled size, tiles intact.

Raises:

Type Description
ValueError

If factor is not positive, or scales the quilt below one pixel per tile.

Source code in src/quiltwright/quilt.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def scaled(self, factor: float) -> QuiltSpec:
    """Same view grid at a fraction of the pixel size.

    Casting at full preset size is rarely what you want: rendering costs
    about a second, but the wait is Bridge loading the resulting PNG, and
    that scales with its area.  Halving the linear size quarters it.

    The scaled dimensions are rounded **down to a multiple of the tile
    grid**, which is the part that is easy to get wrong: scale naively and
    the quilt no longer divides evenly into tiles, so every view lands on a
    fractional pixel boundary and the whole light field smears.

    :param factor: Linear scale factor, e.g. ``0.5`` for quarter the pixels.
    :return: A new :class:`QuiltSpec` at the scaled size, tiles intact.
    :raises ValueError: If *factor* is not positive, or scales the quilt
        below one pixel per tile.
    """
    if factor <= 0:
        raise ValueError(f"scale factor must be positive, got {factor}")
    width = int(self.quilt_width * factor) // self.columns * self.columns
    height = int(self.quilt_height * factor) // self.rows * self.rows
    if width < self.columns or height < self.rows:
        raise ValueError(
            f"scale factor {factor} leaves a {width}x{height} quilt, "
            f"too small for a {self.columns}x{self.rows} tile grid"
        )
    return replace(self, quilt_width=width, quilt_height=height)

still(height=1100)

The same view, once, as a flat image at this device's aspect.

A one-tile "quilt" is the cheapest way to check framing, lighting and materials before paying for the whole sweep, and it is what the gallery images are: :func:view_offsets returns a single zero offset at n_views == 1, so the render is the centre view and nothing else. The width follows :attr:aspect rather than being fixed, so the still is framed like the panel it is standing in for -- a still of a landscape device is a landscape image.

Parameters:

Name Type Description Default
height int

Image height in pixels.

1100

Returns:

Type Description
QuiltSpec

A new 1x1 :class:QuiltSpec at this spec's aspect.

Raises:

Type Description
ValueError

If height is not positive.

Source code in src/quiltwright/quilt.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def still(self, height: int = 1100) -> QuiltSpec:
    """The same view, once, as a flat image at this device's aspect.

    A one-tile "quilt" is the cheapest way to check framing, lighting and
    materials before paying for the whole sweep, and it is what the
    gallery images are: :func:`view_offsets` returns a single zero offset
    at ``n_views == 1``, so the render is the centre view and nothing
    else.  The width follows :attr:`aspect` rather than being fixed, so
    the still is framed like the panel it is standing in for -- a still
    of a landscape device is a landscape image.

    :param height: Image height in pixels.
    :return: A new 1x1 :class:`QuiltSpec` at this spec's aspect.
    :raises ValueError: If *height* is not positive.
    """
    if height <= 0:
        raise ValueError(f"still height must be positive, got {height}")
    width = max(1, round(height * self.aspect))
    return replace(self, columns=1, rows=1, quilt_width=width, quilt_height=height)

tile_origin(view_index)

Pixel (x, y) of a view's top-left corner within the quilt image.

Quilt convention: view 0 at the bottom-left, advancing left-to-right then bottom-to-top. The returned y is measured from the image top (numpy/PIL row order).

Parameters:

Name Type Description Default
view_index int

View number in [0, n_views).

required

Returns:

Type Description
tuple[int, int]

(x, y) pixel offsets of the tile.

Source code in src/quiltwright/quilt.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def tile_origin(self, view_index: int) -> tuple[int, int]:
    """Pixel ``(x, y)`` of a view's top-left corner within the quilt image.

    Quilt convention: view 0 at the *bottom-left*, advancing
    left-to-right then bottom-to-top.  The returned ``y`` is measured
    from the image top (numpy/PIL row order).

    :param view_index: View number in ``[0, n_views)``.
    :return: ``(x, y)`` pixel offsets of the tile.
    """
    if not 0 <= view_index < self.n_views:
        raise ValueError(f"view_index {view_index} outside [0, {self.n_views})")
    col = view_index % self.columns
    row = view_index // self.columns  # 0 = bottom row
    x = col * self.tile_width
    y = (self.rows - 1 - row) * self.tile_height
    return x, y

with_grid(columns, rows)

Same quilt at a different view-grid density.

Total quilt pixels stay fixed, so more views means fewer pixels per view: the device's lenticular optics interpolate between views, so extra views give smoother look-around at the cost of per-view sharpness. The official presets are the factory-calibrated balance.

Parameters:

Name Type Description Default
columns int

New number of tile columns.

required
rows int

New number of tile rows.

required

Returns:

Type Description
QuiltSpec

A new :class:QuiltSpec with the requested grid.

Source code in src/quiltwright/quilt.py
104
105
106
107
108
109
110
111
112
113
114
115
116
def with_grid(self, columns: int, rows: int) -> QuiltSpec:
    """Same quilt at a different view-grid density.

    Total quilt pixels stay fixed, so more views means fewer pixels per
    view: the device's lenticular optics interpolate between views, so
    extra views give smoother look-around at the cost of per-view
    sharpness.  The official presets are the factory-calibrated balance.

    :param columns: New number of tile columns.
    :param rows: New number of tile rows.
    :return: A new :class:`QuiltSpec` with the requested grid.
    """
    return replace(self, columns=columns, rows=rows)

assemble_quilt(views, spec)

Tile per-view images into a single quilt image.

This is the renderer-agnostic half of quilt production: it takes views that some backend already rendered -- VTK via :func:render_quilt, a ray-tracer via :mod:quiltwright.povray -- and lays them out in quilt order. Views are consumed lazily, so a backend can stream them without holding all n_views frames in memory at once.

Views whose pixel size differs from the tile size are resampled, which is what makes anamorphic quilts (tile pixel aspect != view aspect, e.g. the 27" presets) come out correctly.

Parameters:

Name Type Description Default
views Iterable[ndarray]

Iterable of uint8 RGB (or RGBA) arrays in view order -- view 0 is the leftmost camera. Must yield exactly spec.n_views.

required
spec QuiltSpec

Quilt specification (grid, size, aspect).

required

Returns:

Type Description
ndarray

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

Raises:

Type Description
ValueError

If the number of views does not match spec.

Source code in src/quiltwright/quilt.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def assemble_quilt(views: Iterable[np.ndarray], spec: QuiltSpec) -> np.ndarray:
    """Tile per-view images into a single quilt image.

    This is the renderer-agnostic half of quilt production: it takes views
    that some backend already rendered -- VTK via :func:`render_quilt`, a
    ray-tracer via :mod:`quiltwright.povray` -- and lays them out in quilt
    order.  Views are consumed lazily, so a backend can stream them without
    holding all ``n_views`` frames in memory at once.

    Views whose pixel size differs from the tile size are resampled, which
    is what makes anamorphic quilts (tile pixel aspect != view aspect, e.g.
    the 27" presets) come out correctly.

    :param views: Iterable of ``uint8`` RGB (or RGBA) arrays in view order --
        view 0 is the leftmost camera.  Must yield exactly ``spec.n_views``.
    :param spec: Quilt specification (grid, size, aspect).
    :return: ``uint8`` RGB array of shape ``(quilt_height, quilt_width, 3)``.
    :raises ValueError: If the number of views does not match ``spec``.
    """
    quilt = np.zeros((spec.quilt_height, spec.quilt_width, 3), dtype=np.uint8)
    n = 0
    for i, img in enumerate(views):
        if i >= spec.n_views:
            raise ValueError(
                f"got more than {spec.n_views} views for a {spec.columns}x{spec.rows} quilt"
            )
        img = np.asarray(img)[..., :3]
        if img.shape[:2] != (spec.tile_height, spec.tile_width):
            img = _resize_view(img, spec.tile_width, spec.tile_height)
        x, y = spec.tile_origin(i)
        quilt[y : y + spec.tile_height, x : x + spec.tile_width] = img
        n = i + 1
    if n != spec.n_views:
        raise ValueError(
            f"expected {spec.n_views} views for a {spec.columns}x{spec.rows} quilt, got {n}"
        )
    return quilt

camera_frame(camera)

Decompose a vtkCamera into position, focal point, right/up basis, distance.

Parameters:

Name Type Description Default
camera

A pv.Camera / vtkCamera.

required

Returns:

Type Description
tuple[ndarray, ndarray, ndarray, ndarray, float]

(position, focal_point, right, up, distance).

Source code in src/quiltwright/lfd.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
def camera_frame(camera) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
    """Decompose a vtkCamera into position, focal point, right/up basis, distance.

    :param camera: A ``pv.Camera`` / vtkCamera.
    :return: ``(position, focal_point, right, up, distance)``.
    """
    pos = np.asarray(camera.position, dtype="d")
    focal = np.asarray(camera.focal_point, dtype="d")
    up = np.asarray(camera.up, dtype="d")
    forward = focal - pos
    distance = float(np.linalg.norm(forward))
    forward /= distance
    right = np.cross(forward, up)
    right /= np.linalg.norm(right)
    true_up = np.cross(right, forward)
    return pos, focal, right, true_up, distance

cast_quilt(quilt_path, spec, *, bridge_url=BRIDGE_URL, playlist='quiltwright', timeout=10.0, head_index=-1)

Show a saved quilt on the connected Looking Glass via Bridge.

Requires Looking Glass Bridge <https://lookingglassfactory.com/software/looking-glass-bridge>_ (>= 2.2) running on the machine the display is plugged into. Follows Bridge's orchestration sequence: enter orchestration, show the display window, create a playlist holding the quilt, and play it.

Parameters:

Name Type Description Default
quilt_path str | Path

Path to a quilt PNG on the Bridge host's filesystem.

required
spec QuiltSpec

Quilt specification (tiling + aspect sent to Bridge).

required
bridge_url str

Base URL of the Bridge HTTP API.

BRIDGE_URL
playlist str

Name of the Bridge playlist to (re)create.

'quiltwright'
timeout float

HTTP timeout in seconds per request.

10.0
head_index int

Which Bridge output device to play on. -1 lets Bridge choose, which is right on a single-panel machine. Bridge enumerates ordinary monitors alongside Looking Glass panels -- a laptop screen appears as hardwareVersion: thirdparty with no calibration -- so on a multi-display box the default can land the window somewhere that is not the glass. available_output_devices lists the indices; quiltwright cast --check prints them.

-1

Returns:

Type Description
dict

Decoded JSON response of the final play_playlist call.

Source code in src/quiltwright/bridge.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def cast_quilt(
    quilt_path: str | Path,
    spec: QuiltSpec,
    *,
    bridge_url: str = BRIDGE_URL,
    playlist: str = "quiltwright",
    timeout: float = 10.0,
    head_index: int = -1,
) -> dict:
    """Show a saved quilt on the connected Looking Glass via Bridge.

    Requires `Looking Glass Bridge <https://lookingglassfactory.com/software/looking-glass-bridge>`_
    (>= 2.2) running on the machine the display is plugged into.  Follows
    Bridge's orchestration sequence: enter orchestration, show the display
    window, create a playlist holding the quilt, and play it.

    :param quilt_path: Path to a quilt PNG on the *Bridge host's* filesystem.
    :param spec: Quilt specification (tiling + aspect sent to Bridge).
    :param bridge_url: Base URL of the Bridge HTTP API.
    :param playlist: Name of the Bridge playlist to (re)create.
    :param timeout: HTTP timeout in seconds per request.
    :param head_index: Which Bridge output device to play on.  ``-1`` lets
        Bridge choose, which is right on a single-panel machine.  Bridge
        enumerates ordinary monitors alongside Looking Glass panels -- a
        laptop screen appears as ``hardwareVersion: thirdparty`` with no
        calibration -- so on a multi-display box the default can land the
        window somewhere that is not the glass.  ``available_output_devices``
        lists the indices; ``quiltwright cast --check`` prints them.
    :return: Decoded JSON response of the final ``play_playlist`` call.
    """
    token = enter_orchestration(bridge_url, timeout)

    bridge_post(
        bridge_url,
        "show_window",
        {"orchestration": token, "show_window": True, "head_index": head_index},
        timeout,
    )
    bridge_post(
        bridge_url,
        "instance_playlist",
        {"orchestration": token, "name": playlist, "loop": True},
        timeout,
    )
    bridge_post(
        bridge_url,
        "insert_playlist_entry",
        {
            "orchestration": token,
            "name": playlist,
            "index": 0,
            "uri": str(Path(quilt_path).resolve()),
            "rows": spec.rows,
            "cols": spec.columns,
            "aspect": spec.aspect,
            "view_count": spec.n_views,
            "durationMS": 20000,
            "isRGBD": 0,
        },
        timeout,
    )
    return bridge_post(
        bridge_url,
        "play_playlist",
        {"orchestration": token, "name": playlist, "head_index": head_index},
        timeout,
    )

depth_report(plotter, spec, *, fov=14.0, zoom=None, labels=DEPTH_LABELS, extra_depths=None, soft_px=5.5)

Depth budget for a PyVista scene, as a report to print before rendering.

The PyVista counterpart to :func:~quiltwright.povray.format_depth_budget. Pass the same fov and zoom you will pass to :func:render_quilt, so the numbers describe the render you are about to make.

Parameters:

Name Type Description Default
plotter

Plotter with the scene composed and the camera framed.

required
spec QuiltSpec

Quilt specification.

required
fov float | None

FOV that will be used for the render; see :func:render_quilt.

14.0
zoom float | None

Zoom that will be used for the render.

None
labels tuple[str, str, str]

Names for the near, focal and far depths.

DEPTH_LABELS
extra_depths Mapping[str, float] | None

Further labelled depths to include, e.g. {"sky": math.inf} for a backdrop at infinity.

None
soft_px float

Disparity above which a row is flagged as soft.

5.5

Returns:

Type Description
str

Multi-line report.

Source code in src/quiltwright/lfd.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def depth_report(
    plotter,
    spec: QuiltSpec,
    *,
    fov: float | None = 14.0,
    zoom: float | None = None,
    labels: tuple[str, str, str] = DEPTH_LABELS,
    extra_depths: Mapping[str, float] | None = None,
    soft_px: float = 5.5,
) -> str:
    """Depth budget for a PyVista scene, as a report to print before rendering.

    The PyVista counterpart to
    :func:`~quiltwright.povray.format_depth_budget`.  Pass the same *fov*
    and *zoom* you will pass to :func:`render_quilt`, so the numbers
    describe the render you are about to make.

    :param plotter: Plotter with the scene composed and the camera framed.
    :param spec: Quilt specification.
    :param fov: FOV that will be used for the render; see :func:`render_quilt`.
    :param zoom: Zoom that will be used for the render.
    :param labels: Names for the near, focal and far depths.
    :param extra_depths: Further labelled depths to include, e.g.
        ``{"sky": math.inf}`` for a backdrop at infinity.
    :param soft_px: Disparity above which a row is flagged as soft.
    :return: Multi-line report.
    """
    from quiltwright.povray import format_depth_budget

    depths = scene_depths(plotter, fov=fov, zoom=zoom, labels=labels)
    if extra_depths:
        depths.update(extra_depths)
    lens = _Lens(
        fov=float(plotter.camera.view_angle if fov is None else fov),
        focal_distance=float(depths[labels[1]]),
    )
    return format_depth_budget(spec, lens, depths, soft_px=soft_px)

find_ffmpeg()

Locate an ffmpeg binary: system PATH first, then imageio-ffmpeg's.

Returns:

Type Description
str

Path to an ffmpeg executable.

Raises:

Type Description
RuntimeError

If no ffmpeg can be found.

Source code in src/quiltwright/runtime.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def find_ffmpeg() -> str:
    """Locate an ffmpeg binary: system PATH first, then imageio-ffmpeg's.

    :return: Path to an ffmpeg executable.
    :raises RuntimeError: If no ffmpeg can be found.
    """
    import shutil

    found = shutil.which("ffmpeg")
    if found:
        return found
    try:
        import imageio_ffmpeg

        return imageio_ffmpeg.get_ffmpeg_exe()
    except ImportError as exc:
        raise RuntimeError(
            "Quilt video encoding requires ffmpeg.\n"
            "Install it system-wide, or:  pip install imageio-ffmpeg"
        ) from exc

focal_distance_for_range(near, far)

Focal distance that balances disparity between the nearest and farthest content -- their harmonic mean.

Disparity grows with |1 - Z/depth|, which is asymmetric in depth: placing the focal plane at the arithmetic midpoint leaves the near content far worse off than the far content. Equalising the two, Z/near - 1 = 1 - Z/far, gives Z = 2/(1/near + 1/far).

With far at infinity this reduces to 2 * near.

Parameters:

Name Type Description Default
near float

Distance to the nearest content, in scene units.

required
far float

Distance to the farthest content; may be math.inf.

required

Returns:

Type Description
float

Focal distance to aim the camera at.

Raises:

Type Description
ValueError

If near is not positive or exceeds far.

Source code in src/quiltwright/quilt.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def focal_distance_for_range(near: float, far: float) -> float:
    """Focal distance that balances disparity between the nearest and
    farthest content -- their harmonic mean.

    Disparity grows with ``|1 - Z/depth|``, which is asymmetric in depth:
    placing the focal plane at the arithmetic midpoint leaves the near
    content far worse off than the far content.  Equalising the two,
    ``Z/near - 1 = 1 - Z/far``, gives ``Z = 2/(1/near + 1/far)``.

    With *far* at infinity this reduces to ``2 * near``.

    :param near: Distance to the nearest content, in scene units.
    :param far: Distance to the farthest content; may be ``math.inf``.
    :return: Focal distance to aim the camera at.
    :raises ValueError: If *near* is not positive or exceeds *far*.
    """
    if near <= 0:
        raise ValueError(f"near must be positive, got {near}")
    if far < near:
        raise ValueError(f"far ({far}) must be >= near ({near})")
    if math.isinf(far):
        return 2.0 * near
    return 2.0 / (1.0 / near + 1.0 / far)

frame_and_focus(plotter, *, fov=14.0, margin=1.15)

Frame a PyVista scene tightly at its final view, and focus it.

The PyVista counterpart to :func:~quiltwright.cycles.frame_camera, and the thing to call once the camera is pointing where you want it. reset_camera() fits the un-tilted bounds, so once the view is tilted -- by an orbit, or an explicit camera_position -- that framing is too loose and the subject reads as small with a lot of empty margin: ask for a mountain hologram and get a speck.

This re-fits from scratch at the final view direction. The eight bounding-box corners are projected onto the camera's own right/up/forward axes, which accounts for foreshortening -- a flat, elongated terrain viewed obliquely needs far less distance than its bounding sphere would suggest -- giving the tightest distance that still keeps every corner in frame at the target FOV and window aspect. The focal plane then goes at the harmonic mean of the resulting near and far depths, the same balance :func:focal_distance_for_range gives the POV-Ray path, measured from exact geometry rather than a rendered plane sweep.

The camera is modified, unlike :func:scene_depths, which measures copies: position, view angle and focal point are all overwritten. Only the view direction survives. Having locked the camera here, pass fov=None to :func:render_quilt so it does not frame the scene a second time from scratch.

Parameters:

Name Type Description Default
plotter

A pv.Plotter with the data added, window_size already set to the final render resolution (the aspect matters), and the camera pointing in the desired direction.

required
fov float

Vertical field of view to lock the camera to, in degrees. Must match what the render actually uses, or the depth budget describes a different camera than the one that renders.

14.0
margin float

Headroom beyond the tight corner-projected fit, as a fraction -- 1.15 leaves 15% so the subject does not touch the frame edges.

1.15

Returns:

Type Description
tuple[float, float, float]

(near, far, focal_distance) in scene units, measured from the final camera position -- the numbers :func:view_disparity expects.

Raises:

Type Description
ImportError

If PyVista is not installed.

Source code in src/quiltwright/lfd.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def frame_and_focus(
    plotter,
    *,
    fov: float = 14.0,
    margin: float = 1.15,
) -> tuple[float, float, float]:
    """Frame a PyVista scene tightly at its final view, and focus it.

    The PyVista counterpart to
    :func:`~quiltwright.cycles.frame_camera`, and the thing to call once the
    camera is pointing where you want it.  ``reset_camera()`` fits the
    *un-tilted* bounds, so once the view is tilted -- by an orbit, or an
    explicit ``camera_position`` -- that framing is too loose and the subject
    reads as small with a lot of empty margin: ask for a mountain hologram
    and get a speck.

    This re-fits from scratch at the final view direction.  The eight
    bounding-box corners are projected onto the camera's own right/up/forward
    axes, which accounts for foreshortening -- a flat, elongated terrain
    viewed obliquely needs far less distance than its bounding *sphere* would
    suggest -- giving the tightest distance that still keeps every corner in
    frame at the target FOV and window aspect.  The focal plane then goes at
    the harmonic mean of the resulting near and far depths, the same balance
    :func:`focal_distance_for_range` gives the POV-Ray path, measured from
    exact geometry rather than a rendered plane sweep.

    **The camera is modified**, unlike :func:`scene_depths`, which measures
    copies: position, view angle and focal point are all overwritten.  Only
    the view *direction* survives.  Having locked the camera here, pass
    ``fov=None`` to :func:`render_quilt` so it does not frame the scene a
    second time from scratch.

    :param plotter: A ``pv.Plotter`` with the data added, ``window_size``
        already set to the final render resolution (the aspect matters), and
        the camera pointing in the desired direction.
    :param fov: Vertical field of view to lock the camera to, in degrees.
        Must match what the render actually uses, or the depth budget
        describes a different camera than the one that renders.
    :param margin: Headroom beyond the tight corner-projected fit, as a
        fraction -- ``1.15`` leaves 15% so the subject does not touch the
        frame edges.
    :return: ``(near, far, focal_distance)`` in scene units, measured from
        the final camera position -- the numbers :func:`view_disparity`
        expects.
    :raises ImportError: If PyVista is not installed.
    """
    require_pyvista("frame_and_focus")
    camera = plotter.camera
    position = np.asarray(camera.position, dtype="d")
    focus = np.asarray(camera.focal_point, dtype="d")
    up = np.asarray(camera.up, dtype="d")
    forward = focus - position
    forward /= np.linalg.norm(forward)
    right = np.cross(forward, up)
    right /= np.linalg.norm(right)
    true_up = np.cross(right, forward)

    xmin, xmax, ymin, ymax, zmin, zmax = plotter.bounds
    lo = np.array([xmin, ymin, zmin], dtype="d")
    hi = np.array([xmax, ymax, zmax], dtype="d")
    centre = (lo + hi) / 2.0
    corners = np.array(
        [[x, y, z] for x in (xmin, xmax) for y in (ymin, ymax) for z in (zmin, zmax)],
        dtype="d",
    )
    offsets = corners - centre
    f = offsets @ forward  # signed depth of each corner relative to the centre
    r = offsets @ right
    u = offsets @ true_up

    win_w, win_h = plotter.window_size
    half_v = math.tan(math.radians(fov) / 2.0)
    half_h = half_v * (win_w / win_h)

    # Smallest distance-from-centre D such that every corner's angular extent
    # |u_i|/(D + f_i) (and |r_i|/half_h) still fits inside the FOV.
    needed = np.concatenate([np.abs(u) / half_v - f, np.abs(r) / half_h - f])
    distance = margin * max(float(needed.max()), 1.0)

    camera.position = tuple(centre - forward * distance)
    camera.view_angle = fov
    depths = distance + f  # each corner's distance from the new camera
    near = float(depths.min())
    far = float(depths.max())

    focal_distance = focal_distance_for_range(near, far)
    camera.focal_point = tuple(np.asarray(camera.position) + forward * focal_distance)
    return near, far, focal_distance

pause_quilt(*, bridge_url=BRIDGE_URL, timeout=10.0)

Pause playback on the connected Looking Glass.

Freezes the current frame; the playlist and its position are retained, so :func:resume_quilt continues from where it left off. This is Bridge's transport control group -- there is no stop_playlist or pause_playlist endpoint (a guessed endpoint name doesn't 404: Bridge answers with 200 OK and an empty body, indistinguishable from a slow success unless you check that the response has no status field). Confirmed against the endpoint list in the official bridge.js <https://github.com/Looking-Glass/bridge.js>_ SDK source.

Parameters:

Name Type Description Default
bridge_url str

Base URL of the Bridge HTTP API.

BRIDGE_URL
timeout float

HTTP timeout in seconds.

10.0

Returns:

Type Description
dict

Decoded JSON response of the transport_control_pause call.

Source code in src/quiltwright/bridge.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def pause_quilt(*, bridge_url: str = BRIDGE_URL, timeout: float = 10.0) -> dict:
    """Pause playback on the connected Looking Glass.

    Freezes the current frame; the playlist and its position are retained,
    so :func:`resume_quilt` continues from where it left off. This is
    Bridge's *transport control* group -- there is no ``stop_playlist`` or
    ``pause_playlist`` endpoint (a guessed endpoint name doesn't 404: Bridge
    answers with ``200 OK`` and an empty body, indistinguishable from a slow
    success unless you check that the response has no ``status`` field).
    Confirmed against the endpoint list in the official
    `bridge.js <https://github.com/Looking-Glass/bridge.js>`_ SDK source.

    :param bridge_url: Base URL of the Bridge HTTP API.
    :param timeout: HTTP timeout in seconds.
    :return: Decoded JSON response of the ``transport_control_pause`` call.
    """
    token = enter_orchestration(bridge_url, timeout)
    return bridge_post(bridge_url, "transport_control_pause", {"orchestration": token}, timeout)

render_quilt(plotter, spec, *, view_cone=None, fov=14.0, zoom=None)

Render the plotter's scene into a quilt image.

The plotter's current camera defines the centre view; its focal point becomes the holographic focal plane (the physical surface of the display). Position the camera before calling -- e.g. via plotter.camera_position or plotter.reset_camera() -- exactly as you would for a normal screenshot.

Parameters:

Name Type Description Default
plotter

An off-screen pv.Plotter with the scene composed.

required
spec QuiltSpec

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

required
view_cone float | None

Override the spec's view cone in degrees.

None
fov float | None

Vertical field of view in degrees for the quilt cameras. Looking Glass recommends ~14° (matches real-world parallax at typical viewing distance); the camera is dollied back so the scene stays the same size in frame. Pass None to keep the plotter's current FOV and distance.

14.0
zoom float | None

Optional camera zoom factor applied after framing, before the view sweep. Values > 1 make the subject fill more of each tile, which is what drives perceived depth -- parallax is proportional to on-screen size, so a subject occupying a third of the frame yields a third of the available look-around.

None

Returns:

Type Description
ndarray

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

Source code in src/quiltwright/lfd.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
def render_quilt(
    plotter,
    spec: QuiltSpec,
    *,
    view_cone: float | None = None,
    fov: float | None = 14.0,
    zoom: float | None = None,
) -> np.ndarray:
    """Render the plotter's scene into a quilt image.

    The plotter's current camera defines the centre view; its focal point
    becomes the holographic focal plane (the physical surface of the
    display).  Position the camera before calling -- e.g. via
    ``plotter.camera_position`` or ``plotter.reset_camera()`` -- exactly as
    you would for a normal screenshot.

    :param plotter: An *off-screen* ``pv.Plotter`` with the scene composed.
    :param spec: Quilt specification (grid, size, aspect, cone).
    :param view_cone: Override the spec's view cone in degrees.
    :param fov: Vertical field of view in degrees for the quilt cameras.
        Looking Glass recommends ~14° (matches real-world parallax at
        typical viewing distance); the camera is dollied back so the scene
        stays the same size in frame.  Pass ``None`` to keep the plotter's
        current FOV and distance.
    :param zoom: Optional camera zoom factor applied after framing, before
        the view sweep.  Values > 1 make the subject fill more of each tile,
        which is what drives perceived depth -- parallax is proportional to
        on-screen size, so a subject occupying a third of the frame yields a
        third of the available look-around.
    :return: ``uint8`` RGB array of shape ``(quilt_height, quilt_width, 3)``.
    """
    require_pyvista("render_quilt")
    if view_cone is not None:
        spec = replace(spec, view_cone=view_cone)

    # Views are *captured* at the declared view aspect (= display aspect) so
    # the frustum is undistorted, then resampled into the tile.  For most
    # devices these match; some ideal quilts (e.g. 27") store views
    # anamorphically, with tile pixel aspect != view aspect.
    render_h = spec.tile_height
    render_w = round(render_h * spec.aspect)
    plotter.window_size = (render_w, render_h)
    if not plotter.camera.is_set:
        # Mirror pyvista's first-render behaviour (it only runs on
        # show()/screenshot(), after we have already read the camera).
        plotter.camera_position = plotter.renderer.get_default_cam_pos()
        plotter.reset_camera()
    plotter.render()

    camera = plotter.camera
    if fov is not None:
        # Narrow the FOV and dolly back so the focal plane stays the same
        # size in frame: new_distance = half_height / tan(fov/2).
        pos, focal, _, _, distance = camera_frame(camera)
        half_height = distance * math.tan(math.radians(camera.view_angle) / 2.0)
        new_distance = half_height / math.tan(math.radians(fov) / 2.0)
        forward = (focal - pos) / distance
        camera.position = tuple(focal - forward * new_distance)
        camera.view_angle = fov
    if zoom is not None and zoom != 1.0:
        # Dolly rather than narrow the view angle: pulling the camera in
        # magnifies the subject while preserving the FOV the parallax
        # geometry was built around, and the focal plane stays on the
        # display surface.  view_offsets() rescales with the new distance,
        # so the angular look-around is unchanged.
        camera.Dolly(zoom)
    base = camera_frame(camera)
    distance = base[4]
    offsets = view_offsets(spec, distance)
    render_aspect = render_w / render_h

    def views():
        for offset in offsets:
            _apply_off_axis_view(camera, base, float(offset), render_aspect)
            # The dolly + lateral sweep move the camera well outside the range
            # VTK computed for the original position; re-fit it to the scene.
            plotter.renderer.reset_camera_clipping_range()
            # screenshot() alone returns the previous framebuffer; force a
            # render so each view reflects this view's camera.
            plotter.render()
            yield plotter.screenshot(None, return_img=True)

    quilt = assemble_quilt(views(), spec)

    # Restore the centre view so the plotter is reusable afterwards.
    _apply_off_axis_view(camera, base, 0.0, render_aspect)
    camera.SetWindowCenter(0.0, 0.0)
    plotter.renderer.reset_camera_clipping_range()
    return quilt

render_quilt_video(plotter, spec, out_stem, *, n_frames=180, fps=24, orbit_degrees=360.0, view_cone=None, fov=14.0, zoom=None, crf=18, on_frame=None, progress=True)

Render an animated quilt video (default: a full turntable orbit).

Renders one quilt per frame, rotating the camera about the focal point between frames, then encodes the sequence to MP4 per the Looking Glass quilt-video spec (yuv420p; H.264, or HEVC for 8K quilts). The filename carries the _qs<cols>x<rows>a<aspect> suffix so Studio / Bridge auto-detect playback settings.

Note the cost: a Portrait video renders n_frames x 48 views.

Parameters:

Name Type Description Default
plotter

An off-screen pv.Plotter with the scene composed.

required
spec QuiltSpec

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

required
out_stem str | Path

Output path; quilt suffix + .mp4 are appended.

required
n_frames int

Number of video frames (with fps sets loop duration).

180
orbit_degrees float

Total camera orbit over the clip; 360 loops seamlessly. Pass 0 to disable the turntable (use on_frame).

360.0
view_cone float | None

Override the spec's view cone in degrees.

None
fov float | None

Per-view vertical FOV; see :func:render_quilt.

14.0
zoom float | None

Camera dolly factor; see :func:render_quilt.

None
crf int

x264/x265 quality (lower = better; 15-20 sensible).

18
on_frame

Optional callback(frame_index) invoked before each frame renders -- mutate the scene here for custom animation.

None
progress bool

Print a progress line while rendering.

True

Returns:

Type Description
Path

Path of the quilt MP4 written.

Source code in src/quiltwright/lfd.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
def render_quilt_video(
    plotter,
    spec: QuiltSpec,
    out_stem: str | Path,
    *,
    n_frames: int = 180,
    fps: int = 24,
    orbit_degrees: float = 360.0,
    view_cone: float | None = None,
    fov: float | None = 14.0,
    zoom: float | None = None,
    crf: int = 18,
    on_frame=None,
    progress: bool = True,
) -> Path:
    """Render an animated quilt video (default: a full turntable orbit).

    Renders one quilt per frame, rotating the camera about the focal point
    between frames, then encodes the sequence to MP4 per the Looking Glass
    quilt-video spec (``yuv420p``; H.264, or HEVC for 8K quilts).  The
    filename carries the ``_qs<cols>x<rows>a<aspect>`` suffix so Studio /
    Bridge auto-detect playback settings.

    Note the cost: a Portrait video renders ``n_frames x 48`` views.

    :param plotter: An *off-screen* ``pv.Plotter`` with the scene composed.
    :param spec: Quilt specification (grid, size, aspect, cone).
    :param out_stem: Output path; quilt suffix + ``.mp4`` are appended.
    :param n_frames: Number of video frames (with *fps* sets loop duration).
    :param orbit_degrees: Total camera orbit over the clip; 360 loops
        seamlessly.  Pass 0 to disable the turntable (use *on_frame*).
    :param view_cone: Override the spec's view cone in degrees.
    :param fov: Per-view vertical FOV; see :func:`render_quilt`.
    :param zoom: Camera dolly factor; see :func:`render_quilt`.
    :param crf: x264/x265 quality (lower = better; 15-20 sensible).
    :param on_frame: Optional ``callback(frame_index)`` invoked before each
        frame renders -- mutate the scene here for custom animation.
    :param progress: Print a progress line while rendering.
    :return: Path of the quilt MP4 written.
    """
    import subprocess
    import tempfile

    require_pyvista("render_quilt_video")
    ffmpeg = find_ffmpeg()

    try:
        from PIL import Image
    except ImportError as exc:
        raise ImportError(
            "render_quilt_video() requires pillow.\nInstall with:  poetry install --with viz"
        ) from exc

    out_stem = Path(out_stem)
    if out_stem.suffix.lower() == ".mp4":
        out_stem = out_stem.with_suffix("")
    out_path = out_stem.parent / spec.filename(out_stem.name, ext="mp4")
    out_path.parent.mkdir(parents=True, exist_ok=True)

    step = orbit_degrees / n_frames if n_frames else 0.0
    with tempfile.TemporaryDirectory(prefix="quilt_frames_") as tmp:
        for i in range(n_frames):
            if on_frame is not None:
                on_frame(i)
            # Zoom only on the first frame: render_quilt leaves the camera at
            # the dollied distance, and Azimuth() preserves it, so re-applying
            # it every frame would compound into a creeping zoom-in.
            quilt = render_quilt(
                plotter, spec, view_cone=view_cone, fov=fov, zoom=zoom if i == 0 else None
            )
            Image.fromarray(quilt).save(f"{tmp}/frame{i:05d}.png")
            plotter.camera.Azimuth(step)
            if progress:
                print(f"\r  quilt frame {i + 1}/{n_frames}", end="", flush=True)
        if progress:
            print()

        cmd = [
            ffmpeg,
            "-y",
            "-framerate",
            str(fps),
            "-i",
            f"{tmp}/frame%05d.png",
            *_encode_args(spec, crf),
            str(out_path),
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            raise RuntimeError(f"ffmpeg failed ({result.returncode}):\n{result.stderr[-2000:]}")
    return out_path

resume_quilt(*, bridge_url=BRIDGE_URL, timeout=10.0)

Resume playback after :func:pause_quilt.

Parameters:

Name Type Description Default
bridge_url str

Base URL of the Bridge HTTP API.

BRIDGE_URL
timeout float

HTTP timeout in seconds.

10.0

Returns:

Type Description
dict

Decoded JSON response of the transport_control_play call.

Source code in src/quiltwright/bridge.py
207
208
209
210
211
212
213
214
215
def resume_quilt(*, bridge_url: str = BRIDGE_URL, timeout: float = 10.0) -> dict:
    """Resume playback after :func:`pause_quilt`.

    :param bridge_url: Base URL of the Bridge HTTP API.
    :param timeout: HTTP timeout in seconds.
    :return: Decoded JSON response of the ``transport_control_play`` call.
    """
    token = enter_orchestration(bridge_url, timeout)
    return bridge_post(bridge_url, "transport_control_play", {"orchestration": token}, timeout)

save_and_cast_quilt(quilt, stem, spec, *, cast=True, bridge_url=BRIDGE_URL, timeout=10.0)

Write a quilt to disk, then hand Bridge the path to it.

The two calls this composes take different argument types, and the mistake is invisible until a panel is connected: :func:save_quilt takes the array, :func:cast_quilt takes a path. Passing the array to the caster raises argument should be a str or an os.PathLike object ... not 'ndarray' -- after the render, which for a ray-traced quilt is minutes later and the worst possible moment to find out.

The file is confirmed on disk before Bridge is contacted, and a failed cast is returned rather than raised, so losing the display never costs the render.

Parameters:

Name Type Description Default
quilt ndarray

RGB array from :func:render_quilt or :func:assemble_quilt.

required
stem str | Path

Output path without the quilt suffix or extension.

required
spec QuiltSpec

Quilt specification, used for both the filename and Bridge.

required
cast bool

Whether to push the written file to the Looking Glass. False writes and returns without contacting Bridge.

True
bridge_url str

Base URL of the Bridge HTTP API.

BRIDGE_URL
timeout float

HTTP timeout in seconds per Bridge request.

10.0

Returns:

Type Description
tuple[Path, str | None]

(written path, error message or None). The path is always real; a non-None error means the file is on disk but is not showing.

Source code in src/quiltwright/bridge.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def save_and_cast_quilt(
    quilt: np.ndarray,
    stem: str | Path,
    spec: QuiltSpec,
    *,
    cast: bool = True,
    bridge_url: str = BRIDGE_URL,
    timeout: float = 10.0,
) -> tuple[Path, str | None]:
    """Write a quilt to disk, then hand Bridge the **path** to it.

    The two calls this composes take different argument types, and the mistake
    is invisible until a panel is connected: :func:`save_quilt` takes the
    array, :func:`cast_quilt` takes a path.  Passing the array to the caster
    raises ``argument should be a str or an os.PathLike object ... not
    'ndarray'`` -- after the render, which for a ray-traced quilt is minutes
    later and the worst possible moment to find out.

    The file is confirmed on disk before Bridge is contacted, and a failed
    cast is *returned* rather than raised, so losing the display never costs
    the render.

    :param quilt: RGB array from :func:`render_quilt` or :func:`assemble_quilt`.
    :param stem: Output path *without* the quilt suffix or extension.
    :param spec: Quilt specification, used for both the filename and Bridge.
    :param cast: Whether to push the written file to the Looking Glass.
        ``False`` writes and returns without contacting Bridge.
    :param bridge_url: Base URL of the Bridge HTTP API.
    :param timeout: HTTP timeout in seconds per Bridge request.
    :return: ``(written path, error message or None)``.  The path is always
        real; a non-None error means the file is on disk but is not showing.
    """
    out = save_quilt(quilt, stem, spec)
    if not cast:
        return out, None
    if not out.exists():
        return out, f"{out} was not written"
    try:
        cast_quilt(out, spec, bridge_url=bridge_url, timeout=timeout)
    except Exception as exc:  # noqa: BLE001 - the quilt file is kept regardless
        return out, str(exc)
    return out, None

save_quilt(quilt, stem, spec)

Write a quilt to PNG using the Looking Glass filename convention.

Parameters:

Name Type Description Default
quilt ndarray

RGB array from :func:render_quilt.

required
stem str | Path

Output path without the quilt suffix or extension. Any .png extension is stripped first.

required
spec QuiltSpec

Quilt specification (encodes the suffix metadata).

required

Returns:

Type Description
Path

The path written, e.g. renders/quilts/helix_qs8x6a0.75.png.

Source code in src/quiltwright/quilt.py
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def save_quilt(quilt: np.ndarray, stem: str | Path, spec: QuiltSpec) -> Path:
    """Write a quilt to PNG using the Looking Glass filename convention.

    :param quilt: RGB array from :func:`render_quilt`.
    :param stem: Output path *without* the quilt suffix or extension.
        Any ``.png`` extension is stripped first.
    :param spec: Quilt specification (encodes the suffix metadata).
    :return: The path written, e.g. ``renders/quilts/helix_qs8x6a0.75.png``.
    """
    try:
        from PIL import Image
    except ImportError as exc:
        raise ImportError(
            "save_quilt() requires pillow.\nInstall with:  poetry install --with viz"
        ) from exc

    stem = Path(stem)
    if stem.suffix.lower() == ".png":
        stem = stem.with_suffix("")
    out_path = stem.parent / spec.filename(stem.name)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    Image.fromarray(quilt).save(out_path)
    return out_path

scene_depths(plotter, *, fov=14.0, zoom=None, labels=DEPTH_LABELS)

Near, focal and far distances for the scene, as :func:render_quilt will see them.

Measures the plotter's bounding box along the view axis, then applies the same framing :func:render_quilt applies before sweeping -- narrowing the FOV and dollying back, then the optional zoom dolly. Reading the camera as-is instead is the tempting shortcut and it is wrong: the render's FOV and focal distance are both different by then, so the disparity computed from them describes a picture nobody is going to make. Nothing is mutated; the arithmetic is done on copies.

Parameters:

Name Type Description Default
plotter

A pv.Plotter with the scene composed and the camera positioned as it will be for the render.

required
fov float | None

The vertical FOV that will be passed to :func:render_quilt. None keeps the plotter's current FOV, matching that argument.

14.0
zoom float | None

The zoom that will be passed to :func:render_quilt.

None
labels tuple[str, str, str]

Names for the near, focal and far entries, in that order.

DEPTH_LABELS

Returns:

Type Description
dict[str, float]

Labelled distances from the render camera, in scene units, ready to hand to :func:~quiltwright.povray.format_depth_budget.

Source code in src/quiltwright/lfd.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
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
def scene_depths(
    plotter,
    *,
    fov: float | None = 14.0,
    zoom: float | None = None,
    labels: tuple[str, str, str] = DEPTH_LABELS,
) -> dict[str, float]:
    """Near, focal and far distances for the scene, as :func:`render_quilt` will see them.

    Measures the plotter's bounding box along the view axis, then applies the
    same framing :func:`render_quilt` applies before sweeping -- narrowing the
    FOV and dollying back, then the optional zoom dolly.  Reading the camera
    as-is instead is the tempting shortcut and it is wrong: the render's FOV
    and focal distance are both different by then, so the disparity computed
    from them describes a picture nobody is going to make.  Nothing is
    mutated; the arithmetic is done on copies.

    :param plotter: A ``pv.Plotter`` with the scene composed and the camera
        positioned as it will be for the render.
    :param fov: The vertical FOV that will be passed to :func:`render_quilt`.
        ``None`` keeps the plotter's current FOV, matching that argument.
    :param zoom: The zoom that will be passed to :func:`render_quilt`.
    :param labels: Names for the near, focal and far entries, in that order.
    :return: Labelled distances from the render camera, in scene units,
        ready to hand to :func:`~quiltwright.povray.format_depth_budget`.
    """
    require_pyvista("scene_depths")
    camera = plotter.camera
    pos, focal, _right, _up, distance = camera_frame(camera)
    forward = (focal - pos) / distance

    # Mirror render_quilt's framing: narrow the FOV, dolly back to preserve
    # the framing, then apply the zoom dolly (VTK divides the distance).
    final_distance = distance
    if fov is not None:
        half_height = distance * math.tan(math.radians(camera.view_angle) / 2.0)
        final_distance = half_height / math.tan(math.radians(fov) / 2.0)
    if zoom is not None and zoom != 1.0:
        final_distance /= zoom

    # The camera retreats along -forward, so every measured depth grows by
    # the same shift; the focal plane sits at the new distance by definition.
    shift = final_distance - distance
    xmin, xmax, ymin, ymax, zmin, zmax = plotter.bounds
    corners = np.array(
        [[x, y, z] for x in (xmin, xmax) for y in (ymin, ymax) for z in (zmin, zmax)],
        dtype="d",
    )
    along = (corners - pos) @ forward
    return {
        labels[0]: float(along.min()) + shift,
        labels[1]: final_distance,
        labels[2]: float(along.max()) + shift,
    }

stop_quilt(*, bridge_url=BRIDGE_URL, timeout=10.0)

Stop playback: pause the current frame and hide the display window.

Bridge's own bridge.js <https://github.com/Looking-Glass/bridge.js>_ SDK documents delete_playlist as the way to stop a playlist, and an earlier version of this function called it. In testing it reliably left Bridge unresponsive to every further HTTP call -- reproduced twice, once mid-video and once on a single still image, so it isn't a large-file decode race. This function deliberately avoids delete_playlist and reaches the same end state (nothing visible, playback halted) through calls already proven safe: the playlist from :func:cast_quilt is left instantiated but paused and hidden, rather than deleted, so :func:cast_quilt can safely replace it later.

Parameters:

Name Type Description Default
bridge_url str

Base URL of the Bridge HTTP API.

BRIDGE_URL
timeout float

HTTP timeout in seconds.

10.0

Returns:

Type Description
dict

Decoded JSON response of the final show_window call.

Source code in src/quiltwright/bridge.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def stop_quilt(*, bridge_url: str = BRIDGE_URL, timeout: float = 10.0) -> dict:
    """Stop playback: pause the current frame and hide the display window.

    Bridge's own `bridge.js <https://github.com/Looking-Glass/bridge.js>`_
    SDK documents ``delete_playlist`` as *the* way to stop a playlist, and
    an earlier version of this function called it. In testing it reliably
    left Bridge unresponsive to every further HTTP call -- reproduced twice,
    once mid-video and once on a single still image, so it isn't a
    large-file decode race. This function deliberately avoids
    ``delete_playlist`` and reaches the same end state (nothing visible,
    playback halted) through calls already proven safe: the playlist from
    :func:`cast_quilt` is left instantiated but paused and hidden, rather
    than deleted, so :func:`cast_quilt` can safely replace it later.

    :param bridge_url: Base URL of the Bridge HTTP API.
    :param timeout: HTTP timeout in seconds.
    :return: Decoded JSON response of the final ``show_window`` call.
    """
    token = enter_orchestration(bridge_url, timeout)
    bridge_post(bridge_url, "transport_control_pause", {"orchestration": token}, timeout)
    return bridge_post(
        bridge_url,
        "show_window",
        {"orchestration": token, "show_window": False, "head_index": -1},
        timeout,
    )

sweep_spec(n_views, view_cone, tile_width, tile_height)

Geometry for a plain ordered view sweep rather than a tiled quilt.

A quilt's view count is columns * rows, so a rectangular grid cannot express a prime count. A single row can express any count at all, which is what consumers that want the views as separate frames -- hologram printers, lenticular interlacers -- actually ask for. The camera sweep is identical either way; only the packing differs.

Parameters:

Name Type Description Default
n_views int

Number of views in the sweep.

required
view_cone float

Total horizontal camera sweep in degrees.

required
tile_width int

Pixel width of one view.

required
tile_height int

Pixel height of one view.

required

Returns:

Type Description
QuiltSpec

A single-row :class:QuiltSpec whose n_views is exactly n_views.

Source code in src/quiltwright/quilt.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def sweep_spec(n_views: int, view_cone: float, tile_width: int, tile_height: int) -> QuiltSpec:
    """Geometry for a plain ordered view sweep rather than a tiled quilt.

    A quilt's view count is ``columns * rows``, so a rectangular grid cannot
    express a prime count.  A single row can express any count at all, which
    is what consumers that want the views as separate frames -- hologram
    printers, lenticular interlacers -- actually ask for.  The camera sweep is
    identical either way; only the packing differs.

    :param n_views: Number of views in the sweep.
    :param view_cone: Total horizontal camera sweep in degrees.
    :param tile_width: Pixel width of one view.
    :param tile_height: Pixel height of one view.
    :return: A single-row :class:`QuiltSpec` whose ``n_views`` is exactly
        *n_views*.
    """
    if n_views < 2:
        raise ValueError(f"a sweep needs at least 2 views, got {n_views}")
    return QuiltSpec(
        columns=n_views,
        rows=1,
        quilt_width=n_views * tile_width,
        quilt_height=tile_height,
        aspect=tile_width / tile_height,
        view_cone=view_cone,
    )

view_disparity(spec, fov, focal_distance, depth)

Pixel shift of a feature between adjacent quilt views.

This is the number that decides whether a hologram fuses. A lenticular display blends neighbouring views optically, so content that moves only a pixel or two between them reads as solid depth, while larger shifts read as ghosting or a visible stack of copies. Rendered scenes that "look fine" flat routinely blow this budget.

Derived from the off-axis projection: a point at depth along the view axis lands at image coordinate (D/aspect)(x/depth + s(1/Z - 1/depth)) for eye offset s, so the shift across the whole cone is [tan(cone/2)/tan(fov/2)] * (1 - Z/depth) * tile_height pixels, which divided between n_views - 1 gaps gives this. The aspect ratio cancels. Verified against ray-traced renders to within 0.5%.

Two consequences worth internalising: content at the focal plane has zero disparity, and a narrower FOV increases disparity, because it magnifies the scene and the parallax along with it.

Parameters:

Name Type Description Default
spec QuiltSpec

Quilt specification (view count + cone angle + tile size).

required
fov float

Vertical field of view in degrees.

required
focal_distance float

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

required
depth float

Distance of the content of interest from the camera, in scene units. Use math.inf for sky or a backdrop at infinity.

required

Returns:

Type Description
float

Adjacent-view shift in pixels. Roughly 4-5 px is the practical ceiling; beyond ~8 px expect visible ghosting on hard edges.

Source code in src/quiltwright/quilt.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def view_disparity(spec: QuiltSpec, fov: float, focal_distance: float, depth: float) -> float:
    """Pixel shift of a feature between *adjacent* quilt views.

    This is the number that decides whether a hologram fuses.  A lenticular
    display blends neighbouring views optically, so content that moves only
    a pixel or two between them reads as solid depth, while larger shifts
    read as ghosting or a visible stack of copies.  Rendered scenes that
    "look fine" flat routinely blow this budget.

    Derived from the off-axis projection: a point at *depth* along the view
    axis lands at image coordinate ``(D/aspect)(x/depth + s(1/Z - 1/depth))``
    for eye offset ``s``, so the shift across the whole cone is
    ``[tan(cone/2)/tan(fov/2)] * (1 - Z/depth) * tile_height`` pixels, which
    divided between ``n_views - 1`` gaps gives this.  The aspect ratio
    cancels.  Verified against ray-traced renders to within 0.5%.

    Two consequences worth internalising: content *at* the focal plane has
    zero disparity, and a *narrower* FOV increases disparity, because it
    magnifies the scene and the parallax along with it.

    :param spec: Quilt specification (view count + cone angle + tile size).
    :param fov: Vertical field of view in degrees.
    :param focal_distance: Camera-to-focal-plane distance, in scene units.
    :param depth: Distance of the content of interest from the camera, in
        scene units.  Use ``math.inf`` for sky or a backdrop at infinity.
    :return: Adjacent-view shift in pixels.  Roughly 4-5 px is the practical
        ceiling; beyond ~8 px expect visible ghosting on hard edges.
    """
    if spec.n_views < 2:
        return 0.0
    magnification = math.tan(math.radians(spec.view_cone) / 2.0) / math.tan(math.radians(fov) / 2.0)
    parallax = 1.0 if math.isinf(depth) else abs(1.0 - focal_distance / depth)
    return magnification * parallax * spec.tile_height / (spec.n_views - 1)

view_offsets(spec, distance)

Horizontal camera offsets (world units) for every view in the quilt.

Cameras sweep a total angle of spec.view_cone centred on the base camera position, at constant distance from the focal plane. Offsets are ordered to match quilt view order: view 0 is the leftmost camera.

Parameters:

Name Type Description Default
spec QuiltSpec

Quilt specification (view count + cone angle).

required
distance float

Distance from camera to the focal plane.

required

Returns:

Type Description
ndarray

Array of shape (n_views,) with signed offsets along the camera's right vector.

Source code in src/quiltwright/quilt.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def view_offsets(spec: QuiltSpec, distance: float) -> np.ndarray:
    """Horizontal camera offsets (world units) for every view in the quilt.

    Cameras sweep a total angle of ``spec.view_cone`` centred on the base
    camera position, at constant distance from the focal plane.  Offsets are
    ordered to match quilt view order: view 0 is the leftmost camera.

    :param spec: Quilt specification (view count + cone angle).
    :param distance: Distance from camera to the focal plane.
    :return: Array of shape ``(n_views,)`` with signed offsets along the
        camera's right vector.
    """
    half_cone = math.radians(spec.view_cone) / 2.0
    n = spec.n_views
    if n == 1:
        return np.zeros(1)
    # Even angular spacing across the cone; tan() converts angle to lateral
    # shift so the focal plane is sampled like the physical display does.
    angles = np.linspace(-half_cone, half_cone, n)
    return distance * np.tan(angles)