Skip to content

Quilt geometry

quiltwright.quilt

Quilt geometry, assembly, and save.

Renderer-agnostic half of quilt production: tiling grid, device presets, view offsets, the assembler every backend feeds, and the Looking Glass filename convention. numpy and pillow only -- importing this module must not load VTK.

Typical usage::

from quiltwright.quilt import QUILT_PRESETS, assemble_quilt, save_quilt

spec = QUILT_PRESETS["portrait"]
save_quilt(assemble_quilt(views, spec), "torus", spec)

Re-exported from :mod:quiltwright.lfd for one release, so from quiltwright.lfd import QuiltSpec keeps working.

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

HasLens

Bases: Protocol

fov and focal_distance -- all the depth budget reads.

A typing :class:~typing.Protocol, not a runtime base class. :class:QuiltCamera satisfies this, as does a tiny namespace with those two attributes (see :class:~quiltwright.lfd._Lens) so a depth report does not have to construct a throwaway camera.

QuiltCamera

Bases: Protocol

Look-at camera that can feed a quilt sweep.

:class:~quiltwright.povray.PovCamera and :class:~quiltwright.cycles.CyclesCamera both satisfy this. Named QuiltCamera rather than CameraFrame so it does not collide with kg_utils.viz3d.layout.CameraFrame in the growth engine.

Handedness is the camera's own: POV-Ray is left-handed, Cycles and VTK are right-handed. :meth:basis returns (forward, right, up) in that convention. Callers that emit a renderer-specific frustum convert :func:window_shear into the units that renderer takes.

Every :class:QuiltCamera is also a :class:HasLens.

focal_distance property

Distance from the eye to the look-at point, in scene units.

basis()

Orthonormal (forward, right, up) in this camera's handedness.

Source code in src/quiltwright/quilt.py
283
284
285
def basis(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Orthonormal ``(forward, right, up)`` in this camera's handedness."""
    ...

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

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)

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

sweep_extent(spec, focal_distance)

Half-width of the lateral eye travel the quilt's view sweep needs.

The outermost views sit focal_distance * tan(cone/2) to either side of the centre view -- the largest magnitude in :func:view_offsets, in closed form. For an object on a turntable that space is empty; inside a room it is furniture and walls, so compare it against a measured :class:~quiltwright.povray.Clearance before committing to a render.

Parameters:

Name Type Description Default
spec QuiltSpec

Quilt specification (supplies the view cone).

required
focal_distance float

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

required

Returns:

Type Description
float

Half the total eye sweep, in scene units.

Source code in src/quiltwright/quilt.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def sweep_extent(spec: QuiltSpec, focal_distance: float) -> float:
    """Half-width of the lateral eye travel the quilt's view sweep needs.

    The outermost views sit ``focal_distance * tan(cone/2)`` to either side
    of the centre view -- the largest magnitude in :func:`view_offsets`, in
    closed form.  For an object on a turntable that space is empty; inside a
    room it is furniture and walls, so compare it against a measured
    :class:`~quiltwright.povray.Clearance` before committing to a render.

    :param spec: Quilt specification (supplies the view cone).
    :param focal_distance: Camera-to-focal-plane distance, in scene units.
    :return: Half the total eye sweep, in scene units.
    """
    return focal_distance * math.tan(math.radians(spec.view_cone) / 2.0)

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)

window_shear(offset, focal_distance, fov, aspect)

Dimensionless horizontal window shift that pins the look-at point.

The eye has translated offset along the camera's right vector. This is the shear that slides the frustum window back so the original look-at point stays centred -- the off-axis projection, never a toe-in.

VTK's SetWindowCenter takes this value directly (units of half the image width). Blender's shift_x is this value divided by 2 (fractions of the full frame width). POV-Ray slides the image-plane centre by window_shear * (aspect / 2) along right, which is -offset * D / Z for image-plane distance D.

Parameters:

Name Type Description Default
offset float

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

required
focal_distance float

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

required
fov float

Vertical field of view in degrees.

required
aspect float

Width / height of the rendered view.

required

Returns:

Type Description
float

The dimensionless window centre. Zero at the centre view; negative when the eye has moved right.

Source code in src/quiltwright/quilt.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def window_shear(offset: float, focal_distance: float, fov: float, aspect: float) -> float:
    """Dimensionless horizontal window shift that pins the look-at point.

    The eye has translated *offset* along the camera's right vector.  This
    is the shear that slides the frustum window back so the original look-at
    point stays centred -- the off-axis projection, never a toe-in.

    VTK's ``SetWindowCenter`` takes this value directly (units of half the
    image width).  Blender's ``shift_x`` is this value divided by 2
    (fractions of the full frame width).  POV-Ray slides the image-plane
    centre by ``window_shear * (aspect / 2)`` along ``right``, which is
    ``-offset * D / Z`` for image-plane distance ``D``.

    :param offset: Lateral eye offset along the camera's right vector, in
        scene units, from :func:`view_offsets`.
    :param focal_distance: Camera-to-focal-plane distance, in scene units.
    :param fov: Vertical field of view in degrees.
    :param aspect: Width / height of the rendered view.
    :return: The dimensionless window centre.  Zero at the centre view;
        negative when the eye has moved right.
    """
    return -offset / (focal_distance * math.tan(math.radians(fov) / 2.0) * aspect)