#!/usr/bin/env python3
# $Id: render-views 336 2026-09-05 13:20:12Z peter $
#
# render-views
#
# Copyright (C) 2026 Peter Felecan
# Assisted by Claude Sonnet 5 (Anthropic).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ---------------------------------------------------------------------
# PURPOSE
#
# Render a BSPPFit model from a systematic sample of viewpoints on
# the surface of a sphere centred at the model's local origin, with
# the model inscribed inside the sphere. Each PNG is named with the
# spherical coordinates (latitude, longitude) of the camera position
# plus an ordinal frame number so the viewpoint is identifiable from
# the filename alone, and the frames can be aggregated into an MP4
# turntable through ffmpeg.
#
# Designed at iteration 0.10.0 in response to project-author
# proposal that ad-hoc multi-view PNG uploads were ambiguous and
# error-prone. The renderer is the first artefact of a /communication
# jig/: a standard way to inspect any model from the assistant /and/
# project-author side, with viewpoints that map one-to-one to
# coordinates rather than to free-form camera positions.
#
# ARCHITECTURAL ROLE
#
# Sits at the project level (top of the source tree, alongside
# checkxref) and is parameterised on the model
# to render. Makefile.am invokes it via per-model
# `render-<name>' targets that produce PNG and (optional) MP4
# outputs under each model's Renders/ subdirectory.
#
# Generates renders by invoking OpenSCAD in batch mode, one call per
# frame, with --camera computed from the spherical coordinates of
# the chosen viewpoint. Optionally aggregates the frames into a
# turntable MP4 via ffmpeg.
#
# COORDINATE CONVENTION
#
# Geography convention: (latitude, longitude) where
#   latitude in [-90, +90] degrees: -90 = south pole (camera below
#     model looking up), 0 = equator (camera level with model
#     centre), +90 = north pole (camera above model looking down).
#   longitude in [0, 360) degrees: 0 along +X axis, 90 along +Y,
#     180 along -X, 270 along -Y.
#
# The model is centred at the world origin (0, 0, 0); each model's
# attachable bounding-box centre lies at origin by the library's
# convention.
#
# COVERAGE MODES
#
# turntable  : equator only, 36 frames every 10 degrees. Fast (~1
#              minute on a typical machine for a moderate model);
#              sufficient for shape understanding and routine
#              communication. /Default mode./
# hemisphere : equator + +30 + +60 latitude rings + north pole =
#              109 frames. Useful for asymmetric models where the
#              top of the model has features that the equator
#              alone does not reveal.
# sphere     : adds -30, -60, south pole = 182 frames. Full
#              coverage; use when the model has dissimilar top
#              and bottom faces (Cap, Plug, etc).
#
# USAGE
#
#   render-views \
#       --scad-file Elbow.scad \
#       --model-name Elbow \
#       --mode turntable \
#       --output-dir Renders/Elbow \
#       --mp4
#
# With a non-default OpenSCAD build (e.g. a development build with
# the Manifold backend that the system-package OpenSCAD does not
# yet ship):
#
#   render-views \
#       --scad-file Elbow.scad \
#       --model-name Elbow \
#       --output-dir Renders/Elbow \
#       --openscad-binary /opt/openscad-dev/openscad
#
# The autotools configure script picks the OpenSCAD path from
# --with-openscad=PATH and propagates it to make's per-model
# render targets, so users who configure with --with-openscad do
# not need to pass --openscad-binary on every invocation.
#
# RENDER VARIANT SELECTION
#
# Each BSPPFit model file carries a render-dispatch module
# (<Model>_Render) that switches on a string variable `render' which
# OpenSCAD's -D option can set. --render-variant NAME makes this
# script pass -D render="NAME" to every OpenSCAD invocation, so a
# single .scad file can be rendered in any of its named variations
# (for the Union: assembled, exploded, the individual pieces) without
# a per-variant copy of the file. With no --render-variant the file's
# own default applies (the assembled/primary view). The mechanism is
# documented in DevelopmentStandard.org, sections Render Dispatch
# Module and Render Variant Selection.
#
# --model-name is independent of --render-variant: it is the filename
# stem only. Makefile.am sets the stem to <Model>-<variant> for
# variant targets so a variant render does not overwrite the default
# render's PNGs, tar, and MP4.
#
# The renderer prints progress every 10 frames and writes a final
# summary line at the end. Existing PNGs are overwritten without
# warning.
# ---------------------------------------------------------------------

import argparse
import atexit
import concurrent.futures
import math
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path


# ---------------------------------------------------------------------
# DEFAULTS
# ---------------------------------------------------------------------

DEFAULT_IMG_SIZE = (1024, 1024)
DEFAULT_FPS = 12
DEFAULT_DISTANCE = 100.0  # mm; overridden by --viewall in OpenSCAD
DEFAULT_LONGITUDE_STEP = 10  # degrees
DEFAULT_HEMISPHERE_LATS = [0, 30, 60]  # plus north pole frame
DEFAULT_SPHERE_LATS = [-60, -30, 0, 30, 60]  # plus both poles


# ---------------------------------------------------------------------
# SPHERICAL COORDINATES
# ---------------------------------------------------------------------

def spherical_to_cartesian(lat_deg, lon_deg, radius):
    """
    Convert (latitude, longitude, radius) in geography convention to
    Cartesian (x, y, z).

    Geography convention:
      lat = +90 -> +Z (north pole)
      lat = 0, lon = 0 -> +X (equator at zero longitude)
      lat = 0, lon = 90 -> +Y
    """
    lat = math.radians(lat_deg)
    lon = math.radians(lon_deg)
    x = radius * math.cos(lat) * math.cos(lon)
    y = radius * math.cos(lat) * math.sin(lon)
    z = radius * math.sin(lat)
    return x, y, z


def build_frame_list(mode, longitude_step):
    """
    Build the ordered list of (lat, lon) tuples for the given mode.

    Returns a list of tuples ordered to produce a smooth MP4 turntable:
    each latitude ring is traversed in increasing longitude order, and
    rings are traversed equator-first, then alternating north/south.
    Poles are single frames added at appropriate positions.

    Edge case: poles are slightly inset (89.0 / -89.0 instead of
    exact 90.0) to avoid OpenSCAD up-vector degeneracy.
    """
    longitudes = list(range(0, 360, longitude_step))
    frames = []

    if mode == "turntable":
        # Equator only.
        for lon in longitudes:
            frames.append((0, lon))
        return frames

    if mode == "hemisphere":
        # Equator -> +30 -> +60 -> north pole.
        for lat in [0, 30, 60]:
            for lon in longitudes:
                frames.append((lat, lon))
        frames.append((89, 0))
        return frames

    if mode == "sphere":
        # Equator -> +30 -> +60 -> north pole ->
        # south pole -> -60 -> -30.
        # Ordering chosen for smooth MP4: we go up, jump to bottom,
        # then come back up. A purely smooth ordering across the
        # whole sphere is not possible in a single MP4 without
        # bouncing; this ordering is a reasonable compromise.
        for lat in [0, 30, 60]:
            for lon in longitudes:
                frames.append((lat, lon))
        frames.append((89, 0))
        frames.append((-89, 0))
        for lat in [-60, -30]:
            for lon in longitudes:
                frames.append((lat, lon))
        return frames

    raise ValueError(f"unknown mode: {mode}")


# ---------------------------------------------------------------------
# OPENSCAD INVOCATION
# ---------------------------------------------------------------------

def render_one_frame(scad_file, output_path, eye, center, img_size,
                     openscad_binary="openscad",
                     extra_args=None, colorscheme=None, verbose=False):
    """
    Invoke OpenSCAD once to render a single PNG at the given camera
    eye and look-at center.

    Uses OpenSCAD's --camera=eyex,eyey,eyez,centerx,centery,centerz
    LookAt form. --viewall is also passed so the model is fitted to
    the viewport regardless of the chosen distance; the distance in
    `eye' acts purely as a direction indicator under --viewall.

    --render is passed so the output uses the full geometry pipeline
    (manifold or CGAL) rather than the preview backend; this avoids
    the on-screen axes overlay that appears in preview mode.

    `openscad_binary' lets the caller select an OpenSCAD build other
    than the one on PATH --- e.g. a development build with the
    Manifold backend that the system-package OpenSCAD does not yet
    ship. The project's autotools configure script picks this up
    from --with-openscad=PATH and passes it to the make-driven
    invocations; standalone invocations of this script can pass it
    explicitly.
    """
    cmd = [openscad_binary,
           "--imgsize=" + ",".join(str(v) for v in img_size),
           "--render",
           "--viewall",
           ("--camera="
            + f"{eye[0]:.6f},{eye[1]:.6f},{eye[2]:.6f},"
            + f"{center[0]:.6f},{center[1]:.6f},{center[2]:.6f}"),
           "-o", str(output_path)]
    # An explicit colour scheme (e.g. "Tomorrow") makes the still and
    # turntable output independent of the OpenSCAD build's default
    # scheme. With colorscheme=None the option is omitted and OpenSCAD
    # uses its built-in default, preserving pre-existing behaviour.
    if colorscheme is not None:
        cmd.append("--colorscheme=" + colorscheme)
    # extra_args (e.g. the -D render="..." variant selector) go before
    # the positional .scad file. OpenSCAD treats some long options
    # (notably --render) as able to consume a following token, so a
    # bare option must never sit immediately before the file argument;
    # keeping extra_args ahead of the file and the file strictly last
    # avoids that ambiguity.
    if extra_args:
        cmd.extend(extra_args)
    cmd.append(str(scad_file))

    if verbose:
        print("  +", " ".join(cmd))

    result = subprocess.run(cmd, capture_output=not verbose, text=True)
    if result.returncode != 0:
        stderr = result.stderr if not verbose else ""
        raise RuntimeError(
            f"OpenSCAD failed (exit {result.returncode}) on {scad_file}\n"
            f"--- stderr ---\n{stderr}\n--- end ---")


# ---------------------------------------------------------------------
# FILENAME FORMAT
# ---------------------------------------------------------------------

def frame_filename(model_name, lat, lon, frame_num):
    """
    Build the per-frame PNG filename.

    Format (project-author convention adopted at iteration 0.10.1,
    superseding the verbose 0.10.0 format):
      <model>_NNN{lat_sign}LL{lon_sign}LLL.png

    where:
      NNN  : zero-padded three-digit frame number, FIRST so
             lexicographic sort matches frame order without ambiguity.
      LL   : signed two-digit latitude in degrees, range -89..+89.
      LLL  : signed three-digit longitude in degrees, range 0..359.

    The three pieces are positionally encoded: no labels (lat=, lon=,
    frame=), no separators between numeric fields. The leading sign
    of each numeric field acts as the implicit separator.

    Examples:
      Elbow_001+00+000.png   first frame, equator, longitude 0
      Elbow_010+00+090.png   tenth frame, equator, longitude 90
      Elbow_109+89+000.png   north-pole frame in hemisphere mode

    Rationale: lexicographic ordering of the filenames matches frame
    order (because NNN is first and zero-padded). This is exactly
    what ffmpeg's glob-based input needs, and it makes the directory
    listing of a Renders/ folder show the turntable progression
    naturally.
    """
    lat_str = f"{lat:+03d}"
    lon_str = f"{lon:+04d}"
    return f"{model_name}_{frame_num:03d}{lat_str}{lon_str}.png"


# ---------------------------------------------------------------------
# MP4 AGGREGATION
# ---------------------------------------------------------------------

# Regex for the coordinate suffix in a frame filename.
# Filenames look like <model>_NNN+LL+LLL.png where NNN is the
# zero-padded frame number, LL is the signed two-digit latitude,
# LLL is the signed three-digit longitude (each with a leading
# + or -). The label drawn on the MP4 frames is the suffix
# NNN+LL+LLL (i.e. everything after the model_ prefix, minus the
# extension). See frame_filename() for the producer side.
_FRAME_COORD_RE = re.compile(
    r"_(\d{3}[+-]\d{2}[+-]\d{3})\.png$"
)


def _label_text_from_filename(png_path):
    """
    Extract the NNN+LL+LLL label from a frame's filename. Returns
    None for paths that don't match the expected pattern (which
    would indicate an upstream bug in frame_filename(), so callers
    treat None as a hard error rather than silently skipping).
    """
    m = _FRAME_COORD_RE.search(str(png_path))
    return m.group(1) if m else None


# Default font /family/ for the label burn-in. The actual TTF
# path is resolved at run time via fc-match (0.50.0) --- this
# allows the tool to pick the system's best match for the named
# family rather than hard-coding a Debian/Ubuntu file path that
# does not exist on minimal containers, BSD distributions, macOS,
# etc. The family chosen "DejaVu Sans Mono:bold" requests a bold
# monospace face; fc-match returns the closest available font's
# path. The --label-font CLI flag accepts either a fontconfig
# pattern (resolved via fc-match) or an explicit file path (used
# verbatim if it exists).
_DEFAULT_LABEL_FONT_FAMILY = "DejaVu Sans Mono:bold"


def resolve_label_font(spec):
    """
    Resolve `spec' to a TTF file path for the label burn-in.

    If `spec' is a path that exists, return it verbatim. Otherwise,
    interpret it as a fontconfig pattern (e.g. "Courier New:bold")
    and resolve via fc-match. Raises RuntimeError if neither path
    nor pattern resolves to a readable file.

    The two-step resolution lets the CLI flag accept either form:
    a literal /usr/share/.../foo.ttf for setups that pin a specific
    file, or a family-name pattern that's portable across systems
    without DejaVu fonts.
    """
    spec_path = Path(spec)
    if spec_path.is_file():
        return str(spec_path)

    # Treat spec as a fontconfig pattern. fc-match's -f '%{file}'
    # prints the file path of the best match (with no trailing
    # newline when -f is used).
    try:
        result = subprocess.run(
            ["fc-match", "-f", "%{file}", spec],
            capture_output=True, text=True, check=True,
        )
    except FileNotFoundError as exc:
        raise RuntimeError(
            f"resolve_label_font: fc-match(1) not available and "
            f"{spec!r} is not an existing file path. Install "
            f"fontconfig or pass an explicit TTF path."
        ) from exc
    except subprocess.CalledProcessError as exc:
        raise RuntimeError(
            f"resolve_label_font: fc-match failed for pattern "
            f"{spec!r} (exit {exc.returncode}): "
            f"{exc.stderr.strip()}"
        ) from exc

    path = result.stdout.strip()
    if not path or not Path(path).is_file():
        raise RuntimeError(
            f"resolve_label_font: fc-match for {spec!r} returned "
            f"{path!r} which is not a readable file. The host has "
            f"no matching font; pass an explicit TTF path or "
            f"install one matching the pattern."
        )
    return path


# Module-level state for the temporary labelled-frames directory.
# The directory is created lazily by _ensure_label_tmpdir() on the
# first burn-in invocation and cleaned up at process exit (unless
# --keep-labels is set, in which case _cleanup_label_tmpdir() is a
# no-op and the path is printed to stderr so the operator can
# inspect it).
_LABEL_TMPDIR = None
_KEEP_LABEL_TMPDIR = False


def _ensure_label_tmpdir():
    """
    Create the per-process labelled-frames directory if it does not
    already exist, register cleanup at exit, and return its path.

    The directory name follows the Unix temporary-artefact
    convention: program-function-pid, here render-views-label-<pid>,
    placed under tempfile.gettempdir() (which honours the standard
    TMPDIR environment variable; defaults to /tmp).
    """
    global _LABEL_TMPDIR
    if _LABEL_TMPDIR is not None:
        return _LABEL_TMPDIR

    tmp_root = tempfile.gettempdir()
    name = f"render-views-label-{os.getpid()}"
    path = Path(tmp_root) / name
    path.mkdir(exist_ok=True)
    _LABEL_TMPDIR = path
    atexit.register(_cleanup_label_tmpdir)
    return path


def _cleanup_label_tmpdir():
    """
    Remove the labelled-frames directory if --keep-labels was not
    set. When _KEEP_LABEL_TMPDIR is True the directory is left in
    place and a one-line note is printed to stderr so the operator
    can locate it. Called via atexit.register.
    """
    if _LABEL_TMPDIR is None:
        return
    if _KEEP_LABEL_TMPDIR:
        print(
            f"render-views: --keep-labels: labelled frames left in "
            f"{_LABEL_TMPDIR}",
            file=sys.stderr,
        )
        return
    shutil.rmtree(_LABEL_TMPDIR, ignore_errors=True)


def burn_in_labels(output_dir, model_name, ffmpeg_binary="ffmpeg",
                   label_font=None, verbose=False):
    """
    Burn each frame's coordinate label (NNN+LL+LLL extracted from
    the filename) into the lower-left corner of the frame, writing
    labelled copies to a per-process temporary directory. Returns
    the path to that directory; the caller (build_mp4) points its
    glob pattern at this directory instead of the original.

    Original PNGs are not modified --- the design intent is that
    the source-of-truth illustrations stay pristine while the MP4
    review pipeline gets the label overlay.

    /Temporary directory/ (0.50.0): the labelled frames live under
    <TMPDIR>/render-views-label-<pid>/<model>/ following the Unix
    convention for per-process scratch space. The directory is
    cleaned up at exit via an atexit handler. Pass --keep-labels
    on the command line (and forward keep_labels=True if invoking
    burn_in_labels directly) to preserve the directory; the path is
    then printed to stderr at exit so the operator can locate it.

    `label_font' (0.49.0; semantics extended 0.50.0): path or
    fontconfig pattern for the burn-in font. None uses the default
    pattern "DejaVu Sans Mono:bold" resolved via fc-match. Path
    values that exist on disk are used verbatim; non-existent
    values are passed to fc-match as patterns. See resolve_label_font.

    Implementation: one ffmpeg invocation per frame, applying the
    drawtext filter. ffmpeg startup overhead (~50 ms per call)
    dominates the cost: a 36-frame turntable adds ~2 s of overhead;
    a ~700-frame full sphere adds ~35 s. Acceptable for an
    opt-in flag.

    Alternative considered: one ffmpeg invocation reading all
    frames at once, with N drawtext filters chained and gated by
    enable='eq(n,k)'. Faster (no per-frame startup) but the
    filter graph grows linearly with frame count and becomes
    unwieldy beyond ~100 frames. The per-frame approach trades
    startup overhead for simpler code.
    """
    output_dir = Path(output_dir)

    # Per-model subdirectory inside the per-process tmpdir.
    # Subdirs (one per model) keep multi-model runs separated:
    # build_mp4 reads from this directory directly, so its glob
    # must see only the current model's frames. Sharing one tmpdir
    # across models without subdirs would collide if model names
    # share a prefix.
    tmpdir_root = _ensure_label_tmpdir()
    labelled_dir = tmpdir_root / model_name
    # Fresh start: if a prior invocation in the same process used
    # the same model name (rare, but possible via direct API use),
    # clear its frames so the MP4 glob does not see stale ones.
    if labelled_dir.exists():
        shutil.rmtree(labelled_dir)
    labelled_dir.mkdir(parents=True)

    spec = label_font if label_font is not None \
        else _DEFAULT_LABEL_FONT_FAMILY
    font_path = resolve_label_font(spec)
    if verbose:
        print(f"  burn-in font: {spec!r} -> {font_path}")

    # Find all frame PNGs for this model, in lexicographic order
    # (which matches frame order thanks to the zero-padded frame
    # number at the start of the coordinate suffix).
    frames = sorted(output_dir.glob(f"{model_name}_[0-9]*.png"))
    if not frames:
        raise RuntimeError(
            f"burn_in_labels: no frames found for {model_name} "
            f"in {output_dir}"
        )

    for png in frames:
        label = _label_text_from_filename(png)
        if label is None:
            raise RuntimeError(
                f"burn_in_labels: filename does not match the "
                f"expected NNN+LL+LLL pattern: {png}"
            )

        # The drawtext filter expression: white text on a
        # semi-transparent black box, anchored to the lower-left
        # corner with a 10-pixel margin from the bottom and left
        # edges. Font size 18 produces a label about 90x20 pixels
        # at typical render resolutions --- visible but not
        # dominant. The box (boxcolor=black@0.5) gives contrast
        # against any background colour the render uses.
        drawtext = (
            f"drawtext="
            f"fontfile={font_path}:"
            f"text='{label}':"
            f"fontcolor=white:"
            f"fontsize=18:"
            f"box=1:"
            f"boxcolor=black@0.5:"
            f"boxborderw=4:"
            f"x=10:"
            f"y=h-th-10"
        )

        out_path = labelled_dir / png.name
        cmd = [
            ffmpeg_binary,
            "-y",
            "-loglevel", "error",
            "-i", str(png),
            "-vf", drawtext,
            str(out_path),
        ]

        if verbose:
            print("  burn-in:", " ".join(cmd))

        result = subprocess.run(cmd, capture_output=not verbose,
                                text=True)
        if result.returncode != 0:
            stderr = result.stderr if not verbose else ""
            raise RuntimeError(
                f"ffmpeg burn-in failed on {png} "
                f"(exit {result.returncode})\n"
                f"--- stderr ---\n{stderr}\n--- end ---"
            )

    return labelled_dir


def build_mp4(output_dir, model_name, mode, fps,
              ffmpeg_binary="ffmpeg", label_frames=False,
              label_font=None, verbose=False):
    """
    Aggregate the rendered PNGs into a single MP4 via ffmpeg's
    glob pattern. Writes <model_name>_<mode>.mp4 in the same
    directory (e.g. Elbow_turntable.mp4, Elbow_sphere.mp4).

    The mode-qualified filename was introduced in iteration 0.10.2;
    earlier iterations always wrote <model_name>_turntable.mp4
    regardless of the actual coverage mode, which collided when a
    user ran turntable and then hemisphere on the same model.

    Assumes the PNG filenames sort in frame order (which they do
    given the zero-padded frame number at the start of the
    coordinate portion of the filename --- see frame_filename()).

    `ffmpeg_binary' lets the caller select an ffmpeg build other
    than the one on PATH (mirrors the openscad_binary mechanism).

    `label_frames' (added 0.48.0): when True, run a per-frame
    burn-in pass to a sibling .Labelled directory before
    aggregating, so each MP4 frame carries its NNN+LL+LLL
    coordinate label in the lower-left corner. Original PNGs are
    not modified. Useful for selecting the most photogenic camera
    position when reviewing the MP4 in slow motion.

    `label_font' (added 0.49.0): path to the truetype font used
    by the burn-in pass. None uses the default DejaVu Sans Mono
    Bold; pass any other monospace TTF path to override. Only
    consulted when label_frames is True.
    """
    output_dir = Path(output_dir)

    if label_frames:
        source_dir = burn_in_labels(
            output_dir, model_name, ffmpeg_binary,
            label_font=label_font, verbose=verbose
        )
    else:
        source_dir = output_dir

    # The 0.10.1 filename format is <model>_NNN{sign}LL{sign}LLL.png;
    # the glob pattern catches any file matching <model>_NNN... with
    # the frame number leading.
    pattern = str(source_dir / f"{model_name}_[0-9]*.png")
    mp4_path = output_dir / f"{model_name}_{mode}.mp4"

    cmd = [ffmpeg_binary,
           "-y",  # overwrite without prompting
           "-framerate", str(fps),
           "-pattern_type", "glob",
           "-i", pattern,
           "-c:v", "libx264",
           "-pix_fmt", "yuv420p",
           "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
           str(mp4_path)]

    if verbose:
        print(" ", " ".join(cmd))

    result = subprocess.run(cmd, capture_output=not verbose, text=True)
    if result.returncode != 0:
        stderr = result.stderr if not verbose else ""
        raise RuntimeError(
            f"ffmpeg failed (exit {result.returncode})\n"
            f"--- stderr ---\n{stderr}\n--- end ---")

    return mp4_path


# ---------------------------------------------------------------------
# TAR AGGREGATION
# ---------------------------------------------------------------------

def build_tar(output_dir, model_name, mode, frame_files, verbose=False):
    """
    Aggregate the rendered PNGs into a single uncompressed tar
    archive. Writes <model_name>_<mode>.tar in the same directory
    (e.g. Elbow_turntable.tar, Elbow_sphere.tar).

    Tar is the project-preferred format for uploads to the assistant
    because PNG filenames in the 0.10.1+ format carry the (frame,
    lat, lon) coordinate metadata that the assistant uses to
    identify viewpoints. Unlike the MP4 (which is also produced),
    the tarball preserves filenames byte-for-byte; the assistant
    can extract any single frame and know exactly which camera made
    it without re-running ffmpeg.

    Introduced in iteration 0.10.2 in response to project-author
    request that every render target should produce both a tarball
    AND an MP4 alongside the PNG sequence.

    Uncompressed tar is chosen over tar.xz or tar.gz because:
    (a) the PNGs are already compressed; recompressing them adds
        time without saving meaningful space;
    (b) Python's tarfile module produces uncompressed archives
        without any external dependency;
    (c) the resulting .tar is uploadable as-is in iteration
        reviews.
    """
    import tarfile

    output_dir = Path(output_dir)
    tar_path = output_dir / f"{model_name}_{mode}.tar"

    if verbose:
        print(f"  building tar: {tar_path}")

    with tarfile.open(tar_path, "w") as tar:
        for fpath in frame_files:
            # arcname is the filename only (no directory), so the
            # tar contains a flat list of PNGs at the top level
            # (matching the upload-friendly form used in iteration
            # 0.10.1).
            tar.add(fpath, arcname=Path(fpath).name)

    return tar_path


# ---------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description=("Render a BSPPFit model from a systematic sample "
                     "of viewpoints on a sphere."),
        formatter_class=argparse.RawDescriptionHelpFormatter)

    parser.add_argument(
        "--scad-file", required=True,
        help="Path to the .scad file to render.")
    parser.add_argument(
        "--model-name", required=True,
        help="Short name used as prefix for output PNG filenames "
             "(e.g. 'Elbow').")
    parser.add_argument(
        "--output-dir", required=True,
        help="Directory where output PNGs and the optional MP4 are "
             "written. Created if it does not exist.")
    parser.add_argument(
        "--mode", choices=["turntable", "hemisphere", "sphere"],
        default="turntable",
        help="Coverage mode (default: turntable = equator only, 36 "
             "frames).")
    parser.add_argument(
        "--longitude-step", type=int, default=DEFAULT_LONGITUDE_STEP,
        help=("Longitude step in degrees (default: "
              f"{DEFAULT_LONGITUDE_STEP}). Must divide 360."))
    parser.add_argument(
        "--still", default=None, metavar="LAT,LON",
        help=("Render a single still frame at the given viewpoint "
              "instead of a coverage sweep. LAT in [-89,+89], LON in "
              "[0,359]. The frame is numbered 001 and named with the "
              "coordinate-encoded scheme (e.g. "
              "<model>_001+30+315.png), so the viewpoint stays "
              "traceable from the filename. Overrides --mode; "
              "incompatible with --mp4 / --tar / --label-frames "
              "(a single frame does not aggregate). Per-model "
              "viewpoints are expected: a 3/4 oblique that reads "
              "well for one gallery layout may not for another."))
    parser.add_argument(
        "--colorscheme", default=None, metavar="NAME",
        help=("OpenSCAD colour scheme passed verbatim as "
              "--colorscheme=NAME (e.g. 'Tomorrow', 'Cornfield'). "
              "Omitted by default, so OpenSCAD uses its built-in "
              "default; set it to make output independent of the "
              "build's default scheme."))
    parser.add_argument(
        "--imgsize", default=f"{DEFAULT_IMG_SIZE[0]}x{DEFAULT_IMG_SIZE[1]}",
        help=("Image size as WxH (default: "
              f"{DEFAULT_IMG_SIZE[0]}x{DEFAULT_IMG_SIZE[1]})."))
    parser.add_argument(
        "--distance", type=float, default=DEFAULT_DISTANCE,
        help=("Camera distance from model centre in mm (default: "
              f"{DEFAULT_DISTANCE}). Mainly a directional hint; "
              "--viewall auto-fits the actual zoom."))
    parser.add_argument(
        "--mp4", action="store_true",
        help="Also generate an MP4 turntable from the PNGs via ffmpeg. "
             "Output filename: <Model>_<mode>.mp4.")
    parser.add_argument(
        "--label-frames", action="store_true",
        help="Burn each frame's coordinate label (NNN+LL+LLL: the "
             "sequence number and camera latitude/longitude from the "
             "frame filename) into the lower-left corner of the MP4 "
             "frames. Useful when reviewing the MP4 in slow motion "
             "to identify the most photogenic camera position. "
             "Only affects the MP4 output; PNG files are not modified. "
             "Implies --mp4.")
    parser.add_argument(
        "--label-font", default=None, metavar="FONT",
        help=("Font used by --label-frames for the coordinate label "
              "burn-in. Accepts either an explicit truetype (.ttf) "
              "path or a fontconfig pattern (e.g. 'Courier New:bold') "
              "resolved at run time via fc-match(1). Default: "
              f"'{_DEFAULT_LABEL_FONT_FAMILY}'. Only consulted when "
              "--label-frames is passed."))
    parser.add_argument(
        "--keep-labels", action="store_true",
        help=("Do not delete the temporary directory holding the "
              "labelled frames at exit. The directory is "
              "<TMPDIR>/render-views-label-<pid>/ (TMPDIR defaults "
              "to /tmp). Useful for diagnosing MP4-build failures "
              "or inspecting the labelled PNGs. Without this flag, "
              "the directory is removed at process exit."))
    parser.add_argument(
        "--tar", action="store_true",
        help="Also aggregate the PNGs into an uncompressed tar archive. "
             "Output filename: <Model>_<mode>.tar. Preferred format for "
             "uploads to the assistant since PNG filenames carry "
             "coordinate metadata.")
    parser.add_argument(
        "--fps", type=int, default=DEFAULT_FPS,
        help=f"MP4 frame rate (default: {DEFAULT_FPS}).")
    parser.add_argument(
        "--openscad-binary", default="openscad",
        help=("Path to the OpenSCAD binary to use (default: "
              "'openscad' resolved via PATH). Pass an explicit "
              "path to use a non-default build, e.g. a development "
              "build with the Manifold backend not yet shipped in "
              "your distribution's OpenSCAD package. The autotools "
              "configure script accepts --with-openscad=PATH "
              "and propagates the value to this script via the "
              "Makefile."))
    parser.add_argument(
        "--ffmpeg-binary", default="ffmpeg",
        help=("Path to the ffmpeg binary to use (default: 'ffmpeg' "
              "resolved via PATH). Only consulted when --mp4 is "
              "passed."))
    parser.add_argument(
        "--verbose", action="store_true",
        help="Print each OpenSCAD invocation and progress per frame.")
    parser.add_argument(
        "--render-variant", default=None, metavar="NAME",
        help="Render the model variation named NAME by passing "
             "-D render=\"NAME\" to OpenSCAD. NAME must be one of the "
             "variant names the model file's <Model>_Render dispatch "
             "module recognises (an unknown name makes OpenSCAD abort "
             "with an assertion). With no --render-variant the file's "
             "own default variant is rendered.")
    parser.add_argument(
        "--jobs", type=int, default=1, metavar="N",
        help="Render up to N frames in parallel, each in its own "
             "OpenSCAD subprocess (default: 1, i.e. sequential). "
             "Frames are independent --- each is a separate OpenSCAD "
             "invocation writing a distinct PNG --- so they parallelise "
             "cleanly across cores. The Makefile passes the job count "
             "from 'make -jN' so 'make -j4 render-archetypes' uses four "
             "cores. N <= 0 is treated as 1.")

    args = parser.parse_args()
    if args.jobs < 1:
        args.jobs = 1

    # --- Validate inputs. ---
    scad_file = Path(args.scad_file)
    if not scad_file.is_file():
        print(f"error: not a file: {scad_file}", file=sys.stderr)
        return 1

    if 360 % args.longitude_step != 0:
        print(f"error: longitude_step {args.longitude_step} does not "
              f"divide 360", file=sys.stderr)
        return 1

    # --- Single-still viewpoint (overrides the coverage sweep). ---
    still_viewpoint = None
    if args.still is not None:
        if args.mp4 or args.tar or args.label_frames:
            print("error: --still renders one frame and cannot be "
                  "combined with --mp4, --tar, or --label-frames",
                  file=sys.stderr)
            return 1
        try:
            lat_s, lon_s = (int(v) for v in args.still.split(","))
        except ValueError:
            print(f"error: bad --still {args.still!r}; want LAT,LON "
                  "(e.g. 30,315)", file=sys.stderr)
            return 1
        if not (-89 <= lat_s <= 89) or not (0 <= lon_s <= 359):
            print(f"error: --still out of range: LAT must be -89..+89, "
                  f"LON 0..359; got {lat_s},{lon_s}", file=sys.stderr)
            return 1
        still_viewpoint = (lat_s, lon_s)

    try:
        w, h = (int(v) for v in args.imgsize.lower().split("x"))
    except ValueError:
        print(f"error: bad --imgsize {args.imgsize}; want WxH",
              file=sys.stderr)
        return 1

    # --- Check OpenSCAD is available (consulting --openscad-binary
    # first; if it is an explicit path that doesn't exist, fail
    # loudly; if it is the default 'openscad' resolved via PATH, use
    # shutil.which to fail with the same message). ---
    if os.sep in args.openscad_binary or os.altsep and \
       args.openscad_binary.find(os.altsep) >= 0:
        # Treat as explicit path.
        if not os.path.isfile(args.openscad_binary) or \
           not os.access(args.openscad_binary, os.X_OK):
            print(f"error: openscad binary not executable: "
                  f"{args.openscad_binary}", file=sys.stderr)
            return 1
    else:
        if shutil.which(args.openscad_binary) is None:
            print(f"error: openscad binary not found on PATH: "
                  f"{args.openscad_binary}", file=sys.stderr)
            return 1

    # --- Argument coherence. ---
    # --label-frames is meaningful only for MP4 output; let it imply
    # --mp4 so the user doesn't have to specify both. The PNG burn-in
    # step happens inside build_mp4 when label_frames=True.
    if args.label_frames and not args.mp4:
        args.mp4 = True
        if args.verbose:
            print("note: --label-frames implies --mp4")

    # --keep-labels controls the atexit cleanup of the labelled-
    # frames tmpdir; record it on the module-level flag so the
    # atexit handler can read it. Only meaningful when burn_in_
    # labels actually runs (i.e. --label-frames is set), but
    # cheap to set unconditionally.
    global _KEEP_LABEL_TMPDIR
    _KEEP_LABEL_TMPDIR = args.keep_labels

    # --- Optional ffmpeg check. ---
    if args.mp4:
        if os.sep in args.ffmpeg_binary or os.altsep and \
           args.ffmpeg_binary.find(os.altsep) >= 0:
            if not os.path.isfile(args.ffmpeg_binary) or \
               not os.access(args.ffmpeg_binary, os.X_OK):
                print(f"error: ffmpeg binary not executable: "
                      f"{args.ffmpeg_binary}", file=sys.stderr)
                return 1
        else:
            if shutil.which(args.ffmpeg_binary) is None:
                print(f"error: --mp4 requested but ffmpeg binary "
                      f"not found on PATH: {args.ffmpeg_binary}",
                      file=sys.stderr)
                return 1

    # --- Output directory. ---
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # --- Build frame list. ---
    if still_viewpoint is not None:
        frames = [still_viewpoint]
    else:
        frames = build_frame_list(args.mode, args.longitude_step)

    # --- Render-variant -> OpenSCAD -D flag. ---
    # The variant name is passed as -D render="NAME". subprocess is
    # invoked with an argument list (no shell), so the quotation marks
    # around NAME reach OpenSCAD literally and are parsed there as a
    # string-literal expression; no shell quote-stripping occurs.
    extra_args = None
    if args.render_variant is not None:
        extra_args = ["-D", 'render="%s"' % args.render_variant]

    if args.verbose:
        print(f"plan: {len(frames)} frames in mode '{args.mode}', "
              f"img {w}x{h}, dist {args.distance} mm")
        print(f"  openscad: {args.openscad_binary}")
        if args.render_variant is not None:
            print(f"  variant:  {args.render_variant} "
                  f"(-D render=\"{args.render_variant}\")")
        if args.mp4:
            print(f"  ffmpeg:   {args.ffmpeg_binary}")

    # --- Render each frame; track produced paths for aggregation. ---
    if args.jobs > 1:
        print(f"rendering {len(frames)} frames of {args.model_name} into "
              f"{output_dir}/ ({args.jobs} parallel jobs)...")
    else:
        print(f"rendering {len(frames)} frames of {args.model_name} into "
              f"{output_dir}/...")

    # Build the full work list first so frame_paths is in canonical
    # (frame-index) order regardless of completion order under
    # parallelism. Each item is self-contained: a frame is an
    # independent OpenSCAD invocation writing a distinct PNG.
    work = []
    frame_paths = []
    for idx, (lat, lon) in enumerate(frames, start=1):
        eye = spherical_to_cartesian(lat, lon, args.distance)
        center = (0.0, 0.0, 0.0)
        fname = frame_filename(args.model_name, lat, lon, idx)
        output_path = output_dir / fname
        frame_paths.append(output_path)
        work.append((idx, fname, eye, center, output_path))

    def _do_frame(item):
        # Returns (idx, fname) on success; raises on failure. The
        # OpenSCAD call inside render_one_frame blocks in
        # subprocess.run, which releases the GIL, so a thread pool
        # gives real parallelism across cores.
        idx, fname, eye, center, output_path = item
        render_one_frame(
            scad_file=scad_file,
            output_path=output_path,
            eye=eye,
            center=center,
            img_size=(w, h),
            openscad_binary=args.openscad_binary,
            extra_args=extra_args,
            colorscheme=args.colorscheme,
            verbose=args.verbose)
        return (idx, fname)

    n_total = len(work)
    n_done = 0
    if args.jobs > 1:
        # Parallel: dispatch all frames to a bounded thread pool. On
        # the first failure, cancel the rest and report. Progress is
        # reported in completion order (not frame order) since frames
        # finish out of order under parallelism.
        with concurrent.futures.ThreadPoolExecutor(
                max_workers=args.jobs) as pool:
            futures = {pool.submit(_do_frame, item): item for item in work}
            try:
                for fut in concurrent.futures.as_completed(futures):
                    idx, fname = fut.result()
                    n_done += 1
                    if n_done % 10 == 0 or n_done == n_total:
                        print(f"  {n_done}/{n_total} (latest {fname})")
            except RuntimeError as e:
                for f in futures:
                    f.cancel()
                print(f"  a frame FAILED:", file=sys.stderr)
                print(f"  {e}", file=sys.stderr)
                return 2
    else:
        # Sequential (default): preserves the original behaviour and
        # frame-ordered progress reporting exactly.
        for item in work:
            idx, fname = item[0], item[1]
            try:
                _do_frame(item)
            except RuntimeError as e:
                print(f"  frame {idx}/{n_total} ({fname}) FAILED:",
                      file=sys.stderr)
                print(f"  {e}", file=sys.stderr)
                return 2
            if idx % 10 == 0 or idx == n_total:
                print(f"  {idx}/{n_total} ({fname})")

    # --- Optional MP4. ---
    if args.mp4:
        if args.label_frames:
            print(f"aggregating {len(frames)} frames into MP4 via "
                  f"ffmpeg (with per-frame coordinate labels)...")
        else:
            print(f"aggregating {len(frames)} frames into MP4 via "
                  f"ffmpeg...")
        try:
            mp4_path = build_mp4(
                output_dir=output_dir,
                model_name=args.model_name,
                mode=args.mode,
                fps=args.fps,
                ffmpeg_binary=args.ffmpeg_binary,
                label_frames=args.label_frames,
                label_font=args.label_font,
                verbose=args.verbose)
            print(f"  wrote {mp4_path}")
        except RuntimeError as e:
            print(f"MP4 generation FAILED:", file=sys.stderr)
            print(f"  {e}", file=sys.stderr)
            return 3

    # --- Optional tar archive. ---
    if args.tar:
        print(f"aggregating {len(frames)} frames into tar archive...")
        try:
            tar_path = build_tar(
                output_dir=output_dir,
                model_name=args.model_name,
                mode=args.mode,
                frame_files=frame_paths,
                verbose=args.verbose)
            print(f"  wrote {tar_path}")
        except (OSError, RuntimeError) as e:
            print(f"tar generation FAILED:", file=sys.stderr)
            print(f"  {e}", file=sys.stderr)
            return 4

    print(f"done: {len(frames)} frames in {output_dir}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
