Back to Powder Tool
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool

Release
242076e6da24…
Author-recorded commit
c4489f440f27…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-48e516e34e5

Archive hash verified: f2fd072590aac7a5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/simulation.ts
/** Local, deterministic physics. No browser or shell dependencies. */
export const M = { empty: 0, sand: 1, water: 2, oil: 3, fire: 4, steam: 5, lava: 6, wood: 7, wall: 8, smoke: 9, hydrogen: 10, helium: 11, co2: 12 } as const;
export type Material = number;
export const GAS_IDS: readonly number[] = [M.steam, M.smoke, M.hydrogen, M.helium, M.co2];
export const EMITTABLE: readonly number[] = [M.sand, M.water, M.oil, M.fire, M.steam, M.lava, M.smoke, M.hydrogen, M.helium, M.co2];
export type Outlet = 'down' | 'up' | 'left' | 'right';
export type Emitter = { id: number; x: number; y: number; material: number; rate: number; radius: number; direction: Outlet; enabled: boolean; carry: number };
export type EmitterSettings = Pick<Emitter, 'material' | 'rate' | 'radius' | 'direction' | 'enabled'>;
const gas = (m: number) => m === M.fire || GAS_IDS.includes(m);
export type Vec = { x: number; y: number };
export type BodyKind = 'box' | 'stone';
export type Body = { id: number; kind: BodyKind; x: number; y: number; vx: number; vy: number; angle: number; omega: number; size: number; heat: number; mass: number; invMass: number; invInertia: number; local: Vec[] };
type Collider = { vertices: Vec[]; x: number; y: number; left: number; right: number; top: number; bottom: number };
type Contact = { normal: Vec; depth: number; point: Vec };
export type SceneData = { version: 1 | 2; width: number; height: number; gravity: number; seed: number; cells: number[] | string; life: number[] | string; emitters?: Emitter[]; bodies: Pick<Body, 'kind' | 'x' | 'y' | 'vx' | 'vy' | 'angle' | 'omega' | 'size' | 'heat'>[] };
const density = [0, 4, 2, 1, -.7, -.4, 3, 9, 9, -.2, -.95, -.85, .3];
const solid = (m: number) => m === M.wall || m === M.wood;
const cross = (a: Vec, b: Vec) => a.x * b.y - a.y * b.x;
const dot = (a: Vec, b: Vec) => a.x * b.x + a.y * b.y;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
export function vertices(b: Body): Vec[] {
  const c = Math.cos(b.angle), s = Math.sin(b.angle);
  return b.local.map(p => ({ x: b.x + p.x * c - p.y * s, y: b.y + p.x * s + p.y * c }));
}
export function contains(poly: Vec[], x: number, y: number): boolean {
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i], b = poly[(i + 1) % poly.length];
    if ((b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) < -0.001) return false;
  }
  return true;
}
function bounds(v: Vec[]): Collider {
  const left = Math.min(...v.map(p => p.x)), right = Math.max(...v.map(p => p.x));
  const top = Math.min(...v.map(p => p.y)), bottom = Math.max(...v.map(p => p.y));
  return { vertices: v, left, right, top, bottom, x: (left + right) / 2, y: (top + bottom) / 2 };
}
function rectangle(x: number, y: number, w: number, h: number): Collider {
  return bounds([{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }]);
}
/** SAT for convex polygons. Normal points from B into A. */
function contact(a: Collider, b: Collider): Contact | null {
  if (a.left >= b.right || a.right <= b.left || a.top >= b.bottom || a.bottom <= b.top) return null;
  let depth = Infinity, normal = { x: 0, y: 0 };
  for (const poly of [a.vertices, b.vertices]) {
    for (let i = 0; i < poly.length; i++) {
      const p = poly[i], q = poly[(i + 1) % poly.length];
      const len = Math.hypot(q.x - p.x, q.y - p.y);
      const axis = { x: -(q.y - p.y) / len, y: (q.x - p.x) / len };
      const av = a.vertices.map(v => dot(v, axis)), bv = b.vertices.map(v => dot(v, axis));
      const overlap = Math.min(Math.max(...av) - Math.min(...bv), Math.max(...bv) - Math.min(...av));
      if (overlap <= 0) return null;
      if (overlap < depth) { depth = overlap; normal = axis; }
    }
  }
  if (dot({ x: a.x - b.x, y: a.y - b.y }, normal) < 0) normal = { x: -normal.x, y: -normal.y };
  // Use the contacting face on A, clamped along the tangent to B's extent.
  const minA = Math.min(...a.vertices.map(v => dot(v, normal)));
  const points = a.vertices.filter(v => dot(v, normal) < minA + .35);
  const point = { x: points.reduce((s, v) => s + v.x, 0) / points.length, y: points.reduce((s, v) => s + v.y, 0) / points.length };
  point.x = clamp(point.x, b.left, b.right); point.y = clamp(point.y, b.top, b.bottom);
  return { normal, depth, point };
}
function resolve(a: Body, b: Body | null, hit: Contact): void {
  const n = hit.normal, total = a.invMass + (b?.invMass ?? 0);
  const correction = Math.max(0, hit.depth - .025) * .7 / total;
  a.x += n.x * correction * a.invMass; a.y += n.y * correction * a.invMass;
  if (b) { b.x -= n.x * correction * b.invMass; b.y -= n.y * correction * b.invMass; }
  const ra = { x: hit.point.x - a.x, y: hit.point.y - a.y };
  const rb = { x: hit.point.x - (b?.x ?? hit.point.x), y: hit.point.y - (b?.y ?? hit.point.y) };
  const rv = { x: a.vx - a.omega * ra.y - (b ? b.vx - b.omega * rb.y : 0), y: a.vy + a.omega * ra.x - (b ? b.vy + b.omega * rb.x : 0) };
  const vn = dot(rv, n);
  if (vn >= 0) return;
  const denom = total + cross(ra, n) ** 2 * a.invInertia + cross(rb, n) ** 2 * (b?.invInertia ?? 0);
  const impulse = -(1 + (vn < -8 ? .12 : 0)) * vn / denom;
  const tangent = { x: -n.y, y: n.x };
  const frictionDenom = total + cross(ra, tangent) ** 2 * a.invInertia + cross(rb, tangent) ** 2 * (b?.invInertia ?? 0);
  const friction = clamp(-dot(rv, tangent) / frictionDenom, -impulse * .55, impulse * .55);
  const force = { x: impulse * n.x + friction * tangent.x, y: impulse * n.y + friction * tangent.y };
  a.vx += force.x * a.invMass; a.vy += force.y * a.invMass; a.omega += cross(ra, force) * a.invInertia;
  if (b) { b.vx -= force.x * b.invMass; b.vy -= force.y * b.invMass; b.omega -= cross(rb, force) * b.invInertia; }
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function base64(bytes: number[] | Uint8Array): string {
  let out = '';
  for (let i = 0; i < bytes.length; i += 3) {
    const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
    out += alphabet[(n >>> 18) & 63] + alphabet[(n >>> 12) & 63] + (i + 1 < bytes.length ? alphabet[(n >>> 6) & 63] : '=') + (i + 2 < bytes.length ? alphabet[n & 63] : '=');
  }
  return out;
}
function unbase64(value: string): Uint8Array {
  if (value.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('Invalid scene encoding');
  const bytes: number[] = [];
  for (let i = 0; i < value.length; i += 4) {
    const n = (alphabet.indexOf(value[i]) << 18) | (alphabet.indexOf(value[i + 1]) << 12) | (Math.max(0, alphabet.indexOf(value[i + 2])) << 6) | Math.max(0, alphabet.indexOf(value[i + 3]));
    bytes.push((n >>> 16) & 255);
    if (value[i + 2] !== '=') bytes.push((n >>> 8) & 255);
    if (value[i + 3] !== '=') bytes.push(n & 255);
  }
  return new Uint8Array(bytes);
}
/** Lossless binary RLE, with a raw fallback for noisy fire/gas fields. */
function encode(a: Uint8Array | Uint16Array): string {
  const runs: number[] = [];
  for (let i = 0; i < a.length;) {
    const value = a[i]; let end = i + 1;
    while (end < a.length && a[end] === value && end - i < 65535) end++;
    runs.push(value & 255, value >>> 8, (end - i) & 255, (end - i) >>> 8); i = end;
  }
  if (runs.length < a.length * a.BYTES_PER_ELEMENT) return 'r' + base64(runs);
  const raw: number[] = [];
  for (const value of a) { raw.push(value & 255); if (a.BYTES_PER_ELEMENT === 2) raw.push(value >>> 8); }
  return 'b' + base64(raw);
}
function decode(value: unknown, length: number, max: number): Uint16Array {
  const result = new Uint16Array(length); let offset = 0;
  const append = (v: number, count: number) => {
    if (!Number.isInteger(v) || v < 0 || v > max || !Number.isInteger(count) || count < 1 || offset + count > length) throw new Error('Invalid scene data');
    result.fill(v, offset, offset + count); offset += count;
  };
  if (Array.isArray(value)) {
    if (value.length % 2 || value.length > length * 2) throw new Error('Invalid scene data');
    for (let i = 0; i < value.length; i += 2) append(value[i], value[i + 1]);
  } else if (typeof value === 'string' && value.length <= length * 6 + 16) {
    const bytes = unbase64(value.slice(1));
    if (value[0] === 'r' && bytes.length % 4 === 0) {
      for (let i = 0; i < bytes.length; i += 4) append(bytes[i] | (bytes[i + 1] << 8), bytes[i + 2] | (bytes[i + 3] << 8));
    } else if (value[0] === 'b') {
      const stride = max > 255 ? 2 : 1;
      if (bytes.length !== length * stride) throw new Error('Invalid scene data');
      for (let i = 0; i < bytes.length; i += stride) append(bytes[i] | (stride === 2 ? bytes[i + 1] << 8 : 0), 1);
    } else throw new Error('Invalid scene encoding');
  } else throw new Error('Invalid scene data');
  if (offset !== length) throw new Error('Incomplete scene data');
  return result;
}
export function validEmitter(e: EmitterSettings): boolean {
  return Boolean(e) && EMITTABLE.includes(e.material) && Number.isInteger(e.rate) && e.rate >= 1 && e.rate <= 2400 && Number.isInteger(e.radius) && e.radius >= 1 && e.radius <= 24 && ['up', 'down', 'left', 'right'].includes(e.direction) && typeof e.enabled === 'boolean';
}
export class Simulation {
  width: number; height: number;
  cells: Uint8Array; life: Uint16Array; private moved: Uint32Array; occupancy: Int32Array;
  bodies: Body[] = []; emitters: Emitter[] = []; private nextEmitterId = 1; gravity = 1; tick = 0; seed: number;
  private nextId = 1; private terrainDirty = true; private terrain: Collider[] = [];
  grab: { id: number; x: number; y: number; localX: number; localY: number } | null = null;
  constructor(width = 300, height = 200, seed = 82371) {
    this.width = width; this.height = height; this.seed = seed;
    this.cells = new Uint8Array(width * height); this.life = new Uint16Array(width * height);
    this.moved = new Uint32Array(width * height); this.occupancy = new Int32Array(width * height);
  }
  get units(): number { return Math.max(1, this.width / 300); }
  random(): number { let x = this.seed | 0; x ^= x << 13; x ^= x >>> 17; x ^= x << 5; this.seed = x >>> 0; return this.seed / 4294967296; }
  get(x: number, y: number): number {
    x = Math.floor(x); y = Math.floor(y);
    return x < 0 || x >= this.width || y < 0 || y >= this.height ? M.wall : this.cells[y * this.width + x];
  }
  set(x: number, y: number, m: number, life = 0): void {
    x = Math.floor(x); y = Math.floor(y);
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
    const i = y * this.width + x;
    if (solid(m) || solid(this.cells[i])) this.terrainDirty = true;
    this.cells[i] = m; this.life[i] = life || (m === M.fire ? 40 + Math.floor(this.random() * 80) : m === M.steam ? 360 + Math.floor(this.random() * 240) : m === M.smoke ? 420 + Math.floor(this.random() * 180) : 0);
  }
  paint(x: number, y: number, radius: number, material: number): void {
    const r = Math.max(.5, radius);
    for (let py = Math.floor(y - r); py <= y + r; py++) for (let px = Math.floor(x - r); px <= x + r; px++) {
      if ((px - x) ** 2 + (py - y) ** 2 > r * r || px < 0 || px >= this.width || py < 0 || py >= this.height) continue;
      const i = py * this.width + px;
      if (material === M.empty || (!this.occupancy[i] && (!this.cells[i] || material === M.fire))) {
        if (material === M.empty || solid(material) || this.random() < .6) this.set(px, py, material);
      }
    }
    if (material === M.empty) {
      this.bodies = this.bodies.filter(b => !contains(vertices(b), x, y));
      this.emitters = this.emitters.filter(e => Math.hypot(e.x - x, e.y - y) > Math.max(r, 6 * this.units));
    }
  }
  addBody(kind: BodyKind, x: number, y: number, size = 12): Body | null {
    if (this.bodies.length >= 48) return null;
    // Keep legacy small bodies exact; the UI scales its placement minimum.
    size = clamp(size, 4, 24 * this.units);
    const local = kind === 'box' ? [{ x: -size, y: -size }, { x: size, y: -size }, { x: size, y: size }, { x: -size, y: size }] : Array.from({ length: 7 }, (_, i) => {
      const angle = i * Math.PI * 2 / 7; const radius = size * (i % 2 ? .91 : 1);
      return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
    });
    const mass = size * size * (kind === 'box' ? .55 : 2.8);
    const b: Body = { id: this.nextId++, kind, x: clamp(x, size * 1.5, this.width - size * 1.5), y: clamp(y, size * 1.5, this.height - size * 1.5), vx: 0, vy: 0, angle: kind === 'box' ? -.08 : .2, omega: 0, size, heat: 0, mass, invMass: 1 / mass, invInertia: 1 / (mass * size * size * .67), local };
    // Reject placements that intersect constructed walls or existing bodies.
    const shape = bounds(vertices(b));
    for (let py = Math.max(0, Math.floor(shape.top)); py < Math.min(this.height, shape.bottom); py++) for (let px = Math.max(0, Math.floor(shape.left)); px < Math.min(this.width, shape.right); px++) {
      if (solid(this.get(px, py)) && contains(shape.vertices, px + .5, py + .5)) return null;
    }
    if (this.bodies.some(other => contact(shape, bounds(vertices(other))))) return null;
    this.bodies.push(b); this.rasterBodies(); return b;
  }
  bodyAt(x: number, y: number): Body | undefined { return [...this.bodies].reverse().find(b => contains(vertices(b), x, y)); }
  private swap(i: number, j: number): void {
    const m = this.cells[j], life = this.life[j]; this.cells[j] = this.cells[i]; this.life[j] = this.life[i]; this.cells[i] = m; this.life[i] = life;
    this.moved[j] = this.tick; this.moved[i] = this.tick;
  }
  private move(i: number, x: number, y: number, rising = false): boolean {
    if (x < 0 || x >= this.width || y < 0 || y >= this.height) return false;
    const j = y * this.width + x;
    if (this.occupancy[j] || solid(this.cells[j])) return false;
    const a = this.cells[i], b = this.cells[j];
    if (!b || (!rising && density[a] > density[b] && b !== M.sand)) { this.swap(i, j); return true; }
    return false;
  }
  private react(i: number, x: number, y: number): void {
    const m = this.cells[i];
    if (m !== M.fire && m !== M.lava) return;
    const neighbors = [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]];
    for (const [px, py] of neighbors) {
      const n = this.get(px, py);
      if (n === M.co2 && m === M.fire) { this.set(x, y, M.smoke, 60); return; }
      if (n === M.water) {
        this.set(px, py, M.steam);
        this.set(x, y, m === M.lava ? M.wall : M.steam);
        return;
      }
      if (n === M.hydrogen) this.set(px, py, M.fire, 30 + Math.floor(this.random() * 40));
      if ((n === M.oil || n === M.wood) && this.random() < (n === M.oil ? .24 : .025)) this.set(px, py, M.fire, n === M.wood ? 180 : 100);
      if (m === M.lava && n === M.empty && this.random() < .004) this.set(px, py, M.fire, 35);
    }
  }
  private updateCell(x: number, y: number, gases: boolean): void {
    const i = y * this.width + x, m = this.cells[i];
    if (!m || solid(m) || this.occupancy[i] || this.moved[i] === this.tick) return;
    if (gases !== gas(m)) return;
    const direction = this.random() < .5 ? -1 : 1;
    // Both sides check contact before diffusing, so scan order cannot let fuel
    // or extinguishing gas dodge a heat source that it was already touching.
    if (m === M.hydrogen || m === M.co2) {
      for (const [px, py] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
        const neighbor = this.get(px, py);
        if (m === M.hydrogen && (neighbor === M.fire || neighbor === M.lava)) { this.set(x, y, M.fire, 50); return; }
        if (m === M.co2 && neighbor === M.fire) this.set(px, py, M.smoke, 60);
      }
    }
    if (m === M.fire || m === M.lava) this.react(i, x, y);
    if (this.cells[i] !== m) return;
    if (gases) {
      const finiteLife = m === M.fire || m === M.steam || m === M.smoke;
      if (finiteLife && this.life[i]) this.life[i]--;
      if (finiteLife && !this.life[i]) {
        this.set(x, y, m === M.steam && this.random() < .15 ? M.water : m === M.fire && this.random() < .6 ? M.smoke : M.empty);
        return;
      }
      // Rising gases vent at the open top. Heavy CO₂ pools against the closed floor.
      if (y === 0 && m !== M.co2) { this.set(x, y, M.empty); return; }
      const dy = m === M.co2 ? 1 : -1;
      const speed = m === M.hydrogen ? .97 : m === M.helium ? .9 : m === M.smoke ? .3 : m === M.fire ? .45 : .7;
      const moveGas = (px: number, py: number): boolean => {
        if (px < 0 || px >= this.width || py < 0 || py >= this.height) return false;
        const j = py * this.width + px, target = this.cells[j];
        if (this.occupancy[j]) return false;
        if (!target || (gas(target) && target !== m && ((dy < 0 && density[m] < density[target]) || (dy > 0 && density[m] > density[target])))) { this.swap(i, j); return true; }
        return false;
      };
      if (this.gravity > 0 && this.random() < speed * Math.min(1, this.gravity)) {
        if (moveGas(x, y + dy) || moveGas(x + direction, y + dy) || moveGas(x - direction, y + dy)) return;
      }
      if (this.random() < .45) { if (!moveGas(x + direction, y)) moveGas(x - direction, y); }
      return;
    }
    if (m === M.lava && this.random() > .2) return;
    if (this.gravity > 0 && this.random() < Math.min(1, this.gravity)) {
      if (this.move(i, x, y + 1) || this.move(i, x + direction, y + 1) || this.move(i, x - direction, y + 1)) return;
    }
    if (m !== M.sand) {
      const spread = Math.round((m === M.water ? 5 : m === M.oil ? 3 : 1) * this.units);
      for (const dir of [direction, -direction]) {
        let last = x;
        for (let s = 1; s <= spread; s++) {
          const px = x + dir * s;
          if (px < 0 || px >= this.width || this.cells[y * this.width + px] || this.occupancy[y * this.width + px]) break;
          last = px;
          if (this.gravity > 0 && this.get(px, y + 1) === M.empty) break;
        }
        if (last !== x) { this.move(i, last, y); return; }
      }
    }
  }
  addEmitter(x: number, y: number, settings: EmitterSettings): Emitter | null {
    if (this.emitters.length >= 24 || !validEmitter(settings) || !Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= this.width || y < 0 || y >= this.height || solid(this.get(x, y))) return null;
    const e: Emitter = { ...settings, id: this.nextEmitterId++, x, y, carry: 0 };
    this.emitters.push(e); return e;
  }
  emitterAt(x: number, y: number, radius = 8 * this.units): Emitter | undefined {
    return [...this.emitters].reverse().find(e => Math.hypot(e.x - x, e.y - y) <= radius);
  }
  updateEmitter(id: number, settings: EmitterSettings): boolean {
    const e = this.emitters.find(e => e.id === id);
    if (!e || !validEmitter(settings)) return false;
    Object.assign(e, settings); return true;
  }
  private emit(): void {
    for (const e of this.emitters) {
      if (!e.enabled) continue;
      e.carry += e.rate / 60;
      const count = Math.floor(e.carry); e.carry -= count;
      const dx = e.direction === 'right' ? 1 : e.direction === 'left' ? -1 : 0;
      const dy = e.direction === 'down' ? 1 : e.direction === 'up' ? -1 : 0;
      for (let n = 0; n < count; n++) {
        // A blocked outlet drops its budget; it cannot store up a later burst.
        for (let attempt = 0; attempt < 6; attempt++) {
          const across = (this.random() * 2 - 1) * e.radius, ahead = 2 * this.units + this.random() * (e.radius + 1);
          const x = Math.floor(e.x + dx * ahead - dy * across), y = Math.floor(e.y + dy * ahead + dx * across);
          if (x < 0 || x >= this.width || y < 0 || y >= this.height) continue;
          const i = y * this.width + x;
          if (!this.cells[i] && !this.occupancy[i]) { this.set(x, y, e.material); break; }
        }
      }
    }
  }
  private rebuildTerrain(): void {
    this.terrain = [rectangle(-20, -20, 20, this.height + 40), rectangle(this.width, -20, 20, this.height + 40), rectangle(0, this.height, this.width, 20), rectangle(0, -20, this.width, 20)];
    let previous = new Map<string, Collider>();
    for (let y = 0; y < this.height; y++) {
      const current = new Map<string, Collider>();
      for (let x = 0; x < this.width; x++) {
        if (!solid(this.cells[y * this.width + x])) continue;
        const start = x;
        while (x + 1 < this.width && solid(this.cells[y * this.width + x + 1])) x++;
        const key = `${start}:${x}`;
        const prior = previous.get(key);
        if (prior) { prior.bottom = y + 1; prior.y = (prior.top + prior.bottom) / 2; prior.vertices[2].y = y + 1; prior.vertices[3].y = y + 1; current.set(key, prior); }
        else { const collider = rectangle(start, y, x - start + 1, 1); this.terrain.push(collider); current.set(key, collider); }
      }
      previous = current;
    }
    this.terrainDirty = false;
  }
  private updateBodies(): void {
    if (!this.bodies.length) return;
    if (this.terrainDirty) this.rebuildTerrain();
    const dt = 1 / 120;
    for (let sub = 0; sub < 2; sub++) {
      for (const b of this.bodies) {
        const poly = vertices(b), box = bounds(poly);
        let wet = 0, hot = 0, sand = 0, samples = 0;
        // Sample water just outside the body at each depth; displaced cells stay empty.
        for (let y = Math.ceil(box.top); y < box.bottom; y += 2) {
          const left = this.get(box.left - 2, y), right = this.get(box.right + 1, y);
          wet += Number(left === M.water || left === M.oil) + Number(right === M.water || right === M.oil);
          hot += Number(left === M.fire || left === M.lava) + Number(right === M.fire || right === M.lava); samples += 2;
        }
        for (let x = box.left; x <= box.right; x += 2) sand += Number(this.get(x, box.bottom + 1) === M.sand);
        const fraction = wet / Math.max(1, samples);
        b.vy += 75 * this.units * this.gravity * (1 - fraction * (b.kind === 'box' ? 1.9 : .32)) * dt;
        const drag = Math.exp(-(fraction * 3 + .08) * dt);
        b.vx *= drag; b.vy *= drag; b.omega *= Math.exp(-(fraction * 4 + .7) * dt);
        if (sand > b.size * .3 && b.vy > 0) {
          // Sand supports light crates; heavy stones slowly settle into it.
          b.vy *= b.kind === 'box' ? .15 : .8; b.vx *= .93; b.omega *= .88;
        }
        if (b.kind === 'box') b.heat = clamp(b.heat + (hot ? 1.5 : -1) * dt, 0, 10);
        if (this.grab?.id === b.id) {
          const c = Math.cos(b.angle), s = Math.sin(b.angle);
          const rx = this.grab.localX * c - this.grab.localY * s, ry = this.grab.localX * s + this.grab.localY * c;
          const fx = clamp((this.grab.x - b.x - rx) * 75 - (b.vx - b.omega * ry) * 12, -2500, 2500);
          const fy = clamp((this.grab.y - b.y - ry) * 75 - (b.vy + b.omega * rx) * 12, -2500, 2500);
          b.vx += fx * dt; b.vy += fy * dt; b.omega += (rx * fy - ry * fx) * dt / (b.size * b.size * 1.5);
        }
        b.vx = clamp(b.vx, -160 * this.units, 160 * this.units); b.vy = clamp(b.vy, -160 * this.units, 160 * this.units); b.omega = clamp(b.omega, -8, 8);
        b.x += b.vx * dt; b.y += b.vy * dt; b.angle += b.omega * dt;
      }
      for (let iteration = 0; iteration < 4; iteration++) {
        for (const b of this.bodies) {
          let shape = bounds(vertices(b));
          for (const terrain of this.terrain) {
            const hit = contact(shape, terrain);
            if (hit) { resolve(b, null, hit); shape = bounds(vertices(b)); }
          }
        }
        for (let i = 0; i < this.bodies.length; i++) for (let j = i + 1; j < this.bodies.length; j++) {
          const a = this.bodies[i], b = this.bodies[j];
          if (Math.abs(a.x - b.x) > (a.size + b.size) * 1.5 || Math.abs(a.y - b.y) > (a.size + b.size) * 1.5) continue;
          const hit = contact(bounds(vertices(a)), bounds(vertices(b)));
          if (hit) resolve(a, b, hit);
        }
      }
    }
    const burned = this.bodies.filter(b => b.heat > 3);
    this.bodies = this.bodies.filter(b => b.heat <= 3);
    this.rasterBodies();
    for (const b of burned) this.paint(b.x, b.y, b.size, M.fire);
  }
  rasterBodies(): void {
    this.occupancy.fill(0);
    for (const b of this.bodies) {
      const poly = vertices(b), box = bounds(poly);
      for (let y = Math.max(0, Math.floor(box.top)); y < Math.min(this.height, box.bottom); y++) for (let x = Math.max(0, Math.floor(box.left)); x < Math.min(this.width, box.right); x++) {
        if (contains(poly, x + .5, y + .5)) this.occupancy[y * this.width + x] = b.id;
      }
    }
    // Displace engulfed particles to free space instead of deleting them.
    for (const b of this.bodies) {
      const box = bounds(vertices(b));
      for (let y = Math.max(0, Math.floor(box.top)); y < Math.min(this.height, box.bottom); y++) for (let x = Math.max(0, Math.floor(box.left)); x < Math.min(this.width, box.right); x++) {
        const i = y * this.width + x;
        if (this.occupancy[i] !== b.id || !this.cells[i] || solid(this.cells[i])) continue;
        let found = false;
        for (let r = 1; r <= b.size * 3 && !found; r++) {
          const side = x < b.x ? -1 : 1;
          for (const [dx, dy] of [[side * r, -Math.floor(r / 3)], [side * r, 0], [-side * r, 0], [0, -r]]) {
            const px = x + dx, py = y + dy;
            if (px < 0 || px >= this.width || py < 0 || py >= this.height) continue;
            const j = py * this.width + px;
            if (!this.occupancy[j] && !this.cells[j]) { this.swap(i, j); found = true; break; }
          }
        }
      }
    }
  }
  step(): void {
    this.tick++;
    this.updateBodies();
    this.emit();
    for (let y = this.height - 1; y >= 0; y--) {
      const reverse = (this.tick + y) % 2;
      for (let n = 0; n < this.width; n++) this.updateCell(reverse ? this.width - 1 - n : n, y, false);
    }
    for (let y = 0; y < this.height; y++) {
      const reverse = (this.tick + y) % 2;
      for (let n = 0; n < this.width; n++) this.updateCell(reverse ? this.width - 1 - n : n, y, true);
    }
  }
  snapshot(): SceneData {
    return { version: 2, emitters: this.emitters.map(e => ({ ...e })), width: this.width, height: this.height, gravity: this.gravity, seed: this.seed, cells: encode(this.cells), life: encode(this.life), bodies: this.bodies.map(({ kind, x, y, vx, vy, angle, omega, size, heat }) => ({ kind, x, y, vx, vy, angle, omega, size, heat })) };
  }
  static restore(value: unknown): Simulation {
    if (!value || typeof value !== 'object') throw new Error('Invalid scene');
    const s = value as SceneData;
    if ((s.version !== 1 && s.version !== 2) || !Number.isInteger(s.width) || !Number.isInteger(s.height) || s.width < 60 || s.width > 720 || s.height < 60 || s.height > 720 || !Number.isFinite(s.gravity) || s.gravity < 0 || s.gravity > 2 || !Number.isInteger(s.seed) || s.seed <= 0 || s.seed > 4294967295) throw new Error('Unsupported scene');
    const sim = new Simulation(s.width, s.height, s.seed);
    sim.cells = new Uint8Array(decode(s.cells, s.width * s.height, s.version === 1 ? M.wall : M.co2)); sim.life = decode(s.life, s.width * s.height, s.version === 1 ? 255 : 65535); sim.gravity = s.gravity;
    if (!Array.isArray(s.bodies) || s.bodies.length > 48) throw new Error('Invalid bodies');
    for (const item of s.bodies) {
      if (!item || (item.kind !== 'box' && item.kind !== 'stone') || !['x', 'y', 'vx', 'vy', 'angle', 'omega', 'size', 'heat'].every(key => Number.isFinite(item[key as keyof typeof item])) || item.size < 4 || item.size > 24 * sim.units || item.x < 0 || item.x > s.width || item.y < 0 || item.y > s.height || Math.abs(item.vx) > 200 * sim.units || Math.abs(item.vy) > 200 * sim.units || Math.abs(item.omega) > 20 || Math.abs(item.angle) > 1e8 || item.heat < 0 || item.heat > 10) throw new Error('Invalid body');
      // Restore exact poses, including transient contacts; interactive placement checks do not apply.
      const holder = new Simulation(s.width, s.height);
      const b = holder.addBody(item.kind, item.x, item.y, item.size)!;
      Object.assign(b, item, { id: sim.nextId++ }); sim.bodies.push(b);
    }
    if (s.version === 2) {
      if (!Array.isArray(s.emitters) || s.emitters.length > 24) throw new Error('Invalid emitters');
      const ids = new Set<number>();
      for (const e of s.emitters) {
        if (!validEmitter(e) || !Number.isSafeInteger(e.id) || e.id < 1 || ids.has(e.id) || !Number.isFinite(e.x) || !Number.isFinite(e.y) || e.x < 0 || e.x >= s.width || e.y < 0 || e.y >= s.height || !Number.isFinite(e.carry) || e.carry < 0 || e.carry >= 1) throw new Error('Invalid emitter');
        ids.add(e.id); sim.emitters.push({ id: e.id, x: e.x, y: e.y, material: e.material, rate: e.rate, radius: e.radius, direction: e.direction, enabled: e.enabled, carry: e.carry }); sim.nextEmitterId = Math.max(sim.nextEmitterId, e.id + 1);
      }
    }
    sim.rasterBodies(); return sim;
  }
  /** Resample the whole stage, preserving shapes, relative poses, and source settings. */
  resized(width: number, height: number): Simulation {
    if (!Number.isInteger(width) || !Number.isInteger(height) || width < 60 || height < 60 || width > 720 || height > 720) throw new Error('Invalid stage resolution');
    const next = new Simulation(width, height, this.seed); next.gravity = this.gravity;
    const sx = width / this.width, sy = height / this.height, scale = Math.min(sx, sy);
    for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
      const src = Math.min(this.height - 1, Math.floor(y / sy)) * this.width + Math.min(this.width - 1, Math.floor(x / sx));
      next.cells[y * width + x] = this.cells[src]; next.life[y * width + x] = this.life[src];
    }
    const snapshot = next.snapshot();
    snapshot.bodies = this.snapshot().bodies.map(b => ({ ...b, x: b.x * sx, y: b.y * sy, vx: b.vx * sx, vy: b.vy * sy, size: clamp(b.size * scale, 4, 24 * next.units) }));
    snapshot.emitters = this.emitters.map(e => ({ ...e, x: e.x * sx, y: e.y * sy, radius: clamp(Math.round(e.radius * scale), 1, 24), rate: e.rate }));
    return Simulation.restore(snapshot);
  }
}
export function makeScene(name: string, width = 300, height = 200): Simulation {
  const sim = new Simulation(width, height);
  const rect = (x: number, y: number, w: number, h: number, m: number) => {
    for (let py = Math.floor(y * height); py < (y + h) * height; py++) for (let px = Math.floor(x * width); px < (x + w) * width; px++) sim.set(px, py, m);
  };
  const body = (kind: BodyKind, x: number, y: number, size: number) => sim.addBody(kind, x * width, y * height, size * Math.min(width, height));
  if (name === 'empty') return sim;
  if (name === 'gaslab') {
    rect(.08, .86, .84, .025, M.wall);
    rect(.08, .54, .014, .32, M.wall); rect(.90, .54, .02, .32, M.wall);
    rect(.48, .58, .014, .28, M.wall);
    for (const [material, x, y, direction] of [[M.helium, .2, .76, 'up'], [M.smoke, .38, .76, 'up'], [M.co2, .65, .34, 'down'], [M.hydrogen, .81, .76, 'up']] as const) {
      sim.addEmitter(x * width, y * height, { material, rate: Math.round(100 * sim.units), radius: Math.round(4 * sim.units), direction, enabled: true });
    }
    body('box', .58, .74, .04);
  } else if (name === 'waterworks') {
    rect(.08, .42, .3, .025, M.wall); rect(.07, .18, .015, .27, M.wall); rect(.08, .25, .19, .16, M.water);
    rect(.58, .64, .32, .025, M.wall); rect(.885, .43, .015, .23, M.wall);
    rect(.05, .88, .9, .035, M.wall); rect(.05, .65, .012, .24, M.wall); rect(.94, .65, .012, .24, M.wall);
    rect(.065, .78, .87, .1, M.water); body('box', .5, .52, .045); body('stone', .3, .56, .045);
  } else if (name === 'furnace') {
    rect(.12, .85, .76, .04, M.wall); rect(.12, .55, .02, .3, M.wall); rect(.86, .55, .02, .3, M.wall);
    rect(.14, .76, .72, .09, M.lava); rect(.3, .55, .36, .035, M.wood);
    rect(.32, .37, .045, .18, M.wood); rect(.59, .37, .045, .18, M.wood);
    rect(.32, .31, .32, .055, M.oil); body('box', .45, .2, .055); body('stone', .75, .3, .05);
  } else {
    // An open basin, a sand bank, a timber bridge and a small oil pocket.
    rect(.055, .86, .89, .035, M.wall); rect(.055, .57, .015, .29, M.wall); rect(.93, .55, .015, .31, M.wall);
    rect(.39, .735, .54, .125, M.water);
    for (let y = Math.floor(height * .57); y < height * .86; y++) {
      const half = (y / height - .57) * width * .6;
      for (let x = Math.max(width * .07, width * .25 - half); x < width * .25 + half; x++) sim.set(x, y, M.sand);
    }
    rect(.11, .38, .19, .02, M.wall); rect(.108, .27, .012, .13, M.wall); rect(.285, .27, .015, .13, M.wall);
    rect(.123, .31, .16, .07, M.oil);
    rect(.45, .56, .19, .02, M.wood); rect(.455, .58, .016, .14, M.wood); rect(.617, .58, .016, .14, M.wood);
    body('box', .51, .47, .047); body('box', .58, .35, .034);
    body('box', .8, .64, .047); body('stone', .73, .38, .045); body('stone', .87, .5, .032);
  }
  sim.rasterBodies(); return sim;
}