shrike/artkit.py¶

Part of SHRIKE.

   1"""Procedural art kit for SHRIKE: every mesh, material, sky and grade is generated.
   2
   3No asset file ships with the game. Hulls, enemies, stations, wrecks and rocks
   4are kitbashed from engine primitives, CSG cuts, swept extrusions and noise
   5displacement; materials are metallic-roughness with the emissive accents that
   6carry the readability contract; the sky is a synthesized nebula cubemap that
   7also drives image-based lighting; and the per-act look is a grade applied to a
   8``WorldEnvironment``.
   9
  10Nothing here needs a GPU or a window: every entry point produces plain CPU data
  11and ordinary scene nodes, so the whole kit constructs headlessly.
  12
  13Conventions
  14===========
  15
  16* Ships and enemies are built nose-along local **+X**, matching
  17  :func:`runtime.heading_to_direction` at heading 0, so yawing a node about +Y
  18  by its heading aims it where the gameplay code says it points.
  19* Y is up. A build is centred on its own origin, so a node sitting at
  20  ``y = runtime.PLANE_Y`` puts the hull's waist on the flight plane.
  21* Every build returns a :class:`Node3D` carrying at most three
  22  :class:`MeshInstance3D` children, named :data:`ROLE_HULL`, :data:`ROLE_TRIM`
  23  and :data:`ROLE_ACCENT`. ``Accent`` holds the emissive geometry;
  24  :func:`apply_faction_materials` re-skins all three from one palette.
  25
  26The readability contract
  27========================
  28
  29Ownership reads off emissive colour against low-albedo surroundings: player
  30warm gold and white, enemy cold magenta, telegraphs pure white, environments
  31near-black. :data:`PALETTES` is the single place those three values live, and
  32:func:`apply_faction_materials` is the only way geometry acquires them.
  33"""
  34
  35from __future__ import annotations
  36
  37import math
  38from dataclasses import dataclass
  39from functools import cache
  40
  41import numpy as np
  42
  43from simvx.core import (
  44    CSGOperation,
  45    FastNoiseLite,
  46    FractalType,
  47    Material,
  48    Mesh,
  49    MeshInstance3D,
  50    Node3D,
  51    NoiseType,
  52    Quat,
  53    Vec3,
  54    WorldEnvironment,
  55    csg_combine,
  56)
  57from simvx.core.math.matrices import quat_to_mat4
  58
  59from . import balance
  60from .runtime import PLANE_Y
  61
  62# ============================================================================
  63# Module-local constants
  64#
  65# None of these is a balance number: they are proportions and render settings,
  66# which balance.py deliberately does not carry.
  67# ============================================================================
  68
  69#: Mesh instance names, one per material role in a build.
  70ROLE_HULL = "Hull"
  71ROLE_TRIM = "Trim"
  72ROLE_ACCENT = "Accent"
  73ROLES = (ROLE_HULL, ROLE_TRIM, ROLE_ACCENT)
  74
  75#: Every faction :func:`apply_faction_materials` accepts.
  76FACTIONS = ("player", "enemy", "hunter", "environment", "telegraph")
  77
  78#: Per-face resolution of the synthesized nebula cubemap. A face covers 90
  79#: degrees, so the face size sets how small a star can be: at 256 one texel
  80#: spanned a third of a degree, which is five screen pixels, and a star stamped
  81#: into a single texel came out as a hard white square the size of a die. 512
  82#: halves that, and the soft stamp below spreads a star over it.
  83NEBULA_FACE_SIZE = 512
  84
  85#: Resolution the cloud noise is actually evaluated at, before it is resampled
  86#: up to the face size. Clouds are soft and dim, so they cost the same few
  87#: milliseconds they always did however sharp the star layer gets.
  88NEBULA_CLOUD_SIZE = 64
  89
  90#: How bright the cloud layer is allowed to get. The readability contract wants
  91#: a near-black sky so emissive accents own the screen; the raw noise is graded
  92#: for shape, and this is what keeps it a backdrop rather than a light box.
  93NEBULA_CLOUD_ENERGY = 0.5
  94
  95#: Stars stamped into one cube face at :data:`NEBULA_FACE_SIZE`, scaled by area
  96#: for any other bake size, and the energy band one star peaks at. Only the top of the band clears the bloom threshold,
  97#: so a sky has a handful of bright stars in a field of faint ones rather than
  98#: several hundred lamps.
  99STARS_PER_FACE = 70
 100STAR_ENERGY_MIN = 0.35
 101STAR_ENERGY_MAX = 2.4
 102#: However small a face is baked, it gets at least this many stars: a 16-pixel
 103#: test bake must still contain a sky.
 104STAR_MIN_PER_FACE = 3
 105#: A star is a Gaussian splat, not a texel: this is its standard deviation in
 106#: texels and the radius, in texels, the splat is evaluated out to.
 107STAR_SIGMA = 0.85
 108STAR_KERNEL_RADIUS = 2
 109#: How far a star's hue is pushed from white toward warm or cold. Real skies
 110#: are not monochrome, and the variation is what stops a field of dots reading
 111#: as a texture artefact.
 112STAR_TINT = 0.16
 113
 114#: Silent running drops the whole scene's exposure by this factor.
 115SILENT_RUNNING_EXPOSURE_MULT = 0.65
 116
 117#: Body segments the Shrike is built from (head plus tail sections).
 118SHRIKE_SEGMENT_COUNT = 7
 119
 120# Cube face bases in Vulkan order [+X, -X, +Y, -Y, +Z, -Z]: (right, up, forward).
 121_FACE_BASIS = (
 122    ((0.0, 0.0, -1.0), (0.0, -1.0, 0.0), (1.0, 0.0, 0.0)),
 123    ((0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (-1.0, 0.0, 0.0)),
 124    ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0)),
 125    ((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, -1.0, 0.0)),
 126    ((1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
 127    ((-1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
 128)
 129
 130# Quaternions that reorient a +Y primitive (cylinder, cone) onto another axis.
 131_Y_TO_X = Quat.from_axis_angle(Vec3(0.0, 0.0, 1.0), -math.pi / 2.0)
 132_Y_TO_Z = Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), math.pi / 2.0)
 133_Y_TO_NEG_Z = Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), -math.pi / 2.0)
 134_Y_TO_NEG_X = Quat.from_axis_angle(Vec3(0.0, 0.0, 1.0), math.pi / 2.0)
 135
 136
 137# ============================================================================
 138# Kitbash builder
 139# ============================================================================
 140
 141
 142class Kit:
 143    """Accumulates transformed primitives into one merged :class:`Mesh`.
 144
 145    This is the kitbash itself: a build places scaled, rotated copies of a
 146    handful of shared unit primitives and ends with a single mesh, so a whole
 147    archetype costs one draw rather than one per greeble.
 148
 149    Mirrored parts are ordinary: a negative scale factor flips the winding and
 150    the normals back, so ``scale=(1, 1, -1)`` gives a correct opposite wing.
 151    """
 152
 153    def __init__(self) -> None:
 154        self._positions: list[np.ndarray] = []
 155        self._normals: list[np.ndarray] = []
 156        self._texcoords: list[np.ndarray] = []
 157        self._indices: list[np.ndarray] = []
 158        self._vertex_count = 0
 159
 160    @property
 161    def empty(self) -> bool:
 162        return self._vertex_count == 0
 163
 164    def add(
 165        self,
 166        mesh: Mesh,
 167        *,
 168        position: tuple[float, float, float] = (0.0, 0.0, 0.0),
 169        rotation: Quat | None = None,
 170        scale: float | tuple[float, float, float] = 1.0,
 171    ) -> Kit:
 172        """Place a transformed copy of *mesh*. The source is never mutated."""
 173        if mesh.indices is None:
 174            raise ValueError("Kit.add requires an indexed mesh")
 175        factors = np.asarray(scale if isinstance(scale, (tuple, list, np.ndarray)) else (scale,) * 3, dtype=np.float32)
 176        positions = mesh.positions * factors
 177        normals = mesh.normals if mesh.normals is not None else np.zeros_like(mesh.positions)
 178        # Normals transform by the inverse transpose, which for a diagonal
 179        # scale is the reciprocal; renormalised below.
 180        normals = normals / np.where(np.abs(factors) < 1e-9, 1e-9, factors)
 181        if rotation is not None:
 182            rot = np.asarray(quat_to_mat4(rotation), dtype=np.float32)[:3, :3]
 183            positions = positions @ rot.T
 184            normals = normals @ rot.T
 185        positions = positions + np.asarray(position, dtype=np.float32)
 186        lengths = np.linalg.norm(normals, axis=1, keepdims=True)
 187        normals = np.divide(normals, lengths, where=lengths > 1e-9, out=np.zeros_like(normals))
 188
 189        texcoords = mesh.texcoords if mesh.texcoords is not None else np.zeros((len(positions), 2), dtype=np.float32)
 190        triangles = mesh.indices.reshape(-1, 3) + self._vertex_count
 191        if float(np.prod(factors)) < 0.0:
 192            # A mirroring scale turns every triangle inside out. The reciprocal
 193            # above has already flipped the normals; the winding needs the same.
 194            triangles = triangles[:, ::-1]
 195
 196        self._positions.append(positions.astype(np.float32))
 197        self._normals.append(normals.astype(np.float32))
 198        self._texcoords.append(np.asarray(texcoords, dtype=np.float32))
 199        self._indices.append(triangles.astype(np.uint32))
 200        self._vertex_count += len(positions)
 201        return self
 202
 203    def build(self) -> Mesh:
 204        """Merge everything placed so far into one mesh."""
 205        if self.empty:
 206            raise ValueError("Kit.build called with nothing placed")
 207        return Mesh(
 208            np.concatenate(self._positions),
 209            np.concatenate(self._indices).ravel(),
 210            np.concatenate(self._normals),
 211            np.concatenate(self._texcoords),
 212        )
 213
 214
 215@cache
 216def _unit_cube() -> Mesh:
 217    return Mesh.cube(1.0)
 218
 219
 220@cache
 221def _unit_cylinder(segments: int = 12) -> Mesh:
 222    return Mesh.cylinder(radius=0.5, height=1.0, segments=segments)
 223
 224
 225@cache
 226def _unit_cone(segments: int = 10) -> Mesh:
 227    return Mesh.cone(radius=0.5, height=1.0, segments=segments)
 228
 229
 230@cache
 231def _unit_sphere(rings: int = 8, segments: int = 12) -> Mesh:
 232    return Mesh.sphere(radius=0.5, rings=rings, segments=segments)
 233
 234
 235def _greeble(kit: Kit, rng: np.random.Generator, *, span: tuple[float, float, float], count: int, size: float) -> None:
 236    """Scatter small plated boxes over a body of the given half-extents.
 237
 238    Greebling is what makes one kit read as a whole faction: the same hull with
 239    a different scatter seed is a different ship at a glance.
 240    """
 241    for _ in range(count):
 242        along = float(rng.uniform(-span[0], span[0]))
 243        side = float(rng.uniform(-span[2], span[2]))
 244        top = span[1] * float(rng.choice([-1.0, 1.0])) * float(rng.uniform(0.75, 1.0))
 245        block = (
 246            size * float(rng.uniform(0.6, 2.2)),
 247            size * float(rng.uniform(0.3, 0.8)),
 248            size * float(rng.uniform(0.6, 1.8)),
 249        )
 250        kit.add(
 251            _unit_cube(),
 252            position=(along, top, side),
 253            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(rng.uniform(0.0, math.tau))),
 254            scale=block,
 255        )
 256
 257
 258def _displaced_rock(seed: int, radius: float, *, relief: float = 0.34, rings: int = 12, segments: int = 16) -> Mesh:
 259    """A sphere pushed around by fractal noise: the asteroid and deposit body.
 260
 261    The UV sphere's seam and pole vertices are coincident, so sampling the
 262    noise in object space displaces them identically and the surface stays
 263    watertight.
 264    """
 265    mesh = Mesh.sphere(radius=radius, rings=rings, segments=segments)
 266    noise = FastNoiseLite(seed=int(seed), noise_type=NoiseType.SIMPLEX, frequency=1.05 / max(radius, 1e-3))
 267    noise.fractal_type = FractalType.FBM
 268    noise.fractal_octaves = 4
 269    points = mesh.positions
 270    amount = noise.get_noise_3d_array(points[:, 0], points[:, 1], points[:, 2])
 271    mesh.positions = (points * (1.0 + amount[:, None] * relief)).astype(np.float32)
 272    # The mesh is no longer the primitive its factory spec claims.
 273    mesh.factory_spec = None
 274    mesh.generate_normals()
 275    # A UV sphere's pole fan is degenerate, so face-averaged normals there come
 276    # out zero. The body stays star-shaped under displacement, so the radial
 277    # direction is the right normal for exactly those vertices.
 278    radius_per_vertex = np.linalg.norm(mesh.positions, axis=1, keepdims=True)
 279    radial = np.divide(
 280        mesh.positions,
 281        radius_per_vertex,
 282        where=radius_per_vertex > 1e-9,
 283        out=np.zeros_like(mesh.positions),
 284    )
 285    degenerate = np.linalg.norm(mesh.normals, axis=1) < 1e-6
 286    mesh.normals[degenerate] = radial[degenerate]
 287    return mesh
 288
 289
 290def _mesh_instance(name: str, mesh: Mesh, material: Material) -> MeshInstance3D:
 291    return MeshInstance3D(name=name, mesh=mesh, material=material)
 292
 293
 294# ============================================================================
 295# Materials: the readability contract
 296# ============================================================================
 297
 298
 299@dataclass(frozen=True)
 300class FactionPalette:
 301    """One faction's metallic-roughness look plus its emissive signature.
 302
 303    ``accent`` and ``accent_strength`` are the readability contract: they are
 304    what the player reads at a glance through two hundred particles, so they
 305    are deliberately few and far apart in hue.
 306    """
 307
 308    id: str
 309    hull_colour: tuple[float, float, float, float]
 310    hull_metallic: float
 311    hull_roughness: float
 312    trim_colour: tuple[float, float, float, float]
 313    trim_metallic: float
 314    trim_roughness: float
 315    accent: tuple[float, float, float]
 316    accent_strength: float
 317    #: Faint hull self-light, as an emissive strength on the hull's own colour.
 318    #: Zero for everything that is allowed to read as a silhouette. A highly
 319    #: metallic hull under a dark skybox reflects mostly nothing, and a blind
 320    #: playtest read the flown ship as a black cutout against the bright acts'
 321    #: nebulae; the glow floors the hull's brightness without touching the
 322    #: emissive accents that own the readability contract, and it sits far
 323    #: under the bloom threshold.
 324    hull_glow: float = 0.0
 325
 326
 327#: Player warm gold and white, enemy cold magenta, telegraph white, hunter a
 328#: bruised violet, environments near-black so all four still win.
 329PALETTES: dict[str, FactionPalette] = {
 330    "player": FactionPalette(
 331        "player",
 332        hull_colour=(0.60, 0.62, 0.66, 1.0),
 333        hull_metallic=0.62,
 334        hull_roughness=0.42,
 335        trim_colour=(0.24, 0.25, 0.28, 1.0),
 336        trim_metallic=0.70,
 337        trim_roughness=0.52,
 338        accent=(1.0, 0.80, 0.42),
 339        accent_strength=5.0,
 340        hull_glow=0.30,
 341    ),
 342    "enemy": FactionPalette(
 343        "enemy",
 344        hull_colour=(0.26, 0.23, 0.30, 1.0),
 345        hull_metallic=0.72,
 346        hull_roughness=0.48,
 347        trim_colour=(0.14, 0.12, 0.17, 1.0),
 348        trim_metallic=0.55,
 349        trim_roughness=0.68,
 350        accent=(1.0, 0.10, 0.72),
 351        accent_strength=5.0,
 352    ),
 353    "hunter": FactionPalette(
 354        "hunter",
 355        hull_colour=(0.10, 0.09, 0.13, 1.0),
 356        hull_metallic=0.48,
 357        hull_roughness=0.72,
 358        trim_colour=(0.05, 0.05, 0.07, 1.0),
 359        trim_metallic=0.40,
 360        trim_roughness=0.82,
 361        accent=(0.86, 0.62, 1.0),
 362        accent_strength=6.5,
 363    ),
 364    "environment": FactionPalette(
 365        "environment",
 366        hull_colour=(0.045, 0.048, 0.055, 1.0),
 367        hull_metallic=0.12,
 368        hull_roughness=0.88,
 369        trim_colour=(0.030, 0.032, 0.038, 1.0),
 370        trim_metallic=0.30,
 371        trim_roughness=0.74,
 372        accent=(0.95, 0.55, 0.20),
 373        accent_strength=2.0,
 374    ),
 375    "telegraph": FactionPalette(
 376        "telegraph",
 377        hull_colour=(0.90, 0.90, 0.90, 1.0),
 378        hull_metallic=0.0,
 379        hull_roughness=1.0,
 380        trim_colour=(0.90, 0.90, 0.90, 1.0),
 381        trim_metallic=0.0,
 382        trim_roughness=1.0,
 383        accent=(1.0, 1.0, 1.0),
 384        accent_strength=8.0,
 385    ),
 386}
 387
 388#: Ceiling on any environment albedo channel. Emissive readability only works
 389#: against surroundings that stay dark, so this is asserted, not hoped for.
 390ENVIRONMENT_ALBEDO_CEILING = 0.12
 391
 392
 393def palette(faction: str) -> FactionPalette:
 394    """The palette for *faction*, raising on anything outside :data:`FACTIONS`."""
 395    try:
 396        return PALETTES[faction]
 397    except KeyError:
 398        raise ValueError(f"Unknown faction {faction!r}; expected one of {', '.join(FACTIONS)}") from None
 399
 400
 401def faction_material(faction: str, role: str = ROLE_HULL) -> Material:
 402    """A fresh material for one *role* of one *faction*.
 403
 404    Fresh rather than shared: consumers flash accents white for telegraphs and
 405    tint hulls for elites, and a shared material would leak that across every
 406    instance in the sector.
 407    """
 408    if role not in ROLES:
 409        raise ValueError(f"Unknown material role {role!r}; expected one of {', '.join(ROLES)}")
 410    pal = palette(faction)
 411    if role == ROLE_ACCENT:
 412        return Material(
 413            colour=(*pal.accent, 1.0),
 414            metallic=0.0,
 415            roughness=0.45,
 416            emissive_colour=pal.accent,
 417            emissive_strength=pal.accent_strength,
 418        )
 419    if role == ROLE_TRIM:
 420        return Material(colour=pal.trim_colour, metallic=pal.trim_metallic, roughness=pal.trim_roughness)
 421    if pal.hull_glow > 0.0:
 422        return Material(
 423            colour=pal.hull_colour,
 424            metallic=pal.hull_metallic,
 425            roughness=pal.hull_roughness,
 426            emissive_colour=pal.hull_colour[:3],
 427            emissive_strength=pal.hull_glow,
 428        )
 429    return Material(colour=pal.hull_colour, metallic=pal.hull_metallic, roughness=pal.hull_roughness)
 430
 431
 432def apply_faction_materials(node: Node3D, faction: str) -> None:
 433    """Re-skin every mesh under *node* from one faction palette.
 434
 435    The single enforcement point for the readability contract: geometry gets
 436    its ownership colour here and nowhere else, so a telegraph flash is a
 437    re-skin to ``"telegraph"`` and back rather than an ad hoc colour poke.
 438    """
 439    palette(faction)  # Validate before touching anything.
 440    for instance in node.find_all(MeshInstance3D):
 441        role = instance.name if instance.name in ROLES else ROLE_HULL
 442        instance.material = faction_material(faction, role)
 443
 444
 445# ============================================================================
 446# Player hulls
 447# ============================================================================
 448
 449
 450@dataclass(frozen=True)
 451class HullSpec:
 452    """Proportions of one player hull. Socket counts come from balance."""
 453
 454    id: str
 455    length: float
 456    beam: float
 457    depth: float
 458    engines: int
 459    greebles: int
 460
 461
 462HULLS: dict[str, HullSpec] = {
 463    "vagrant": HullSpec("vagrant", length=2.6, beam=1.7, depth=0.62, engines=2, greebles=14),
 464    "barge": HullSpec("barge", length=3.2, beam=2.6, depth=0.92, engines=2, greebles=22),
 465    "dart": HullSpec("dart", length=3.4, beam=1.1, depth=0.48, engines=1, greebles=8),
 466    "hive": HullSpec("hive", length=2.8, beam=2.2, depth=0.78, engines=4, greebles=16),
 467}
 468
 469#: The reference hull length the Shrike is sized against.
 470PLAYER_SHIP_LENGTH = HULLS["vagrant"].length
 471
 472#: Where the weapon rack bolts a gun, in the kit's own nose-along-+X frame.
 473#: ``weapons.HARDPOINT_OFFSETS`` names the same four places in the hull's local
 474#: frame, where the nose runs down -Z; the ship mounts this build with a quarter
 475#: turn about +Y, which sends art +X onto ship -Z, so ``(art_x, art_z)`` is
 476#: ``(-ship_z, ship_x)``. The table is restated rather than imported because
 477#: ``weapons`` sits above the art kit in the import order; ``test_ship`` pins the
 478#: two together so they cannot drift apart in silence.
 479HARDPOINT_MOUNTS = ((-0.15, -0.85), (-0.15, 0.85), (0.55, -1.45), (0.55, 1.45))
 480
 481#: How many mounts a hull carries unless the caller says otherwise. A bare mount
 482#: is still built: an empty pylon is what tells a pilot the hull has room, and it
 483#: is the only reason a third and fourth gun look like they belong on the ship
 484#: rather than floating a metre off its flank.
 485HARDPOINTS_DEFAULT = balance.HARDPOINTS_STARTER
 486
 487
 488def hull_spec(hull: str) -> HullSpec:
 489    """The :class:`HullSpec` for *hull*, raising on an unknown id."""
 490    try:
 491        return HULLS[hull]
 492    except KeyError:
 493        raise ValueError(f"Unknown hull {hull!r}; expected one of {', '.join(HULLS)}") from None
 494
 495
 496def _add_hardpoint_mounts(trim: Kit, spec: HullSpec, hardpoints: int) -> None:
 497    """Bolt a pylon and a collar under each of the hull's first *hardpoints* mounts.
 498
 499    Mounts go in the trim mesh rather than the hull mesh on purpose: the hull
 500    mesh is the silhouette, and four pylons at fixed offsets would give every
 501    hull the same beam and erase the difference between a Dart and a Barge.
 502    """
 503    for mount_x, mount_z in HARDPOINT_MOUNTS[: max(0, int(hardpoints))]:
 504        root_z = math.copysign(spec.beam * 0.30, mount_z)
 505        drop = -spec.depth * 0.22
 506        trim.add(
 507            _unit_cube(),
 508            position=(mount_x, drop, (root_z + mount_z) * 0.5),
 509            scale=(spec.length * 0.09, spec.depth * 0.24, abs(mount_z - root_z)),
 510        )
 511        trim.add(
 512            _unit_cylinder(10),
 513            position=(mount_x, drop, mount_z),
 514            rotation=_Y_TO_X,
 515            scale=(spec.depth * 0.36, spec.length * 0.11, spec.depth * 0.36),
 516        )
 517
 518
 519def _build_player_meshes(hull: str, seed: int, hardpoints: int) -> tuple[Mesh, Mesh, Mesh]:
 520    spec = hull_spec(hull)
 521    rng = np.random.default_rng((seed, hash(hull) & 0xFFFF))
 522    half = spec.length / 2.0
 523
 524    body = Kit()
 525    # Spine: a tapered fuselage cut from a box by a wedge, so the nose reads
 526    # sharp without a separate cone joint.
 527    fuselage = csg_combine(
 528        _scaled(_unit_cube(), (spec.length * 0.78, spec.depth, spec.beam * 0.46)),
 529        _scaled(_unit_cone(12), (spec.beam * 1.9, spec.length * 1.4, spec.beam * 1.9), rotation=_Y_TO_X),
 530        CSGOperation.INTERSECT,
 531    )
 532    body.add(fuselage)
 533    body.add(
 534        _unit_cone(10),
 535        position=(half * 0.86, 0.0, 0.0),
 536        rotation=_Y_TO_X,
 537        scale=(spec.beam * 0.34, half * 0.5, spec.depth * 0.9),
 538    )
 539    # Swept wings, mirrored across the spine.
 540    for side in (1.0, -1.0):
 541        body.add(
 542            _unit_cube(),
 543            position=(-spec.length * 0.06, 0.0, side * spec.beam * 0.34),
 544            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.26),
 545            scale=(spec.length * 0.42, spec.depth * 0.42, spec.beam * 0.5),
 546        )
 547    _greeble(body, rng, span=(spec.length * 0.34, spec.depth * 0.5, spec.beam * 0.4), count=spec.greebles, size=0.1)
 548
 549    trim = Kit()
 550    engine_spacing = spec.beam * 0.62 / max(spec.engines, 1)
 551    for index in range(spec.engines):
 552        offset = (index - (spec.engines - 1) / 2.0) * engine_spacing * 2.0
 553        trim.add(
 554            _unit_cylinder(12),
 555            position=(-half * 0.82, 0.0, offset),
 556            rotation=_Y_TO_X,
 557            scale=(spec.depth * 0.72, spec.length * 0.26, spec.depth * 0.72),
 558        )
 559    # Dorsal sensor spine and the shield-emitter ring collar.
 560    trim.add(
 561        _unit_cube(),
 562        position=(half * 0.1, spec.depth * 0.52, 0.0),
 563        scale=(spec.length * 0.22, spec.depth * 0.3, spec.beam * 0.12),
 564    )
 565    trim.add(
 566        _unit_cylinder(12),
 567        position=(0.0, 0.0, 0.0),
 568        rotation=_Y_TO_X,
 569        scale=(spec.beam * 0.5, spec.length * 0.05, spec.beam * 0.5),
 570    )
 571    _add_hardpoint_mounts(trim, spec, hardpoints)
 572
 573    accent = Kit()
 574    for index in range(spec.engines):
 575        offset = (index - (spec.engines - 1) / 2.0) * engine_spacing * 2.0
 576        accent.add(
 577            _unit_cylinder(12),
 578            position=(-half * 0.94, 0.0, offset),
 579            rotation=_Y_TO_X,
 580            scale=(spec.depth * 0.58, spec.length * 0.04, spec.depth * 0.58),
 581        )
 582    # Cockpit strip: the one warm line that says which end is the nose.
 583    accent.add(
 584        _unit_cube(), position=(half * 0.42, spec.depth * 0.34, 0.0), scale=(spec.length * 0.16, 0.04, spec.beam * 0.1)
 585    )
 586    return body.build(), trim.build(), accent.build()
 587
 588
 589def _scaled(
 590    mesh: Mesh,
 591    scale: tuple[float, float, float],
 592    *,
 593    rotation: Quat | None = None,
 594    position: tuple[float, float, float] = (0.0, 0.0, 0.0),
 595) -> Mesh:
 596    """A standalone transformed copy of *mesh*, for feeding CSG operands."""
 597    return Kit().add(mesh, scale=scale, rotation=rotation, position=position).build()
 598
 599
 600def build_player_ship(seed: int = 0, *, hull: str = "vagrant", hardpoints: int = HARDPOINTS_DEFAULT) -> Node3D:
 601    """The player's ship, nose along +X, as one hull, trim and accent mesh.
 602
 603    *hull* selects one of the four flyable hulls in :data:`HULLS`; the default
 604    is the starting Vagrant. *seed* only moves the greeble scatter, so two
 605    ships of the same hull read as the same class of ship. *hardpoints* builds
 606    that many gun pylons at :data:`HARDPOINT_MOUNTS`, so a hull that carries
 607    four mounts looks like a hull that carries four mounts even before the
 608    third and fourth guns are bought.
 609    """
 610    hardpoints = max(0, min(int(hardpoints), len(HARDPOINT_MOUNTS)))
 611    body, trim, accent = _cached(
 612        ("player_ship", hull, seed, hardpoints), lambda: _build_player_meshes(hull, seed, hardpoints)
 613    )
 614    node = Node3D(name="ShipArt")
 615    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("player", ROLE_HULL)))
 616    node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("player", ROLE_TRIM)))
 617    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("player", ROLE_ACCENT)))
 618    return node
 619
 620
 621# ============================================================================
 622# The exhaust plume
 623# ============================================================================
 624
 625#: Names of the two meshes an exhaust plume is made of.
 626PLUME_FLARE = "PlumeFlare"
 627PLUME_CORE = "PlumeCore"
 628
 629#: The flare is squashed vertically: seen down a camera that is 60 degrees off
 630#: the plane, a flattened plume reads as a streak laid along the thrust vector
 631#: where a round one reads as a ball of fire behind the ship.
 632PLUME_FLATTEN = 0.45
 633#: Radius of the core relative to the flare's mouth, and its share of the length.
 634PLUME_CORE_RADIUS = 0.34
 635PLUME_CORE_LENGTH = 0.62
 636
 637
 638def _build_plume_meshes() -> tuple[Mesh, Mesh]:
 639    """The flare and core cones of one exhaust plume, as unit meshes.
 640
 641    Both are built mouth-first: the wide end sits on the node's origin, which is
 642    the nozzle, and the taper runs down local -Z, which is the axis
 643    :meth:`Node3D.face_along` aims. A caller therefore points a plume by facing
 644    the node along the exhaust vector and scales it by throttle, with no
 645    offset to keep in step.
 646    """
 647    flare = Kit().add(_unit_cone(14), position=(0.0, 0.0, -0.5), rotation=_Y_TO_NEG_Z, scale=(2.0, 1.0, 2.0)).build()
 648    flare = Kit().add(flare, scale=(1.0, PLUME_FLATTEN, 1.0)).build()
 649    core = (
 650        Kit()
 651        .add(
 652            _unit_cone(12),
 653            position=(0.0, 0.0, -PLUME_CORE_LENGTH * 0.5),
 654            rotation=_Y_TO_NEG_Z,
 655            scale=(PLUME_CORE_RADIUS * 2.0, PLUME_CORE_LENGTH, PLUME_CORE_RADIUS * 2.0),
 656        )
 657        .build()
 658    )
 659    return flare, Kit().add(core, scale=(1.0, PLUME_FLATTEN, 1.0)).build()
 660
 661
 662def plume_meshes() -> tuple[Mesh, Mesh]:
 663    """The shared unit flare and core meshes of the exhaust plume."""
 664    return _cached(("plume",), _build_plume_meshes)
 665
 666
 667def build_engine_plume(faction: str = "player") -> Node3D:
 668    """One nozzle's plume: a tapered flare with a hotter core inside it.
 669
 670    Continuous geometry rather than particles, because a plume is one jet of
 671    burning gas and a scatter of grains behind a ship reads as debris coming
 672    off it. :class:`shrike.vfx.EngineRibbon` owns one of these, stretches it
 673    with the throttle and points it down the thrust vector; the fast mote
 674    stream it also carries is the texture on top, not the plume itself.
 675    """
 676    accent = palette(faction).accent
 677    flare_mesh, core_mesh = plume_meshes()
 678    node = Node3D(name="Plume")
 679    node.add_child(
 680        _mesh_instance(
 681            PLUME_FLARE,
 682            flare_mesh,
 683            Material(
 684                colour=(*accent, 0.5),
 685                blend="alpha",
 686                metallic=0.0,
 687                roughness=1.0,
 688                double_sided=True,
 689                emissive_colour=accent,
 690                emissive_strength=palette(faction).accent_strength,
 691            ),
 692        )
 693    )
 694    node.add_child(
 695        _mesh_instance(
 696            PLUME_CORE,
 697            core_mesh,
 698            Material(
 699                colour=(1.0, 1.0, 1.0, 0.85),
 700                blend="alpha",
 701                metallic=0.0,
 702                roughness=1.0,
 703                double_sided=True,
 704                emissive_colour=(1.0, 0.96, 0.88),
 705                emissive_strength=palette(faction).accent_strength * 1.6,
 706            ),
 707        )
 708    )
 709    return node
 710
 711
 712# ============================================================================
 713# Power modules
 714#
 715# The three generation modules are gameplay-real (they widen the hitbox, they
 716# take fire, they pay in fuel and noise) and until now they had no geometry at
 717# all: a pilot pressed X, the wings signal fired, the capacitor started filling
 718# and the ship on screen did not change in any way. Everything below exists so
 719# that the power economy is something the pilot watches rather than infers.
 720# ============================================================================
 721
 722#: The two wing pivots of a solar array, in the order a build returns them.
 723SOLAR_WING_PORT = "WingPort"
 724SOLAR_WING_STARBOARD = "WingStarboard"
 725SOLAR_WING_NAMES = (SOLAR_WING_PORT, SOLAR_WING_STARBOARD)
 726
 727#: One wing, root to tip and fore to aft, in world units, plus how thick a panel
 728#: is. Sized against the Vagrant's 1.7-unit beam: a deployed array is about as
 729#: wide again as the hull, which is what makes the widened hitbox honest.
 730SOLAR_WING_SPAN = 1.15
 731SOLAR_WING_CHORD = 0.85
 732SOLAR_PANEL_THICKNESS = 0.05
 733#: Where a wing's root sits outboard of the module's own origin.
 734SOLAR_ROOT_OFFSET = 0.22
 735#: Cells in one panel array: along the hull, then out along the span. The grid
 736#: is the whole point of the accent mesh; a single lit slab reads as a mirror.
 737SOLAR_CELLS_CHORDWISE = 3
 738SOLAR_CELLS_SPANWISE = 4
 739#: Fraction of a cell's footprint left as the frame between cells.
 740SOLAR_CELL_GAP = 0.22
 741
 742#: The generator's block, its vent face and the cowling ribs over it.
 743GENERATOR_BLOCK = (0.72, 0.46, 0.58)
 744GENERATOR_VENT_BARS = 5
 745GENERATOR_RIBS = 3
 746
 747#: The RTG: a small finned drum with one glowing window band.
 748RTG_LENGTH = 0.56
 749RTG_RADIUS = 0.19
 750RTG_FINS = 6
 751
 752
 753def _panel_grid(kit: Kit, *, chord: float, span: float, root: float, thickness: float) -> None:
 754    """Lay a grid of emissive cells over one wing's planform."""
 755    cell_chord = chord / SOLAR_CELLS_CHORDWISE
 756    cell_span = span / SOLAR_CELLS_SPANWISE
 757    for i in range(SOLAR_CELLS_CHORDWISE):
 758        along = (i - (SOLAR_CELLS_CHORDWISE - 1) / 2.0) * cell_chord
 759        for j in range(SOLAR_CELLS_SPANWISE):
 760            out = root + (j + 0.5) * cell_span
 761            kit.add(
 762                _unit_cube(),
 763                position=(along, thickness * 0.6, out),
 764                scale=(cell_chord * (1.0 - SOLAR_CELL_GAP), thickness * 0.5, cell_span * (1.0 - SOLAR_CELL_GAP)),
 765            )
 766
 767
 768def _build_solar_wing_meshes(seed: int) -> tuple[Mesh, Mesh]:
 769    """The starboard wing's spar and cell grid, span running out along +Z."""
 770    rng = np.random.default_rng((seed, 0x5011))
 771    span = SOLAR_WING_SPAN
 772    chord = SOLAR_WING_CHORD
 773
 774    spar = Kit()
 775    # The hinge boom out to the root, then the panel's own frame.
 776    spar.add(
 777        _unit_cylinder(8),
 778        position=(0.0, 0.0, SOLAR_ROOT_OFFSET * 0.5),
 779        rotation=_Y_TO_Z,
 780        scale=(0.11, SOLAR_ROOT_OFFSET, 0.11),
 781    )
 782    spar.add(
 783        _unit_cube(),
 784        position=(0.0, 0.0, SOLAR_ROOT_OFFSET + span * 0.5),
 785        scale=(chord * 1.02, SOLAR_PANEL_THICKNESS, span),
 786    )
 787    # Two stiffeners along the span, angled so the array is not a plain slab.
 788    for side in (1.0, -1.0):
 789        spar.add(
 790            _unit_cube(),
 791            position=(side * chord * 0.44, SOLAR_PANEL_THICKNESS * 0.9, SOLAR_ROOT_OFFSET + span * 0.5),
 792            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.06),
 793            scale=(chord * 0.1, SOLAR_PANEL_THICKNESS * 1.4, span * 0.98),
 794        )
 795    _greeble(spar, rng, span=(chord * 0.3, SOLAR_PANEL_THICKNESS, span * 0.4), count=4, size=0.05)
 796
 797    cells = Kit()
 798    _panel_grid(cells, chord=chord, span=span, root=SOLAR_ROOT_OFFSET, thickness=SOLAR_PANEL_THICKNESS)
 799    return spar.build(), cells.build()
 800
 801
 802def build_solar_wings(seed: int = 0) -> Node3D:
 803    """A folding solar array: two named wing pivots, each its own spar and cells.
 804
 805    The pivots are the animation surface. ``power.SolarWings`` yaws each one
 806    about the hull's fore-aft axis to fold it and scales it along the span, and
 807    dims or hides one when its cells are shot away, so the deployment state and
 808    the wing damage the gameplay already tracks are both on the hull.
 809    """
 810    spar, cells = _cached(("solar_wing", seed), lambda: _build_solar_wing_meshes(seed))
 811    flip = (1.0, 1.0, -1.0)
 812    mirrored = _cached(("solar_wing_port", seed), lambda: (_scaled(spar, flip), _scaled(cells, flip)))
 813    node = Node3D(name="SolarArray")
 814    for name, (spar_mesh, cell_mesh) in zip(SOLAR_WING_NAMES, (mirrored, (spar, cells)), strict=True):
 815        wing = node.add_child(Node3D(name=name))
 816        wing.add_child(_mesh_instance(ROLE_TRIM, spar_mesh, faction_material("player", ROLE_TRIM)))
 817        wing.add_child(_mesh_instance(ROLE_ACCENT, cell_mesh, faction_material("player", ROLE_ACCENT)))
 818    return node
 819
 820
 821def _build_generator_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh]:
 822    """The generator's casing, its cowling ribs and the emissive vent bars."""
 823    rng = np.random.default_rng((seed, 0x6E17))
 824    length, height, width = GENERATOR_BLOCK
 825
 826    body = Kit()
 827    body.add(_unit_cube(), scale=(length, height, width))
 828    body.add(
 829        _unit_cylinder(10),
 830        position=(-length * 0.42, height * 0.24, 0.0),
 831        rotation=_Y_TO_X,
 832        scale=(height * 0.34, length * 0.5, height * 0.34),
 833    )
 834    _greeble(body, rng, span=(length * 0.35, height * 0.5, width * 0.35), count=5, size=0.07)
 835
 836    trim = Kit()
 837    for index in range(GENERATOR_RIBS):
 838        along = (index - (GENERATOR_RIBS - 1) / 2.0) * length * 0.3
 839        trim.add(_unit_cube(), position=(along, height * 0.54, 0.0), scale=(length * 0.09, height * 0.14, width * 1.04))
 840    # The exhaust stack: what the vent light spills out of.
 841    trim.add(
 842        _unit_cylinder(8),
 843        position=(length * 0.3, height * 0.72, 0.0),
 844        scale=(width * 0.28, height * 0.5, width * 0.28),
 845    )
 846
 847    accent = Kit()
 848    for index in range(GENERATOR_VENT_BARS):
 849        along = (index - (GENERATOR_VENT_BARS - 1) / 2.0) * length * 0.17
 850        accent.add(
 851            _unit_cube(),
 852            position=(along, 0.0, width * 0.52),
 853            scale=(length * 0.1, height * 0.62, 0.04),
 854        )
 855    accent.add(_unit_cylinder(8), position=(length * 0.3, height * 0.97, 0.0), scale=(width * 0.2, 0.06, width * 0.2))
 856    return body.build(), trim.build(), accent.build()
 857
 858
 859def build_generator(seed: int = 0) -> Node3D:
 860    """The fuel generator: a plated block whose vent glows while it is running.
 861
 862    The vent is the accent mesh, so ``power.Generator`` pulses one emissive
 863    strength in step with the hum that is the game's signature warning, and a
 864    running generator is visible on the hull from across the sector.
 865    """
 866    body, trim, accent = _cached(("generator", seed), lambda: _build_generator_meshes(seed))
 867    node = Node3D(name="GeneratorArt")
 868    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("player", ROLE_HULL)))
 869    node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("player", ROLE_TRIM)))
 870    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("player", ROLE_ACCENT)))
 871    return node
 872
 873
 874def _build_rtg_meshes(seed: int) -> tuple[Mesh, Mesh]:
 875    """The RTG's finned drum and its one steady window band."""
 876    del seed
 877    body = Kit()
 878    body.add(_unit_cylinder(10), rotation=_Y_TO_X, scale=(RTG_RADIUS * 2.0, RTG_LENGTH, RTG_RADIUS * 2.0))
 879    for index in range(RTG_FINS):
 880        angle = index * math.tau / RTG_FINS
 881        body.add(
 882            _unit_cube(),
 883            position=(0.0, math.sin(angle) * RTG_RADIUS * 1.2, math.cos(angle) * RTG_RADIUS * 1.2),
 884            rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), -angle),
 885            scale=(RTG_LENGTH * 0.82, RTG_RADIUS * 0.6, 0.035),
 886        )
 887    accent = Kit()
 888    accent.add(
 889        _unit_cylinder(10),
 890        rotation=_Y_TO_X,
 891        scale=(RTG_RADIUS * 1.9, RTG_LENGTH * 0.22, RTG_RADIUS * 1.9),
 892    )
 893    return body.build(), accent.build()
 894
 895
 896def build_rtg(seed: int = 0) -> Node3D:
 897    """The radioisotope trickle: a small finned drum with a steady glow band.
 898
 899    Steady is the whole reading. It is the one source that survives silent
 900    running, so its light never pulses and never goes out, and a dark hull with
 901    one warm band still lit is the picture of what silent running costs.
 902    """
 903    body, accent = _cached(("rtg", seed), lambda: _build_rtg_meshes(seed))
 904    node = Node3D(name="RtgArt")
 905    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("player", ROLE_HULL)))
 906    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("player", ROLE_ACCENT)))
 907    return node
 908
 909
 910# ============================================================================
 911# Enemy archetypes
 912# ============================================================================
 913
 914
 915@dataclass(frozen=True)
 916class EnemyShape:
 917    """Silhouette anchors for one archetype. Read only by the builders below.
 918
 919    ``length`` runs nose to tail along +X, ``beam`` across the flight plane and
 920    ``depth`` through it; ``limbs`` counts whatever the archetype repeats, be
 921    that spikes, arms or mortar tubes.
 922    """
 923
 924    length: float
 925    beam: float
 926    depth: float
 927    limbs: int
 928    greebles: int
 929
 930
 931#: One entry per archetype in ``balance.ENEMIES``. The player's hull is 2.6
 932#: units long, so these are read against that: a Mite is a splinter a third of
 933#: the ship's length, a Bombardier is heavier than the ship in every dimension.
 934#: Nothing here is smaller than it needs to be to be told apart in a fight,
 935#: because an enemy the player cannot name is an enemy the player cannot answer.
 936ENEMY_SHAPES: dict[str, EnemyShape] = {
 937    "mite": EnemyShape(1.15, 0.36, 0.20, 2, 0),
 938    "skimmer": EnemyShape(2.4, 3.2, 0.42, 2, 5),
 939    "lancer": EnemyShape(4.0, 0.80, 0.52, 2, 5),
 940    "mag_mine": EnemyShape(2.0, 2.0, 2.0, 12, 0),
 941    "welder": EnemyShape(2.6, 2.5, 0.95, 2, 10),
 942    "bombardier": EnemyShape(3.2, 2.4, 1.5, 3, 12),
 943    "screamer": EnemyShape(1.6, 1.6, 3.0, 4, 4),
 944    "husk_turret": EnemyShape(2.8, 2.8, 1.7, 1, 10),
 945    "herald": EnemyShape(3.2, 2.3, 0.6, 3, 6),
 946}
 947
 948
 949def _align_y(direction: tuple[float, float, float]) -> Quat:
 950    """The rotation that takes a +Y primitive onto *direction*."""
 951    target = Vec3(*direction).normalized()
 952    along = float(target.y)
 953    if along > 1.0 - 1e-9:
 954        return Quat()
 955    if along < -1.0 + 1e-9:
 956        return Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), math.pi)
 957    axis = Vec3(0.0, 1.0, 0.0).cross(target).normalized()
 958    return Quat.from_axis_angle(axis, math.acos(max(-1.0, min(1.0, along))))
 959
 960
 961def _sphere_directions(count: int) -> list[tuple[float, float, float]]:
 962    """*count* roughly even directions on the unit sphere, for radial spikes."""
 963    golden = math.pi * (3.0 - math.sqrt(5.0))
 964    directions = []
 965    for index in range(count):
 966        y = 1.0 - 2.0 * (index + 0.5) / count
 967        radius = math.sqrt(max(0.0, 1.0 - y * y))
 968        angle = golden * index
 969        directions.append((math.cos(angle) * radius, y, math.sin(angle) * radius))
 970    return directions
 971
 972
 973def _build_mite(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
 974    """A splinter: all nose, no hull, one spark of drive at the back."""
 975    half = shape.length / 2.0
 976    body.add(
 977        _unit_cone(4),
 978        position=(half * 0.14, 0.0, 0.0),
 979        rotation=_Y_TO_X,
 980        scale=(shape.beam, shape.length * 0.92, shape.depth),
 981    )
 982    for side in (1.0, -1.0):
 983        body.add(
 984            _unit_cube(),
 985            position=(-half * 0.42, 0.0, side * shape.beam * 0.36),
 986            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.55),
 987            scale=(shape.length * 0.28, shape.depth * 0.7, shape.beam * 0.5),
 988        )
 989    accent.add(_unit_sphere(5, 6), position=(-half * 0.62, 0.0, 0.0), scale=(shape.beam * 0.85,) * 3)
 990
 991
 992def _build_skimmer(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
 993    """A winged wedge with two claw prongs: a thief built to close and grab."""
 994    half = shape.length / 2.0
 995    body.add(
 996        _unit_cone(6),
 997        position=(half * 0.18, 0.0, 0.0),
 998        rotation=_Y_TO_X,
 999        scale=(shape.beam * 0.30, shape.length * 0.8, shape.depth * 1.7),
1000    )
1001    # Wings, swept back hard so the outline is a delta from any distance.
1002    for side in (1.0, -1.0):
1003        body.add(
1004            _unit_cube(),
1005            position=(-shape.length * 0.14, 0.0, side * shape.beam * 0.28),
1006            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.52),
1007            scale=(shape.length * 0.5, shape.depth * 0.4, shape.beam * 0.52),
1008        )
1009    # The claws: two prongs reaching past the nose, canted inward around the maw.
1010    for side in (1.0, -1.0):
1011        body.add(
1012            _unit_cone(6),
1013            position=(half * 0.66, 0.0, side * shape.beam * 0.2),
1014            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.2) * _Y_TO_X,
1015            scale=(shape.depth * 0.55, shape.length * 0.62, shape.depth * 0.55),
1016        )
1017    _greeble(body, rng, span=(shape.length * 0.22, shape.depth * 0.4, shape.beam * 0.3), count=shape.greebles, size=0.1)
1018    # The maw between the claws is the emissive: a Skimmer is read by its grab.
1019    accent.add(
1020        _unit_cube(),
1021        position=(half * 0.5, 0.0, 0.0),
1022        scale=(shape.length * 0.1, shape.depth * 0.5, shape.beam * 0.3),
1023    )
1024    for side in (1.0, -1.0):
1025        accent.add(
1026            _unit_sphere(5, 6),
1027            position=(half * 0.94, 0.0, side * shape.beam * 0.14),
1028            scale=(shape.depth * 0.42,) * 3,
1029        )
1030
1031
1032def _build_lancer(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1033    """A needle with a lit tip: the whole ship is the weapon, and it points."""
1034    half = shape.length / 2.0
1035    body.add(
1036        _unit_cone(8),
1037        position=(half * 0.08, 0.0, 0.0),
1038        rotation=_Y_TO_X,
1039        scale=(shape.beam * 0.42, shape.length * 0.94, shape.beam * 0.42),
1040    )
1041    body.add(
1042        _unit_cylinder(10),
1043        position=(-half * 0.5, 0.0, 0.0),
1044        rotation=_Y_TO_X,
1045        scale=(shape.beam * 0.85, shape.length * 0.1, shape.beam * 0.85),
1046    )
1047    for side in (1.0, -1.0):
1048        body.add(
1049            _unit_cube(),
1050            position=(-half * 0.72, 0.0, side * shape.beam * 0.45),
1051            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.55),
1052            scale=(shape.length * 0.24, shape.depth * 0.55, shape.beam * 0.62),
1053        )
1054    _greeble(body, rng, span=(shape.length * 0.2, shape.depth * 0.3, shape.beam * 0.2), count=shape.greebles, size=0.09)
1055    # The lit tip: the one part of a Lancer a player must watch.
1056    accent.add(_unit_sphere(6, 8), position=(half * 0.9, 0.0, 0.0), scale=(shape.beam * 0.62,) * 3)
1057    accent.add(
1058        _unit_cube(),
1059        position=(half * 0.1, 0.0, 0.0),
1060        scale=(shape.length * 0.52, shape.depth * 0.14, shape.depth * 0.14),
1061    )
1062
1063
1064def _build_mag_mine(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1065    """A spiked sphere: radially symmetric, so it reads the same from any bearing."""
1066    radius = shape.length / 2.0
1067    body.add(_unit_sphere(10, 14), scale=(shape.length * 0.62,) * 3)
1068    for direction in _sphere_directions(shape.limbs):
1069        body.add(
1070            _unit_cone(5),
1071            position=tuple(component * radius * 0.62 for component in direction),
1072            rotation=_align_y(direction),
1073            scale=(radius * 0.3, radius * 0.95, radius * 0.3),
1074        )
1075        # A lit spike tip on every spine: the thing has no front, so the glow
1076        # has to be everywhere the hull is.
1077        accent.add(
1078            _unit_sphere(4, 6),
1079            position=tuple(component * radius * 1.02 for component in direction),
1080            scale=(radius * 0.17,) * 3,
1081        )
1082
1083
1084def _build_welder(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1085    """A crab: a squat body carried behind two long beam arms."""
1086    half = shape.length / 2.0
1087    body.add(
1088        _unit_sphere(8, 12),
1089        position=(-half * 0.24, 0.0, 0.0),
1090        scale=(shape.length * 0.52, shape.depth * 1.05, shape.beam * 0.62),
1091    )
1092    body.add(
1093        _unit_cube(),
1094        position=(-half * 0.62, 0.0, 0.0),
1095        scale=(shape.length * 0.22, shape.depth * 0.7, shape.beam * 0.42),
1096    )
1097    # Two arms, each an upper limb angled out and a forearm angled back in, so
1098    # the pair frames the space the umbilical crosses.
1099    for side in (1.0, -1.0):
1100        body.add(
1101            _unit_cylinder(8),
1102            position=(half * 0.06, 0.0, side * shape.beam * 0.28),
1103            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -side * 0.5) * _Y_TO_X,
1104            scale=(shape.depth * 0.34, shape.length * 0.62, shape.depth * 0.34),
1105        )
1106        body.add(
1107            _unit_cylinder(8),
1108            position=(half * 0.62, 0.0, side * shape.beam * 0.4),
1109            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.42) * _Y_TO_X,
1110            scale=(shape.depth * 0.28, shape.length * 0.5, shape.depth * 0.28),
1111        )
1112        # The emitter head at the end of each arm.
1113        accent.add(
1114            _unit_sphere(6, 8),
1115            position=(half * 0.88, 0.0, side * shape.beam * 0.26),
1116            scale=(shape.depth * 0.55,) * 3,
1117        )
1118    _greeble(
1119        body, rng, span=(shape.length * 0.24, shape.depth * 0.5, shape.beam * 0.24), count=shape.greebles, size=0.11
1120    )
1121    accent.add(
1122        _unit_cube(),
1123        position=(-half * 0.24, shape.depth * 0.42, 0.0),
1124        scale=(shape.length * 0.3, shape.depth * 0.12, shape.beam * 0.16),
1125    )
1126
1127
1128def _build_bombardier(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1129    """A barrel with mortar tubes: heavy, slow and obviously carrying shells."""
1130    half = shape.length / 2.0
1131    body.add(
1132        _unit_cylinder(14),
1133        rotation=_Y_TO_X,
1134        scale=(shape.depth * 1.05, shape.length * 0.78, shape.beam * 0.9),
1135    )
1136    # Hoops, which is what makes a cylinder read as a barrel rather than a pipe.
1137    for offset in (-0.28, 0.06, 0.34):
1138        body.add(
1139            _unit_cylinder(16),
1140            position=(shape.length * offset, 0.0, 0.0),
1141            rotation=_Y_TO_X,
1142            scale=(shape.depth * 1.18, shape.length * 0.05, shape.beam * 1.0),
1143        )
1144    body.add(
1145        _unit_cone(10),
1146        position=(-half * 0.52, 0.0, 0.0),
1147        rotation=_Y_TO_NEG_X,
1148        scale=(shape.depth * 0.9, shape.length * 0.3, shape.beam * 0.8),
1149    )
1150    _greeble(
1151        body, rng, span=(shape.length * 0.28, shape.depth * 0.55, shape.beam * 0.3), count=shape.greebles, size=0.13
1152    )
1153    # Mortar tubes on the spine, tilted up: the shells come from up there.
1154    for index in range(shape.limbs):
1155        offset = (index - (shape.limbs - 1) / 2.0) * shape.beam * 0.3
1156        tilt = Quat.from_axis_angle(Vec3(0.0, 0.0, 1.0), -0.5)
1157        body.add(
1158            _unit_cylinder(10),
1159            position=(shape.length * 0.1, shape.depth * 0.6, offset),
1160            rotation=tilt,
1161            scale=(shape.depth * 0.28, shape.length * 0.42, shape.depth * 0.28),
1162        )
1163        accent.add(
1164            _unit_cylinder(8),
1165            position=(shape.length * 0.28, shape.depth * 0.86, offset),
1166            rotation=tilt,
1167            scale=(shape.depth * 0.22, shape.length * 0.05, shape.depth * 0.22),
1168        )
1169    accent.add(
1170        _unit_cylinder(16),
1171        position=(shape.length * 0.2, 0.0, 0.0),
1172        rotation=_Y_TO_X,
1173        scale=(shape.depth * 1.12, shape.length * 0.04, shape.beam * 0.96),
1174    )
1175
1176
1177def _build_screamer(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1178    """An antenna cluster: a small hull under a mast that is all broadcast."""
1179    body.add(_unit_cylinder(10), scale=(shape.length * 0.5, shape.depth * 0.28, shape.beam * 0.5))
1180    body.add(
1181        _unit_cylinder(8),
1182        position=(0.0, shape.depth * 0.34, 0.0),
1183        scale=(shape.length * 0.11, shape.depth * 0.62, shape.length * 0.11),
1184    )
1185    # A stack of crossbars, each shorter than the one below, each ending in a
1186    # dish: an aerial, from the outline alone.
1187    for index in range(shape.limbs):
1188        height = shape.depth * (0.34 + 0.16 * (index + 1))
1189        reach = shape.beam * (0.62 - 0.09 * index)
1190        angle = math.tau * index / shape.limbs + 0.4
1191        for sign in (1.0, -1.0):
1192            direction = (math.cos(angle) * sign, 0.0, math.sin(angle) * sign)
1193            body.add(
1194                _unit_cube(),
1195                position=(direction[0] * reach * 0.5, height, direction[2] * reach * 0.5),
1196                rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -angle),
1197                scale=(reach, shape.depth * 0.05, shape.depth * 0.05),
1198            )
1199            accent.add(
1200                _unit_cone(6),
1201                position=(direction[0] * reach, height, direction[2] * reach),
1202                rotation=_align_y(direction),
1203                scale=(shape.depth * 0.11, shape.depth * 0.1, shape.depth * 0.11),
1204            )
1205    _greeble(body, rng, span=(shape.length * 0.2, shape.depth * 0.12, shape.beam * 0.2), count=shape.greebles, size=0.1)
1206    accent.add(_unit_sphere(7, 9), position=(0.0, shape.depth * 0.98, 0.0), scale=(shape.length * 0.2,) * 3)
1207
1208
1209def _build_husk_turret(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1210    """A gun bolted to a dead hull: broken base, live drum, one barrel."""
1211    half = shape.length / 2.0
1212    # The wreck it is built on: a torn slab with a plate or two still standing.
1213    body.add(
1214        _displaced_rock(int(rng.integers(0, 1 << 20)), shape.length * 0.42, relief=0.3, rings=8, segments=12),
1215        scale=(1.15, 0.34, 1.0),
1216    )
1217    for _ in range(2):
1218        body.add(
1219            _unit_cube(),
1220            position=(
1221                float(rng.uniform(-half * 0.6, half * 0.6)),
1222                shape.depth * 0.16,
1223                float(rng.uniform(-half * 0.6, half * 0.6)),
1224            ),
1225            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(rng.uniform(0.0, math.tau))),
1226            scale=(shape.length * 0.3, shape.depth * 0.28, shape.beam * 0.1),
1227        )
1228    _greeble(body, rng, span=(shape.length * 0.3, shape.depth * 0.2, shape.beam * 0.3), count=shape.greebles, size=0.13)
1229    # The drum: the live half, sat proud of the wreck so it is unmistakable.
1230    body.add(
1231        _unit_cylinder(14),
1232        position=(0.0, shape.depth * 0.42, 0.0),
1233        scale=(shape.length * 0.44, shape.depth * 0.45, shape.beam * 0.44),
1234    )
1235    accent.add(
1236        _unit_cylinder(16),
1237        position=(0.0, shape.depth * 0.6, 0.0),
1238        scale=(shape.length * 0.46, shape.depth * 0.06, shape.beam * 0.46),
1239    )
1240
1241
1242def _build_turret_barrel_meshes(shape: EnemyShape) -> tuple[Mesh, Mesh]:
1243    """The Husk turret's barrel, as its own build: the class swivels it."""
1244    body, accent = Kit(), Kit()
1245    body.add(
1246        _unit_cylinder(10),
1247        position=(shape.length * 0.32, 0.0, 0.0),
1248        rotation=_Y_TO_X,
1249        scale=(shape.depth * 0.3, shape.length * 0.9, shape.depth * 0.3),
1250    )
1251    body.add(
1252        _unit_cylinder(10),
1253        position=(0.0, 0.0, 0.0),
1254        rotation=_Y_TO_X,
1255        scale=(shape.depth * 0.46, shape.length * 0.22, shape.depth * 0.46),
1256    )
1257    accent.add(
1258        _unit_cylinder(10),
1259        position=(shape.length * 0.76, 0.0, 0.0),
1260        rotation=_Y_TO_X,
1261        scale=(shape.depth * 0.34, shape.length * 0.06, shape.depth * 0.34),
1262    )
1263    return body.build(), accent.build()
1264
1265
1266def build_turret_barrel(seed: int = 0) -> Node3D:
1267    """The Husk turret's swivelling barrel, pointing along +X."""
1268    body, accent = _cached(("turret_barrel", seed), lambda: _build_turret_barrel_meshes(ENEMY_SHAPES["husk_turret"]))
1269    node = Node3D(name="BarrelArt")
1270    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1271    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("enemy", ROLE_ACCENT)))
1272    return node
1273
1274
1275def _build_herald(body: Kit, accent: Kit, shape: EnemyShape, rng: np.random.Generator) -> None:
1276    """A shard of the Shrike: angular, bladed, lit down its spine."""
1277    half = shape.length / 2.0
1278    body.add(
1279        _unit_cone(3),
1280        position=(half * 0.12, 0.0, 0.0),
1281        rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), 0.5) * _Y_TO_X,
1282        scale=(shape.beam * 0.34, shape.length * 0.95, shape.depth * 1.5),
1283    )
1284    # Two quills swept back off the shard, the Shrike's own signature.
1285    for side in (1.0, -1.0):
1286        body.add(
1287            _unit_cone(3),
1288            position=(-half * 0.34, 0.0, side * shape.beam * 0.3),
1289            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 1.05 + math.pi) * _Y_TO_X,
1290            scale=(shape.depth * 0.9, shape.length * 0.62, shape.depth * 0.9),
1291        )
1292    _greeble(body, rng, span=(shape.length * 0.22, shape.depth * 0.4, shape.beam * 0.3), count=shape.greebles, size=0.1)
1293    # Slits down the spine: lit segments, the way the Shrike's own body is lit.
1294    for index in range(shape.limbs):
1295        offset = (index - (shape.limbs - 1) / 2.0) * shape.length * 0.22
1296        accent.add(
1297            _unit_cube(),
1298            position=(offset, shape.depth * 0.2, 0.0),
1299            scale=(shape.length * 0.05, shape.depth * 0.16, shape.beam * 0.26),
1300        )
1301    accent.add(_unit_sphere(5, 7), position=(half * 0.82, 0.0, 0.0), scale=(shape.depth * 0.5,) * 3)
1302
1303
1304#: One builder per archetype. Silhouette follows role: a player who has met a
1305#: Lancer once can name the next one from its outline before it is in range.
1306ENEMY_BUILDERS = {
1307    "mite": _build_mite,
1308    "skimmer": _build_skimmer,
1309    "lancer": _build_lancer,
1310    "mag_mine": _build_mag_mine,
1311    "welder": _build_welder,
1312    "bombardier": _build_bombardier,
1313    "screamer": _build_screamer,
1314    "husk_turret": _build_husk_turret,
1315    "herald": _build_herald,
1316}
1317
1318
1319def _build_enemy_meshes(archetype: str, seed: int) -> tuple[Mesh, Mesh]:
1320    shape = ENEMY_SHAPES[archetype]
1321    rng = np.random.default_rng((seed, hash(archetype) & 0xFFFF))
1322    body, accent = Kit(), Kit()
1323    ENEMY_BUILDERS[archetype](body, accent, shape, rng)
1324    return body.build(), accent.build()
1325
1326
1327def enemy_mesh(archetype: str, seed: int = 0) -> Mesh:
1328    """The merged hull mesh for *archetype*, for multimesh instancing.
1329
1330    ``MiteShoal`` draws its whole shoal from one ``MultiMeshInstance3D``, which
1331    wants a mesh rather than a node; this is that mesh, cached so every shoal
1332    in a sector shares it.
1333    """
1334    return _enemy_meshes(archetype, seed)[0]
1335
1336
1337def _enemy_meshes(archetype: str, seed: int) -> tuple[Mesh, Mesh]:
1338    if archetype not in balance.ENEMIES:
1339        raise ValueError(f"Unknown archetype {archetype!r}; expected one of {', '.join(balance.ENEMIES)}")
1340    return _cached(("enemy", archetype, seed), lambda: _build_enemy_meshes(archetype, seed))
1341
1342
1343def build_enemy(archetype: str, seed: int = 0) -> Node3D:
1344    """One enemy archetype's art, nose along +X.
1345
1346    *archetype* must be a key of ``balance.ENEMIES``; anything else raises, so
1347    a typo in a wave recipe fails where it is written rather than spawning an
1348    invisible enemy.
1349    """
1350    body, accent = _enemy_meshes(archetype, seed)
1351    node = Node3D(name="EnemyArt")
1352    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1353    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("enemy", ROLE_ACCENT)))
1354    return node
1355
1356
1357# ============================================================================
1358# Bounty set pieces
1359# ============================================================================
1360
1361#: Silhouette anchors for the two set-piece hostiles, read against the same
1362#: player-hull yardstick as :data:`ENEMY_SHAPES`. The Warden is a bastion as
1363#: broad as it is long; the Magistrates share one hunter frame leaner than a
1364#: Lancer and half again the length of the player's hull.
1365WARDEN_SHAPE = EnemyShape(6.2, 6.2, 2.0, 8, 16)
1366MAGISTRATE_SHAPE = EnemyShape(4.2, 2.0, 0.66, 2, 8)
1367
1368#: Radius the Warden's shield emitters orbit at, and how many blocks draw the
1369#: visible cone. The cone spans ``balance.SHIELD_ARC_DEGREES``, the same arc
1370#: the class blocks with, so what the player sees is what the maths honours.
1371WARDEN_ARC_RADIUS = 4.1
1372WARDEN_ARC_SEGMENTS = 5
1373
1374
1375def _build_warden_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh, Mesh]:
1376    """The bastion drum, its static trim, the shield cone, and the gun."""
1377    shape = WARDEN_SHAPE
1378    rng = np.random.default_rng((seed, 0xBA57))
1379    body, accent, arc, gun = Kit(), Kit(), Kit(), Kit()
1380    radius = shape.length / 2.0
1381
1382    # The drum: broad and squat, a fort rather than a hull, with an armour
1383    # skirt of leaning slabs the whole way round.
1384    body.add(_unit_cylinder(16), scale=(shape.length * 0.84, shape.depth, shape.beam * 0.84))
1385    body.add(
1386        _unit_cylinder(16),
1387        position=(0.0, shape.depth * 0.3, 0.0),
1388        scale=(shape.length * 0.6, shape.depth * 0.5, shape.beam * 0.6),
1389    )
1390    for index in range(shape.limbs):
1391        angle = math.tau * index / shape.limbs
1392        direction = (math.cos(angle), 0.0, math.sin(angle))
1393        body.add(
1394            _unit_cube(),
1395            position=(direction[0] * radius * 0.78, -shape.depth * 0.08, direction[2] * radius * 0.78),
1396            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -angle),
1397            scale=(shape.length * 0.1, shape.depth * 0.85, shape.beam * 0.28),
1398        )
1399    _greeble(
1400        body, rng, span=(shape.length * 0.26, shape.depth * 0.5, shape.beam * 0.26), count=shape.greebles, size=0.16
1401    )
1402
1403    # Static light: the crown ring and the core lamp, lit whether it is awake
1404    # or not, because a Warden is a landmark before it is a fight.
1405    accent.add(
1406        _unit_cylinder(18),
1407        position=(0.0, shape.depth * 0.46, 0.0),
1408        scale=(shape.length * 0.64, shape.depth * 0.06, shape.beam * 0.64),
1409    )
1410    accent.add(_unit_sphere(8, 10), position=(0.0, shape.depth * 0.66, 0.0), scale=(shape.beam * 0.2,) * 3)
1411
1412    # The visible arc: emitter blocks spread over the shield cone, centred on
1413    # local +X, with a taller pylon on each edge so the gap reads at a glance.
1414    half_arc = math.radians(balance.SHIELD_ARC_DEGREES) / 2.0
1415    for index in range(WARDEN_ARC_SEGMENTS):
1416        angle = -half_arc + 2.0 * half_arc * index / (WARDEN_ARC_SEGMENTS - 1)
1417        direction = (math.cos(angle), 0.0, -math.sin(angle))
1418        arc.add(
1419            _unit_cube(),
1420            position=(direction[0] * WARDEN_ARC_RADIUS, shape.depth * 0.1, direction[2] * WARDEN_ARC_RADIUS),
1421            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), angle),
1422            scale=(0.26, shape.depth * 0.7, 1.05),
1423        )
1424    for sign in (1.0, -1.0):
1425        angle = sign * half_arc
1426        direction = (math.cos(angle), 0.0, -math.sin(angle))
1427        arc.add(
1428            _unit_cone(6),
1429            position=(
1430                direction[0] * (WARDEN_ARC_RADIUS + 0.35),
1431                shape.depth * 0.45,
1432                direction[2] * (WARDEN_ARC_RADIUS + 0.35),
1433            ),
1434            scale=(0.55, shape.depth * 0.75, 0.55),
1435        )
1436
1437    # The emplacement: a mantlet and one long barrel down local +X.
1438    gun.add(_unit_cube(), position=(0.0, shape.depth * 0.58, 0.0), scale=(1.3, shape.depth * 0.45, 1.05))
1439    gun.add(
1440        _unit_cylinder(10),
1441        position=(shape.length * 0.3, shape.depth * 0.58, 0.0),
1442        rotation=_Y_TO_X,
1443        scale=(0.5, shape.length * 0.56, 0.5),
1444    )
1445    return body.build(), accent.build(), arc.build(), gun.build()
1446
1447
1448def build_warden(seed: int = 0) -> Node3D:
1449    """The Warden's bastion, with the arc and the gun on children of their own.
1450
1451    The drum and its trim are static. ``ArcArt`` carries the emissive shield
1452    cone, centred on local +X, so the class shows the live arc by yawing that
1453    one node; ``GunArt`` carries the emplacement, barrel along local +X, and is
1454    yawed at whatever the Warden is shooting.
1455    """
1456    body, accent, arc, gun = _cached(("warden", seed), lambda: _build_warden_meshes(seed))
1457    node = Node3D(name="WardenArt")
1458    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1459    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("enemy", ROLE_ACCENT)))
1460    arc_art = node.add_child(Node3D(name="ArcArt"))
1461    arc_art.add_child(_mesh_instance(ROLE_ACCENT, arc, faction_material("enemy", ROLE_ACCENT)))
1462    gun_art = node.add_child(Node3D(name="GunArt"))
1463    gun_art.add_child(_mesh_instance(ROLE_HULL, gun, faction_material("enemy", ROLE_HULL)))
1464    return node
1465
1466
1467def _magistrate_frame(body: Kit, accent: Kit, rng: np.random.Generator) -> None:
1468    """The hunter frame all three Magistrates share: a sleek dart, finned aft.
1469
1470    Sharing the frame is the point: a Magistrate is recognised twice, once as
1471    "a hunter is here" from the family outline, and once by name from the
1472    variant geometry bolted onto it.
1473    """
1474    shape = MAGISTRATE_SHAPE
1475    half = shape.length / 2.0
1476    body.add(
1477        _unit_cone(8),
1478        position=(half * 0.22, 0.0, 0.0),
1479        rotation=_Y_TO_X,
1480        scale=(shape.beam * 0.34, shape.length * 0.8, shape.depth * 1.1),
1481    )
1482    body.add(
1483        _unit_cone(8),
1484        position=(-half * 0.5, 0.0, 0.0),
1485        rotation=_Y_TO_NEG_X,
1486        scale=(shape.beam * 0.3, shape.length * 0.36, shape.depth * 0.95),
1487    )
1488    for side in (1.0, -1.0):
1489        body.add(
1490            _unit_cube(),
1491            position=(-half * 0.42, 0.0, side * shape.beam * 0.3),
1492            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), side * 0.6),
1493            scale=(shape.length * 0.34, shape.depth * 0.32, shape.beam * 0.4),
1494        )
1495    _greeble(
1496        body, rng, span=(shape.length * 0.2, shape.depth * 0.35, shape.beam * 0.16), count=shape.greebles, size=0.08
1497    )
1498    # The family's shared light: one strip down the spine, nose to waist.
1499    accent.add(
1500        _unit_cube(),
1501        position=(half * 0.2, shape.depth * 0.28, 0.0),
1502        scale=(shape.length * 0.44, shape.depth * 0.1, shape.depth * 0.16),
1503    )
1504
1505
1506def _variant_harrier(body: Kit, accent: Kit) -> None:
1507    """Marshal Harrier: twin booms around the needle nose, tips lit."""
1508    shape = MAGISTRATE_SHAPE
1509    half = shape.length / 2.0
1510    for side in (1.0, -1.0):
1511        body.add(
1512            _unit_cylinder(8),
1513            position=(half * 0.1, 0.0, side * shape.beam * 0.52),
1514            rotation=_Y_TO_X,
1515            scale=(shape.depth * 0.5, shape.length * 0.95, shape.depth * 0.5),
1516        )
1517        accent.add(
1518            _unit_sphere(5, 7),
1519            position=(half * 0.6, 0.0, side * shape.beam * 0.52),
1520            scale=(shape.depth * 0.42,) * 3,
1521        )
1522
1523
1524def _variant_caracara(body: Kit, accent: Kit) -> None:
1525    """Vice-Marshal Caracara: a broad waist ring and two claws past the nose."""
1526    shape = MAGISTRATE_SHAPE
1527    half = shape.length / 2.0
1528    body.add(
1529        _unit_cylinder(16),
1530        position=(-half * 0.1, 0.0, 0.0),
1531        scale=(shape.beam * 1.5, shape.depth * 0.34, shape.beam * 1.5),
1532    )
1533    accent.add(
1534        _unit_cylinder(18),
1535        position=(-half * 0.1, shape.depth * 0.18, 0.0),
1536        scale=(shape.beam * 1.52, shape.depth * 0.05, shape.beam * 1.52),
1537    )
1538    for side in (1.0, -1.0):
1539        body.add(
1540            _unit_cone(6),
1541            position=(half * 0.7, 0.0, side * shape.beam * 0.34),
1542            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -side * 0.18) * _Y_TO_X,
1543            scale=(shape.depth * 0.5, shape.length * 0.52, shape.depth * 0.5),
1544        )
1545        accent.add(
1546            _unit_sphere(4, 6),
1547            position=(half * 0.95, 0.0, side * shape.beam * 0.28),
1548            scale=(shape.depth * 0.32,) * 3,
1549        )
1550
1551
1552def _variant_nightjar(body: Kit, accent: Kit) -> None:
1553    """Magistrate Nightjar: the stretched shard, and the one shuttered lamp."""
1554    shape = MAGISTRATE_SHAPE
1555    half = shape.length / 2.0
1556    body.add(
1557        _unit_cone(6),
1558        position=(half * 0.05, shape.depth * 0.34, 0.0),
1559        rotation=_Y_TO_X,
1560        scale=(shape.beam * 0.2, shape.length * 1.05, shape.depth * 0.5),
1561    )
1562    # The cowl, then the lamp half-proud of it: shuttered, not hidden.
1563    body.add(
1564        _unit_cylinder(8),
1565        position=(half * 0.42, shape.depth * 0.2, 0.0),
1566        rotation=_Y_TO_X,
1567        scale=(shape.depth * 1.05, shape.length * 0.16, shape.depth * 1.05),
1568    )
1569    accent.add(
1570        _unit_sphere(7, 9),
1571        position=(half * 0.5, shape.depth * 0.2, 0.0),
1572        scale=(shape.depth * 0.62,) * 3,
1573    )
1574
1575
1576#: One variant builder per named bounty hunter, keyed the way bounty.py spells
1577#: them. Each earns the silhouette its codex line promises.
1578MAGISTRATE_VARIANTS = {
1579    "harrier": _variant_harrier,
1580    "caracara": _variant_caracara,
1581    "nightjar": _variant_nightjar,
1582}
1583
1584
1585def _build_magistrate_meshes(magistrate_id: str, seed: int) -> tuple[Mesh, Mesh]:
1586    # Seeded from integers only: a hull must build the same way in every
1587    # process, and Python's string hashing is salted per run.
1588    rng = np.random.default_rng((seed, list(MAGISTRATE_VARIANTS).index(magistrate_id)))
1589    body, accent = Kit(), Kit()
1590    _magistrate_frame(body, accent, rng)
1591    MAGISTRATE_VARIANTS[magistrate_id](body, accent)
1592    return body.build(), accent.build()
1593
1594
1595def build_magistrate(magistrate_id: str, seed: int = 0, *, accent: tuple | None = None) -> Node3D:
1596    """One named bounty hunter's art, nose along +X.
1597
1598    All three ride the same sleek hunter frame in the enemy palette's magenta
1599    accent language; the variant geometry is what carries the name. *accent*,
1600    when given, tints the emissive to that Magistrate's own shade of it.
1601    """
1602    if magistrate_id not in MAGISTRATE_VARIANTS:
1603        raise ValueError(f"Unknown magistrate {magistrate_id!r}; expected one of {', '.join(MAGISTRATE_VARIANTS)}")
1604    body, glow = _cached(("magistrate", magistrate_id, seed), lambda: _build_magistrate_meshes(magistrate_id, seed))
1605    node = Node3D(name="MagistrateArt")
1606    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("enemy", ROLE_HULL)))
1607    accent_material = faction_material("enemy", ROLE_ACCENT)
1608    if accent is not None:
1609        shade = tuple(float(channel) for channel in accent[:3])
1610        accent_material.colour = (*shade, 1.0)
1611        accent_material.emissive_colour = shade
1612    node.add_child(_mesh_instance(ROLE_ACCENT, glow, accent_material))
1613    return node
1614
1615
1616# ============================================================================
1617# The Shrike
1618# ============================================================================
1619
1620
1621def _build_shrike_meshes(index: int, seed: int) -> tuple[Mesh, Mesh]:
1622    total_length = PLAYER_SHIP_LENGTH * balance.SHRIKE_LENGTH_MULT
1623    segment_length = total_length / SHRIKE_SEGMENT_COUNT
1624    # Taper from the head back down the body.
1625    taper = 1.0 - 0.62 * (index / max(SHRIKE_SEGMENT_COUNT - 1, 1))
1626    girth = segment_length * 0.62 * taper
1627    rng = np.random.default_rng((seed, index))
1628
1629    body, accent = Kit(), Kit()
1630    body.add(_unit_sphere(8, 12), scale=(segment_length * 0.95, girth, girth * 1.15))
1631    # Quill fins: the shearable plating along the spine.
1632    for side in (1.0, -1.0):
1633        body.add(
1634            _unit_cone(8),
1635            position=(0.0, girth * 0.2, side * girth * 0.55),
1636            rotation=Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), side * 0.5) * _Y_TO_Z,
1637            scale=(girth * 0.34, girth * 1.5, girth * 0.14),
1638        )
1639    _greeble(body, rng, span=(segment_length * 0.3, girth * 0.5, girth * 0.4), count=6, size=girth * 0.18)
1640
1641    if index == 0:
1642        # The head carries the lantern housing and the jaw shear.
1643        jaw = csg_combine(
1644            _scaled(_unit_cone(12), (girth * 1.3, segment_length * 1.1, girth * 1.3), rotation=_Y_TO_X),
1645            _scaled(_unit_cube(), (segment_length * 0.5, girth * 0.5, girth * 2.0)),
1646            CSGOperation.SUBTRACT,
1647        )
1648        body.add(jaw, position=(segment_length * 0.55, 0.0, 0.0))
1649        accent.add(
1650            _unit_sphere(8, 12),
1651            position=(segment_length * 0.62, 0.0, 0.0),
1652            scale=(girth * 0.5, girth * 0.5, girth * 0.5),
1653        )
1654    else:
1655        accent.add(
1656            _unit_cylinder(10),
1657            position=(0.0, 0.0, 0.0),
1658            rotation=_Y_TO_X,
1659            scale=(girth * 0.72, segment_length * 0.06, girth * 0.72),
1660        )
1661    return body.build(), accent.build()
1662
1663
1664def build_shrike_segment(index: int, seed: int = 0) -> Node3D:
1665    """One body segment of the Shrike, index 0 being the lantern head.
1666
1667    Segments are built along +X and chained by the hunter module; the taper and
1668    the quill count fall off with *index*, so the eel reads as one animal.
1669    """
1670    if index < 0:
1671        raise ValueError(f"Shrike segment index must be non-negative, got {index}")
1672    body, accent = _cached(("shrike", index, seed), lambda: _build_shrike_meshes(index, seed))
1673    node = Node3D(name="ShrikeSegmentArt")
1674    node.add_child(_mesh_instance(ROLE_HULL, body, faction_material("hunter", ROLE_HULL)))
1675    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("hunter", ROLE_ACCENT)))
1676    return node
1677
1678
1679# ============================================================================
1680# Environment geometry: rocks, deposits, wrecks, stations
1681# ============================================================================
1682
1683
1684def _build_deposit_meshes(rich: bool, seed: int) -> tuple[Mesh, Mesh]:
1685    radius = 2.2 if rich else 1.6
1686    rock = _displaced_rock(seed, radius, relief=0.38)
1687    rng = np.random.default_rng((seed, int(rich)))
1688    veins = Kit()
1689    # Ore veins sit in the deepest folds, so the surface layer visibly runs out
1690    # before the core the design pays double for.
1691    points = rock.positions
1692    picks = rng.choice(len(points), size=10 if rich else 6, replace=False)
1693    for pick in picks:
1694        spot = points[int(pick)] * 0.97
1695        veins.add(
1696            _unit_sphere(5, 6),
1697            position=(float(spot[0]), float(spot[1]), float(spot[2])),
1698            scale=radius * (0.22 if rich else 0.15),
1699        )
1700    return rock, veins.build()
1701
1702
1703def build_deposit(rich: bool, seed: int = 0) -> Node3D:
1704    """A minable deposit: a noise-displaced rock veined with visible ore.
1705
1706    A rich node is bigger and more heavily veined, which is the only warning
1707    the player gets that tapping it costs signature.
1708    """
1709    rock, veins = _cached(("deposit", bool(rich), seed), lambda: _build_deposit_meshes(bool(rich), seed))
1710    node = Node3D(name="DepositArt")
1711    node.add_child(_mesh_instance(ROLE_HULL, rock, faction_material("environment", ROLE_HULL)))
1712    accent = faction_material("environment", ROLE_ACCENT)
1713    if rich:
1714        accent.emissive_strength = palette("environment").accent_strength * 2.0
1715    node.add_child(_mesh_instance(ROLE_ACCENT, veins, accent))
1716    return node
1717
1718
1719def build_asteroid(seed: int = 0, radius: float = 3.0) -> Node3D:
1720    """A plain noise-displaced rock: cover, collision and clutter.
1721
1722    Not in the module contract but wanted by every sector layout, so it lives
1723    beside the deposit it shares its displacement with.
1724    """
1725    if radius <= 0.0:
1726        raise ValueError(f"Asteroid radius must be positive, got {radius}")
1727    rock = _cached(("asteroid", seed, round(float(radius), 3)), lambda: (_displaced_rock(seed, radius),))[0]
1728    node = Node3D(name="AsteroidArt")
1729    node.add_child(_mesh_instance(ROLE_HULL, rock, faction_material("environment", ROLE_HULL)))
1730    return node
1731
1732
1733def _build_wreck_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh]:
1734    rng = np.random.default_rng(seed)
1735    length = float(rng.uniform(5.0, 8.0))
1736    beam = length * 0.34
1737    # A torn hull: the blast hole is a real subtraction, so the break reads as
1738    # a break from any angle rather than a painted-on decal.
1739    torso = csg_combine(
1740        _scaled(_unit_cube(), (length * 0.5, beam * 0.6, beam)),
1741        _scaled(_unit_sphere(6, 8), (beam * 1.5, beam * 1.5, beam * 1.5)),
1742        CSGOperation.SUBTRACT,
1743    )
1744    hull = Kit()
1745    hull.add(torso)
1746    hull.add(
1747        _unit_cone(10),
1748        position=(length * 0.36, 0.0, 0.0),
1749        rotation=_Y_TO_X,
1750        scale=(beam * 0.7, length * 0.28, beam * 0.7),
1751    )
1752    _greeble(hull, rng, span=(length * 0.3, beam * 0.3, beam * 0.4), count=18, size=0.22)
1753
1754    # Exposed spars, swept as extruded ribs out of the break.
1755    trim = Kit()
1756    for _ in range(4):
1757        start = (float(rng.uniform(-length * 0.1, length * 0.2)), 0.0, 0.0)
1758        centreline = [
1759            start,
1760            (start[0] + float(rng.uniform(0.6, 1.4)), float(rng.uniform(-0.5, 0.5)), float(rng.uniform(-0.9, 0.9))),
1761            (start[0] + float(rng.uniform(1.6, 2.8)), float(rng.uniform(-0.9, 0.9)), float(rng.uniform(-1.8, 1.8))),
1762        ]
1763        trim.add(Mesh.extrude_path(centreline, sides=5, radius=float(rng.uniform(0.06, 0.14))))
1764
1765    # A cargo lamp still running on the wreck's last cell.
1766    accent = Kit()
1767    accent.add(_unit_cube(), position=(-length * 0.3, beam * 0.32, 0.0), scale=(0.5, 0.05, 0.12))
1768    return hull.build(), trim.build(), accent.build()
1769
1770
1771def build_wreck(seed: int = 0) -> Node3D:
1772    """A salvageable hulk: torn plating, exposed spars and one live lamp."""
1773    hull, trim, accent = _cached(("wreck", seed), lambda: _build_wreck_meshes(seed))
1774    node = Node3D(name="WreckArt")
1775    node.add_child(_mesh_instance(ROLE_HULL, hull, faction_material("environment", ROLE_HULL)))
1776    node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("environment", ROLE_TRIM)))
1777    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("environment", ROLE_ACCENT)))
1778    return node
1779
1780
1781def _build_depot_meshes(seed: int) -> tuple[Mesh, Mesh, Mesh]:
1782    rng = np.random.default_rng(seed)
1783    radius = 5.0
1784    height = 2.6
1785    # Core drum with the docking bay cut through the rim: the cutter is offset
1786    # past the radius so the bay opens outward instead of leaving a sealed void.
1787    drum = csg_combine(
1788        _scaled(_unit_cylinder(16), (radius * 2.0, height, radius * 2.0)),
1789        _scaled(_unit_cube(), (radius * 1.4, height * 0.55, radius * 0.7), position=(radius * 0.7, 0.0, 0.0)),
1790        CSGOperation.SUBTRACT,
1791    )
1792    hull = Kit()
1793    hull.add(drum)
1794    _greeble(hull, rng, span=(radius * 0.7, height * 0.5, radius * 0.7), count=24, size=0.3)
1795
1796    # Habitation ring, swept as a closed extrusion around the drum.
1797    ring_points = [
1798        (math.cos(math.tau * i / 24) * radius * 1.5, 0.0, math.sin(math.tau * i / 24) * radius * 1.5) for i in range(24)
1799    ]
1800    trim = Kit()
1801    trim.add(Mesh.extrude_path(ring_points, sides=6, radius=0.32, closed=True))
1802    for index in range(4):
1803        angle = math.tau * index / 4
1804        trim.add(
1805            _unit_cube(),
1806            position=(math.cos(angle) * radius * 1.25, 0.0, math.sin(angle) * radius * 1.25),
1807            rotation=Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), -angle),
1808            scale=(radius * 0.6, 0.16, 0.3),
1809        )
1810    trim.add(_unit_cylinder(8), position=(0.0, height * 0.9, 0.0), scale=(0.22, height * 0.9, 0.22))
1811
1812    # Approach lights around the bay mouth, the thing a docking pilot reads.
1813    accent = Kit()
1814    for index in range(10):
1815        angle = math.tau * index / 10
1816        accent.add(
1817            _unit_sphere(5, 6),
1818            position=(math.cos(angle) * radius * 1.02, height * 0.42, math.sin(angle) * radius * 1.02),
1819            scale=0.22,
1820        )
1821    accent.add(_unit_cube(), position=(radius * 0.75, 0.0, 0.0), scale=(0.12, height * 0.4, radius * 0.62))
1822    return hull.build(), trim.build(), accent.build()
1823
1824
1825def build_depot(seed: int = 0) -> Node3D:
1826    """A drift depot: drum, habitation ring, cut docking bay and approach lights."""
1827    hull, trim, accent = _cached(("depot", seed), lambda: _build_depot_meshes(seed))
1828    node = Node3D(name="DepotArt")
1829    node.add_child(_mesh_instance(ROLE_HULL, hull, faction_material("environment", ROLE_HULL)))
1830    node.add_child(_mesh_instance(ROLE_TRIM, trim, faction_material("environment", ROLE_TRIM)))
1831    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("environment", ROLE_ACCENT)))
1832    return node
1833
1834
1835def _build_vault_meshes(seed: int) -> tuple[Mesh, Mesh]:
1836    rng = np.random.default_rng(seed)
1837    size = 2.8
1838    # A sealed block with a recessed hatch: the Shrike's shear opens it later.
1839    shell = csg_combine(
1840        _scaled(_unit_cube(), (size, size * 0.8, size)),
1841        _scaled(_unit_cylinder(12), (size * 0.66, size * 0.3, size * 0.66), rotation=_Y_TO_X),
1842        CSGOperation.SUBTRACT,
1843    )
1844    hull = Kit()
1845    hull.add(shell)
1846    _greeble(hull, rng, span=(size * 0.35, size * 0.4, size * 0.35), count=10, size=0.22)
1847    accent = Kit()
1848    accent.add(
1849        _unit_cylinder(12), position=(size * 0.42, 0.0, 0.0), rotation=_Y_TO_X, scale=(size * 0.5, 0.06, size * 0.5)
1850    )
1851    return hull.build(), accent.build()
1852
1853
1854def build_vault(seed: int = 0) -> Node3D:
1855    """A sealed vault: the twenty-second hack, and what the Shrike shears open.
1856
1857    Not in the module contract but the sector needs one, and it belongs with
1858    the station kit it is cut from.
1859    """
1860    hull, accent = _cached(("vault", seed), lambda: _build_vault_meshes(seed))
1861    node = Node3D(name="VaultArt")
1862    node.add_child(_mesh_instance(ROLE_HULL, hull, faction_material("environment", ROLE_HULL)))
1863    node.add_child(_mesh_instance(ROLE_ACCENT, accent, faction_material("environment", ROLE_ACCENT)))
1864    return node
1865
1866
1867# ============================================================================
1868# Mesh cache
1869# ============================================================================
1870
1871_MESH_CACHE: dict[tuple, tuple[Mesh, ...]] = {}
1872
1873
1874def _cached(key: tuple, factory) -> tuple:
1875    """Build once per key and share the result: meshes here are never mutated."""
1876    cached = _MESH_CACHE.get(key)
1877    if cached is None:
1878        cached = tuple(factory())
1879        _MESH_CACHE[key] = cached
1880    return cached
1881
1882
1883def clear_mesh_cache() -> None:
1884    """Drop every cached build. For tests and for a sector teardown."""
1885    _MESH_CACHE.clear()
1886
1887
1888# ============================================================================
1889# The nebula sky
1890# ============================================================================
1891
1892
1893@dataclass(frozen=True)
1894class ActGrade:
1895    """The look of one act: ambient, fog, exposure, bloom and nebula tints."""
1896
1897    id: str
1898    ambient_colour: tuple[float, float, float, float]
1899    ambient_energy: float
1900    fog_colour: tuple[float, float, float, float]
1901    fog_density: float
1902    tonemap_exposure: float
1903    bloom_threshold: float
1904    bloom_intensity: float
1905    sky_top: tuple[float, float, float, float]
1906    nebula_deep: tuple[float, float, float]
1907    nebula_bright: tuple[float, float, float]
1908
1909
1910#: Shallows cold cyan, Claims amber rust, Deep blood-violet on black. Exposure
1911#: falls act by act: the Deep is the darkest screen in the game.
1912ACT_GRADES: dict[int, ActGrade] = {
1913    1: ActGrade(
1914        "shallows",
1915        ambient_colour=(0.06, 0.11, 0.14, 1.0),
1916        ambient_energy=0.85,
1917        fog_colour=(0.05, 0.12, 0.16, 1.0),
1918        fog_density=0.010,
1919        tonemap_exposure=1.05,
1920        bloom_threshold=1.0,
1921        bloom_intensity=0.75,
1922        sky_top=(0.014, 0.030, 0.042, 1.0),
1923        nebula_deep=(0.02, 0.10, 0.14),
1924        nebula_bright=(0.16, 0.52, 0.60),
1925    ),
1926    2: ActGrade(
1927        "claims",
1928        ambient_colour=(0.12, 0.08, 0.05, 1.0),
1929        ambient_energy=0.70,
1930        fog_colour=(0.13, 0.08, 0.04, 1.0),
1931        fog_density=0.014,
1932        tonemap_exposure=0.95,
1933        bloom_threshold=1.05,
1934        bloom_intensity=0.85,
1935        sky_top=(0.036, 0.022, 0.012, 1.0),
1936        nebula_deep=(0.12, 0.06, 0.02),
1937        nebula_bright=(0.58, 0.32, 0.12),
1938    ),
1939    3: ActGrade(
1940        "deep",
1941        ambient_colour=(0.09, 0.03, 0.07, 1.0),
1942        ambient_energy=0.55,
1943        fog_colour=(0.09, 0.02, 0.06, 1.0),
1944        fog_density=0.020,
1945        tonemap_exposure=0.82,
1946        bloom_threshold=1.15,
1947        bloom_intensity=1.0,
1948        sky_top=(0.026, 0.006, 0.018, 1.0),
1949        nebula_deep=(0.10, 0.01, 0.05),
1950        nebula_bright=(0.48, 0.08, 0.26),
1951    ),
1952}
1953
1954
1955def act_grade(act: int) -> ActGrade:
1956    """The grade for *act* (1 to ``balance.CHART_ACTS``), raising outside that."""
1957    try:
1958        return ACT_GRADES[act]
1959    except KeyError:
1960        raise ValueError(f"No grade for act {act!r}; acts are 1 to {balance.CHART_ACTS}") from None
1961
1962
1963def _resample_square(field: np.ndarray, size: int) -> np.ndarray:
1964    """Bilinearly resize a square field to ``size`` by ``size``.
1965
1966    Sampling at texel centres, so the cloud layer arrives at the face
1967    resolution as a smooth gradient rather than as visible blocks.
1968    """
1969    source = field.shape[0]
1970    if source == size:
1971        return field
1972    coords = (np.arange(size, dtype=np.float64) + 0.5) * source / size - 0.5
1973    low = np.clip(np.floor(coords).astype(np.int64), 0, source - 1)
1974    high = np.clip(low + 1, 0, source - 1)
1975    weight = np.clip(coords - low, 0.0, 1.0)
1976    rows = field[low] * (1.0 - weight)[:, None] + field[high] * weight[:, None]
1977    return rows[:, low] * (1.0 - weight)[None, :] + rows[:, high] * weight[None, :]
1978
1979
1980def nebula_cubemap_faces(seed: int = 0, act: int = 1, size: int = NEBULA_FACE_SIZE) -> list[np.ndarray]:
1981    """Six float32 RGBA cube faces of a procedural nebula, in Vulkan face order.
1982
1983    The result is linear HDR: the clouds stay dim so environments read
1984    near-black, and the star field runs above 1.0 so bloom picks it up. Feeding
1985    it to ``WorldEnvironment.environment_map`` drives both the skybox and the
1986    image-based lighting from the same bake.
1987
1988    The two layers are baked at different resolutions on purpose. Clouds are
1989    evaluated on a :data:`NEBULA_CLOUD_SIZE` grid and resampled up, because
1990    noise is the expensive half and a soft cloud gains nothing from detail;
1991    stars are stamped at the full face resolution, because the face size is
1992    what decides how small a star can be.
1993
1994    Stars are Gaussian splats at sub-texel centres rather than single texels.
1995    A texel of a cube face is a third of a degree of arc even at this
1996    resolution, and a lone hot texel magnified onto the screen is a square: the
1997    splat is what makes it a round point of light instead.
1998    """
1999    if size < 8:
2000        raise ValueError(f"Nebula face size must be at least 8, got {size}")
2001    cloud_size = min(int(size), NEBULA_CLOUD_SIZE)
2002    grade = act_grade(act)
2003    clouds = FastNoiseLite(seed=int(seed), noise_type=NoiseType.SIMPLEX, frequency=1.0)
2004    clouds.fractal_type = FractalType.FBM
2005    clouds.fractal_octaves = 5
2006    filaments = FastNoiseLite(seed=int(seed) + 977, noise_type=NoiseType.SIMPLEX, frequency=2.4)
2007    filaments.fractal_type = FractalType.RIDGED
2008    filaments.fractal_octaves = 4
2009
2010    grid = (np.arange(cloud_size, dtype=np.float64) + 0.5) / cloud_size * 2.0 - 1.0
2011    u, v = np.meshgrid(grid, grid)
2012    deep = np.asarray(grade.nebula_deep, dtype=np.float32)
2013    bright = np.asarray(grade.nebula_bright, dtype=np.float32)
2014    scale = (size / NEBULA_FACE_SIZE) ** 2
2015    star_count = max(STAR_MIN_PER_FACE, int(round(STARS_PER_FACE * scale)))
2016
2017    faces: list[np.ndarray] = []
2018    for index, (right, up, forward) in enumerate(_FACE_BASIS):
2019        direction = (
2020            np.asarray(forward)[None, None, :]
2021            + u[..., None] * np.asarray(right)[None, None, :]
2022            + v[..., None] * np.asarray(up)[None, None, :]
2023        )
2024        direction /= np.linalg.norm(direction, axis=-1, keepdims=True)
2025        x = direction[..., 0].ravel()
2026        y = direction[..., 1].ravel()
2027        z = direction[..., 2].ravel()
2028
2029        body = (np.asarray(clouds.get_noise_3d_array(x, y, z)).reshape(cloud_size, cloud_size) * 0.5 + 0.5) ** 2.2
2030        threads = np.asarray(filaments.get_noise_3d_array(x, y, z)).reshape(cloud_size, cloud_size)
2031        thread = np.clip(threads, 0.0, 1.0) ** 3.0
2032        mix = _resample_square(np.clip(body * 0.8 + thread * 0.45, 0.0, 1.0), size).astype(np.float32)
2033
2034        face = np.empty((size, size, 4), dtype=np.float32)
2035        cloud = deep[None, None, :] * 0.35 + (bright - deep)[None, None, :] * mix[..., None]
2036        face[..., :3] = cloud * NEBULA_CLOUD_ENERGY
2037        _stamp_stars(face, seed=seed, act=act, face_index=index, count=star_count)
2038        face[..., 3] = 1.0
2039        faces.append(np.maximum(face, 0.0))
2040    return faces
2041
2042
2043def _stamp_stars(face: np.ndarray, *, seed: int, act: int, face_index: int, count: int) -> None:
2044    """Add *count* soft, tinted stars to one cube face, in place.
2045
2046    Seeded per face so no two skies repeat, and splatted rather than masked, so
2047    the cost is the star count and not the face area.
2048    """
2049    size = face.shape[0]
2050    rng = np.random.default_rng((int(seed), act, face_index))
2051    centres = rng.uniform(0.0, float(size), size=(count, 2))
2052    energy = rng.uniform(STAR_ENERGY_MIN, STAR_ENERGY_MAX, count)
2053    # One number per star bends its hue from cold blue-white to warm amber.
2054    temperature = rng.uniform(-1.0, 1.0, count)
2055    tint = np.stack(
2056        (
2057            1.0 + STAR_TINT * temperature,
2058            np.ones(count),
2059            1.0 - STAR_TINT * temperature,
2060        ),
2061        axis=1,
2062    )
2063
2064    steps = np.arange(-STAR_KERNEL_RADIUS, STAR_KERNEL_RADIUS + 1)
2065    offset_rows, offset_columns = (axis.ravel() for axis in np.meshgrid(steps, steps, indexing="ij"))
2066    anchor = np.floor(centres).astype(np.int64)
2067    delta_rows = anchor[:, 0, None] + offset_rows[None, :] + 0.5 - centres[:, 0, None]
2068    delta_columns = anchor[:, 1, None] + offset_columns[None, :] + 0.5 - centres[:, 1, None]
2069    weights = np.exp(-(delta_rows**2 + delta_columns**2) / (2.0 * STAR_SIGMA**2))
2070
2071    # Wrapping rather than clipping: a star near an edge keeps its round
2072    # profile instead of piling its whole splat into the last texel.
2073    rows = np.mod(anchor[:, 0, None] + offset_rows[None, :], size)
2074    columns = np.mod(anchor[:, 1, None] + offset_columns[None, :], size)
2075    light = (weights * energy[:, None])[..., None] * tint[:, None, :]
2076    np.add.at(face, (rows.ravel(), columns.ravel(), slice(0, 3)), light.reshape(-1, 3).astype(np.float32))
2077
2078
2079def apply_nebula_sky(env: WorldEnvironment, seed: int = 0, act: int = 1, size: int = NEBULA_FACE_SIZE) -> None:
2080    """Install a freshly baked nebula as the scene's skybox and IBL source."""
2081    grade = act_grade(act)
2082    env.sky_mode = "colour"
2083    env.sky_colour_top = grade.sky_top
2084    env.sky_colour_bottom = grade.sky_top
2085    env.environment_map = {"faces": nebula_cubemap_faces(seed, act, size)}
2086
2087
2088# ============================================================================
2089# Per-act grading and exposure
2090# ============================================================================
2091
2092
2093def apply_act_grading(env: WorldEnvironment, act: int, *, silent_running: bool = False) -> None:
2094    """Grade the scene for *act*, optionally under silent running.
2095
2096    Silent running drops the whole scene's exposure by
2097    :data:`SILENT_RUNNING_EXPOSURE_MULT`: the quietest screen is also the
2098    darkest, which is the point.
2099    """
2100    grade = act_grade(act)
2101    env.ambient_light_colour = grade.ambient_colour
2102    env.ambient_light_energy = grade.ambient_energy
2103    env.fog_enabled = True
2104    env.fog_colour = grade.fog_colour
2105    env.fog_density = grade.fog_density
2106    env.tonemap_mode = "aces"
2107    env.tonemap_exposure = grade.tonemap_exposure * (SILENT_RUNNING_EXPOSURE_MULT if silent_running else 1.0)
2108    env.bloom_enabled = True
2109    env.bloom_threshold = grade.bloom_threshold
2110    env.bloom_intensity = grade.bloom_intensity
2111
2112
2113def configure_environment(
2114    env: WorldEnvironment,
2115    act: int = 1,
2116    seed: int = 0,
2117    *,
2118    silent_running: bool = False,
2119    nebula_size: int = NEBULA_FACE_SIZE,
2120) -> WorldEnvironment:
2121    """Set a run scene's whole look in one call: sky, grade and post chain.
2122
2123    SSAO grounds the wrecks, screen-space reflections carry the plating, and
2124    bloom is driven entirely by the emissives the readability contract owns.
2125    """
2126    apply_nebula_sky(env, seed, act, nebula_size)
2127    apply_act_grading(env, act, silent_running=silent_running)
2128    env.ssao_enabled = True
2129    env.ssao_radius = 0.6
2130    env.ssao_intensity = 1.1
2131    env.ssr_enabled = True
2132    env.ssr_intensity = 0.55
2133    env.ssr_roughness_cutoff = 0.45
2134    env.fxaa_enabled = True
2135    return env
2136
2137
2138def build_environment(
2139    act: int = 1,
2140    seed: int = 0,
2141    *,
2142    silent_running: bool = False,
2143    nebula_size: int = NEBULA_FACE_SIZE,
2144) -> WorldEnvironment:
2145    """A fully configured ``WorldEnvironment`` for a run scene."""
2146    return configure_environment(
2147        WorldEnvironment(name="Environment"),
2148        act,
2149        seed,
2150        silent_running=silent_running,
2151        nebula_size=nebula_size,
2152    )
2153
2154
2155# ============================================================================
2156# Placement helper
2157# ============================================================================
2158
2159
2160def place_on_plane(node: Node3D, x: float, z: float, *, heading: float = 0.0) -> Node3D:
2161    """Put *node* on the flight plane at ``(x, z)`` facing *heading*.
2162
2163    A build points along +X, so a yaw about +Y by the heading matches
2164    :func:`runtime.heading_to_direction` exactly.
2165    """
2166    node.position = Vec3(float(x), PLANE_Y, float(z))
2167    node.rotation = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), float(heading))
2168    return node