Skip to content

POV-Ray backend

quiltwright.povray

POV-Ray Quilt Renderer

Drives the POV-Ray <https://www.povray.org/>_ ray-tracer to produce quilts for Looking Glass holographic displays, so existing .pov scenes can be shown as holograms without being ported to another renderer.

The scene file is never modified. Rendering wraps it::

#include "<your scene>.pov"
camera { ... }               // off-axis camera for one view

POV-Ray uses the last camera statement it parses and warns about the earlier ones, so appending a camera overrides whatever the scene declared while leaving its geometry and, by default, its lighting untouched. One wrapper is written per view, each carrying that view's camera.

Optional lighting on :func:render_pov_quilt appends a parallel sun (and prefix #declare QW_* so a scene can opt in). That is real sun altitude/azimuth -- not POV-Ray's clock, which is the animation parameter (+K) and has nothing to do with wall-clock time.

Off-axis projection. POV-Ray builds its frustum from location (the eye), direction (which places the centre of the image plane) and right/up (which span it), and it does not re-orthogonalise those vectors. Tilting direction while holding right and up fixed therefore shears the frustum, leaving the image plane parallel to itself -- exactly the projection a light-field display needs. Using look_at instead would rotate the camera ("toe-in"), which introduces vertical parallax and keystone distortion and prevents the views from fusing.

For an eye offset s along the unit right vector r, with focal distance Z and image-plane distance D:

.. code-block:: text

location  = L + s*r
direction = D*f - (s*D/Z)*r

The subtracted term slides the image-plane centre back onto the original view axis, so the look-at point stays pinned to the centre of every view. That point is the holographic focal plane: it lands on the physical glass, with nearer geometry floating in front and farther geometry behind.

POV-Ray emits Camera vectors are not perpendicular for such a camera. That warning is expected and benign -- it is the shear.

Framing an existing scene. A scene composed as a still needs three things changed before it sweeps well, and all three are measured from the scene rather than guessed: the focal plane moves to the distance that balances the disparity budget, the eye slides to the middle of whatever lateral corridor the geometry leaves, and the view cone is derived from the clearance that remains. :meth:PovCamera.aimed performs the first two without disturbing the view direction or the lens, :class:Clearance holds the measured corridor and the cone it permits, and :func:format_depth_budget reports the result before the ray-tracer is asked to spend an hour on it.

:func:render_pov_hld_video renders the same scenes for the other Looking Glass technology -- Hololuminescent Displays, which play ordinary 2-D video rather than a quilt. See :mod:quiltwright.hld for what that means and :func:render_pov_hld_video's own docstring for the POV-Ray specifics: a full camera.location orbit around look_at rather than the off-axis sweep above.

Requirements -- a povray binary on PATH (brew install povray), plus pillow for quilt assembly (poetry install --with viz).

Typical usage::

from quiltwright.quilt import QUILT_PRESETS, save_quilt
from quiltwright.povray import PovCamera, render_pov_quilt

camera = PovCamera(location=(35, 18.5, 0), look_at=(35, 20, 58), fov=14)
spec = QUILT_PRESETS["portrait"]
quilt = render_pov_quilt("museum.pov", spec, camera,
                         include_paths=["../myinclude"])
save_quilt(quilt, "museum", spec)   # -> museum_qs8x6a0.75.png

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

Clearance(left, right, margin=0.0) dataclass

The lateral corridor an interior leaves for the view sweep.

This is the constraint peculiar to enclosed scenes, and the one that bites hardest. A cone chosen without checking it does not fail loudly: the centre view -- the one you preview -- is perfect, while the outer views quietly render the unlit back face of a wall.

Measure the corridor by rendering at candidate eye offsets along the camera's right vector and watching for the frame to collapse. It is rarely symmetric about the scene's own eye position, hence centre, which slides the eye to the middle of the room before the sweep starts.

Parameters:

Name Type Description Default
left float

Most negative usable offset along the right vector, in scene units.

required
right float

Most positive usable offset.

required
margin float

Safety margin held back at each end. Walls are not perfectly planar and grazing one dims the outer views well before the camera actually passes through it.

0.0

centre property

Offset that puts the eye in the middle of the corridor.

half_width property

Usable travel to either side of :attr:centre, net of margin.

cone(focal_distance)

Widest view cone whose outermost eye still clears the walls.

cone = 2 * atan((half_width) / focal_distance). Narrowing the cone to fit costs less than it looks: with the focal plane at the harmonic mean of the depth range, disparity at the extremes tracks the physical baseline and the scene's depth range, so trading cone for clearance trades look-around, not sharpness.

Parameters:

Name Type Description Default
focal_distance float

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

required

Returns:

Type Description
float

Total sweep in degrees.

Raises:

Type Description
ValueError

If the margin has consumed the whole corridor, or focal_distance is not positive.

Source code in src/quiltwright/povray.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def cone(self, focal_distance: float) -> float:
    """Widest view cone whose outermost eye still clears the walls.

    ``cone = 2 * atan((half_width) / focal_distance)``.  Narrowing the
    cone to fit costs less than it looks: with the focal plane at the
    harmonic mean of the depth range, disparity at the extremes tracks
    the physical baseline and the scene's depth range, so trading cone
    for clearance trades look-around, not sharpness.

    :param focal_distance: Camera-to-focal-plane distance, in scene units.
    :return: Total sweep in degrees.
    :raises ValueError: If the margin has consumed the whole corridor,
        or *focal_distance* is not positive.
    """
    if focal_distance <= 0:
        raise ValueError(f"focal_distance must be positive, got {focal_distance}")
    if self.half_width <= 0:
        raise ValueError(
            f"clearance margin {self.margin} leaves no room in a corridor "
            f"of width {self.right - self.left}"
        )
    return 2.0 * math.degrees(math.atan(self.half_width / focal_distance))

fits(spec, focal_distance)

True if the sweep spec asks for stays inside the corridor.

A cone from :meth:cone lands the sweep exactly on :attr:half_width, where rounding can put it a few ulps over, so the comparison is made to within a relative tolerance rather than reporting a wall strike for the cone this class just derived.

Source code in src/quiltwright/povray.py
573
574
575
576
577
578
579
580
581
582
def fits(self, spec: QuiltSpec, focal_distance: float) -> bool:
    """True if the sweep *spec* asks for stays inside the corridor.

    A cone from :meth:`cone` lands the sweep exactly on
    :attr:`half_width`, where rounding can put it a few ulps over, so
    the comparison is made to within a relative tolerance rather than
    reporting a wall strike for the cone this class just derived.
    """
    sweep = sweep_extent(spec, focal_distance)
    return sweep <= self.half_width or math.isclose(sweep, self.half_width, rel_tol=1e-9)

PovCamera(location, look_at, sky=(0.0, 1.0, 0.0), fov=14.0) dataclass

A POV-Ray camera in look_at form, plus the quilt's focal geometry.

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

Coordinates here are POV-Ray's own -- left-handed -- not the right-handed world :mod:quiltwright.povgen authors scenes in. Nothing converts a camera you construct yourself: :func:camera_block emits it verbatim. Only :func:~quiltwright.povgen.pov_camera_from_plotter converts, by running :func:~quiltwright.povgen.to_pov over the plotter's position, focal point and up vector.

So a scene written with the default handedness="flip-z" needs its camera converted too::

from quiltwright.povgen import to_pov

camera = PovCamera(
    location=to_pov((0.0, -8.0, 3.0)),   # right-handed, +z up
    look_at=to_pov((0.0, 0.0, 3.0)),
    sky=to_pov((0.0, 0.0, 1.0)),
)

Skip that and the geometry sits at negative z while the lens aims at positive z: POV-Ray renders a clean picture of empty space. Nothing in the scene file looks wrong, and any check comparing the camera against the right-handed bounds it was derived from will pass.

Parameters:

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

Eye position (x, y, z), in POV-Ray coordinates.

required
look_at tuple[float, float, float]

Point the camera is aimed at, in POV-Ray coordinates. Becomes the focal plane.

required
sky tuple[float, float, float]

Up-hint used to build the camera basis, matching POV-Ray's sky vector, in POV-Ray coordinates. Must not be parallel to the view direction. A +z-up right-handed scene wants (0, 0, -1) here, which is what to_pov((0, 0, 1)) returns.

(0.0, 1.0, 0.0)
fov float

Vertical field of view in degrees. Looking Glass recommends ~14° for object-centric content, where the camera is dollied in until the subject fills the frame. Do not carry that number over to architectural interiors: a narrow FOV magnifies parallax along with everything else (see :func:~quiltwright.lfd.view_disparity), so a room shot at 14° ghosts where the same room at its native wide angle fuses cleanly. Set the depth budget with the focal plane and the view cone instead, and keep the scene's own FOV.

14.0

focal_distance property

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

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

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

A scene's camera was composed for a still: its aim point was chosen for framing, and its eye sits wherever the composition wanted it. Neither survives contact with a quilt unedited -- the focal plane wants the distance that balances the disparity budget (see :func:~quiltwright.lfd.focal_distance_for_range), and inside an interior the eye wants to sit in the middle of whatever lateral corridor the walls leave (see :class:Clearance).

Both are changed here without touching the view direction or the lens: the new look-at point stays on the original aim ray, so the scene is framed as its author framed it.

Parameters:

Name Type Description Default
location Sequence[float]

The scene's eye position.

required
aim Sequence[float]

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

required
fov float

Vertical field of view in degrees -- usually the scene's own, see :class:PovCamera.

required
focal_distance float | None

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

None
lateral_shift float

Distance to slide the eye along the camera's right vector before re-aiming. The look-at point slides with it, so the view direction is unchanged.

0.0
sky tuple[float, float, float]

Up-hint, as on :class:PovCamera.

(0.0, 1.0, 0.0)

Returns:

Type Description
PovCamera

The centre-view camera.

Raises:

Type Description
ValueError

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

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

    A scene's camera was composed for a still: its aim point was chosen
    for framing, and its eye sits wherever the composition wanted it.
    Neither survives contact with a quilt unedited -- the focal plane
    wants the distance that balances the disparity budget (see
    :func:`~quiltwright.lfd.focal_distance_for_range`), and inside an
    interior the eye wants to sit in the middle of whatever lateral
    corridor the walls leave (see :class:`Clearance`).

    Both are changed here without touching the view *direction* or the
    lens: the new look-at point stays on the original aim ray, so the
    scene is framed as its author framed it.

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

basis()

Orthonormal camera basis (forward, right, up).

POV-Ray is left-handed -- with up at +y and direction at +z, right is +x -- which is what right = sky x forward reproduces. Getting this ordering wrong mirrors the view sweep and inverts the hologram's depth.

Returns:

Type Description
tuple[ndarray, ndarray, ndarray]

Three unit vectors as (3,) arrays.

Raises:

Type Description
ValueError

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

Source code in src/quiltwright/povray.py
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
def basis(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Orthonormal camera basis ``(forward, right, up)``.

    POV-Ray is left-handed -- with ``up`` at ``+y`` and ``direction`` at
    ``+z``, ``right`` is ``+x`` -- which is what ``right = sky x forward``
    reproduces.  Getting this ordering wrong mirrors the view sweep and
    inverts the hologram's depth.

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

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

image_plane_distance()

|direction| reproducing fov for a unit-height image plane.

The emitted camera sets up to a unit vector, so the image plane is one unit tall and tan(fov/2) = 0.5 / |direction|.

Source code in src/quiltwright/povray.py
228
229
230
231
232
233
234
def image_plane_distance(self) -> float:
    """``|direction|`` reproducing *fov* for a unit-height image plane.

    The emitted camera sets ``up`` to a unit vector, so the image plane
    is one unit tall and ``tan(fov/2) = 0.5 / |direction|``.
    """
    return 0.5 / math.tan(math.radians(self.fov) / 2.0)

camera_block(camera, offset, aspect)

Emit the POV-Ray camera { } statement for one quilt view.

Parameters:

Name Type Description Default
camera PovCamera

Base (centre-view) camera.

required
offset float

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

required
aspect float

Width / height of the rendered view.

required

Returns:

Type Description
str

A POV-Ray camera statement.

Source code in src/quiltwright/povray.py
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
def camera_block(camera: PovCamera, offset: float, aspect: float) -> str:
    """Emit the POV-Ray ``camera { }`` statement for one quilt view.

    :param camera: Base (centre-view) camera.
    :param offset: Lateral eye offset along the camera's right vector, in
        scene units, from :func:`~quiltwright.lfd.view_offsets`.
    :param aspect: Width / height of the rendered view.
    :return: A POV-Ray camera statement.
    """
    forward, right, up = camera.basis()
    dist = camera.image_plane_distance()
    eye = np.asarray(camera.location, dtype="d") + right * offset
    # Shear: slide the image-plane centre back onto the original view axis so
    # the focal plane stays pinned across the sweep.  window_shear is in
    # half-widths; the image plane is ``aspect`` wide, so the world-space
    # slide along ``right`` is ``shear * aspect / 2``.  Never emit `angle`
    # here -- it would override |direction| and silently undo this.
    shear = window_shear(offset, camera.focal_distance, camera.fov, aspect)
    direction = forward * dist + right * (shear * aspect / 2.0)
    return (
        "camera {\n"
        f"  location  {_vec(eye)}\n"
        f"  direction {_vec(direction)}\n"
        f"  right     {_vec(right * aspect)}\n"
        f"  up        {_vec(up)}\n"
        "}\n"
    )

depth_budget(spec, camera, depths)

Adjacent-view disparity at each depth of interest.

A thin pairing of :func:~quiltwright.quilt.view_disparity with the labelled depths measured from a scene, kept separate from :func:format_depth_budget so the numbers can be asserted on rather than only printed.

Parameters:

Name Type Description Default
spec QuiltSpec

Quilt specification.

required
camera HasLens

Centre-view camera; a :class:~quiltwright.quilt.QuiltCamera or anything with fov and focal_distance (:class:~quiltwright.quilt.HasLens).

required
depths Mapping[str, float]

Labelled distances from the camera, in scene units. Use math.inf for sky or a backdrop at infinity.

required

Returns:

Type Description
list[tuple[str, float, float]]

(label, depth, disparity_px) in the order given.

Source code in src/quiltwright/povray.py
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
def depth_budget(
    spec: QuiltSpec, camera: HasLens, depths: Mapping[str, float]
) -> list[tuple[str, float, float]]:
    """Adjacent-view disparity at each depth of interest.

    A thin pairing of :func:`~quiltwright.quilt.view_disparity` with the
    labelled depths measured from a scene, kept separate from
    :func:`format_depth_budget` so the numbers can be asserted on rather
    than only printed.

    :param spec: Quilt specification.
    :param camera: Centre-view camera; a :class:`~quiltwright.quilt.QuiltCamera`
        or anything with ``fov`` and ``focal_distance``
        (:class:`~quiltwright.quilt.HasLens`).
    :param depths: Labelled distances from the camera, in scene units.  Use
        ``math.inf`` for sky or a backdrop at infinity.
    :return: ``(label, depth, disparity_px)`` in the order given.
    """
    return [
        (label, depth, view_disparity(spec, camera.fov, camera.focal_distance, depth))
        for label, depth in depths.items()
    ]

depth_sweep(scene, camera, distances, *, include_paths=(), width=320, height=180, quality=11, threads=None, binary=None, extra_args=(), progress=True)

Trace a scene's cumulative depth histogram by plane sweep.

The depth budget wants two numbers from a scene -- where its nearest content sits and where its farthest structured content ends -- and guessing them costs a render to find out. This measures them: an opaque, self-lit plane slides along the view axis at distance d, hiding everything beyond it, so the fraction of the frame that is not the marker colour is the fraction occupied by geometry nearer than d::

d= 31   0.2%   <- nearest geometry appears
d= 47  35.1%
d= 96  93.9%   <- 95% of everything occludable
d=inf  93.9%   <- the remaining 6.1% is sky, at effective infinity

Three cautions, each learned the hard way:

Render at the quality you will ship. POV-Ray disables transparency and refraction below +Q8, so a cheap probe at +Q3 reports a room with no windows and no sky at all.

Measure through the camera you will render with. A hologram's eye is usually not the scene's own; see :meth:PovCamera.aimed.

Sky is not far content. A backdrop at infinity never occludes, so it shows up as a residual that never closes. Leave it out of the near/far balance -- it is low-contrast and can afford the disparity.

Parameters:

Name Type Description Default
scene str | Path

Scene to probe. Not modified.

required
camera PovCamera

Camera to measure through.

required
distances Iterable[float]

Distances along the view axis to test, in scene units.

required
include_paths Sequence[str | Path]

Extra #include directories. The scene's own directory is always added.

()
width int

Probe frame width in pixels. Small is fine -- this is a pixel count, not an image anyone looks at.

320
height int

Probe frame height in pixels.

180
quality int

POV-Ray +Q. Keep at 8 or above, or glass reads solid.

11
threads int | None

POV-Ray +WT. None applies the courtesy cap described in :func:resolve_work_threads -- a sweep is hundreds of small frames back to back, and taking every core for the duration is as rude as a quilt doing it.

None
binary str | None

POV-Ray executable; defaults to the usual search.

None
extra_args Sequence[str]

Extra POV-Ray arguments, e.g. ["+MV3.1"] for a pre-2000 scene carrying no #version pragma of its own.

()
progress bool

Print a one-line probe counter.

True

Returns:

Type Description
list[tuple[float, float]]

(distance, fraction_in_front) pairs, in the order given.

Raises:

Type Description
FileNotFoundError

If the scene does not exist.

RuntimeError

If POV-Ray fails, or the calibration frame is not uniformly the marker colour -- which means geometry is already inside the near plane and every reading would be measured against it.

Source code in src/quiltwright/povray.py
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
def depth_sweep(
    scene: str | Path,
    camera: PovCamera,
    distances: Iterable[float],
    *,
    include_paths: Sequence[str | Path] = (),
    width: int = 320,
    height: int = 180,
    quality: int = 11,
    threads: int | None = None,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
    progress: bool = True,
) -> list[tuple[float, float]]:
    """Trace a scene's cumulative depth histogram by plane sweep.

    The depth budget wants two numbers from a scene -- where its nearest
    content sits and where its farthest *structured* content ends -- and
    guessing them costs a render to find out.  This measures them: an opaque,
    self-lit plane slides along the view axis at distance ``d``, hiding
    everything beyond it, so the fraction of the frame that is *not* the
    marker colour is the fraction occupied by geometry nearer than ``d``::

        d= 31   0.2%   <- nearest geometry appears
        d= 47  35.1%
        d= 96  93.9%   <- 95% of everything occludable
        d=inf  93.9%   <- the remaining 6.1% is sky, at effective infinity

    Three cautions, each learned the hard way:

    *Render at the quality you will ship.*  POV-Ray disables transparency and
    refraction below ``+Q8``, so a cheap probe at ``+Q3`` reports a room with
    no windows and no sky at all.

    *Measure through the camera you will render with.*  A hologram's eye is
    usually not the scene's own; see :meth:`PovCamera.aimed`.

    *Sky is not far content.*  A backdrop at infinity never occludes, so it
    shows up as a residual that never closes.  Leave it out of the near/far
    balance -- it is low-contrast and can afford the disparity.

    :param scene: Scene to probe.  Not modified.
    :param camera: Camera to measure through.
    :param distances: Distances along the view axis to test, in scene units.
    :param include_paths: Extra ``#include`` directories.  The scene's own
        directory is always added.
    :param width: Probe frame width in pixels.  Small is fine -- this is a
        pixel *count*, not an image anyone looks at.
    :param height: Probe frame height in pixels.
    :param quality: POV-Ray ``+Q``.  Keep at 8 or above, or glass reads solid.
    :param threads: POV-Ray ``+WT``.  ``None`` applies the courtesy cap
        described in :func:`resolve_work_threads` -- a sweep is hundreds of
        small frames back to back, and taking every core for the duration is
        as rude as a quilt doing it.
    :param binary: POV-Ray executable; defaults to the usual search.
    :param extra_args: Extra POV-Ray arguments, e.g. ``["+MV3.1"]`` for a
        pre-2000 scene carrying no ``#version`` pragma of its own.
    :param progress: Print a one-line probe counter.
    :return: ``(distance, fraction_in_front)`` pairs, in the order given.
    :raises FileNotFoundError: If the scene does not exist.
    :raises RuntimeError: If POV-Ray fails, or the calibration frame is not
        uniformly the marker colour -- which means geometry is already inside
        the near plane and every reading would be measured against it.
    """
    from PIL import Image

    povray = _find_povray(binary)
    scene_path = Path(scene).expanduser().resolve()
    if not scene_path.is_file():
        raise FileNotFoundError(f"scene not found: {scene_path}")
    library_paths = [scene_path.parent, *(Path(p).expanduser().resolve() for p in include_paths)]
    aspect = width / height

    # Same courtesy cap the quilt renderers apply; an explicit +WT wins.
    if not any(str(a).startswith("+WT") for a in extra_args):
        capped = resolve_work_threads(threads)
        if capped is not None:
            extra_args = [*extra_args, f"+WT{capped}"]

    with tempfile.TemporaryDirectory(prefix="qw_depth_probe_") as tmp:
        workdir = Path(tmp)
        wrapper = workdir / "probe.pov"
        out_png = workdir / "probe.png"

        def frame(distance: float) -> np.ndarray:
            wrapper.write_text(_probe_wrapper(scene_path, camera, aspect, distance))
            out_png.unlink(missing_ok=True)
            _render_view(
                povray,
                wrapper,
                out_png,
                width,
                height,
                library_paths,
                None,  # no antialiasing: this counts pixels, it does not show them
                quality,
                extra_args,
                workdir,
            )
            return np.asarray(Image.open(out_png).convert("RGB")).astype(int)

        calibration = frame(1.0)
        if calibration.std(axis=(0, 1)).max() > 2:
            raise RuntimeError(
                "calibration frame is not uniform: something is in front of the "
                "probe plane at d=1, so the sweep would measure against it. "
                "Check the camera position."
            )
        marker = calibration.reshape(-1, 3).mean(0)

        rows = []
        distances = list(distances)
        for i, d in enumerate(distances):
            image = frame(float(d))
            fraction = float((np.abs(image - marker).sum(-1) > 30).mean())
            rows.append((float(d), fraction))
            if progress:
                print(
                    f"\r  probe {i + 1}/{len(distances)}  d={d:.0f} {fraction * 100:5.1f}%",
                    end="",
                    flush=True,
                )
        if progress:
            print()
    return rows

format_depth_budget(spec, camera, depths, *, clearance=None, soft_px=5.5, indent=' ')

Render the sweep geometry and depth budget as a report.

Print this before committing to a render: it is where a blown disparity budget or a sweep that walks through a wall shows up, at no cost, rather than after the ray-tracer has spent an hour on it.

Parameters:

Name Type Description Default
spec QuiltSpec

Quilt specification.

required
camera HasLens

Centre-view camera; a :class:~quiltwright.quilt.QuiltCamera or anything with fov and focal_distance (:class:~quiltwright.quilt.HasLens).

required
depths Mapping[str, float]

Labelled depths, as for :func:depth_budget.

required
clearance Clearance | None

Measured lateral corridor, if the scene is enclosed. When given, the sweep is checked against it and a warning emitted if the outer views would leave the room.

None
soft_px float

Disparity above which a row is flagged as soft. Roughly 4-5 px is the practical ceiling; past ~8 px expect visible ghosting.

5.5
indent str

Leading whitespace for the outermost lines.

' '

Returns:

Type Description
str

A multi-line report, without a trailing newline.

Source code in src/quiltwright/povray.py
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
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
def format_depth_budget(
    spec: QuiltSpec,
    camera: HasLens,
    depths: Mapping[str, float],
    *,
    clearance: Clearance | None = None,
    soft_px: float = 5.5,
    indent: str = "  ",
) -> str:
    """Render the sweep geometry and depth budget as a report.

    Print this before committing to a render: it is where a blown disparity
    budget or a sweep that walks through a wall shows up, at no cost, rather
    than after the ray-tracer has spent an hour on it.

    :param spec: Quilt specification.
    :param camera: Centre-view camera; a :class:`~quiltwright.quilt.QuiltCamera`
        or anything with ``fov`` and ``focal_distance``
        (:class:`~quiltwright.quilt.HasLens`).
    :param depths: Labelled depths, as for :func:`depth_budget`.
    :param clearance: Measured lateral corridor, if the scene is enclosed.
        When given, the sweep is checked against it and a warning emitted if
        the outer views would leave the room.
    :param soft_px: Disparity above which a row is flagged as soft.  Roughly
        4-5 px is the practical ceiling; past ~8 px expect visible ghosting.
    :param indent: Leading whitespace for the outermost lines.
    :return: A multi-line report, without a trailing newline.
    """
    z = camera.focal_distance
    sweep = sweep_extent(spec, z)
    lines = [
        f"{indent}focal plane      {z:.1f} units",
        f"{indent}view cone        {spec.view_cone:.1f} deg over {spec.n_views} views",
    ]
    if clearance is None:
        lines.append(f"{indent}eye sweep        +/-{sweep:.1f} units")
    else:
        lines.append(
            f"{indent}eye sweep        +/-{sweep:.1f} units "
            f"(clearance +/-{clearance.half_width:.1f} after {clearance.margin:.1f} margin)"
        )
        if not clearance.fits(spec, z):
            lines.append(f"{indent}  WARNING: sweep exceeds clearance; outer views will be black")

    lines.append(f"{indent}adjacent-view disparity:")
    for label, depth, px in depth_budget(spec, camera, depths):
        flag = "" if px <= soft_px else "  <- soft"
        lines.append(f"{indent}  {label:<18} {depth:>8.1f}  {px:5.2f} px{flag}")
    return "\n".join(lines)

lighting_block(camera, *, appearance=None, sun=None)

Lighting appended after the camera for Dynamic Desktop stills.

appearance="light" leaves the scene alone: an additive key on top of an authored white light washes the plate out. appearance="dark" adds a cool moon plus fog so Dark Mode still reads as night without editing the scene. Pass sun (altitude, azimuth) for solar frames that need an explicit parallel sun. This is real sun position -- not POV-Ray's clock.

Parameters:

Name Type Description Default
camera PovCamera

Centre-view camera; look_at is point_at.

required
appearance str | None

"light" or "dark", or None.

None
sun tuple[float, float] | None

(altitude, azimuth); defaults to the appearance preset when appearance is "dark".

None

Returns:

Type Description
str

POV-Ray SDL, or "".

Source code in src/quiltwright/povray.py
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
def lighting_block(
    camera: PovCamera,
    *,
    appearance: str | None = None,
    sun: tuple[float, float] | None = None,
) -> str:
    """Lighting appended after the camera for Dynamic Desktop stills.

    ``appearance="light"`` leaves the scene alone: an additive key on top of
    an authored white light washes the plate out.  ``appearance="dark"``
    adds a cool moon plus fog so Dark Mode still reads as night without
    editing the scene.  Pass *sun* ``(altitude, azimuth)`` for solar frames
    that need an explicit parallel sun.  This is real sun position -- not
    POV-Ray's ``clock``.

    :param camera: Centre-view camera; ``look_at`` is ``point_at``.
    :param appearance: ``"light"`` or ``"dark"``, or ``None``.
    :param sun: ``(altitude, azimuth)``; defaults to the appearance preset
        when *appearance* is ``"dark"``.
    :return: POV-Ray SDL, or ``""``.
    """
    if appearance is None and sun is None:
        return ""
    if appearance is not None and appearance not in APPEARANCE_SUN:
        raise ValueError(f"appearance must be 'light' or 'dark', got {appearance!r}")
    # Light Mode = scene as authored.  Declares still go out via
    # lighting_declares so a scene can branch on QW_Appearance.
    if appearance == "light" and sun is None:
        return ""
    alt, az = sun if sun is not None else APPEARANCE_SUN[appearance or "light"]
    direction = np.asarray(sun_direction(alt, az), dtype="d")
    look = np.asarray(camera.look_at, dtype="d")
    distance = max(camera.focal_distance * 8.0, 1.0)
    location = look + direction * distance
    r, g, b = _sun_color(alt, appearance=appearance)
    parts = [
        "// quiltwright lighting: parallel sun (not POV-Ray clock)\n"
        "light_source {\n"
        f"  {_vec(location)} color rgb <{r:.5g}, {g:.5g}, {b:.5g}>\n"
        "  parallel\n"
        f"  point_at {_vec(look)}\n"
        "}\n"
    ]
    if appearance == "dark":
        # Soften the scene's own lights without burying the subject.  Fog
        # distance scales with focal distance; ~1.2x keeps Dark Mode
        # readable on a Mac laptop while still separating from Light.
        fog_d = max(camera.focal_distance * 1.2, 1.0)
        parts.append(
            "background { color rgb <0.04, 0.05, 0.10> }\n"
            "global_settings { ambient_light rgb <0.08, 0.09, 0.14> }\n"
            "fog {\n"
            "  fog_type 1\n"
            f"  distance {fog_d:.6g}\n"
            "  color rgb <0.05, 0.06, 0.12>\n"
            "}\n"
        )
    return "".join(parts)

lighting_declares(*, appearance=None, sun=None)

#declare QW_* prefix so a scene can honour the sun without a parser.

Parameters:

Name Type Description Default
appearance str | None

"light" or "dark", or None.

None
sun tuple[float, float] | None

(altitude, azimuth) overriding the appearance preset.

None

Returns:

Type Description
str

SDL to emit before the #include, or "".

Source code in src/quiltwright/povray.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
def lighting_declares(
    *,
    appearance: str | None = None,
    sun: tuple[float, float] | None = None,
) -> str:
    """``#declare QW_*`` prefix so a scene can honour the sun without a parser.

    :param appearance: ``"light"`` or ``"dark"``, or ``None``.
    :param sun: ``(altitude, azimuth)`` overriding the appearance preset.
    :return: SDL to emit *before* the ``#include``, or ``""``.
    """
    if appearance is None and sun is None:
        return ""
    if appearance is not None and appearance not in APPEARANCE_SUN:
        raise ValueError(f"appearance must be 'light' or 'dark', got {appearance!r}")
    alt, az = sun if sun is not None else APPEARANCE_SUN[appearance or "light"]
    flag = 0 if appearance == "dark" else 1
    return (
        f"#declare QW_Appearance = {flag};\n"
        f"#declare QW_SunAltitude = {float(alt):.10g};\n"
        f"#declare QW_SunAzimuth = {float(az):.10g};\n"
    )

render_pov_hld_video(scene, camera, out_stem, *, include_paths=(), n_frames=300, fps=30, orbit_degrees=360.0, sway_degrees=None, spin_degrees=None, resolution=HLD_RESOLUTION, antialias=0.3, quality=9, jobs=1, threads=None, binary=None, extra_args=(), crf=18, rotate_for_player=False, keep_frames=None, progress=True, lighting=None, sun=None, suppress_overlays=False, encode_args=None)

Render a POV-Ray scene as a turntable HLD master video.

The POV-Ray counterpart of :func:~quiltwright.hld.render_hld_video: an ordinary full-frame render per frame (no off-axis shear -- see :func:camera_block), with camera revolving around its own look_at point via :func:_orbit_camera instead of PyVista's camera.Azimuth, encoded to the same official HLD master spec (3840x2160 landscape HEVC bt709).

Unlike :func:render_pov_quilt / :func:render_pov_views, this takes no :class:~quiltwright.quilt.QuiltSpec -- an HLD master has no view cone or tile grid, just a frame count and an orbit.

Parameters:

Name Type Description Default
scene str | Path

Path to the .pov scene. Not modified.

required
camera PovCamera

Base camera; frame 0 of the orbit. look_at is the pivot, sky the orbit axis.

required
out_stem str | Path

Output path; _hld.mp4 is appended.

required
include_paths Sequence[str | Path]

Extra directories searched for #include files.

()
n_frames int

Frame count (default 300 @ 30 fps = 10 s loop).

300
fps int

30 or 60 per the HLD spec.

30
orbit_degrees float

Total orbit over the clip; 360 loops seamlessly. Pass 0 to hold the camera still (e.g. for a lit-window test render). Ignored when sway_degrees is set.

360.0
sway_degrees float | None

Oscillate the camera back and forth instead of sweeping all the way around -- one full cycle per clip, swinging sway_degrees either side of camera's own position (frame 0 sits at centre and the loop returns there exactly, so it is seamless like a 360-degree orbit). A scene composed as a single-viewpoint diorama (a backdrop behind the subject, camera-pinned overlay text) usually reads better with a modest sway than a full spin -- and some physical HLD panels only rock through a limited angle themselves, so sweeping wider than the panel moves buys nothing. None (the default) uses orbit_degrees instead.

None
spin_degrees float | None

Independent of camera motion entirely -- emits #declare QW_Spin_Angle = <degrees>; before the scene each frame, sweeping linearly from 0 to spin_degrees over the clip (360 loops seamlessly, same as orbit_degrees). A scene turns its own object with it, e.g. object { subject #ifdef(QW_Spin_Angle) rotate y*QW_Spin_Angle #end ... } -- the same QW_* convention as suppress_overlays. Meant for orbit_degrees=0 (a static camera, the composed still unchanged): a subject rotating in place inside a backdrop that never moves, rather than a camera sweep around it. None (the default) leaves the scene's rotation, if any, alone.

None
resolution tuple[int, int]

Render (width, height); default 3840x2160.

HLD_RESOLUTION
antialias float | None

POV-Ray +A threshold; None disables it.

0.3
quality int

POV-Ray +Q quality level, 0-11.

9
jobs int

Number of POV-Ray processes to run concurrently -- frames are independent, like quilt views.

1
threads int | None

POV-Ray worker threads per process. None applies the courtesy cap described in :func:resolve_work_threads.

None
binary str | None

POV-Ray executable; defaults to POVRAY_BINARY or povray on PATH.

None
extra_args Sequence[str]

Additional POV-Ray command-line arguments, e.g. radiosity cache flags -- the lighting is identical across the orbit, so recomputing it per frame is pure waste (see :func:render_pov_quilt).

()
crf int

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

18
rotate_for_player bool

Rotate 90 degrees CCW before output. Leave False (default) for HLD Author and signage/HDMI delivery.

False
keep_frames str | Path | None

Directory to retain the per-frame PNGs and generated wrapper scenes in, for inspection. Discarded if None.

None
progress bool

Print a progress line while rendering.

True
lighting str | None

See :func:render_pov_quilt.

None
sun tuple[float, float] | None

See :func:render_pov_quilt.

None
suppress_overlays bool

Emit #declare QW_HLD_Turntable = 1; before the scene, the same QW_*-prefix convention :func:lighting_declares uses for appearance. A scene composed as a still often pins text -- a title, a signature -- in world space at a spot calibrated for one authored viewpoint; from the back of a 360-degree orbit that text reads mirrored. A scene can guard such an object with #ifndef(QW_HLD_Turntable) ... #end to skip it only for a turntable render. No effect on a scene that does not check the flag.

False
encode_args Sequence[str] | None

Replace :func:~quiltwright.hld._hld_encode_args's ffmpeg output arguments entirely -- fps and crf are then unused for encoding (still used for the file itself/frame timing). The default targets the official HLD master spec (HEVC bt709), which is what HLD Author and the big Portrait HLD panels want; a device that consumes video directly rather than through HLD Author -- e.g. LKG's musubi frame, whose own generated clips are plain H.264 Baseline, no HEVC in sight -- needs its own target instead: ["-vcodec", "libx264", "-profile:v", "baseline", "-pix_fmt", "yuv420p"]. rotate_for_player still applies on top.

None

Returns:

Type Description
Path

Path of the MP4 written.

Source code in src/quiltwright/povray.py
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
def render_pov_hld_video(
    scene: str | Path,
    camera: PovCamera,
    out_stem: str | Path,
    *,
    include_paths: Sequence[str | Path] = (),
    n_frames: int = 300,
    fps: int = 30,
    orbit_degrees: float = 360.0,
    sway_degrees: float | None = None,
    spin_degrees: float | None = None,
    resolution: tuple[int, int] = HLD_RESOLUTION,
    antialias: float | None = 0.3,
    quality: int = 9,
    jobs: int = 1,
    threads: int | None = None,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
    crf: int = 18,
    rotate_for_player: bool = False,
    keep_frames: str | Path | None = None,
    progress: bool = True,
    lighting: str | None = None,
    sun: tuple[float, float] | None = None,
    suppress_overlays: bool = False,
    encode_args: Sequence[str] | None = None,
) -> Path:
    """Render a POV-Ray scene as a turntable HLD master video.

    The POV-Ray counterpart of
    :func:`~quiltwright.hld.render_hld_video`: an ordinary full-frame render
    per frame (no off-axis shear -- see :func:`camera_block`), with *camera*
    revolving around its own ``look_at`` point via :func:`_orbit_camera`
    instead of PyVista's ``camera.Azimuth``, encoded to the same official
    HLD master spec (3840x2160 landscape HEVC bt709).

    Unlike :func:`render_pov_quilt` / :func:`render_pov_views`, this takes
    no :class:`~quiltwright.quilt.QuiltSpec` -- an HLD master has no view
    cone or tile grid, just a frame count and an orbit.

    :param scene: Path to the ``.pov`` scene.  Not modified.
    :param camera: Base camera; frame 0 of the orbit.  ``look_at`` is the
        pivot, ``sky`` the orbit axis.
    :param out_stem: Output path; ``_hld.mp4`` is appended.
    :param include_paths: Extra directories searched for ``#include`` files.
    :param n_frames: Frame count (default 300 @ 30 fps = 10 s loop).
    :param fps: 30 or 60 per the HLD spec.
    :param orbit_degrees: Total orbit over the clip; 360 loops seamlessly.
        Pass 0 to hold the camera still (e.g. for a lit-window test render).
        Ignored when *sway_degrees* is set.
    :param sway_degrees: Oscillate the camera back and forth instead of
        sweeping all the way around -- one full cycle per clip, swinging
        *sway_degrees* either side of *camera*'s own position (frame 0 sits
        at centre and the loop returns there exactly, so it is seamless like
        a 360-degree orbit).  A scene composed as a single-viewpoint diorama
        (a backdrop behind the subject, camera-pinned overlay text) usually
        reads better with a modest sway than a full spin -- and some
        physical HLD panels only rock through a limited angle themselves, so
        sweeping wider than the panel moves buys nothing.  ``None`` (the
        default) uses *orbit_degrees* instead.
    :param spin_degrees: Independent of camera motion entirely -- emits
        ``#declare QW_Spin_Angle = <degrees>;`` before the scene each frame,
        sweeping linearly from 0 to *spin_degrees* over the clip (360 loops
        seamlessly, same as *orbit_degrees*).  A scene turns its own object
        with it, e.g. ``object { subject #ifdef(QW_Spin_Angle) rotate
        y*QW_Spin_Angle #end ... }`` -- the same ``QW_*`` convention as
        *suppress_overlays*.  Meant for *orbit_degrees=0* (a static camera,
        the composed still unchanged): a subject rotating in place inside a
        backdrop that never moves, rather than a camera sweep around it.
        ``None`` (the default) leaves the scene's rotation, if any, alone.
    :param resolution: Render ``(width, height)``; default 3840x2160.
    :param antialias: POV-Ray ``+A`` threshold; ``None`` disables it.
    :param quality: POV-Ray ``+Q`` quality level, 0-11.
    :param jobs: Number of POV-Ray processes to run concurrently -- frames
        are independent, like quilt views.
    :param threads: POV-Ray worker threads per process.  ``None`` applies
        the courtesy cap described in :func:`resolve_work_threads`.
    :param binary: POV-Ray executable; defaults to ``POVRAY_BINARY`` or
        ``povray`` on ``PATH``.
    :param extra_args: Additional POV-Ray command-line arguments, e.g.
        radiosity cache flags -- the lighting is identical across the orbit,
        so recomputing it per frame is pure waste (see
        :func:`render_pov_quilt`).
    :param crf: x265 quality (lower = better; 15-20 sensible).
    :param rotate_for_player: Rotate 90 degrees CCW before output.  Leave
        ``False`` (default) for HLD Author and signage/HDMI delivery.
    :param keep_frames: Directory to retain the per-frame PNGs and generated
        wrapper scenes in, for inspection.  Discarded if ``None``.
    :param progress: Print a progress line while rendering.
    :param lighting: See :func:`render_pov_quilt`.
    :param sun: See :func:`render_pov_quilt`.
    :param suppress_overlays: Emit ``#declare QW_HLD_Turntable = 1;`` before
        the scene, the same ``QW_*``-prefix convention :func:`lighting_declares`
        uses for appearance.  A scene composed as a still often pins text --
        a title, a signature -- in world space at a spot calibrated for one
        authored viewpoint; from the back of a 360-degree orbit that text
        reads mirrored.  A scene can guard such an object with
        ``#ifndef(QW_HLD_Turntable) ... #end`` to skip it only for a
        turntable render.  No effect on a scene that does not check the flag.
    :param encode_args: Replace :func:`~quiltwright.hld._hld_encode_args`'s
        ffmpeg output arguments entirely -- *fps* and *crf* are then unused
        for encoding (still used for the file itself/frame timing).  The
        default targets the *official* HLD master spec (HEVC bt709), which
        is what HLD Author and the big Portrait HLD panels want; a device
        that consumes video directly rather than through HLD Author -- e.g.
        LKG's musubi frame, whose own generated clips are plain H.264
        Baseline, no HEVC in sight -- needs its own target instead:
        ``["-vcodec", "libx264", "-profile:v", "baseline", "-pix_fmt",
        "yuv420p"]``.  *rotate_for_player* still applies on top.
    :return: Path of the MP4 written.
    """
    povray = _find_povray(binary)
    ffmpeg = find_ffmpeg()
    scene_path = Path(scene).expanduser().resolve()
    if not scene_path.is_file():
        raise FileNotFoundError(f"POV-Ray scene not found: {scene_path}")

    out_stem = Path(out_stem)
    if out_stem.suffix.lower() == ".mp4":
        out_stem = out_stem.with_suffix("")
    out_path = out_stem.parent / f"{out_stem.name}_hld.mp4"
    out_path.parent.mkdir(parents=True, exist_ok=True)

    if not any(str(a).startswith("+WT") for a in extra_args):
        if jobs > 1:
            extra_args = [*extra_args, f"+WT{max(1, (os.cpu_count() or jobs) // jobs)}"]
        else:
            capped = resolve_work_threads(threads)
            if capped is not None:
                extra_args = [*extra_args, f"+WT{capped}"]

    render_w, render_h = resolution
    render_aspect = render_w / render_h
    library_paths = [scene_path.parent, *(Path(p).expanduser().resolve() for p in include_paths)]
    lighting_prefix = lighting_declares(appearance=lighting, sun=sun)
    if suppress_overlays:
        lighting_prefix += "#declare QW_HLD_Turntable = 1;\n"
    lighting_suffix = lighting_block(camera, appearance=lighting, sun=sun)
    if sway_degrees is not None:
        angles = [sway_degrees * math.sin(2.0 * math.pi * i / n_frames) for i in range(n_frames)]
    else:
        step = orbit_degrees / n_frames if n_frames else 0.0
        angles = [step * i for i in range(n_frames)]

    spin_step = spin_degrees / n_frames if (spin_degrees is not None and n_frames) else 0.0

    with tempfile.TemporaryDirectory(prefix="pov_hld_") as tmp:
        workdir = Path(tmp)
        frames = []
        for i in range(n_frames):
            frame_camera = _orbit_camera(camera, angles[i])
            frame_prefix = lighting_prefix
            if spin_degrees is not None:
                frame_prefix += f"#declare QW_Spin_Angle = {spin_step * i:.6g};\n"
            wrapper = workdir / f"frame{i:05d}.pov"
            wrapper.write_text(
                _wrapper_source(
                    scene_path,
                    i,
                    n_frames,
                    0.0,
                    frame_camera,
                    render_aspect,
                    lighting_prefix=frame_prefix,
                    lighting_suffix=lighting_suffix,
                )
            )
            frames.append((wrapper, workdir / f"frame{i:05d}.png"))

        done = 0

        def run(job):
            nonlocal done
            wrapper, out_png = job
            _render_view(
                povray,
                wrapper,
                out_png,
                render_w,
                render_h,
                library_paths,
                antialias,
                quality,
                extra_args,
                workdir,
            )
            done += 1
            if progress:
                print(f"\r  pov hld frame {done}/{n_frames}", end="", flush=True)

        if jobs > 1:
            with ThreadPoolExecutor(max_workers=jobs) as pool:
                list(pool.map(run, frames))
        else:
            for job in frames:
                run(job)
        if progress:
            print()

        if keep_frames is not None:
            _copy_views(frames, keep_frames, wrappers=True)

        args = list(encode_args) if encode_args is not None else _hld_encode_args(fps, crf)
        if rotate_for_player:
            args += ["-vf", "transpose=2"]  # 90 degrees counter-clockwise
        cmd = [
            ffmpeg,
            "-y",
            "-framerate",
            str(fps),
            "-i",
            f"{workdir}/frame%05d.png",
            *args,
            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

render_pov_quilt(scene, spec, camera, *, include_paths=(), view_cone=None, antialias=0.3, quality=9, jobs=1, threads=None, binary=None, extra_args=(), keep_views=None, progress=True, lighting=None, sun=None)

Render a POV-Ray scene into a Looking Glass quilt.

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

Cost scales linearly with the view count: a Portrait quilt is 48 full ray-traces. For scenes using radiosity or photons, render one view with the cache saved and the rest with it loaded (via extra_args) -- the lighting is identical across a view sweep, so recomputing it per view is pure waste.

Parameters:

Name Type Description Default
scene str | Path

Path to the .pov scene. Not modified.

required
spec QuiltSpec

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

required
camera PovCamera

Base camera; its look_at becomes the focal plane.

required
include_paths Sequence[str | Path]

Extra directories searched for #include files. The scene's own directory is always searched, which is usually enough for scenes whose includes sit alongside them.

()
view_cone float | None

Override the spec's view cone in degrees.

None
antialias float | None

POV-Ray +A threshold; lower is higher quality (0.3 is a good default, 0.1 for finals). None disables anti-aliasing.

0.3
quality int

POV-Ray +Q quality level, 0-11.

9
jobs int

Number of POV-Ray processes to run concurrently. Views are independent, so one process per core is the efficient shape for a quilt: raising this splits the machine's cores between the jobs via +WT rather than letting each process claim all of them. Pass your own +WT in extra_args to override that split.

1
threads int | None

POV-Ray worker threads per process. None applies the courtesy cap described in :func:resolve_work_threads; 0 lets POV-Ray take every core, which is its own default.

None
binary str | None

POV-Ray executable; defaults to POVRAY_BINARY or povray on PATH.

None
extra_args Sequence[str]

Additional POV-Ray command-line arguments, e.g. ["+HImy.ini"] or radiosity cache flags.

()
keep_views str | Path | None

Directory to retain the per-view PNGs and generated wrapper scenes in, for inspection or debugging. Discarded if None.

None
progress bool

Print a progress line while rendering.

True
lighting str | None

"light" or "dark" -- append a parallel sun at the matching :data:APPEARANCE_SUN preset. None (the default) leaves the scene's own lights alone. This is appearance for a Dynamic Desktop still, not POV-Ray's clock.

None
sun tuple[float, float] | None

(altitude, azimuth) in degrees, POV-Ray Y-up (see :func:sun_direction). Overrides the preset direction when lighting is also set. Alone, it still appends the sun.

None

Returns:

Type Description
ndarray

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

Source code in src/quiltwright/povray.py
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
def render_pov_quilt(
    scene: str | Path,
    spec: QuiltSpec,
    camera: PovCamera,
    *,
    include_paths: Sequence[str | Path] = (),
    view_cone: float | None = None,
    antialias: float | None = 0.3,
    quality: int = 9,
    jobs: int = 1,
    threads: int | None = None,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
    keep_views: str | Path | None = None,
    progress: bool = True,
    lighting: str | None = None,
    sun: tuple[float, float] | None = None,
) -> np.ndarray:
    """Render a POV-Ray scene into a Looking Glass quilt.

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

    Cost scales linearly with the view count: a Portrait quilt is 48 full
    ray-traces.  For scenes using radiosity or photons, render one view with
    the cache saved and the rest with it loaded (via *extra_args*) -- the
    lighting is identical across a view sweep, so recomputing it per view is
    pure waste.

    :param scene: Path to the ``.pov`` scene.  Not modified.
    :param spec: Quilt specification (grid, size, aspect, cone).
    :param camera: Base camera; its ``look_at`` becomes the focal plane.
    :param include_paths: Extra directories searched for ``#include`` files.
        The scene's own directory is always searched, which is usually
        enough for scenes whose includes sit alongside them.
    :param view_cone: Override the spec's view cone in degrees.
    :param antialias: POV-Ray ``+A`` threshold; lower is higher quality
        (0.3 is a good default, 0.1 for finals).  ``None`` disables
        anti-aliasing.
    :param quality: POV-Ray ``+Q`` quality level, 0-11.
    :param jobs: Number of POV-Ray processes to run concurrently.  Views are
        independent, so one process per core is the efficient shape for a
        quilt: raising this splits the machine's cores between the jobs via
        ``+WT`` rather than letting each process claim all of them.  Pass
        your own ``+WT`` in *extra_args* to override that split.
    :param threads: POV-Ray worker threads per process.  ``None`` applies the
        courtesy cap described in :func:`resolve_work_threads`; ``0`` lets
        POV-Ray take every core, which is its own default.
    :param binary: POV-Ray executable; defaults to ``POVRAY_BINARY`` or
        ``povray`` on ``PATH``.
    :param extra_args: Additional POV-Ray command-line arguments, e.g.
        ``["+HImy.ini"]`` or radiosity cache flags.
    :param keep_views: Directory to retain the per-view PNGs and generated
        wrapper scenes in, for inspection or debugging.  Discarded if
        ``None``.
    :param progress: Print a progress line while rendering.
    :param lighting: ``"light"`` or ``"dark"`` -- append a parallel sun at
        the matching :data:`APPEARANCE_SUN` preset.  ``None`` (the default)
        leaves the scene's own lights alone.  This is appearance for a
        Dynamic Desktop still, not POV-Ray's ``clock``.
    :param sun: ``(altitude, azimuth)`` in degrees, POV-Ray Y-up (see
        :func:`sun_direction`).  Overrides the preset direction when
        *lighting* is also set.  Alone, it still appends the sun.
    :return: ``uint8`` RGB array of shape ``(quilt_height, quilt_width, 3)``.
    """
    from PIL import Image

    povray = _find_povray(binary)
    scene_path = Path(scene).expanduser().resolve()
    if not scene_path.is_file():
        raise FileNotFoundError(f"POV-Ray scene not found: {scene_path}")

    if view_cone is not None:
        spec = replace(spec, view_cone=view_cone)

    # POV-Ray threads one render across every core it can see, so N concurrent
    # processes each ask for the whole machine.  At jobs=14 on 18 cores that
    # is 336 render threads competing for 18, which buys context switching
    # and cache thrash rather than throughput.  Divide the cores between the
    # jobs instead; an explicit +WT from the caller wins.
    if not any(str(a).startswith("+WT") for a in extra_args):
        if jobs > 1:
            extra_args = [*extra_args, f"+WT{max(1, (os.cpu_count() or jobs) // jobs)}"]
        else:
            capped = resolve_work_threads(threads)
            if capped is not None:
                extra_args = [*extra_args, f"+WT{capped}"]

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

    library_paths = [scene_path.parent, *(Path(p).expanduser().resolve() for p in include_paths)]
    offsets = view_offsets(spec, camera.focal_distance)

    with tempfile.TemporaryDirectory(prefix="pov_quilt_") as tmp:
        workdir = Path(tmp)
        views = _sweep(
            povray,
            scene_path,
            spec,
            camera,
            offsets,
            workdir,
            render_w,
            render_h,
            render_aspect,
            library_paths,
            antialias,
            quality,
            extra_args,
            jobs,
            progress,
            lighting=lighting,
            sun=sun,
        )

        quilt = assemble_quilt(
            (np.asarray(Image.open(png).convert("RGB")) for _, png in views), spec
        )

        if keep_views is not None:
            _copy_views(views, keep_views, wrappers=True)

    return quilt

render_pov_views(scene, spec, camera, out_dir, *, include_paths=(), view_cone=None, antialias=0.3, quality=9, jobs=1, threads=None, binary=None, extra_args=(), keep_wrappers=False, progress=True, lighting=None, sun=None)

Render a POV-Ray scene as a sweep of separate view images.

Identical camera geometry to :func:render_pov_quilt -- the same off-axis sheared frustum, the same focal plane on the look_at point -- but the frames are written out individually instead of being tiled into a quilt. That is the form consumers other than a light-field panel ask for: a hologram printer slicing views into hogels, or a lenticular interlacer.

Pair it with :func:~quiltwright.lfd.sweep_spec when the view count is not a convenient rectangle::

from quiltwright.quilt import LITIHOLO_SWEEP
render_pov_views("risedronate.pov", LITIHOLO_SWEEP, camera, "sweep/")
# -> sweep/view000.png ... sweep/view022.png

The depth-budget arithmetic in :func:format_depth_budget still applies and is still worth running first: a sweep that would ghost on a lenticular panel is a sweep whose parallax exceeds what the medium can resolve, and there is no evidence that a hologram's hogels are more forgiving than a lens sheet.

Parameters:

Name Type Description Default
scene str | Path

Path to the .pov scene. Not modified.

required
spec QuiltSpec

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

required
camera PovCamera

Base camera; its look_at becomes the focal plane.

required
out_dir str | Path

Directory to write the frames into; created if absent.

required
include_paths Sequence[str | Path]

Extra directories searched for #include files.

()
view_cone float | None

Override the spec's view cone in degrees.

None
antialias float | None

POV-Ray +A threshold; None disables it.

0.3
quality int

POV-Ray +Q quality level, 0-11.

9
jobs int

Number of POV-Ray processes to run concurrently.

1
threads int | None

POV-Ray worker threads per process. None applies the courtesy cap described in :func:resolve_work_threads; 0 lets POV-Ray take every core.

None
binary str | None

POV-Ray executable; defaults to POVRAY_BINARY or povray on PATH.

None
extra_args Sequence[str]

Additional POV-Ray command-line arguments.

()
keep_wrappers bool

Also write the generated per-view .pov wrappers alongside the frames, for inspection.

False
progress bool

Print a progress line while rendering.

True
lighting str | None

See :func:render_pov_quilt.

None
sun tuple[float, float] | None

See :func:render_pov_quilt.

None

Returns:

Type Description
list[Path]

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

Source code in src/quiltwright/povray.py
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
def render_pov_views(
    scene: str | Path,
    spec: QuiltSpec,
    camera: PovCamera,
    out_dir: str | Path,
    *,
    include_paths: Sequence[str | Path] = (),
    view_cone: float | None = None,
    antialias: float | None = 0.3,
    quality: int = 9,
    jobs: int = 1,
    threads: int | None = None,
    binary: str | None = None,
    extra_args: Sequence[str] = (),
    keep_wrappers: bool = False,
    progress: bool = True,
    lighting: str | None = None,
    sun: tuple[float, float] | None = None,
) -> list[Path]:
    """Render a POV-Ray scene as a sweep of separate view images.

    Identical camera geometry to :func:`render_pov_quilt` -- the same off-axis
    sheared frustum, the same focal plane on the ``look_at`` point -- but the
    frames are written out individually instead of being tiled into a quilt.
    That is the form consumers other than a light-field panel ask for: a
    hologram printer slicing views into hogels, or a lenticular interlacer.

    Pair it with :func:`~quiltwright.lfd.sweep_spec` when the view count is
    not a convenient rectangle::

        from quiltwright.quilt import LITIHOLO_SWEEP
        render_pov_views("risedronate.pov", LITIHOLO_SWEEP, camera, "sweep/")
        # -> sweep/view000.png ... sweep/view022.png

    The depth-budget arithmetic in :func:`format_depth_budget` still applies
    and is still worth running first: a sweep that would ghost on a
    lenticular panel is a sweep whose parallax exceeds what the medium can
    resolve, and there is no evidence that a hologram's hogels are more
    forgiving than a lens sheet.

    :param scene: Path to the ``.pov`` scene.  Not modified.
    :param spec: Sweep or quilt specification supplying view count, view
        cone, and per-view pixel size.
    :param camera: Base camera; its ``look_at`` becomes the focal plane.
    :param out_dir: Directory to write the frames into; created if absent.
    :param include_paths: Extra directories searched for ``#include`` files.
    :param view_cone: Override the spec's view cone in degrees.
    :param antialias: POV-Ray ``+A`` threshold; ``None`` disables it.
    :param quality: POV-Ray ``+Q`` quality level, 0-11.
    :param jobs: Number of POV-Ray processes to run concurrently.
    :param threads: POV-Ray worker threads per process.  ``None`` applies the
        courtesy cap described in :func:`resolve_work_threads`; ``0`` lets
        POV-Ray take every core.
    :param binary: POV-Ray executable; defaults to ``POVRAY_BINARY`` or
        ``povray`` on ``PATH``.
    :param extra_args: Additional POV-Ray command-line arguments.
    :param keep_wrappers: Also write the generated per-view ``.pov`` wrappers
        alongside the frames, for inspection.
    :param progress: Print a progress line while rendering.
    :param lighting: See :func:`render_pov_quilt`.
    :param sun: See :func:`render_pov_quilt`.
    :return: Paths to the written frames, in view order -- view 0 leftmost.
    """
    povray = _find_povray(binary)
    scene_path = Path(scene).expanduser().resolve()
    if not scene_path.is_file():
        raise FileNotFoundError(f"POV-Ray scene not found: {scene_path}")

    if view_cone is not None:
        spec = replace(spec, view_cone=view_cone)

    if not any(str(a).startswith("+WT") for a in extra_args):
        if jobs > 1:
            extra_args = [*extra_args, f"+WT{max(1, (os.cpu_count() or jobs) // jobs)}"]
        else:
            capped = resolve_work_threads(threads)
            if capped is not None:
                extra_args = [*extra_args, f"+WT{capped}"]

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

    library_paths = [scene_path.parent, *(Path(p).expanduser().resolve() for p in include_paths)]
    offsets = view_offsets(spec, camera.focal_distance)

    with tempfile.TemporaryDirectory(prefix="pov_sweep_") as tmp:
        views = _sweep(
            povray,
            scene_path,
            spec,
            camera,
            offsets,
            Path(tmp),
            render_w,
            render_h,
            render_w / render_h,
            library_paths,
            antialias,
            quality,
            extra_args,
            jobs,
            progress,
            lighting=lighting,
            sun=sun,
        )
        return _copy_views(views, out_dir, wrappers=keep_wrappers)

resolve_work_threads(requested=None)

Decide the +WT thread count for a render, or None for none.

POV-Ray threads a single render across every core it can see, which on a workstation means a quilt makes the desktop unusable for the length of the render. Two mechanisms already existed to stop that and neither covered the common case:

  • A Work_Threads line in the INI named by POVINI. This repo's Makefile writes one, so make renders were capped -- but calling a render script directly set no POVINI and took the whole machine.
  • jobs > 1, which splits cores between processes. At the documented jobs=1 it does nothing.

So the default here is a courtesy cap of cpu_count - COURTESY_CORES_HELD_BACK, applied only when nothing else has spoken. An INI does speak: a command-line +WT overrides POVINI entirely, so capping on top of a Work_Threads line would silently defeat make quilts RENDER_THREADS=$(nproc).

Parameters:

Name Type Description Default
requested int | None

Explicit thread count. None asks for the courtesy default; 0 or negative means uncapped -- let POV-Ray take everything.

None

Returns:

Type Description
int | None

Thread count for +WT, or None to pass no +WT at all.

Source code in src/quiltwright/povray.py
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
def resolve_work_threads(requested: int | None = None) -> int | None:
    """Decide the ``+WT`` thread count for a render, or ``None`` for none.

    POV-Ray threads a single render across every core it can see, which on a
    workstation means a quilt makes the desktop unusable for the length of
    the render.  Two mechanisms already existed to stop that and neither
    covered the common case:

    * A ``Work_Threads`` line in the INI named by ``POVINI``.  This repo's
      Makefile writes one, so ``make`` renders were capped -- but calling a
      render script directly set no ``POVINI`` and took the whole machine.
    * ``jobs > 1``, which splits cores between processes.  At the documented
      ``jobs=1`` it does nothing.

    So the default here is a courtesy cap of ``cpu_count -
    COURTESY_CORES_HELD_BACK``, applied only when nothing else has spoken.
    An INI *does* speak: a command-line ``+WT`` overrides ``POVINI``
    entirely, so capping on top of a ``Work_Threads`` line would silently
    defeat ``make quilts RENDER_THREADS=$(nproc)``.

    :param requested: Explicit thread count.  ``None`` asks for the courtesy
        default; ``0`` or negative means uncapped -- let POV-Ray take
        everything.
    :return: Thread count for ``+WT``, or ``None`` to pass no ``+WT`` at all.
    """
    if requested is not None:
        return requested if requested > 0 else None

    ini = os.environ.get("POVINI", "")
    if ini:
        try:
            for line in Path(ini).read_text(errors="replace").splitlines():
                key, sep, value = line.partition("=")
                if sep and key.strip().lower() == "work_threads" and value.strip().isdigit():
                    return None  # POVINI governs; do not override it
        except OSError:
            pass

    cores = os.cpu_count()
    if not cores:
        return None
    return max(1, cores - COURTESY_CORES_HELD_BACK)

summarise_depth_sweep(rows, *, appear=0.001, structured=0.95)

Reduce a :func:depth_sweep to the numbers the depth budget needs.

far is taken as a share of what the sweep actually accumulated rather than of the whole frame, so a scene with sky in it is not penalised for the part that never occludes. That share is reported as sky_fraction: content the sweep could never hide, at effective infinity, which belongs outside the near/far balance.

A backdrop that runs to the horizon needs one step more than this function does. The room's walls close a plane sweep out, so the curve flattens and this rule lands on real content -- but a sea keeps eating a little more of the frame at every distance and never closes, so structured returns the end of the sweep and nothing useful. There, fit the far tail (which is pure backdrop), subtract that linear creep, and take structured of what is left.

Parameters:

Name Type Description Default
rows Sequence[tuple[float, float]]

Output of :func:depth_sweep.

required
appear float

Frame fraction counting as "geometry has appeared".

0.001
structured float

Share of occludable content that defines far.

0.95

Returns:

Type Description
dict[str, float]

near, far and sky_fraction.

Raises:

Type Description
ValueError

If rows is empty.

Source code in src/quiltwright/povray.py
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
def summarise_depth_sweep(
    rows: Sequence[tuple[float, float]],
    *,
    appear: float = 0.001,
    structured: float = 0.95,
) -> dict[str, float]:
    """Reduce a :func:`depth_sweep` to the numbers the depth budget needs.

    *far* is taken as a share of what the sweep actually accumulated rather
    than of the whole frame, so a scene with sky in it is not penalised for
    the part that never occludes.  That share is reported as
    ``sky_fraction``: content the sweep could never hide, at effective
    infinity, which belongs outside the near/far balance.

    A backdrop that runs to the horizon needs one step more than this
    function does.  The room's walls close a plane sweep out, so the curve
    flattens and this rule lands on real content -- but a sea keeps eating a
    little more of the frame at every distance and never closes, so
    *structured* returns the end of the sweep and nothing useful.  There, fit
    the far tail (which is pure backdrop), subtract that linear creep, and
    take *structured* of what is left.

    :param rows: Output of :func:`depth_sweep`.
    :param appear: Frame fraction counting as "geometry has appeared".
    :param structured: Share of occludable content that defines *far*.
    :return: ``near``, ``far`` and ``sky_fraction``.
    :raises ValueError: If *rows* is empty.
    """
    if not rows:
        raise ValueError("no probe rows to summarise")
    d = np.array([r[0] for r in rows], dtype="d")
    f = np.array([r[1] for r in rows], dtype="d")
    saturation = float(f.max())
    return {
        "near": float(d[int(np.argmax(f > appear))]),
        "far": float(d[int(np.argmax(f >= structured * saturation))]),
        "sky_fraction": 1.0 - saturation,
    }

sun_direction(altitude, azimuth)

Unit vector pointing toward the sun, POV-Ray Y-up.

Parameters:

Name Type Description Default
altitude float

Degrees above the Y = 0 plane (horizon). 90 is +Y.

required
azimuth float

Degrees from +Z toward +X, [0, 360).

required

Returns:

Type Description
tuple[float, float, float]

(x, y, z) of length 1.

Source code in src/quiltwright/povray.py
363
364
365
366
367
368
369
370
371
372
373
def sun_direction(altitude: float, azimuth: float) -> tuple[float, float, float]:
    """Unit vector pointing *toward* the sun, POV-Ray Y-up.

    :param altitude: Degrees above the Y = 0 plane (horizon).  90 is +Y.
    :param azimuth: Degrees from +Z toward +X, ``[0, 360)``.
    :return: ``(x, y, z)`` of length 1.
    """
    alt = math.radians(altitude)
    az = math.radians(azimuth)
    cos_alt = math.cos(alt)
    return (math.sin(az) * cos_alt, math.sin(alt), math.cos(az) * cos_alt)