"""
performing_power_sims.py
========================

Reconstructed reference implementation of the Python simulations shown in
"Performing power: a new sensory investigation of the Etruscan painted tomb
at Tarquinia" (Forte & Ortoleva, CIPA 2025 / AIA).

The original deck states the simulations were developed with AI assistance
(GPT-5 Thinking) and run in Python 3.11.8 using NumPy / SciPy / Matplotlib.
The .pptx contained the *figures* only (no source), so this file
reconstructs each figure's logic from the stated stack. It deliberately
uses NOTHING beyond numpy / scipy / matplotlib.

IMPORTANT — interpretive status:
    The saliency, affordance and "acoustic/visual field" maps below are
    HEURISTIC, ILLUSTRATIVE models — hand-parameterised scalar fields and
    relative (0-10) scores. They are NOT measurements of human attention or
    brain activity, and they do not perform a physical acoustic simulation.
    The hard acoustic figures cited in the talk (RT60, AlCons, 125 Hz) come
    from the associated acoustic study, not from this code.

Run:
    python performing_power_sims.py          # writes PNGs to ./figures/
    python performing_power_sims.py mesh.npy  # use a real (N,3) point cloud

Each figure is produced by its own function so you can import and reuse them.
"""

from __future__ import annotations
import os
import sys

import numpy as np
from scipy.spatial import cKDTree
from scipy.ndimage import gaussian_filter

import matplotlib
matplotlib.use("Agg")          # headless; remove for interactive use
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from mpl_toolkits.mplot3d import Axes3D  # noqa: F401  (enables 3d projection)

RNG = np.random.default_rng(42)
OUTDIR = "figures"


# ---------------------------------------------------------------------------
# Core analytic helpers
# ---------------------------------------------------------------------------
def surface_variation_saliency(points: np.ndarray, k: int = 24) -> np.ndarray:
    """Per-point curvature 'saliency' via local covariance eigen-analysis.

    For each point we take its k nearest neighbours (scipy.spatial.cKDTree),
    form the 3x3 covariance of the neighbourhood, and compute the surface
    variation  sigma = lambda0 / (lambda0 + lambda1 + lambda2)  with
    lambda0 <= lambda1 <= lambda2 (Pauly et al. 2002). High sigma == high
    local curvature (edges, carved relief). Returned normalised to [0, 1].
    """
    tree = cKDTree(points)
    _, idx = tree.query(points, k=k)
    sigma = np.empty(len(points))
    for i, nb in enumerate(idx):
        cov = np.cov(points[nb].T)
        ev = np.linalg.eigvalsh(cov)          # ascending
        ev = np.clip(ev, 0, None)
        s = ev.sum()
        sigma[i] = ev[0] / s if s > 0 else 0.0
    lo, hi = np.percentile(sigma, [2, 98])    # robust normalisation
    return np.clip((sigma - lo) / (hi - lo + 1e-12), 0, 1)


def gaussian_field(nx: int, nz: int, peaks, smooth: float = 0.0) -> np.ndarray:
    """Build a scalar field as a sum of 2-D Gaussian 'attention' blobs.

    peaks: iterable of (cx, cz, amplitude, sigma) in grid coordinates.
    Optionally smoothed with scipy.ndimage.gaussian_filter.
    """
    zz, xx = np.mgrid[0:nz, 0:nx]
    field = np.zeros((nz, nx), float)
    for cx, cz, amp, sig in peaks:
        field += amp * np.exp(-(((xx - cx) ** 2 + (zz - cz) ** 2) / (2 * sig ** 2)))
    if smooth:
        field = gaussian_filter(field, smooth)
    return field


def synth_tomb_pointcloud(n: int = 60000) -> np.ndarray:
    """Synthesise a Tomba-del-Gallo-like cloud: a long dromos + a chamber
    with two niches. Used as a fallback when no real point cloud is given so
    the script runs out of the box. Replace by loading your photogrammetric
    model (see load_pointcloud)."""
    pts = []
    # dromos: narrow corridor along -X
    m = int(n * 0.35)
    x = RNG.uniform(-11, -4, m)
    y = RNG.uniform(0.0, 1.6, m)
    z = RNG.uniform(23.1, 23.9, m)
    wall = RNG.integers(0, 3, m)
    y = np.where(wall == 0, 0.0, y)            # floor
    y = np.where(wall == 1, 1.6, y)            # ceiling
    pts.append(np.c_[x, y, z])
    # chamber: box around X in [-4, -1]
    m = n - m
    x = RNG.uniform(-4, -0.5, m)
    y = RNG.uniform(0.0, 2.0, m)
    z = RNG.uniform(22.6, 24.4, m)
    pts.append(np.c_[x, y, z])
    P = np.vstack(pts)
    P += RNG.normal(0, 0.01, P.shape)          # surface noise -> curvature
    return P


def load_pointcloud(path: str | None) -> np.ndarray:
    """Load a real (N,3) cloud from .npy / .csv / .xyz, else synthesise.
    (PLY/OBJ meshes would need trimesh/open3d, intentionally not used here.)"""
    if path is None:
        print("[info] no point cloud supplied -> using synthetic demo cloud")
        return synth_tomb_pointcloud()
    if path.endswith(".npy"):
        P = np.load(path)
    else:
        P = np.loadtxt(path, delimiter="," if path.endswith(".csv") else None)
    return np.asarray(P, float)[:, :3]


# ---------------------------------------------------------------------------
# Figures
# ---------------------------------------------------------------------------
def fig_pointcloud_saliency_3d(points, sal, fname="01_pointcloud_saliency_3d.png"):
    """Slide 4 — 3D 'Perceptual Saliency Map' over the tomb point cloud."""
    fig = plt.figure(figsize=(7, 5))
    ax = fig.add_subplot(111, projection="3d")
    p = ax.scatter(points[:, 0], points[:, 2], points[:, 1],
                   c=sal, cmap="viridis", s=1.5, alpha=0.7, linewidths=0)
    ax.set_xlabel("X"); ax.set_ylabel("Z"); ax.set_zlabel("Y")
    ax.set_title("Perceptual Saliency Map \u2014 Tomba del Gallo")
    cb = fig.colorbar(p, ax=ax, shrink=0.6, pad=0.1); cb.set_label("Perceptual Saliency")
    ax.view_init(elev=22, azim=-60)
    _save(fig, fname)


def fig_affordance_map(fname="02_affordance_map.png"):
    """Slide 5 — affordance scatter: marker=type, size=saliency, alpha=confidence."""
    types = {"Bench/Ledge": ("^", "#E8A33D"),
             "Focal Panel": ("s", "#E0552B"),
             "Niche": ("D", "#C0392B"),
             "Threshold/Bottleneck": ("o", "#E84393")}
    fig, ax = plt.subplots(figsize=(6.6, 5.4))
    for name, (mk, col) in types.items():
        nx = RNG.integers(4, 7)
        x = RNG.uniform(-11, -4, nx); z = RNG.uniform(23.1, 23.9, nx)
        sal = RNG.uniform(0.3, 1.0, nx); conf = RNG.uniform(0.4, 1.0, nx)
        ax.scatter(x, z, s=120 * sal + 30, c=col, marker=mk,
                   alpha=0.85, edgecolors="none")
        # opacity encodes confidence (drawn as a second, fainter pass)
        ax.scatter(x, z, s=120 * sal + 30, c=col, marker=mk,
                   alpha=0.25 + 0.5 * conf.mean(), edgecolors="none")
    handles = [Line2D([0], [0], marker=mk, color="w", markerfacecolor=col,
                      markersize=10, label=name) for name, (mk, col) in types.items()]
    ax.legend(handles=handles, title="Affordance Type", loc="center left", fontsize=8)
    ax.set_xlabel("X (m)"); ax.set_ylabel("Z (m)")
    ax.set_title("Tomba del Gallo \u2014 Affordances (Altar removed)\n"
                 "Bubble Size = Mean Saliency, Opacity = Confidence, Marker = Type",
                 fontsize=10)
    _save(fig, fname)


def fig_movement_path_saliency(fname="03_movement_path_saliency.png"):
    """Slide 6 — movement path colour-coded by perceptual saliency."""
    t = np.linspace(0, 1, 240)
    x = -11 + 10.5 * t + 0.3 * np.sin(8 * t)
    z = 23.5 + 0.25 * np.sin(3 * t)
    sal = 0.2 + 0.8 * np.clip(np.exp(-((t - 0.55) ** 2) / 0.01) +
                              0.6 * np.exp(-((t - 0.85) ** 2) / 0.02), 0, 1)
    fig, ax = plt.subplots(figsize=(6.8, 4.4))
    sc = ax.scatter(x, z, c=sal, cmap="plasma", s=18)
    ax.plot(x, z, color="0.6", lw=0.6, zorder=0)
    fig.colorbar(sc, ax=ax, label="Perceptual saliency")
    ax.set_xlabel("X (m)"); ax.set_ylabel("Z (m)")
    ax.set_title("Movement path: dromos approach \u2192 central chamber")
    _save(fig, fname)


def fig_plan_saliency_heatmap(fname="04_plan_saliency_heatmap.png"):
    """Slide 7a — plan saliency heatmap with threshold/bottleneck overlay."""
    nx, nz = 200, 120
    field = gaussian_field(nx, nz,
                           [(120, 60, 1.0, 10), (150, 62, 0.8, 8),
                            (90, 58, 0.6, 7)], smooth=2)
    fig, ax = plt.subplots(figsize=(6.8, 3.4))
    ax.imshow(field, origin="lower", cmap="magma", aspect="auto",
              extent=[-11, -1, 22.0, 24.5])
    bx = RNG.uniform(-7, -4, 22); bz = 23.4 + RNG.normal(0, 0.05, 22)
    ax.scatter(bx, bz, marker="x", c="cyan", s=18, linewidths=1)
    ax.set_xlabel("X"); ax.set_ylabel("Z")
    ax.set_title("Plan Saliency Heatmap with Threshold/Bottleneck Overlay", fontsize=10)
    _save(fig, fname)


def fig_movement_profile(fname="05_movement_profile.png"):
    """Slide 7b — corridor width vs mean saliency (twin-axis)."""
    s = np.linspace(-3, 6, 200)
    width = 1.6 + 0.6 * np.sin(2.2 * s) + 0.3 * RNG.normal(0, 1, s.size)
    width = np.clip(width, 0.7, 3.0)
    # robust smoothing via rolling median (numpy/percentile)
    w_robust = np.array([np.median(width[max(0, i - 4):i + 5]) for i in range(len(s))])
    sal = 0.12 + 0.18 * np.clip(np.exp(-((s - 0) ** 2) / 1.5), 0, 1) \
        + 0.10 * np.clip(np.exp(-((s - 3.5) ** 2) / 1.0), 0, 1)
    fig, ax1 = plt.subplots(figsize=(6.8, 4.0))
    ax1.plot(s, w_robust, color="#E8A33D", lw=2, label="width")
    ax1.set_xlabel("Distance along movement axis (arbitrary units)")
    ax1.set_ylabel("Cross-section width (planar, robust)")
    ax2 = ax1.twinx()
    ax2.plot(s, sal, color="#E0552B", lw=1.6, ls="--", label="saliency")
    ax2.set_ylabel("Mean saliency")
    ax1.set_title("Movement Profile \u2014 Corridor Width vs Mean Saliency", fontsize=10)
    _save(fig, fname)


def fig_iconography_ranking(fname="06_iconography_ranking.png"):
    """Slide 7c — iconography affordances ranked by mean saliency."""
    labels = ["Banquet", "Procession", "Threshold", "Hunt"]
    vals = [0.082, 0.061, 0.049, 0.033]
    fig, ax = plt.subplots(figsize=(5.2, 4.0))
    ax.bar(labels, vals, color="#E8A33D")
    ax.set_ylabel("Mean Perceptual Saliency (0\u20131)")
    ax.set_title("Iconography Affordances \u2014 Ranking by Mean Saliency", fontsize=10)
    _save(fig, fname)


def fig_wall_saliency_heatmaps(fname="07_wall_saliency_heatmaps.png"):
    """Slide 9 — four per-wall saliency heat maps (Demoni Azzurri)."""
    nx, nz = 200, 130
    walls = {
        "1. Left Wall: Procession": [(55, 75, 1.0, 16), (120, 75, 0.8, 16)],
        "2. Right Wall: The Underworld": [(60, 70, 0.6, 14), (155, 80, 1.0, 18)],
        "3. Rear Wall: The Banquet": [(60, 70, 0.9, 18), (110, 70, 0.9, 18),
                                      (155, 70, 0.9, 18)],
        "4. Entrance Wall: The Hunt": [(60, 80, 0.7, 14), (150, 60, 0.9, 16)],
    }
    fig, axes = plt.subplots(2, 2, figsize=(8.2, 5.4))
    for ax, (title, peaks) in zip(axes.ravel(), walls.items()):
        f = gaussian_field(nx, nz, peaks, smooth=4)
        ax.imshow(f, origin="lower", cmap="inferno", aspect="auto",
                  extent=[0, 5, 0, 2.6])
        ax.set_title(title, fontsize=8)
        ax.set_xlabel("Meters"); ax.set_ylabel("Meters")
    fig.tight_layout()
    _save(fig, fname)


def fig_acoustic_visual_fields(fname="08_acoustic_visual_fields.png"):
    """Slide 11 — acoustic intensity field vs visual saliency/shock field."""
    nx, nz = 100, 100
    zz, xx = np.mgrid[0:nz, 0:nx]
    acoustic = (zz / nz) ** 1.3 * 10           # rumble builds toward rear (low z=entrance)
    acoustic = gaussian_filter(acoustic, 2)
    visual = gaussian_field(nx, nz,
                            [(20, 50, 4, 14),   # procession (guide)
                             (85, 78, 10, 12),  # the demon (visual trap)
                             (50, 12, 5, 16)],  # banquet (goal)
                            smooth=2)
    fig, (a, b) = plt.subplots(1, 2, figsize=(9.5, 4.2))
    im0 = a.imshow(acoustic, origin="lower", cmap="magma", aspect="auto",
                   extent=[0, 96, 0, 96])
    a.set_title("Acoustic Intensity Field\n(the 'rumble' builds as you go deeper)",
                fontsize=9)
    a.set_xlabel("Width (Left to Right)"); a.set_ylabel("Depth (Entrance to Rear)")
    fig.colorbar(im0, ax=a, label="Intensity")
    im1 = b.imshow(visual, origin="lower", cmap="viridis", aspect="auto",
                   extent=[0, 96, 0, 96])
    b.set_title("Visual Saliency / Shock Field\n(where the eye is trapped)", fontsize=9)
    b.set_xlabel("Width (Left to Right)"); b.set_ylabel("Depth (Entrance to Rear)")
    fig.colorbar(im1, ax=b, label="Shock Value")
    fig.tight_layout()
    _save(fig, fname)


def fig_performing_power(fname="09_performing_power_comparison.png"):
    """Slide 13 — comparative performing power (relative, heuristic scores)."""
    groups = ["Acoustic Power\n(Reverberation & Immersion)",
              "Visual Power\n(Saliency & Architectural Staging)"]
    demoni = [9.4, 8.5]      # Tomba dei Demoni Azzurri
    gallo = [2.0, 8.0]       # Tomba del Gallo
    x = np.arange(len(groups)); w = 0.38
    fig, ax = plt.subplots(figsize=(7.6, 4.6))
    ax.bar(x - w / 2, demoni, w, label="Tomba dei Demoni Azzurri", color="#5B8FB9")
    ax.bar(x + w / 2, gallo, w, label="Tomba del Gallo", color="#D08A37")
    ax.set_xticks(x); ax.set_xticklabels(groups)
    ax.set_ylabel("Relative Performing Intensity (0\u201310)")
    ax.set_ylim(0, 10.5)
    ax.set_title("Comparative Performing Power: Acoustic vs. Visual Modalities",
                 fontsize=10)
    ax.legend()
    _save(fig, fname)


# ---------------------------------------------------------------------------
def _save(fig, fname):
    os.makedirs(OUTDIR, exist_ok=True)
    path = os.path.join(OUTDIR, fname)
    fig.savefig(path, dpi=130, bbox_inches="tight")
    plt.close(fig)
    print(f"[saved] {path}")


def main():
    cloud_path = sys.argv[1] if len(sys.argv) > 1 else None
    print(f"Python {sys.version.split()[0]} | "
          f"NumPy {np.__version__} | SciPy "
          f"{__import__('scipy').__version__} | "
          f"Matplotlib {matplotlib.__version__}")
    pts = load_pointcloud(cloud_path)
    print(f"[info] point cloud: {pts.shape[0]} points; computing curvature saliency...")
    sal = surface_variation_saliency(pts, k=24)

    fig_pointcloud_saliency_3d(pts, sal)
    fig_affordance_map()
    fig_movement_path_saliency()
    fig_plan_saliency_heatmap()
    fig_movement_profile()
    fig_iconography_ranking()
    fig_wall_saliency_heatmaps()
    fig_acoustic_visual_fields()
    fig_performing_power()
    print(f"[done] all figures written to ./{OUTDIR}/")


if __name__ == "__main__":
    main()
