src/textures/procedural.js

// ————————————————————————————————————————————————
// Procedural planet textures — Canvas2D → THREE.CanvasTexture.
//
// Everything here is deterministic (seeded mulberry32 RNG) and runs once at
// startup. No network requests, no external images. Works fully offline.
//
// Shared by solar-system.html and scale.html so both views render identical
// planet surfaces.
// ————————————————————————————————————————————————

import * as THREE from 'three';

export function makeCanvas(w, h) {
  const c = document.createElement('canvas');
  c.width = w;
  c.height = h;
  return c;
}

// Deterministic PRNG so the same seed produces the same texture every time.
export function mulberry32(seed) {
  return function () {
    let t = (seed += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Scatter noisy speckles across the canvas for surface grain.
function addSpeckles(ctx, w, h, count, rng, colorFn, sizeMin = 0.5, sizeMax = 2) {
  for (let i = 0; i < count; i++) {
    const x = rng() * w,
      y = rng() * h;
    const r = sizeMin + rng() * (sizeMax - sizeMin);
    ctx.fillStyle = colorFn(rng);
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }
}

// ————————————————————————————————————————————————
// Sun — radial plasma blobs over a warm gradient.
// ————————————————————————————————————————————————
export function sunTexture() {
  const w = 1024,
    h = 512;
  const c = makeCanvas(w, h);
  const ctx = c.getContext('2d');
  const rng = mulberry32(1);

  const g = ctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, '#ffdc6a');
  g.addColorStop(0.5, '#ffb833');
  g.addColorStop(1, '#ff8a1a');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, w, h);

  for (let i = 0; i < 400; i++) {
    const x = rng() * w,
      y = rng() * h;
    const r = 20 + rng() * 60;
    const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
    const hot = rng() > 0.5;
    grd.addColorStop(0, hot ? 'rgba(255,240,180,0.55)' : 'rgba(220,80,30,0.45)');
    grd.addColorStop(1, 'rgba(255,180,60,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }
  addSpeckles(
    ctx,
    w,
    h,
    800,
    rng,
    () => `rgba(255,${(220 + rng() * 35) | 0},${(100 + rng() * 80) | 0},0.5)`,
    0.5,
    1.8
  );
  return new THREE.CanvasTexture(c);
}

// ————————————————————————————————————————————————
// Inner rocky planets
// ————————————————————————————————————————————————
export function mercuryTexture() {
  const w = 1024,
    h = 512,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(2);
  ctx.fillStyle = '#8a8276';
  ctx.fillRect(0, 0, w, h);
  for (let i = 0; i < 60; i++) {
    ctx.fillStyle = `rgba(${(90 + rng() * 40) | 0},${(85 + rng() * 30) | 0},${(75 + rng() * 25) | 0},0.4)`;
    const y = rng() * h,
      hh = 4 + rng() * 12;
    ctx.fillRect(0, y, w, hh);
  }
  for (let i = 0; i < 500; i++) {
    const x = rng() * w,
      y = rng() * h,
      r = 2 + rng() * 10;
    const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
    grd.addColorStop(0, 'rgba(50,45,40,0.55)');
    grd.addColorStop(0.7, 'rgba(180,170,155,0.25)');
    grd.addColorStop(1, 'rgba(180,170,155,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }
  addSpeckles(
    ctx,
    w,
    h,
    6000,
    rng,
    () => `rgba(${(60 + rng() * 60) | 0},${(60 + rng() * 60) | 0},${(55 + rng() * 50) | 0},0.3)`,
    0.4,
    1.2
  );
  return new THREE.CanvasTexture(c);
}

export function venusTexture() {
  const w = 1024,
    h = 512,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(3);
  const g = ctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, '#d4a055');
  g.addColorStop(0.5, '#e6bc74');
  g.addColorStop(1, '#b88540');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, w, h);
  for (let i = 0; i < 30; i++) {
    ctx.fillStyle = `rgba(${(220 + rng() * 30) | 0},${(180 + rng() * 40) | 0},${(100 + rng() * 50) | 0},0.25)`;
    const y = rng() * h,
      hh = 8 + rng() * 40;
    ctx.beginPath();
    ctx.moveTo(0, y);
    for (let x = 0; x <= w; x += 16) {
      ctx.lineTo(x, y + Math.sin(x * 0.01 + i) * hh * 0.2);
    }
    ctx.lineTo(w, y + hh);
    ctx.lineTo(0, y + hh);
    ctx.fill();
  }
  addSpeckles(
    ctx,
    w,
    h,
    4000,
    rng,
    () =>
      `rgba(${(240 - rng() * 40) | 0},${(200 - rng() * 40) | 0},${(120 - rng() * 30) | 0},0.18)`,
    0.5,
    1.5
  );
  return new THREE.CanvasTexture(c);
}

export function earthTexture() {
  const w = 1024,
    h = 512,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(4);
  const g = ctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, '#1e3a6b');
  g.addColorStop(0.5, '#2a5a9a');
  g.addColorStop(1, '#1e3a6b');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, w, h);
  for (let i = 0; i < 40; i++) {
    const cx = rng() * w,
      cy = rng() * h;
    const base = 15 + rng() * 55;
    ctx.fillStyle = ['#3a6b2e', '#4a7d35', '#6b8a40', '#8a6a3a'][i % 4];
    ctx.beginPath();
    const blobs = (8 + rng() * 6) | 0;
    for (let j = 0; j < blobs; j++) {
      const a = (j / blobs) * Math.PI * 2;
      const rr = base * (0.5 + rng() * 0.9);
      const x = cx + Math.cos(a) * rr,
        y = cy + Math.sin(a) * rr * 0.5;
      if (j === 0) ctx.moveTo(x, y);
      else
        ctx.quadraticCurveTo(
          cx + Math.cos(a - 0.3) * rr * 1.2,
          cy + Math.sin(a - 0.3) * rr * 0.7,
          x,
          y
        );
    }
    ctx.closePath();
    ctx.fill();
  }
  ctx.fillStyle = 'rgba(240,245,250,0.85)';
  ctx.fillRect(0, 0, w, 22);
  ctx.fillRect(0, h - 22, w, 22);
  for (let i = 0; i < 120; i++) {
    ctx.fillStyle = `rgba(255,255,255,${0.08 + rng() * 0.18})`;
    const x = rng() * w,
      y = rng() * h,
      rr = 10 + rng() * 40;
    ctx.beginPath();
    ctx.ellipse(x, y, rr, rr * 0.35, rng() * Math.PI, 0, Math.PI * 2);
    ctx.fill();
  }
  return new THREE.CanvasTexture(c);
}

export function moonTexture() {
  const w = 1024,
    h = 512,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(11);
  ctx.fillStyle = '#9a948a';
  ctx.fillRect(0, 0, w, h);
  // A handful of dark maria patches roughly clustered on the near side
  // (centered on x ≈ 0.5w in equirectangular). Real maria are basaltic
  // floods of ancient impact basins; this is just gestural.
  for (let i = 0; i < 9; i++) {
    const cx = w * (0.35 + rng() * 0.3);
    const cy = h * (0.25 + rng() * 0.5);
    const rr = 35 + rng() * 70;
    const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr);
    grd.addColorStop(0, 'rgba(60,55,52,0.65)');
    grd.addColorStop(0.6, 'rgba(80,72,68,0.4)');
    grd.addColorStop(1, 'rgba(154,148,138,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.ellipse(cx, cy, rr, rr * (0.6 + rng() * 0.4), rng() * Math.PI, 0, Math.PI * 2);
    ctx.fill();
  }
  // Crater pocking — same idea as Mercury's, slightly higher contrast so
  // the surface still reads as cratered when the photographic map is missing.
  for (let i = 0; i < 700; i++) {
    const x = rng() * w,
      y = rng() * h,
      r = 2 + rng() * 12;
    const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
    grd.addColorStop(0, 'rgba(40,38,35,0.55)');
    grd.addColorStop(0.7, 'rgba(190,184,172,0.3)');
    grd.addColorStop(1, 'rgba(190,184,172,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }
  addSpeckles(
    ctx,
    w,
    h,
    8000,
    rng,
    () => `rgba(${(90 + rng() * 60) | 0},${(85 + rng() * 55) | 0},${(80 + rng() * 50) | 0},0.28)`,
    0.4,
    1.1
  );
  return new THREE.CanvasTexture(c);
}

// ————————————————————————————————————————————————
// Phobos / Deimos / Miranda — fallbacks for the photographic moon textures
// (#100). Visually distinct from a flat tint so an offline / failed-load
// path still reads as cratered grey rather than a uniform color.
// ————————————————————————————————————————————————
function smallMoonTexture(seed, baseHex, craterCount, craterSize) {
  const w = 512,
    h = 256;
  const c = makeCanvas(w, h);
  const ctx = c.getContext('2d');
  const rng = mulberry32(seed);
  ctx.fillStyle = baseHex;
  ctx.fillRect(0, 0, w, h);
  for (let i = 0; i < craterCount; i++) {
    const x = rng() * w,
      y = rng() * h,
      r = craterSize[0] + rng() * (craterSize[1] - craterSize[0]);
    const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
    grd.addColorStop(0, 'rgba(30,28,25,0.55)');
    grd.addColorStop(0.7, 'rgba(255,255,255,0.18)');
    grd.addColorStop(1, 'rgba(255,255,255,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();
  }
  addSpeckles(
    ctx,
    w,
    h,
    2500,
    rng,
    () => `rgba(${(40 + rng() * 60) | 0},${(40 + rng() * 55) | 0},${(40 + rng() * 50) | 0},0.25)`,
    0.4,
    1.0
  );
  return new THREE.CanvasTexture(c);
}

export function phobosTexture() {
  return smallMoonTexture(101, '#8a7a68', 600, [2, 14]);
}
export function deimosTexture() {
  return smallMoonTexture(102, '#9a8a78', 350, [2, 9]);
}
export function titaniaTexture() {
  return smallMoonTexture(104, '#a09690', 500, [3, 16]);
}
export function oberonTexture() {
  return smallMoonTexture(105, '#8e857f', 550, [3, 18]);
}
export function mirandaTexture() {
  // Brighter base + low-contrast bright/dark albedo bands hinting at Miranda's
  // chevron and Inverness coronae without claiming to depict them.
  const w = 512,
    h = 256;
  const c = makeCanvas(w, h);
  const ctx = c.getContext('2d');
  const rng = mulberry32(103);
  ctx.fillStyle = '#a8b0b6';
  ctx.fillRect(0, 0, w, h);
  for (let i = 0; i < 6; i++) {
    const cx = rng() * w,
      cy = h * (0.25 + rng() * 0.5),
      rr = 50 + rng() * 90;
    const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr);
    grd.addColorStop(0, rng() > 0.5 ? 'rgba(70,75,80,0.45)' : 'rgba(220,225,230,0.35)');
    grd.addColorStop(1, 'rgba(168,176,182,0)');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.ellipse(cx, cy, rr, rr * (0.5 + rng() * 0.5), rng() * Math.PI, 0, Math.PI * 2);
    ctx.fill();
  }
  addSpeckles(
    ctx,
    w,
    h,
    2000,
    rng,
    () =>
      `rgba(${(140 + rng() * 70) | 0},${(150 + rng() * 65) | 0},${(160 + rng() * 60) | 0},0.22)`,
    0.4,
    1.0
  );
  return new THREE.CanvasTexture(c);
}

export function marsTexture() {
  const w = 1024,
    h = 512,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(5);
  const g = ctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, '#8a3a28');
  g.addColorStop(0.5, '#c25a38');
  g.addColorStop(1, '#7a3020');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, w, h);
  for (let i = 0; i < 25; i++) {
    ctx.fillStyle = `rgba(${(90 + rng() * 40) | 0},${(45 + rng() * 20) | 0},${(30 + rng() * 15) | 0},0.5)`;
    const x = rng() * w,
      y = rng() * h,
      rr = 20 + rng() * 70;
    ctx.beginPath();
    ctx.ellipse(x, y, rr, rr * 0.6, rng() * Math.PI, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = 'rgba(245,230,220,0.9)';
  ctx.fillRect(0, 0, w, 18);
  ctx.fillStyle = 'rgba(245,230,220,0.85)';
  ctx.fillRect(0, h - 16, w, 16);
  addSpeckles(
    ctx,
    w,
    h,
    5000,
    rng,
    () => `rgba(${(160 + rng() * 50) | 0},${(70 + rng() * 30) | 0},${(45 + rng() * 20) | 0},0.3)`,
    0.4,
    1.3
  );
  return new THREE.CanvasTexture(c);
}

// ————————————————————————————————————————————————
// Gas giants — stacked horizontal bands with optional storm spot
// ————————————————————————————————————————————————
function gasGiantTexture(seed, bands) {
  const w = 1024,
    h = 512,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(seed);
  let y = 0;
  while (y < h) {
    const bh = (4 + rng() * 28) | 0;
    const col = bands[(rng() * bands.length) | 0];
    const g = ctx.createLinearGradient(0, y, 0, y + bh);
    g.addColorStop(0, col[0]);
    g.addColorStop(1, col[1]);
    ctx.fillStyle = g;
    ctx.fillRect(0, y, w, bh);
    y += bh;
  }
  for (let i = 0; i < 400; i++) {
    ctx.fillStyle = `rgba(${(200 + rng() * 55) | 0},${(180 + rng() * 55) | 0},${(140 + rng() * 60) | 0},${0.05 + rng() * 0.1})`;
    const yy = rng() * h,
      ww = 60 + rng() * 200,
      hh = 1 + rng() * 4;
    ctx.beginPath();
    ctx.ellipse(rng() * w, yy, ww, hh, 0, 0, Math.PI * 2);
    ctx.fill();
  }
  return new THREE.CanvasTexture(c);
}

export function jupiterTexture() {
  const t = gasGiantTexture(6, [
    ['#d9b58a', '#b88a55'],
    ['#e8cfa2', '#c9a47a'],
    ['#8a5d38', '#6a422a'],
    ['#f0dfb8', '#d8bd88'],
    ['#a87855', '#8a5a3a'],
  ]);
  // Great Red Spot
  const ctx = t.image.getContext('2d');
  const sx = t.image.width * 0.35,
    sy = t.image.height * 0.62;
  const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, 55);
  grd.addColorStop(0, 'rgba(180,60,40,0.9)');
  grd.addColorStop(1, 'rgba(180,60,40,0)');
  ctx.fillStyle = grd;
  ctx.beginPath();
  ctx.ellipse(sx, sy, 55, 22, 0, 0, Math.PI * 2);
  ctx.fill();
  t.needsUpdate = true;
  return t;
}

export function saturnTexture() {
  return gasGiantTexture(7, [
    ['#e8d4a3', '#cbb082'],
    ['#f2e3bb', '#d9c59a'],
    ['#c4a474', '#a48555'],
    ['#eed9a8', '#d4bb85'],
  ]);
}

export function uranusTexture() {
  return gasGiantTexture(8, [
    ['#bfe3e8', '#a5d5dd'],
    ['#cfeef2', '#b5dfe6'],
    ['#9acbd3', '#82b7c0'],
  ]);
}

export function neptuneTexture() {
  const t = gasGiantTexture(9, [
    ['#3a5fcf', '#2a4aa5'],
    ['#506fd5', '#3a56b5'],
    ['#2a4697', '#1e3680'],
  ]);
  // Great Dark Spot
  const ctx = t.image.getContext('2d');
  const sx = t.image.width * 0.6,
    sy = t.image.height * 0.45;
  const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, 45);
  grd.addColorStop(0, 'rgba(20,25,60,0.85)');
  grd.addColorStop(1, 'rgba(20,25,60,0)');
  ctx.fillStyle = grd;
  ctx.beginPath();
  ctx.ellipse(sx, sy, 45, 20, 0, 0, Math.PI * 2);
  ctx.fill();
  t.needsUpdate = true;
  return t;
}

// ————————————————————————————————————————————————
// Saturn's rings — 1D radial strip with a Cassini division at ~55%.
// ————————————————————————————————————————————————
export function saturnRingTexture() {
  const w = 512,
    h = 64,
    c = makeCanvas(w, h),
    ctx = c.getContext('2d');
  const rng = mulberry32(10);
  for (let x = 0; x < w; x++) {
    const t = x / w;
    const cassini = Math.abs(t - 0.55) < 0.02 ? 0.15 : 1;
    const noise = 0.85 + rng() * 0.3;
    const brightness = cassini * noise * (0.5 + 0.5 * Math.sin(t * 30));
    const col = `rgba(${(220 * brightness) | 0},${(195 * brightness) | 0},${(145 * brightness) | 0},${0.75 * cassini})`;
    ctx.fillStyle = col;
    ctx.fillRect(x, 0, 1, h);
  }
  return new THREE.CanvasTexture(c);
}

// ————————————————————————————————————————————————
// Lookup table: body name → texture function.
// Used by views that iterate PLANETS from src/data/planets.js.
// ————————————————————————————————————————————————
export const TEXTURE_BY_NAME = {
  Sun: sunTexture,
  Mercury: mercuryTexture,
  Venus: venusTexture,
  Earth: earthTexture,
  Mars: marsTexture,
  Jupiter: jupiterTexture,
  Saturn: saturnTexture,
  Uranus: uranusTexture,
  Neptune: neptuneTexture,
};
← Astrarium