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
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
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

available_output_devices(bridge_url, timeout, *, token=None)

Ask Bridge which output devices it can see.

Every entry Bridge reports, panel or not -- an ordinary monitor shows up with hardware_version: "thirdparty". Filter on that field yourself to count panels only; :func:cast_quilt's own check does not distinguish a panel from a monitor, since head_index=-1 deliberately accepts either -- it only rejects an empty list, the state that means nothing is registered at all.

Parameters:

Name Type Description Default
bridge_url str

Base URL of the Bridge HTTP API.

required
timeout float

HTTP timeout in seconds.

required
token str | None

An existing orchestration token, to skip a redundant enter_orchestration round trip when the caller already holds one (:func:cast_quilt does). Entered fresh when omitted.

None

Returns:

Type Description
list[dict[str, str]]

One dict per head: {"index", "hardware_version", "hwid"}.

Source code in src/quiltwright/bridge.py
 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
def available_output_devices(
    bridge_url: str, timeout: float, *, token: str | None = None
) -> list[dict[str, str]]:
    """Ask Bridge which output devices it can see.

    Every entry Bridge reports, panel or not -- an ordinary monitor shows up
    with ``hardware_version: "thirdparty"``. Filter on that field yourself to
    count panels only; :func:`cast_quilt`'s own check does not distinguish
    a panel from a monitor, since ``head_index=-1`` deliberately accepts
    either -- it only rejects an *empty* list, the state that means nothing
    is registered at all.

    :param bridge_url: Base URL of the Bridge HTTP API.
    :param timeout: HTTP timeout in seconds.
    :param token: An existing orchestration token, to skip a redundant
        ``enter_orchestration`` round trip when the caller already holds one
        (:func:`cast_quilt` does). Entered fresh when omitted.
    :return: One dict per head: ``{"index", "hardware_version", "hwid"}``.
    """
    if token is None:
        token = enter_orchestration(bridge_url, timeout)
    payload = bridge_post(bridge_url, "available_output_devices", {"orchestration": token}, timeout)
    heads = []
    for index, entry in (payload.get("payload", {}).get("value", {}) or {}).items():
        value = entry.get("value", {})
        heads.append(
            {
                "index": str(index),
                "hardware_version": _field(value, "hardwareVersion") or "?",
                "hwid": _field(value, "hwid"),
            }
        )
    return heads

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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
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=None, 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. A one-quilt :func:cast_playlist: see it for the orchestration sequence, for why every call plays a playlist of its own, and for the full parameters.

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 | None

Bridge playlist name; None generates a fresh one, so the cast replaces whatever is showing.

None
timeout float

HTTP timeout in seconds per request.

10.0
head_index int

Which Bridge output device to play on; -1 lets Bridge choose.

-1

Returns:

Type Description
dict

Decoded JSON response of the final play_playlist call.

Raises:

Type Description
RuntimeError

If Bridge answers but reports zero output devices.

Source code in src/quiltwright/bridge.py
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
def cast_quilt(
    quilt_path: str | Path,
    spec: QuiltSpec,
    *,
    bridge_url: str = BRIDGE_URL,
    playlist: str | None = None,
    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.  A one-quilt
    :func:`cast_playlist`: see it for the orchestration sequence, for why
    every call plays a playlist of its own, and for the full parameters.

    :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: Bridge playlist name; ``None`` generates a fresh one, so
        the cast replaces whatever is showing.
    :param timeout: HTTP timeout in seconds per request.
    :param head_index: Which Bridge output device to play on; ``-1`` lets
        Bridge choose.
    :raises RuntimeError: If Bridge answers but reports zero output devices.
    :return: Decoded JSON response of the final ``play_playlist`` call.
    """
    return cast_playlist(
        [(quilt_path, spec)],
        bridge_url=bridge_url,
        playlist=playlist,
        timeout=timeout,
        head_index=head_index,
    )

depth_report(plotter, spec, *, view_cone=None, 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 view_cone, fov and zoom you will pass to :func:render_quilt, so the numbers describe the render you are about to make. Depths come from the plotter's bounds, so report before adding a floor or backdrop.

Parameters:

Name Type Description Default
plotter

Plotter with the scene composed and the camera framed.

required
spec QuiltSpec

Quilt specification.

required
view_cone float | None

Override the spec's view cone in degrees; see :func:render_quilt.

None
fov float | None

FOV that will be used for the render; see :func:render_quilt. Pass None after :func:frame_and_focus, as for the render.

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
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
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
def depth_report(
    plotter,
    spec: QuiltSpec,
    *,
    view_cone: float | None = None,
    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
    *view_cone*, *fov* and *zoom* you will pass to :func:`render_quilt`, so
    the numbers describe the render you are about to make.  Depths come from
    the plotter's bounds, so report before adding a floor or backdrop.

    :param plotter: Plotter with the scene composed and the camera framed.
    :param spec: Quilt specification.
    :param view_cone: Override the spec's view cone in degrees; see
        :func:`render_quilt`.
    :param fov: FOV that will be used for the render; see :func:`render_quilt`.
        Pass ``None`` after :func:`frame_and_focus`, as for the render.
    :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

    if view_cone is not None:
        spec = replace(spec, view_cone=view_cone)
    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
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
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, spec=None)

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.

The fit uses the plotter's bounds, so call this before adding a floor, backdrop or anything else that should not be framed -- a floor that reaches past the camera is framed, and measured, as the subject.

Parameters:

Name Type Description Default
plotter

A pv.Plotter with the data added and the camera pointing in the desired direction. Without spec, window_size must already have the aspect the views render at.

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
spec QuiltSpec | None

Quilt specification the scene will render with. When given, window_size is set to the view size :func:render_quilt uses before framing. That is the display aspect, not the tile aspect, which is the mistake sizing the window by hand invites.

None

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
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
203
204
205
206
207
208
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
def frame_and_focus(
    plotter,
    *,
    fov: float = 14.0,
    margin: float = 1.15,
    spec: QuiltSpec | None = None,
) -> 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.

    The fit uses the plotter's bounds, so call this before adding a floor,
    backdrop or anything else that should not be framed -- a floor that
    reaches past the camera is framed, and measured, as the subject.

    :param plotter: A ``pv.Plotter`` with the data added and the camera
        pointing in the desired direction.  Without *spec*, ``window_size``
        must already have the aspect the views render at.
    :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.
    :param spec: Quilt specification the scene will render with.  When
        given, ``window_size`` is set to the view size :func:`render_quilt`
        uses before framing.  That is the display aspect, not the tile
        aspect, which is the mistake sizing the window by hand invites.
    :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")
    if spec is not None:
        plotter.window_size = _view_window(spec)
    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

litiholo_names(spec, *, mode='HPO')

Filenames LitiHolo's own capture tool gives a sweep, in view order.

The printer consumes a numbered sequence, so the names carry the view ordering and nothing else may. Matching the vendor tool's convention costs nothing and removes one variable from a first print: its captureHPOSequence() writes render-{W}x{H}-HPO-01.jpg through -23.jpg, one-indexed and zero-padded to two digits, leftmost camera first. Quiltwright's own view000.png ordering is the same sweep under a different name, which is exactly the kind of difference that is invisible until a plate comes back mirrored.

Parameters:

Name Type Description Default
spec QuiltSpec

Sweep specification; supplies the view count and the capture size that appears in the name.

required
mode str

"HPO", horizontal parallax only. The tool also has a full-parallax mode writing FP-h{col}v{row}; quiltwright does not render a vertical sweep, so that mode is rejected here rather than named for a view set it cannot produce.

'HPO'

Returns:

Type Description
list[str]

n_views filenames, in view order, view 0 leftmost.

Raises:

Type Description
ValueError

If mode is not "HPO".

Source code in src/quiltwright/quilt.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
def litiholo_names(spec: QuiltSpec, *, mode: str = "HPO") -> list[str]:
    """Filenames LitiHolo's own capture tool gives a sweep, in view order.

    The printer consumes a *numbered sequence*, so the names carry the view
    ordering and nothing else may.  Matching the vendor tool's convention
    costs nothing and removes one variable from a first print: its
    ``captureHPOSequence()`` writes ``render-{W}x{H}-HPO-01.jpg`` through
    ``-23.jpg``, **one**-indexed and zero-padded to two digits, leftmost
    camera first.  Quiltwright's own ``view000.png`` ordering is the same
    sweep under a different name, which is exactly the kind of difference
    that is invisible until a plate comes back mirrored.

    :param spec: Sweep specification; supplies the view count and the
        capture size that appears in the name.
    :param mode: ``"HPO"``, horizontal parallax only.  The tool also has a
        full-parallax mode writing ``FP-h{col}v{row}``; quiltwright does not
        render a vertical sweep, so that mode is rejected here rather than
        named for a view set it cannot produce.
    :return: ``n_views`` filenames, in view order, view 0 leftmost.
    :raises ValueError: If *mode* is not ``"HPO"``.
    """
    if mode != "HPO":
        raise ValueError(
            f"litiholo_names() supports mode='HPO', got {mode!r}; "
            "quiltwright renders no vertical sweep, so it cannot fill an FP grid"
        )
    stem = f"render-{spec.tile_width}x{spec.tile_height}-HPO-"
    return [f"{stem}{i:02d}.jpg" for i in range(1, spec.n_views + 1)]

pack_litiholo_sweep(views, dest, spec, *, zip_output=False, jpeg_quality=95)

Re-encode a rendered sweep into what the LitiHolo tool hands the printer.

The renderer-agnostic delivery step, the same role :func:assemble_quilt plays for a panel: take views some backend already rendered -- POV-Ray via :func:~quiltwright.povray.render_pov_views, anything else that writes ordered frames -- and emit JPEGs named and ordered the way the vendor's own tool emits them (see :func:litiholo_names).

Nothing here changes the geometry, which is set at render time and is the part that actually decides whether a plate comes out right; render with geometry="toe-in" if you are matching the tool rather than the specification sheet.

Parameters:

Name Type Description Default
views Iterable[str | Path]

Rendered view images, in view order, view 0 leftmost. Any format pillow reads; each is converted to RGB, so a transparent background flattens to black -- which is the background the tool's own capture offers and the one a hologram wants.

required
dest str | Path

Output directory; created if absent.

required
spec QuiltSpec

Sweep specification. Its view count must match views, and its tile size names the files.

required
zip_output bool

Also write render-{W}x{H}-HPO-captures.zip alongside the frames, which is the single file the tool downloads.

False
jpeg_quality int

Pillow JPEG quality, 1-95. The vendor tool takes whatever its browser's canvas encoder gives it; 95 is pillow's practical ceiling and errs toward not adding artefacts of our own to an image that is about to be sliced into hogels.

95

Returns:

Type Description
list[Path]

The JPEG paths written, in view order.

Raises:

Type Description
ValueError

If the number of views does not match spec.

Source code in src/quiltwright/quilt.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def pack_litiholo_sweep(
    views: Iterable[str | Path],
    dest: str | Path,
    spec: QuiltSpec,
    *,
    zip_output: bool = False,
    jpeg_quality: int = 95,
) -> list[Path]:
    """Re-encode a rendered sweep into what the LitiHolo tool hands the printer.

    The renderer-agnostic delivery step, the same role :func:`assemble_quilt`
    plays for a panel: take views some backend already rendered -- POV-Ray
    via :func:`~quiltwright.povray.render_pov_views`, anything else that
    writes ordered frames -- and emit JPEGs named and ordered the way the
    vendor's own tool emits them (see :func:`litiholo_names`).

    Nothing here changes the *geometry*, which is set at render time and is
    the part that actually decides whether a plate comes out right; render
    with ``geometry="toe-in"`` if you are matching the tool rather than the
    specification sheet.

    :param views: Rendered view images, in view order, view 0 leftmost.
        Any format pillow reads; each is converted to RGB, so a transparent
        background flattens to black -- which is the background the tool's
        own capture offers and the one a hologram wants.
    :param dest: Output directory; created if absent.
    :param spec: Sweep specification.  Its view count must match *views*,
        and its tile size names the files.
    :param zip_output: Also write ``render-{W}x{H}-HPO-captures.zip``
        alongside the frames, which is the single file the tool downloads.
    :param jpeg_quality: Pillow JPEG quality, 1-95.  The vendor tool takes
        whatever its browser's canvas encoder gives it; 95 is pillow's
        practical ceiling and errs toward not adding artefacts of our own to
        an image that is about to be sliced into hogels.
    :return: The JPEG paths written, in view order.
    :raises ValueError: If the number of views does not match *spec*.
    """
    try:
        from PIL import Image
    except ImportError as exc:
        raise ImportError(
            "pack_litiholo_sweep() requires pillow.\nInstall with:  poetry install --with viz"
        ) from exc

    frames = [Path(v) for v in views]
    if len(frames) != spec.n_views:
        raise ValueError(
            f"spec asks for {spec.n_views} views, got {len(frames)}; "
            "the printer reads the sequence as given and cannot detect a gap"
        )

    out = Path(dest).expanduser()
    out.mkdir(parents=True, exist_ok=True)
    written = []
    for src, name in zip(frames, litiholo_names(spec), strict=True):
        target = out / name
        with Image.open(src) as img:
            # JPEG has no alpha.  convert("RGB") would *discard* it rather
            # than composite, so a transparent background would keep
            # whatever colour happened to sit under it -- white, for a
            # POV-Ray alpha render, which is the one background a hologram
            # must not have.  Paste onto black, the ground the vendor
            # tool's own transparent capture ends up with.
            flat = _flatten_to_black(img, Image)
            flat.save(target, "JPEG", quality=jpeg_quality)
        written.append(target)

    if zip_output:
        import zipfile

        archive = out / f"render-{spec.tile_width}x{spec.tile_height}-HPO-captures.zip"
        with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
            for path in written:
                zf.write(path, path.name)

    return written

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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
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
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
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
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_w, render_h = _view_window(spec)
    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
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
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
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
332
333
334
335
336
337
338
339
340
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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
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
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
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
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
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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
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. The next :func:cast_quilt plays a playlist of its own rather than reusing this one, so what is left behind does not block it.

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
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
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. The next :func:`cast_quilt` plays a playlist of its own
    rather than reusing this one, so what is left behind does not block it.

    :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
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
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_angles(spec)

Signed angle of every view about the focal point, in degrees.

The sweep's angular sampling, independent of how a backend realises it. An off-axis sweep converts these to lateral eye offsets with :func:view_offsets; a toe-in sweep (:func:~quiltwright.povray.toe_in_cameras) swings the eye around the focal point by these angles directly. Both sample the same angles, so the sampling interval quoted in a depth budget means the same thing either way.

Ordered to match quilt view order: view 0 is the leftmost camera, so angles run from -view_cone/2 to +view_cone/2.

Parameters:

Name Type Description Default
spec QuiltSpec

Quilt or sweep specification (view count + cone angle).

required

Returns:

Type Description
ndarray

Array of shape (n_views,) of degrees, ascending.

Source code in src/quiltwright/quilt.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def view_angles(spec: QuiltSpec) -> np.ndarray:
    """Signed angle of every view about the focal point, in degrees.

    The sweep's angular sampling, independent of how a backend realises it.
    An off-axis sweep converts these to lateral eye offsets with
    :func:`view_offsets`; a toe-in sweep
    (:func:`~quiltwright.povray.toe_in_cameras`) swings the eye around the
    focal point by these angles directly.  Both sample the same angles, so
    the sampling interval quoted in a depth budget means the same thing
    either way.

    Ordered to match quilt view order: view 0 is the leftmost camera, so
    angles run from ``-view_cone/2`` to ``+view_cone/2``.

    :param spec: Quilt or sweep specification (view count + cone angle).
    :return: Array of shape ``(n_views,)`` of degrees, ascending.
    """
    if spec.n_views == 1:
        return np.zeros(1)
    half_cone = spec.view_cone / 2.0
    return np.linspace(-half_cone, half_cone, spec.n_views)

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
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 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.

The eye travels along a straight line perpendicular to the view axis, which is what an off-axis sweep wants -- the image planes stay parallel and the frustum shear does the rest. A toe-in sweep moves the eye along a circular arc instead; see :func:~quiltwright.povray.toe_in_cameras.

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
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
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.

    The eye travels along a *straight line* perpendicular to the view axis,
    which is what an off-axis sweep wants -- the image planes stay parallel
    and the frustum shear does the rest.  A toe-in sweep moves the eye along
    a circular arc instead; see :func:`~quiltwright.povray.toe_in_cameras`.

    :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.
    """
    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.
    return distance * np.tan(np.radians(view_angles(spec)))