Source code for mascaf.visualization

from __future__ import annotations

import argparse
import os
from pathlib import Path
from typing import Any, Sequence, Union

import matplotlib as mpl
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import pyvista as pv
import trimesh
from matplotlib.collections import PolyCollection
from PIL import Image, ImageDraw, ImageFont

MeshLike = Union[str, trimesh.Trimesh, pv.PolyData]

# Matches PyVista's default isometric camera: from (+,+,+) toward the origin.
_DEFAULT_ISO_VIEW_DIR = np.array([1.0, 1.0, 1.0], dtype=float)
_DEFAULT_ISO_VIEW_DIR /= np.linalg.norm(_DEFAULT_ISO_VIEW_DIR)

_WORLD_UP = np.array([0.0, 0.0, 1.0])

# Repo root (parent of ``scripts/``).
_REPO_ROOT = Path(__file__).resolve().parent.parent

_DEFAULT_SIMPLIFIED_MESH_COLOR = "#5d7a99"


def _to_polydata(mesh: MeshLike) -> pv.PolyData:
    """Convert ``mesh`` to ``PolyData`` for plotting."""
    if isinstance(mesh, pv.PolyData):
        return mesh.copy(deep=True)
    if isinstance(mesh, trimesh.Trimesh):
        return pv.wrap(mesh).copy(deep=True)
    if isinstance(mesh, str):
        loaded = trimesh.load(mesh)
        if isinstance(loaded, trimesh.Scene):
            geoms = list(loaded.geometry.values())
            if not geoms:
                raise ValueError(f"No geometry in scene: {mesh}")
            loaded = geoms[0]
        if not isinstance(loaded, trimesh.Trimesh):
            raise TypeError(
                f"Expected a single mesh file, got {type(loaded)} for {mesh}"
            )
        return pv.wrap(loaded).copy(deep=True)
    raise TypeError(f"Unsupported mesh type: {type(mesh)}")


def _subsample_points(pts: np.ndarray, max_points: int) -> np.ndarray:
    n = len(pts)
    if n <= max_points:
        return pts
    rng = np.random.default_rng(0)
    idx = rng.choice(n, size=max_points, replace=False)
    return pts[idx]


def _isometric_target_frame(view_dir: np.ndarray) -> np.ndarray:
    """
    Orthonormal 3×3 ``T`` with columns ``[screen_up, horizontal, toward_camera]``
    matching a default isometric camera with world +Z as view-up hint.

    ``toward_camera`` is a unit vector; ``horizontal`` and ``screen_up`` are
    built so ``{screen_up, horizontal, toward_camera}`` is right-handed.
    """
    v = np.asarray(view_dir, dtype=float)
    v = v / np.linalg.norm(v)
    up_w = _WORLD_UP
    r = np.cross(v, up_w)
    rn = float(np.linalg.norm(r))
    if rn < 1e-12:
        r = np.array([1.0, 0.0, 0.0])
    else:
        r = r / rn
    screen_up = np.cross(r, v)
    sun = float(np.linalg.norm(screen_up))
    if sun < 1e-12:
        return np.eye(3)
    screen_up = screen_up / sun
    t = np.column_stack([screen_up, r, v])
    if float(np.linalg.det(t)) < 0.0:
        t[:, 1] *= -1.0
    return t


def _pca_principal_frame(vertices_centered: np.ndarray) -> np.ndarray | None:
    """
    Columns are orthonormal principal directions for centered vertices, ordered by
    **descending** singular value (row ``vh[0]`` = strongest spread). Returns
    ``None`` if the cloud is nearly spherical (no stable frame).
    """
    pts = _subsample_points(
        np.asarray(vertices_centered, dtype=float), max_points=20_000
    )
    if len(pts) < 3:
        return None
    x = pts - pts.mean(axis=0)
    if np.allclose(x.std(axis=0), 0.0):
        return None
    _, s, vh = np.linalg.svd(x, full_matrices=False)
    s = np.asarray(s, dtype=float)
    if s[0] <= 1e-12 or len(s) < 3:
        return None
    if float(s[2] / s[0]) > 0.999:
        return None
    p = np.column_stack(
        [
            np.asarray(vh[0], dtype=float),
            np.asarray(vh[1], dtype=float),
            np.asarray(vh[2], dtype=float),
        ]
    )
    if float(np.linalg.det(p)) < 0.0:
        p[:, 2] *= -1.0
    return p


def _rotation_principal_axes_to_isometric_screen(
    vertices_centered: np.ndarray,
    view_dir: np.ndarray,
) -> np.ndarray:
    """
    Rotation ``R`` with ``R @ p_i = t_i`` for PCA columns ``p_i`` and target
    isometric basis columns ``t_i`` (up, horizontal, toward camera): ``R = T @ P.T``.
    """
    p = _pca_principal_frame(vertices_centered)
    if p is None:
        return np.eye(3)
    vdir = np.asarray(view_dir, dtype=float)
    vdir = vdir / np.linalg.norm(vdir)
    t = _isometric_target_frame(vdir)
    return t @ p.T


def _prepare_mesh_polydata(
    poly: pv.PolyData,
    *,
    center: bool,
    auto_rotate: bool,
    view_dir: np.ndarray,
) -> tuple[pv.PolyData, float]:
    """
    Center (optional), rotate (optional), return mesh and bounding-sphere radius
    for orthographic framing.
    """
    out = poly.copy(deep=True)
    pts = np.asarray(out.points, dtype=float)
    if pts.size == 0:
        return out, 1.0

    if center:
        c = pts.mean(axis=0)
        pts = pts - c
    else:
        pts = pts.copy()

    if auto_rotate:
        r = _rotation_principal_axes_to_isometric_screen(pts, view_dir)
        pts = (r @ pts.T).T

    out.points = pts
    norms = np.linalg.norm(pts, axis=1)
    radius = float(np.max(norms)) if len(norms) else 1.0
    if radius < 1e-12:
        radius = 1.0
    return out, radius


def _set_isometric_parallel_camera(plotter: pv.Plotter, parallel_scale: float) -> None:
    plotter.enable_parallel_projection()
    plotter.view_isometric()
    plotter.camera.parallel_scale = float(parallel_scale)


def _prepare_meshes_for_grid(
    meshes: Sequence[MeshLike],
    *,
    grid_shape: tuple[int, int],
    center_each: bool,
    auto_rotate_each: bool,
    view_dir: np.ndarray | None,
    parallel_scale_margin: float,
    zoom: float,
) -> tuple[list[pv.PolyData], np.ndarray, float]:
    n_rows, n_cols = int(grid_shape[0]), int(grid_shape[1])
    if n_rows < 1 or n_cols < 1:
        raise ValueError("grid_shape must be positive (n_rows, n_cols)")
    capacity = n_rows * n_cols
    if len(meshes) > capacity:
        raise ValueError(
            f"Need at least {len(meshes)} cells but grid_shape {grid_shape} has capacity {capacity}"
        )

    vdir = np.asarray(
        _DEFAULT_ISO_VIEW_DIR if view_dir is None else view_dir, dtype=float
    )
    vdir = vdir / np.linalg.norm(vdir)

    prepared: list[pv.PolyData] = []
    radii: list[float] = []
    for m in meshes:
        poly = _to_polydata(m)
        p, r = _prepare_mesh_polydata(
            poly,
            center=center_each,
            auto_rotate=auto_rotate_each,
            view_dir=vdir,
        )
        prepared.append(p)
        radii.append(r)

    shared_scale = max(radii) * float(parallel_scale_margin) if radii else 1.0
    zoom_f = float(zoom) if float(zoom) > 1e-12 else 1.0
    parallel_for_camera = shared_scale / zoom_f
    return prepared, vdir, parallel_for_camera


def _tri_faces(poly: pv.PolyData) -> np.ndarray:
    tri = poly.triangulate()
    faces = np.asarray(tri.faces, dtype=np.int64)
    if faces.size == 0:
        return np.empty((0, 3), dtype=np.int64)
    if faces.size % 4 != 0:
        raise ValueError("Unexpected triangulated face layout")
    ff = faces.reshape(-1, 4)
    if not np.all(ff[:, 0] == 3):
        raise ValueError("Expected triangular faces after triangulate()")
    return ff[:, 1:4]


def _draw_vector_mesh_on_axis(
    ax: Any,
    poly: pv.PolyData,
    *,
    color: str,
    view_dir: np.ndarray,
    parallel_scale: float,
) -> None:
    pts = np.asarray(poly.points, dtype=float)
    if pts.size == 0:
        return
    faces = _tri_faces(poly)
    if len(faces) == 0:
        return

    # Same screen basis as camera setup: y=screen_up, x=horizontal, z=toward_camera.
    frame = _isometric_target_frame(view_dir)
    screen_up = frame[:, 0]
    horizontal = frame[:, 1]
    toward_camera = frame[:, 2]

    x = pts @ horizontal
    y = pts @ screen_up
    z = pts @ toward_camera

    # Per-face Lambert-style lighting for vector shading.
    p0 = pts[faces[:, 0]]
    p1 = pts[faces[:, 1]]
    p2 = pts[faces[:, 2]]
    fn = np.cross(p1 - p0, p2 - p0)
    fn_norm = np.linalg.norm(fn, axis=1, keepdims=True)
    fn_norm[fn_norm < 1e-12] = 1.0
    fn = fn / fn_norm

    light_dir = toward_camera + 0.45 * screen_up - 0.25 * horizontal
    light_dir = light_dir / np.linalg.norm(light_dir)
    lambert = np.clip(fn @ light_dir, 0.0, 1.0)
    ambient = 0.35
    diffuse = 0.65
    intensity = ambient + diffuse * lambert

    base_rgb = np.asarray(mcolors.to_rgb(color), dtype=float)
    shaded = np.clip(base_rgb[None, :] * intensity[:, None], 0.0, 1.0)

    depth = np.mean(z[faces], axis=1)
    order = np.argsort(depth)  # far-to-near painter's algorithm
    poly_paths = [np.column_stack([x[f], y[f]]) for f in faces[order]]
    face_colors = shaded[order]

    coll = PolyCollection(
        poly_paths,
        facecolors=face_colors,
        edgecolors="none",
        linewidths=0.0,
        antialiaseds=False,
    )
    ax.add_collection(coll)

    ax.set_aspect("equal", adjustable="box")
    ax.set_ylim(-parallel_scale, parallel_scale)
    xhalf = parallel_scale * float(ax.bbox.width / max(1.0, ax.bbox.height))
    ax.set_xlim(-xhalf, xhalf)
    ax.set_xticks([])
    ax.set_yticks([])
    for spine in ax.spines.values():
        spine.set_visible(False)


def _save_mesh_grid_svg_vector(
    prepared: Sequence[pv.PolyData],
    *,
    grid_shape: tuple[int, int],
    out_path: str | Path,
    parallel_scale: float,
    view_dir: np.ndarray,
    mesh_color: str | None,
    colors: Sequence[str] | None,
    window_size: tuple[int, int] | None,
    background: str,
) -> Path:
    out = Path(out_path)
    if out.suffix.lower() != ".svg":
        raise ValueError(f"SVG output path must end with .svg, got: {out}")
    out.parent.mkdir(parents=True, exist_ok=True)

    n_rows, n_cols = int(grid_shape[0]), int(grid_shape[1])
    if window_size is None:
        base_w, base_h = 320, 320
        window_size = (n_cols * base_w, n_rows * base_h)

    dpi = 100
    fig_w = float(window_size[0]) / dpi
    fig_h = float(window_size[1]) / dpi
    fig, axes = plt.subplots(n_rows, n_cols, figsize=(fig_w, fig_h), dpi=dpi)
    fig.patch.set_facecolor(background)

    if n_rows == 1 and n_cols == 1:
        ax_list = [axes]
    elif n_rows == 1:
        ax_list = list(axes)
    elif n_cols == 1:
        ax_list = list(axes)
    else:
        ax_list = [ax for row in axes for ax in row]

    cmap = colors or ["#8ecae6", "#219ebc", "#023047", "#ffb703", "#fb8500", "#90a955"]
    for idx, ax in enumerate(ax_list):
        ax.set_facecolor(background)
        if idx < len(prepared):
            color = mesh_color if mesh_color is not None else cmap[idx % len(cmap)]
            _draw_vector_mesh_on_axis(
                ax,
                prepared[idx],
                color=color,
                view_dir=view_dir,
                parallel_scale=parallel_scale,
            )
        else:
            ax.set_xticks([])
            ax.set_yticks([])
            for spine in ax.spines.values():
                spine.set_visible(False)

    fig.subplots_adjust(left=0, right=1, top=1, bottom=0, wspace=0, hspace=0)
    fig.savefig(out, format="svg", facecolor=background)
    plt.close(fig)
    return out


def _add_scale_bar_png_bottom_right(
    path: str | Path,
    *,
    parallel_scale: float,
    bar_length_um: float,
    pixels_to_um: float,
    grid_shape: tuple[int, int],
    margin_x: int = 20,
    margin_y: int = 22,
    line_width: int = 3,
    ui_scale: int = 1,
) -> None:
    """
    Draw a horizontal black scale bar and label on a saved PNG (image coordinates).

    Mesh coordinates are in pixels; ``micrometers = pixels * pixels_to_um``.
    Bar extent in mesh units is ``bar_length_um / pixels_to_um``. Orthographic
    vertical span per subplot is ``2 * parallel_scale`` mesh units over the
    subplot pixel height.
    """
    path = Path(path)
    if parallel_scale <= 0 or pixels_to_um <= 0:
        return

    world_len = float(bar_length_um) / float(pixels_to_um)
    n_rows, _n_cols = int(grid_shape[0]), int(grid_shape[1])
    us = max(1, int(ui_scale))

    img = Image.open(path).convert("RGBA")
    w_act, h_act = img.size
    cell_h = float(h_act) / float(n_rows)
    bar_px = world_len * cell_h / (2.0 * float(parallel_scale))
    bar_px = max(8.0 * us, bar_px)

    mx = int(margin_x * us)
    my = int(margin_y * us)
    lw = max(1, int(round(line_width * us)))

    draw = ImageDraw.Draw(img)
    y = h_act - my
    x2 = w_act - mx
    x1 = int(round(x2 - bar_px))
    draw.line([(x1, y), (x2, y)], fill=(0, 0, 0, 255), width=lw)

    label = f"{float(bar_length_um):g} μm"
    font_path = Path(mpl.get_data_path()) / "fonts/ttf/DejaVuSans.ttf"
    try:
        font = ImageFont.truetype(str(font_path), size=max(10, int(round(16 * us))))
    except OSError:
        font = ImageFont.load_default()

    if hasattr(draw, "textbbox"):
        bx0, by0, bx1, by1 = draw.textbbox((0, 0), label, font=font)
        tw, th = bx1 - bx0, by1 - by0
    else:
        tw, th = draw.textsize(label, font=font)
    pad = max(4, int(8 * us))
    text_x = x2 + pad if x2 + pad + tw <= w_act - 4 else max(4, x1 - tw - pad)
    text_y = int(y - th / 2 - 2)
    draw.text((text_x, text_y), label, fill=(0, 0, 0, 255), font=font)

    img.save(path)


[docs] def plot_surface_mesh_grid( meshes: Sequence[MeshLike], grid_shape: tuple[int, int], *, out_path: str | None = None, show: bool = False, center_each: bool = True, auto_rotate_each: bool = True, view_dir: np.ndarray | None = None, parallel_scale_margin: float = 1.08, zoom: float = 1.0, mesh_color: str | None = None, colors: Sequence[str] | None = None, window_size: tuple[int, int] | None = None, screenshot_scale: int | None = None, background: str = "white", off_screen: bool | None = None, scale_bar_um: float | None = None, pixels_to_um: float = 5.0 / 1000.0, ) -> pv.Plotter: """ Plot multiple surface meshes on a regular grid with parallel isometric views. Each cell uses the same orthographic ``parallel_scale``, computed from the largest bounding-sphere radius among prepared meshes, so relative physical size (in the same units) is preserved across panels. Parameters ---------- meshes Sequence of paths, ``trimesh.Trimesh``, or ``pyvista.PolyData``. grid_shape ``(n_rows, n_cols)``. Must satisfy ``n_rows * n_cols >= len(meshes)``. out_path If set, save a raster image (extension chooses format, e.g. ``.png``). show If True, open an interactive window (forces on-screen rendering). center_each Translate each mesh so its vertex centroid is at the origin. auto_rotate_each Apply vertex PCA then map 1st/2nd/3rd principal directions to isometric screen **up**, **horizontal**, and **toward the camera** (see module docstring). Skips rotation for nearly spherical point clouds. view_dir Unit vector from the scene origin toward the camera (default matches PyVista's isometric view: ``(1,1,1) / ||(1,1,1)||``). parallel_scale_margin Multiplier on the shared orthographic half-height after the largest mesh radius is chosen. Values **closer to 1** shrink the empty margin around every panel (meshes look larger and sit visually closer to neighbors). Typical range about ``1.0``–``1.1``; below ``1`` may clip the largest mesh. zoom Values ``> 1`` zoom in (smaller ``parallel_scale``, e.g. ``1.5`` is 1.5×). Combine with a lower ``parallel_scale_margin`` to reduce white space between panel contents. mesh_color If set, every mesh uses this color (overrides ``colors``). colors Optional color per mesh (named colors or hex). Cycles if shorter than ``meshes`` (ignored when ``mesh_color`` is set). window_size ``(width, height)`` in pixels for the whole render window before any ``screenshot_scale`` enlargement. **Increase** (e.g. ``(2400, 2400)``) for more pixels per panel at base scale. screenshot_scale Integer ≥ ``1``. If greater than ``1``, the render **window** is enlarged by this factor (``window_size × scale``) immediately before saving, then a full-size screenshot is taken. This avoids VTK's ``WindowToImageFilter`` scale path, which often leaves the PNG at the original window size on some off-screen / Windows setups. Omitted or ``1`` keeps ``window_size`` as-is. background Matplotlib-like color string for the render window background. off_screen If None, off-screen is used when neither ``show`` nor ``out_path`` is set defaults to False only when ``show`` is True; when saving, off-screen is used automatically on headless setups via PyVista. scale_bar_um If set (and ``out_path`` is set), draw a black horizontal scale bar and label in the bottom-right of the saved image. Mesh units are **pixels**; micrometers = pixels × ``pixels_to_um``. pixels_to_um Conversion from mesh pixel units to micrometers (default ``5/1000``). Returns ------- pyvista.Plotter The plotter (already shown or screenshotted if requested). """ n_rows, n_cols = int(grid_shape[0]), int(grid_shape[1]) prepared, vdir, parallel_for_camera = _prepare_meshes_for_grid( meshes, grid_shape=grid_shape, center_each=center_each, auto_rotate_each=auto_rotate_each, view_dir=view_dir, parallel_scale_margin=parallel_scale_margin, zoom=zoom, ) if window_size is None: base_w, base_h = 320, 320 window_size = (n_cols * base_w, n_rows * base_h) if off_screen is None: off_screen = not show if show: off_screen = False plotter = pv.Plotter( shape=(n_rows, n_cols), off_screen=off_screen, window_size=window_size, border=False, ) plotter.set_background(background) cmap = colors or ["#8ecae6", "#219ebc", "#023047", "#ffb703", "#fb8500", "#90a955"] idx = 0 for r in range(n_rows): for c in range(n_cols): plotter.subplot(r, c) if idx < len(prepared): if mesh_color is not None: color = mesh_color else: color = cmap[idx % len(cmap)] plotter.add_mesh( prepared[idx], color=color, show_edges=False, smooth_shading=True, ) _set_isometric_parallel_camera(plotter, parallel_for_camera) else: _set_isometric_parallel_camera(plotter, 1.0) idx += 1 if out_path: os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) w0, h0 = int(window_size[0]), int(window_size[1]) if screenshot_scale is not None: ss_used = max(1, int(round(float(screenshot_scale)))) else: ss_used = 1 # Enlarge the render window instead of vtkWindowToImageFilter.SetScale: # on several platforms (notably off-screen on Windows) the latter still # writes an image the size of the framebuffer, ignoring scale. if screenshot_scale is not None and ss_used > 1: plotter.window_size = (w0 * ss_used, h0 * ss_used) plotter.render() plotter.screenshot(out_path, return_img=False) if scale_bar_um is not None: ui_scale = ss_used if (screenshot_scale is not None and ss_used > 1) else 1 _add_scale_bar_png_bottom_right( out_path, parallel_scale=parallel_for_camera, bar_length_um=float(scale_bar_um), pixels_to_um=float(pixels_to_um), grid_shape=(n_rows, n_cols), ui_scale=ui_scale, ) if show: plotter.show() return plotter
[docs] def save_surface_meshes_svg( meshes: Sequence[MeshLike], *, out_dir: str | Path, file_stems: Sequence[str] | None = None, file_prefix: str = "mesh", start_index: int = 0, center_each: bool = True, auto_rotate_each: bool = True, view_dir: np.ndarray | None = None, parallel_scale_margin: float = 1.08, zoom: float = 1.0, mesh_color: str | None = None, colors: Sequence[str] | None = None, window_size: tuple[int, int] | None = None, background: str = "white", off_screen: bool = True, ) -> list[Path]: """Save one true-vector SVG file per mesh using the grid camera and framing style. Parameters ---------- meshes : sequence of MeshLike Meshes to render, one SVG file each. out_dir : str or Path Output directory (created if it does not exist). file_stems : sequence of str or None Filenames without ``.svg`` extension, one per mesh. If ``None``, names are generated as ``{file_prefix}_{index:03d}.svg``. file_prefix : str, default "mesh" Prefix used for auto-generated filenames. start_index : int, default 0 Starting index for auto-generated filenames. **kwargs Remaining keyword arguments (``center_each``, ``auto_rotate_each``, ``view_dir``, ``parallel_scale_margin``, ``zoom``, ``mesh_color``, ``colors``, ``window_size``, ``background``, ``off_screen``) are passed through to :func:`plot_surface_mesh_grid` unchanged. Returns ------- list[pathlib.Path] Paths to each written SVG file, in input order. Raises ------ ValueError If ``file_stems`` length does not match ``len(meshes)`` or contains an empty string. """ if file_stems is not None and len(file_stems) != len(meshes): raise ValueError("file_stems must match len(meshes) when provided") out_root = Path(out_dir) out_root.mkdir(parents=True, exist_ok=True) written: list[Path] = [] for idx, mesh in enumerate(meshes): if file_stems is None: stem = f"{file_prefix}_{start_index + idx:03d}" else: stem = str(file_stems[idx]).strip() if not stem: raise ValueError("file_stems entries must be non-empty") out_path = out_root / f"{stem}.svg" prepared, vdir, parallel_for_camera = _prepare_meshes_for_grid( [mesh], grid_shape=(1, 1), center_each=center_each, auto_rotate_each=auto_rotate_each, view_dir=view_dir, parallel_scale_margin=parallel_scale_margin, zoom=zoom, ) written_path = _save_mesh_grid_svg_vector( prepared, grid_shape=(1, 1), out_path=out_path, parallel_scale=parallel_for_camera, view_dir=vdir, mesh_color=mesh_color, colors=colors, window_size=window_size, background=background, ) written.append(written_path) return written
[docs] def save_surface_mesh_grid_svg( meshes: Sequence[MeshLike], grid_shape: tuple[int, int], *, out_path: str | Path, save_individual_meshes: bool = False, individual_out_dir: str | Path | None = None, individual_file_stems: Sequence[str] | None = None, individual_file_prefix: str = "mesh", individual_start_index: int = 0, center_each: bool = True, auto_rotate_each: bool = True, view_dir: np.ndarray | None = None, parallel_scale_margin: float = 1.08, zoom: float = 1.0, mesh_color: str | None = None, colors: Sequence[str] | None = None, window_size: tuple[int, int] | None = None, background: str = "white", off_screen: bool = True, ) -> tuple[Path, list[Path]]: """ Save a mesh-grid figure as a true-vector SVG, with optional per-mesh SVGs. Parameters ---------- meshes : sequence of MeshLike Meshes to render. grid_shape : tuple[int, int] ``(n_rows, n_cols)`` grid layout. out_path : str or Path Destination for the composite grid SVG. save_individual_meshes : bool, default False If ``True``, also save one SVG per mesh. individual_out_dir : str or Path or None Directory for individual SVGs. Defaults to the same directory as *out_path* when ``None``. individual_file_stems : sequence of str or None Filenames (without ``.svg``) for each individual mesh. individual_file_prefix : str, default "mesh" Prefix used when *individual_file_stems* is ``None``. individual_start_index : int, default 0 Starting index for auto-generated filenames. **kwargs Remaining keyword arguments (``center_each``, ``auto_rotate_each``, ``view_dir``, ``parallel_scale_margin``, ``zoom``, ``mesh_color``, ``colors``, ``window_size``, ``background``, ``off_screen``) are passed through to :func:`plot_surface_mesh_grid` unchanged. Returns ------- tuple[pathlib.Path, list[pathlib.Path]] ``(grid_svg_path, individual_svg_paths)`` — the second list is empty unless *save_individual_meshes* is ``True``. """ prepared, vdir, parallel_for_camera = _prepare_meshes_for_grid( meshes, grid_shape=grid_shape, center_each=center_each, auto_rotate_each=auto_rotate_each, view_dir=view_dir, parallel_scale_margin=parallel_scale_margin, zoom=zoom, ) grid_svg = _save_mesh_grid_svg_vector( prepared, grid_shape=grid_shape, out_path=out_path, parallel_scale=parallel_for_camera, view_dir=vdir, mesh_color=mesh_color, colors=colors, window_size=window_size, background=background, ) individual: list[Path] = [] if save_individual_meshes: save_dir = ( Path(individual_out_dir) if individual_out_dir is not None else grid_svg.parent ) individual = save_surface_meshes_svg( meshes, out_dir=save_dir, file_stems=individual_file_stems, file_prefix=individual_file_prefix, start_index=individual_start_index, center_each=center_each, auto_rotate_each=auto_rotate_each, view_dir=view_dir, parallel_scale_margin=parallel_scale_margin, zoom=zoom, mesh_color=mesh_color, colors=colors, window_size=window_size, background=background, off_screen=off_screen, ) return grid_svg, individual