/**
 * The environment map: what every reflective surface in the world is looking at.
 *
 * ## What this fixes
 *
 * Before this file existed there was no `Scene.environment` anywhere in `src/`,
 * and that single absence is the whole explanation for the most common
 * complaint about how this product looks. A `MeshStandardMaterial` with
 * `metalness > 0` has, by construction, almost no diffuse term — metal does not
 * scatter, it *reflects* — so with nothing to reflect it renders as a flat dark
 * grey wash and reads as painted plastic. The library has eleven such roles:
 * `metalTrim` at 0.85, `chairBase` at 0.75, `glazingFrame` and `deskFrame` at
 * 0.7 and 0.65, `partitionFrame`, `deviceMesh`, and the five Model X materials.
 * All of them were being asked to look like metal with a black room around them.
 *
 * The codebase already documents this against itself in two places, which is
 * how you know it is not a matter of taste. `office/optimus.ts:62` abandoned a
 * whole material role over it, and `vehicles/modelX.ts:96` fakes an `emissive`
 * term on the car paint to stand in for the sky bounce that was missing. Both
 * are workarounds for this file not existing.
 *
 * ## What it is allowed to do
 *
 * **It constructs no light. Not one, of any type.** CONTRACT.md §4 makes
 * `Atmosphere` the sole light owner and gives the data exactly one direction to
 * flow: an `Environment` is observed, `atmosphere.apply()` turns it into a
 * `LightingState`, a scene applies that, and nothing writes back. This rig sits
 * at the end of that same one-way street — it is handed the `LightingState`
 * that has *already been decided* and derives an environment from it. It never
 * decides anything about the light itself, so there is no second owner and
 * nothing to keep in sync.
 *
 * That constraint is also why there is no `RoomEnvironment` import here.
 * Three's version is a perfectly good office environment and the office path
 * below is recognisably descended from it, but it is a fixed room: it does not
 * know the hour, the weather, or which way the building faces, so an office at
 * 4 p.m. in August would reflect the same neutral studio light as one at
 * midnight in January. Deriving the room from `LightingState` instead costs
 * about forty lines and means the reflections move with the sun like everything
 * else in the scene does.
 *
 * ## Procedural, like everything else
 *
 * No `.hdr`, no `.exr`, no cubemap faces on disk. The city environment is a
 * `DataTexture` filled in a double loop from the same sky colours the
 * background gradient uses, and the office environment is nine untextured
 * quads. `scripts/check-no-binaries.mjs` stays satisfied for the same reason
 * `textures.ts` keeps it satisfied — the art is the code (CONTRACT.md §3).
 */

import * as THREE from "three";
import type { LightingState } from "./types.ts";

/** Which of the two worlds is being reflected. */
export type EnvironmentKind = "city" | "office";

/** Per-call options for {@link EnvironmentRig.apply}. */
export interface ApplyOptions {
  /**
   * This scene is being built but is not on screen, and must not disturb one
   * that is.
   *
   * ## Why the rig needs to be told
   *
   * The rig holds exactly one cached probe per kind. On a key miss it renders a
   * new one, **disposes the one the visible board is using**, and repoints every
   * applied scene of that kind at the replacement. That is exactly right when
   * the miss is time passing: one clock, one sky, every city scene agrees.
   *
   * It is exactly wrong when the miss comes from a board nobody is looking at.
   * A board built ahead of the camera is observed at *its own* centre — across
   * California about forty minutes of apparent solar time — so its key differs
   * by construction, and the visible board's sky would change because something
   * invisible finished loading behind it.
   *
   * With this set, a key miss is not a rebuild: the off-stage scene borrows
   * whatever probe is already cached, and the correct one arrives on its first
   * on-stage `apply`, which by then is the same call every board already makes.
   * Nothing is disposed, nothing already applied is repointed, and no PMREM
   * convolution runs on the shared renderer inside somebody else's frame.
   */
  offstage?: boolean;
}

export interface EnvironmentRig {
  apply(
    scene: THREE.Scene,
    lighting: LightingState,
    kind: "city" | "office",
    options?: ApplyOptions,
  ): void;
  /**
   * Forget a scene that is being torn down.
   *
   * `apply` records every scene it has written to, so that a rebuilt
   * environment can be pushed to all of them at once rather than only to the
   * one that happened to ask. That ledger is a strong reference, and a page
   * that switches city three times disposes three scenes the rig would
   * otherwise hold forever — the whole graph, because `createScene`'s dispose
   * frees geometries and materials without clearing its children. This is the
   * matching call, and a disposing scene is the only correct caller: it nulls
   * `scene.environment` and drops the entry. The cached PMREM targets are
   * shared across every scene of that kind and are **not** freed here; that is
   * `dispose()`'s job, and it belongs to whoever owns the Stage.
   */
  release(scene: THREE.Scene): void;
  dispose(): void;
}

/**
 * Equirectangular source resolution, in texels.
 *
 * Small on purpose. PMREM takes a cube face of `width / 4`, so 256 gives a
 * 64² cubemap, and the thing being encoded is a vertical gradient with one
 * bright lobe in it — there is no detail here that 512 would preserve and 256
 * would lose. What that buys is the right to rebuild often: the whole cost of
 * a sky change is 32k texels of CPU fill, a 128 KB upload and a handful of
 * quarter-megapixel GPU passes, which is affordable several times a second and
 * therefore affordable during a time-lapse.
 */
const EQUIRECT_WIDTH = 256;
const EQUIRECT_HEIGHT = 128;

/**
 * How much of the sky's brightness the environment carries, against the
 * hemisphere light that was already carrying all of it.
 *
 * This number exists because the environment map and the hemisphere light are
 * two descriptions of the same physical thing — light arriving from the sky —
 * and applying both at full strength counts it twice. Three's
 * `getIBLIrradiance` returns `PI * radiance`, and `BRDF_Lambert` divides by PI,
 * so an environment of uniform radiance R contributes exactly `albedo * R` to a
 * diffuse surface, against `albedo * intensity * colour / PI` from the
 * hemisphere. At 0.12 the environment lands at roughly a fifth of the
 * hemisphere's diffuse contribution, and the day stops in `atmosphere.ts` gave
 * up about a tenth of `hemiIntensity` and a quarter of `ambientIntensity` to
 * make room for it. Those two edits are one decision and were made together.
 *
 * The reason it is a minority share rather than a replacement: a PMREM
 * irradiance probe has no shadow term and no local occlusion, so raising it
 * until it *is* the sky fill would light the inside of a closed room as
 * brightly as the roof of it. The hemisphere light has the same flaw, but it
 * is the flaw the rest of the world is already tuned against.
 */
const SKY_RADIANCE_SHARE = 0.12;

/**
 * The sun's own disc, as a radiance and a tightness.
 *
 * Kept narrow and kept modest. A cosine power of 400 is a lobe about four
 * degrees across, which contributes almost nothing to the irradiance integral —
 * so the directional light `Atmosphere` already owns is not double-counted —
 * while giving every smooth metal and every pane of glass a specular highlight
 * with a *direction* in it. That highlight is most of what separates "this is
 * metal" from "this is grey", and it is the one thing a uniform ambient can
 * never supply.
 */
const SUN_LOBE_RADIANCE = 1.2;
const SUN_LOBE_TIGHTNESS = 400;
const SUN_GLOW_RADIANCE = 0.22;
const SUN_GLOW_TIGHTNESS = 9;

/**
 * How much of the sky bounces back off the ground.
 *
 * The lower half of the sphere is not black — it is the city, or the floor, lit
 * by the same sun. `LightingState.hemisphere.ground` is the number
 * `Atmosphere` already publishes for exactly this and it is reused rather than
 * re-derived, so a hazy afternoon greys the underside of a car and the top of
 * it together.
 */
const GROUND_RADIANCE_SHARE = 0.09;

/**
 * The office room, in metres. A generous meeting room rather than a studio,
 * because the reflection of a room reads as the *proportions* of a room and a
 * cube reads as a lift.
 */
const ROOM_WIDTH = 9;
const ROOM_DEPTH = 7;
const ROOM_HEIGHT = 3.2;

export function createEnvironmentRig(renderer: THREE.WebGLRenderer): EnvironmentRig {
  /**
   * Everything below is built on first use and not before.
   *
   * A `PMREMGenerator` compiles three shader programs the moment it is asked to
   * do anything, and a page that opens on a city board should not pay for the
   * office's blur chain until somebody walks into an office. `createStage`
   * builds one rig for the life of the page (see the note in `stage.ts` about
   * why there is exactly one renderer), so "first use" here means once.
   */
  let pmrem: THREE.PMREMGenerator | null = null;
  let equirect: THREE.DataTexture | null = null;
  let room: RoomProbe | null = null;

  /** One cached PMREM target per kind, with the key it was built from. */
  const built = new Map<EnvironmentKind, { key: string; target: THREE.WebGLRenderTarget }>();

  /**
   * Every scene this rig has written an environment onto, and which kind it
   * was given.
   *
   * The kind is carried rather than just the scene because a page can hold both
   * at once — CONTRACT.md §1 keeps the city alive and paused while an office is
   * on screen — and when one kind rebuilds, the scenes that need the new texture
   * are the ones on *that* kind. Handing a sunset city sky to the office
   * standing beside it would light the room through a wall.
   */
  const applied = new Map<THREE.Scene, EnvironmentKind>();

  let disposed = false;

  function generator(): THREE.PMREMGenerator {
    if (!pmrem) pmrem = new THREE.PMREMGenerator(renderer);
    return pmrem;
  }

  function build(kind: EnvironmentKind, lighting: LightingState): THREE.WebGLRenderTarget {
    if (kind === "office") {
      if (!room) room = createRoomProbe();
      room.tune(lighting);
      // A little blur at capture time. The room is nine flat quads and a hard
      // edge between two of them would show up as a visible seam in the
      // reflection on a polished desk; four hundredths of a radian is under a
      // pixel of the cube face and is enough to take that edge off.
      return generator().fromScene(room.scene, 0.04, 0.1, 40);
    }
    equirect = fillSkyEquirect(equirect, lighting);
    return generator().fromEquirectangular(equirect);
  }

  return {
    apply(scene, lighting, kind, options) {
      if (disposed) return;
      const key = environmentKey(kind, lighting);
      const current = built.get(kind);
      // An off-stage board never rebuilds. See ApplyOptions.offstage: the
      // rebuild path disposes the visible board's probe and repoints it, and a
      // board nobody is looking at has no business doing either. It takes
      // whatever is cached and gets the right one when it is presented.
      const mayRebuild = options?.offstage !== true;

      if (mayRebuild && (!current || current.key !== key)) {
        let target: THREE.WebGLRenderTarget;
        try {
          target = build(kind, lighting);
        } catch (error) {
          /*
           * Degrade rather than take the frame down.
           *
           * Everything in here runs against a live GL context, and the two ways
           * that goes wrong in the field are a lost context and a driver that
           * refuses a half-float render target. Neither is a reason for a city
           * to stop drawing: without an environment the world looks the way it
           * looked before this file was written, which is worse and still a
           * world. The warning is deliberately not swallowed silently — a
           * missing environment is very hard to diagnose from the picture
           * alone, because "everything is slightly duller" does not look like
           * an error.
           */
          console.warn("environmentRig: could not build an environment map", error);
          return;
        }
        current?.target.dispose();
        built.set(kind, { key, target });
        // The previous texture has just been freed, and every scene that was
        // holding it is now pointing at a disposed target — not only the scene
        // that happened to ask for the rebuild. Scenes on the other kind are
        // left strictly alone.
        for (const [other, otherKind] of applied) {
          if (otherKind === kind) other.environment = target.texture;
        }
      }

      const target = built.get(kind);
      if (!target) return;
      scene.environment = target.target.texture;
      // Stated rather than left at its default, because the brightness of the
      // environment is decided by the radiances above and a stray intensity
      // here would silently override all of that reasoning.
      scene.environmentIntensity = 1;
      applied.set(scene, kind);
    },

    release(scene) {
      if (!applied.delete(scene)) return;
      scene.environment = null;
    },

    dispose() {
      disposed = true;
      for (const scene of applied.keys()) {
        scene.environment = null;
      }
      applied.clear();
      for (const entry of built.values()) entry.target.dispose();
      built.clear();
      equirect?.dispose();
      equirect = null;
      room?.dispose();
      room = null;
      // `PMREMGenerator.dispose()` frees its own blur materials and ping-pong
      // target. It does not touch the targets it handed out, which is why they
      // are disposed above first.
      pmrem?.dispose();
      pmrem = null;
    },
  };
}

// ---- Rebuild key ----------------------------------------------------------

/**
 * A coarse fingerprint of the lighting, used to decide whether to rebuild.
 *
 * Coarse is the entire point. `Atmosphere` interpolates its keyframe table
 * continuously, so on a running clock every field of a `LightingState` changes
 * by a fraction every single frame — an exact key would rebuild the environment
 * sixty times a second and none of those rebuilds would be visible. Colours are
 * reduced to five bits a channel and the sun direction to twelfths of a unit
 * vector, which is roughly a five-degree bucket, so a real dawn still rebuilds
 * often enough to track and a static afternoon rebuilds once.
 */
function environmentKey(kind: EnvironmentKind, l: LightingState): string {
  const d = l.sun.direction;
  return [
    kind,
    quantiseColor(l.sky?.top ?? l.hemisphere.sky),
    quantiseColor(l.sky?.horizon ?? l.hemisphere.ground),
    quantiseColor(l.hemisphere.sky),
    quantiseColor(l.hemisphere.ground),
    Math.round(l.hemisphere.intensity * 20),
    quantiseColor(l.ambient.color),
    Math.round(l.ambient.intensity * 20),
    quantiseColor(l.sun.color),
    Math.round(l.sun.intensity * 20),
    Math.round(d[0] * 12),
    Math.round(d[1] * 12),
    Math.round(d[2] * 12),
  ].join(":");
}

/** 24-bit colour down to 15, which is finer than the eye reads off a gradient. */
function quantiseColor(hex: number): number {
  return (((hex >> 19) & 0x1f) << 10) | (((hex >> 11) & 0x1f) << 5) | ((hex >> 3) & 0x1f);
}

// ---- The city sky ---------------------------------------------------------

/**
 * Fill (or refill) the equirectangular sky.
 *
 * Written into an existing buffer where there is one. A `DataTexture` is 128 KB
 * of `Uint16Array` and the rebuild happens on a timer nobody controls, so
 * allocating a new one each time hands the garbage collector a steady drip of
 * medium-sized buffers for no reason — and the GPU-side texture object would be
 * recreated with it, which is the expensive half.
 *
 * The sphere is built in three parts, all of them derived from the
 * `LightingState` and none of them invented here:
 *
 *   - **Above the horizon**, the same top-to-horizon gradient `scenekit.ts`
 *     paints on the background, so the environment and the visible sky are the
 *     same sky. It is blended on `sin(elevation)` raised to a power rather than
 *     linearly on the angle, because that is what puts the pale band near the
 *     horizon where the eye expects it.
 *   - **Below the horizon**, the hemisphere light's ground colour. The lower
 *     half of a real environment is the terrain, and leaving it black is the
 *     single most common way an environment map makes a car look like a toy.
 *   - **The sun**, as a narrow lobe plus a wide glow, at the direction
 *     `Atmosphere` already computed. Half-float storage is what makes this
 *     possible at all: the lobe sits several times above 1.0 and an 8-bit
 *     texture would clip it to the same white as the sky beside it, which is
 *     the exact failure `stage.ts` just removed from the main render path.
 */
function fillSkyEquirect(existing: THREE.DataTexture | null, l: LightingState): THREE.DataTexture {
  const width = EQUIRECT_WIDTH;
  const height = EQUIRECT_HEIGHT;
  const texture =
    existing ??
    new THREE.DataTexture(
      new Uint16Array(width * height * 4),
      width,
      height,
      THREE.RGBAFormat,
      THREE.HalfFloatType,
    );
  const data = texture.image.data as Uint16Array;

  const top = linearOf(l.sky?.top ?? l.hemisphere.sky);
  const horizon = linearOf(l.sky?.horizon ?? l.hemisphere.ground);
  const ground = linearOf(l.hemisphere.ground);

  const skyScale = SKY_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
  const groundScale = GROUND_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
  const sun = linearOf(l.sun.color);
  const sunScale = Math.max(0, l.sun.intensity);
  const [sx, sy, sz] = l.sun.direction;

  const half = THREE.DataUtils.toHalfFloat;

  for (let j = 0; j < height; j++) {
    // Row 0 is v = 0. Three's `equirectUv` puts v = 0 at `dir.y = -1`, and a
    // `DataTexture` does not flip, so row 0 is straight down.
    const v = (j + 0.5) / height;
    const phi = (v - 0.5) * Math.PI;
    const sinPhi = Math.sin(phi);
    const cosPhi = Math.cos(phi);

    // The vertical blend, before the sun is added. Above the horizon it walks
    // the sky gradient; below it fades the ground colour down as it goes under,
    // so there is no hard band at the equator to show up in a mirror.
    let baseR: number;
    let baseG: number;
    let baseB: number;
    if (sinPhi >= 0) {
      const t = Math.pow(sinPhi, 0.55);
      baseR = (horizon[0] + (top[0] - horizon[0]) * t) * skyScale;
      baseG = (horizon[1] + (top[1] - horizon[1]) * t) * skyScale;
      baseB = (horizon[2] + (top[2] - horizon[2]) * t) * skyScale;
    } else {
      const t = Math.pow(-sinPhi, 0.7);
      const dim = 1 - 0.55 * t;
      baseR = (horizon[0] * (1 - t) * skyScale + ground[0] * t * groundScale) * dim;
      baseG = (horizon[1] * (1 - t) * skyScale + ground[1] * t * groundScale) * dim;
      baseB = (horizon[2] * (1 - t) * skyScale + ground[2] * t * groundScale) * dim;
    }

    for (let i = 0; i < width; i++) {
      const u = (i + 0.5) / width;
      const theta = (u - 0.5) * Math.PI * 2;
      const dx = cosPhi * Math.cos(theta);
      const dy = sinPhi;
      const dz = cosPhi * Math.sin(theta);

      const cos = dx * sx + dy * sy + dz * sz;
      let solar = 0;
      if (cos > 0) {
        solar =
          SUN_LOBE_RADIANCE * Math.pow(cos, SUN_LOBE_TIGHTNESS) +
          SUN_GLOW_RADIANCE * Math.pow(cos, SUN_GLOW_TIGHTNESS);
        solar *= sunScale;
      }

      const o = (j * width + i) * 4;
      data[o] = half(baseR + sun[0] * solar);
      data[o + 1] = half(baseG + sun[1] * solar);
      data[o + 2] = half(baseB + sun[2] * solar);
      data[o + 3] = half(1);
    }
  }

  texture.mapping = THREE.EquirectangularReflectionMapping;
  // Half-float data is already linear radiance; naming a transfer function here
  // would apply an sRGB decode to numbers that were never encoded.
  texture.colorSpace = THREE.NoColorSpace;
  texture.needsUpdate = true;
  return texture;
}

// ---- The office room ------------------------------------------------------

interface RoomProbe {
  scene: THREE.Scene;
  tune(l: LightingState): void;
  dispose(): void;
}

/**
 * Nine quads that reflect like a room.
 *
 * The list is short and every entry earns its place in a reflection: a bright
 * ceiling and two brighter light panels (which is what puts the long vertical
 * highlight down the edge of a monitor bezel), a floor darker than the walls, a
 * window wall carrying the sun's own colour, and a warm end wall so the room
 * has a direction to it and a chrome chair leg is not the same colour all the
 * way round.
 *
 * Built once and re-coloured, not rebuilt. `tune` only assigns to
 * `material.color`, so the geometry, the materials and their shader programs
 * survive every change of hour.
 */
function createRoomProbe(): RoomProbe {
  const scene = new THREE.Scene();
  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.MeshBasicMaterial[] = [];

  function quad(
    w: number,
    h: number,
    position: [number, number, number],
    rotation: [number, number, number],
  ): THREE.MeshBasicMaterial {
    const geometry = new THREE.PlaneGeometry(w, h);
    // `DoubleSide` so the probe camera at the origin sees every quad whichever
    // way it was authored — an inside-out wall in an environment reads as a
    // hole, and a hole reads as a black stripe across everything shiny.
    const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.set(position[0], position[1], position[2]);
    mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
    scene.add(mesh);
    geometries.push(geometry);
    materials.push(material);
    return material;
  }

  const HALF_W = ROOM_WIDTH / 2;
  const HALF_D = ROOM_DEPTH / 2;
  const HALF_H = ROOM_HEIGHT / 2;

  const ceiling = quad(ROOM_WIDTH, ROOM_DEPTH, [0, HALF_H, 0], [Math.PI / 2, 0, 0]);
  const floor = quad(ROOM_WIDTH, ROOM_DEPTH, [0, -HALF_H, 0], [-Math.PI / 2, 0, 0]);
  const back = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, -HALF_D], [0, 0, 0]);
  const front = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, HALF_D], [0, Math.PI, 0]);
  const left = quad(ROOM_DEPTH, ROOM_HEIGHT, [-HALF_W, 0, 0], [0, Math.PI / 2, 0]);
  const window = quad(ROOM_DEPTH, ROOM_HEIGHT, [HALF_W, 0, 0], [0, -Math.PI / 2, 0]);
  const panelA = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, -1.4], [Math.PI / 2, 0, 0]);
  const panelB = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, 1.4], [Math.PI / 2, 0, 0]);
  const accent = quad(ROOM_WIDTH * 0.9, 0.35, [0, -0.6, -HALF_D + 0.01], [0, 0, 0]);

  return {
    scene,
    tune(l) {
      const sky = linearOf(l.hemisphere.sky);
      const groundC = linearOf(l.hemisphere.ground);
      const ambient = linearOf(l.ambient.color);
      const sun = linearOf(l.sun.color);

      // An interior probe is normalised against the *fill*, not against the
      // sun: a room's own surfaces are what a desk reflects, and they are lit
      // by whatever is getting inside. Reading `hemisphere.intensity` keeps a
      // night office reflecting a dim room and a noon office a bright one
      // without this file forming its own opinion about either.
      const fill = Math.max(0.05, l.hemisphere.intensity) * SKY_RADIANCE_SHARE;
      const solar = Math.max(0, l.sun.intensity) * SKY_RADIANCE_SHARE;

      setLinear(ceiling, ambient, fill * 1.5);
      setLinear(floor, groundC, fill * 0.8);
      setLinear(back, ambient, fill * 1.1);
      setLinear(front, ambient, fill * 1.0);
      setLinear(left, ambient, fill * 1.2);
      // The window is the only surface that knows what time it is, and it is
      // the one that gives a monitor bezel a bright edge on the daylight side.
      setLinear(window, sun, solar * 2.2 + fill * 0.6);
      setLinear(panelA, sky, fill * 7);
      setLinear(panelB, sky, fill * 7);
      setLinear(accent, groundC, fill * 1.6);
    },
    dispose() {
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      geometries.length = 0;
      materials.length = 0;
      scene.clear();
    },
  };
}

// ---- Colour ---------------------------------------------------------------

const SCRATCH = new THREE.Color();

/**
 * An authored `0xrrggbb` as linear-light RGB.
 *
 * Named rather than inlined because getting it wrong is invisible until it is
 * everywhere: `LightingState` colours are sRGB by the definition in `types.ts`,
 * and everything on this page is radiance, which is linear. Multiplying an
 * un-decoded 0.5 by an intensity is off by more than a factor of two at the
 * dark end of the range, and the symptom is a night sky that reflects like an
 * overcast noon.
 */
function linearOf(hex: number): [number, number, number] {
  SCRATCH.setHex(hex, THREE.SRGBColorSpace);
  return [SCRATCH.r, SCRATCH.g, SCRATCH.b];
}

/**
 * Set a material's colour from linear radiance, which may exceed 1.
 *
 * `Color.setHex` and the `{ color }` constructor argument both go through an
 * sRGB decode and both saturate at 1.0, so neither can express a light panel
 * seven times brighter than the wall beside it. `setRGB` in the working colour
 * space can, and a light panel that cannot be brighter than the wall is not a
 * light panel.
 */
function setLinear(
  material: THREE.MeshBasicMaterial,
  color: readonly [number, number, number],
  scale: number,
): void {
  material.color.setRGB(color[0] * scale, color[1] * scale, color[2] * scale);
}
