Back to Safe Sand · Material studies
SOURCE / PINNED RELEASE

Made of little things.

Safe Sand · Material studies

Release
9101a9cd9fb4…
Author-recorded commit
75d98e1b8edb…
License
Check the original source
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-7682e55a791

The hash-verified playable HTML, which may contain bundled or minified code. This view does not execute it.

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
  <title>Safe Sand · Material studies</title>
  <script type="module" crossorigin>var He=Object.defineProperty;var We=(i,e,s)=>e in i?He(i,e,{enumerable:!0,configurable:!0,writable:!0,value:s}):i[e]=s;var R=(i,e,s)=>We(i,typeof e!="symbol"?e+"":e,s);function Xe(){const i=window;if(!i.napplet)throw new Error("window.napplet is unavailable -- runtime did not inject the napplet namespace");return i.napplet}function p(i){const s=Xe()[i];if(!s)throw new Error(`window.napplet.${String(i)} is unavailable -- runtime did not inject this domain`);return s}var Fe={getItem(i){return p("storage").getItem(i)},setItem(i,e){return p("storage").setItem(i,e)},removeItem(i){return p("storage").removeItem(i)},keys(){return p("storage").keys()},instance:{getItem(i){return p("storage").instance.getItem(i)},setItem(i,e){return p("storage").instance.setItem(i,e)},removeItem(i){return p("storage").instance.removeItem(i)},keys(){return p("storage").instance.keys()}}},Ve={createSession(i){return p("media").createSession(i)},updateSession(i,e){p("media").updateSession(i,e)},destroySession(i){p("media").destroySession(i)},reportState(i,e){p("media").reportState(i,e)},reportCapabilities(i,e){p("media").reportCapabilities(i,e)},sendCommand(i,e,s){p("media").sendCommand(i,e,s)},onCommand(i,e){return p("media").onCommand(i,e)},onState(i,e){return p("media").onState(i,e)},onCapabilities(i,e){return p("media").onCapabilities(i,e)},onControls(i,e){return p("media").onControls(i,e)}},Ke={getPublicKey(){return p("identity").getPublicKey()},onChanged(i){return p("identity").onChanged(i)},getRelays(){return p("identity").getRelays()},getProfile(){return p("identity").getProfile()},getFollows(){return p("identity").getFollows()},getList(i){return p("identity").getList(i)},getZaps(){return p("identity").getZaps()},getMutes(){return p("identity").getMutes()},getBlocked(){return p("identity").getBlocked()},getBadges(){return p("identity").getBadges()}};function $e(i,e){return!!(i!=null&&i[e])}function W(i){return $e(window.napplet,i)}function je(i){const e=i.getContext("webgl2",{antialias:!0,alpha:!1,depth:!0,stencil:!1,premultipliedAlpha:!1,preserveDrawingBuffer:!1,powerPreference:"high-performance"});if(!e)throw new Error("WebGL2 is not available on this device.");if(!e.getExtension("EXT_color_buffer_float"))throw new Error("This GPU cannot render to float textures (EXT_color_buffer_float).");const r=!!e.getExtension("EXT_float_blend");return e.getExtension("EXT_color_buffer_half_float"),{gl:e,floatBlend:r}}function Re(i,e,s,r){const t=i.createShader(e);if(i.shaderSource(t,s),i.compileShader(t),!i.getShaderParameter(t,i.COMPILE_STATUS)){const o=i.getShaderInfoLog(t),u=s.split(`
`).map((l,n)=>`${n+1}: ${l}`).join(`
`);throw console.error(u),new Error(`Shader ${r} failed: ${o}`)}return t}function P(i,e,s,r="program"){const t=i.createProgram(),o=Re(i,i.VERTEX_SHADER,e,r+".vs"),u=Re(i,i.FRAGMENT_SHADER,s,r+".fs");if(i.attachShader(t,o),i.attachShader(t,u),i.linkProgram(t),i.deleteShader(o),i.deleteShader(u),!i.getProgramParameter(t,i.LINK_STATUS))throw new Error(`Program ${r} link failed: ${i.getProgramInfoLog(t)}`);const l={},n=i.getProgramParameter(t,i.ACTIVE_UNIFORMS);for(let h=0;h<n;h++){const d=i.getActiveUniform(t,h),y=d.name.replace(/\[0\]$/,"");l[y]=i.getUniformLocation(t,d.name)}return{p:t,u:l,name:r}}function G(i,e,s,r,t,o,u,l=null){const n=i.createTexture();return i.bindTexture(i.TEXTURE_2D,n),i.texImage2D(i.TEXTURE_2D,0,r,e,s,0,t,o,l),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_MIN_FILTER,u),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_MAG_FILTER,u),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_WRAP_S,i.CLAMP_TO_EDGE),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_WRAP_T,i.CLAMP_TO_EDGE),n}function D(i,e){const s=i.createFramebuffer();i.bindFramebuffer(i.FRAMEBUFFER,s);const r=e.map((o,u)=>(i.framebufferTexture2D(i.FRAMEBUFFER,i.COLOR_ATTACHMENT0+u,i.TEXTURE_2D,o,0),i.COLOR_ATTACHMENT0+u));i.drawBuffers(r);const t=i.checkFramebufferStatus(i.FRAMEBUFFER);if(t!==i.FRAMEBUFFER_COMPLETE)throw new Error("Framebuffer incomplete: 0x"+t.toString(16));return i.bindFramebuffer(i.FRAMEBUFFER,null),s}function I(i,e,s){for(const[r,t]of Object.entries(s)){const o=e.u[r];o!=null&&(typeof t=="number"?i.uniform1f(o,t):t.type==="i"?i.uniform1i(o,t.v):t.type==="u"?i.uniform1ui(o,t.v):t.type==="v4a"?i.uniform4fv(o,t.v):t.length===2?i.uniform2fv(o,t):t.length===3?i.uniform3fv(o,t):t.length===4&&i.uniform4fv(o,t))}}function m(i,e,s,r,t){i.activeTexture(i.TEXTURE0+t),i.bindTexture(i.TEXTURE_2D,r);const o=e.u[s];o&&i.uniform1i(o,t)}const b=`#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
`,ie=`
uniform vec4 uPal[8];
uniform vec4 uSize;
uniform vec4 uSize2;
uniform vec4 uFric;
uniform vec4 uLook;
uniform vec4 uMaterial;

uint pcg(uint v) {
  uint state = v * 747796405u + 2891336453u;
  uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
  return (word >> 22u) ^ word;
}
float rnd(uint i, uint s) { return float(pcg(i * 32u + s) >> 8) / 16777215.0; }
float rnd3(uint i, uint s, uint f) { return float(pcg(pcg(i * 32u + s) + f * 1664525u) >> 8) / 16777215.0; }

struct Grain {
  float r;
  float kind;
  float mu;
  vec3 col;
  float sparkle;
  float transl;
  float aspect;
};

vec3 pickColor(float u) {
  vec3 c = uPal[7].rgb;
  for (int k = 7; k >= 0; k--) { if (u <= uPal[k].a) c = uPal[k].rgb; }
  return c;
}

Grain grain(uint i) {
  Grain g;
  float u = rnd(i, 0u);
  float gs = sqrt(-2.0 * log(max(rnd(i, 1u), 1e-6))) * cos(6.2831853 * rnd(i, 2u));
  if (u < uSize.w) {
    g.kind = 2.0;
    g.r = mix(uSize2.x, uSize2.y, pow(rnd(i, 3u), 2.2));
    g.mu = rnd(i, 23u) < 0.6 ? mix(0.1, 0.22, rnd(i, 4u)) : mix(0.3, 0.5, rnd(i, 4u));
  } else if (u < uSize.w + uSize.z) {
    g.kind = 1.0;
    g.r = mix(uSize2.z, uSize2.w, pow(rnd(i, 3u), 1.5));
    g.mu = rnd(i, 23u) < 0.35 ? mix(uFric.x * 0.6, uFric.x * 1.2, rnd(i, 4u)) : mix(uFric.x * 1.6, uFric.y, rnd(i, 4u)) * 0.85;
  } else {
    g.kind = 0.0;
    g.r = clamp(uSize.x * exp(uSize.y * gs), 0.045, 0.42);
    g.mu = rnd(i, 23u) < 0.25 ? mix(uFric.x * 0.6, uFric.x * 1.2, rnd(i, 4u)) : mix(uFric.x * 1.6, uFric.y, rnd(i, 4u));
  }
  float cu = rnd(i, 5u);
  vec3 c = pickColor(cu);
  float bright = 0.78 + 0.44 * rnd(i, 6u);
  vec3 tint = vec3(1.0) + (vec3(rnd(i, 7u), rnd(i, 8u), rnd(i, 9u)) - 0.5) * 0.1;
  g.col = c * bright * tint;
  if (g.kind == 2.0) {
    float sk = rnd(i, 12u);
    vec3 st = sk < 0.3 ? vec3(0.27, 0.26, 0.245) : sk < 0.45 ? vec3(0.055, 0.055, 0.058) : sk < 0.6 ? vec3(0.42, 0.35, 0.26) : sk < 0.7 ? vec3(0.28, 0.16, 0.09) : sk < 0.8 ? vec3(0.58, 0.56, 0.52) : sk < 0.9 ? vec3(0.15, 0.18, 0.145) : vec3(0.14, 0.155, 0.18);
    g.col = mix(st * bright, g.col * 0.8, 0.3);
  }
  float lum = dot(g.col, vec3(0.3, 0.55, 0.15));
  g.sparkle = step(0.7, rnd(i, 10u)) * smoothstep(0.08, 0.35, lum) * uFric.w;
  g.transl = smoothstep(0.12, 0.6, lum) * (g.kind == 2.0 ? 0.15 : 0.6);
  g.aspect = mix(0.7, 1.0, rnd(i, 11u));
  if (uMaterial.x == 1.0) { g.aspect = 1.0; g.transl = 1.2; g.sparkle = 2.0; }
  if (uMaterial.x == 2.0) { g.aspect = 0.28; g.transl = 0.0; g.sparkle = 1.5; }
  if (uMaterial.x == 3.0) { g.aspect = 0.8; g.sparkle = 5.0; g.transl = 0.1; }
  return g;
}
`,q=b+`
const vec2 P[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
void main() { gl_Position = vec4(P[gl_VertexID], 0.0, 1.0); }
`,Ye=b+ie+`
uniform sampler2D uA;
uniform sampler2D uB;
uniform int uTW;
uniform vec2 uWorld;
uniform vec2 uGrid;
out vec4 vData;
out vec2 vCenter;
out float vRk;
void main() {
  int id = gl_VertexID;
  ivec2 pc = ivec2(id % uTW, id / uTW);
  vec4 A = texelFetch(uA, pc, 0);
  vec4 B = texelFetch(uB, pc, 0);
  Grain g = grain(uint(id));
  if (B.w < 0.5 || B.w > 1.0001 || B.x > g.r) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; }
  vec2 cell = uWorld / uGrid;
  vec2 gc = A.xy / cell;
  float rk = max(1.0, g.r * 1.3 / cell.x);
  gl_PointSize = 2.0 * ceil(rk) + 1.0;
  gl_Position = vec4(gc / uGrid * 2.0 - 1.0, 0.0, 1.0);
  float V = 4.18879 * g.r * g.r * g.r;
  float hc = V / (0.6 * cell.x * cell.y);
  vData = vec4(hc, hc * A.z, hc * A.w, hc * length(A.zw));
  vCenter = gc;
  vRk = rk;
}
`,Qe=b+`
in vec4 vData;
in vec2 vCenter;
in float vRk;
out vec4 o;
void main() {
  vec2 d = abs(gl_FragCoord.xy - vCenter) / vRk;
  float w = max(0.0, 1.0 - d.x) * max(0.0, 1.0 - d.y) / (vRk * vRk);
  o = vData * w;
}
`,Ze=b+`
uniform sampler2D uS;
layout(location = 0) out vec4 o0;
layout(location = 1) out vec4 o1;
void main() {
  ivec2 c = ivec2(gl_FragCoord.xy);
  ivec2 mx = textureSize(uS, 0) - 1;
  vec4 b = vec4(0.0);
  for (int y = -1; y <= 1; y++)
    for (int x = -1; x <= 1; x++) {
      float w = (x == 0 ? 2.0 : 1.0) * (y == 0 ? 2.0 : 1.0);
      b += w * texelFetch(uS, clamp(c + ivec2(x, y), ivec2(0), mx), 0);
    }
  b /= 16.0;
  vec2 vel = b.yz / max(b.x, 1e-4);
  o0 = vec4(b.x, b.x, 0.0, 0.0);
  o1 = vec4(vel, b.w / max(b.x, 1e-4), 0.0);
}
`,Je=b+`
uniform sampler2D uT;
uniform ivec2 uDir;
out vec4 o;
void main() {
  ivec2 c = ivec2(gl_FragCoord.xy);
  ivec2 mx = textureSize(uT, 0) - 1;
  vec4 base = texelFetch(uT, c, 0);
  float s = 0.0;
  float W[5] = float[5](0.2270, 0.1945, 0.1216, 0.0540, 0.0162);
  for (int k = -4; k <= 4; k++) {
    ivec2 q = c + uDir * k;
    ivec2 e = clamp(q, ivec2(0), mx);
    float v = texelFetch(uT, e, 0).y;
    if (q != e) v = max(2.0 * v - texelFetch(uT, clamp(2 * e - q, ivec2(0), mx), 0).y, 0.0);
    s += W[abs(k)] * v;
  }
  o = vec4(base.x, s, base.zw);
}
`,et=b+`
uniform sampler2D uT;
uniform vec2 uCell;
out vec4 o;
void main() {
  ivec2 c = ivec2(gl_FragCoord.xy);
  ivec2 mx = textureSize(uT, 0) - 1;
  float l = texelFetch(uT, clamp(c - ivec2(1, 0), ivec2(0), mx), 0).y;
  float r = texelFetch(uT, clamp(c + ivec2(1, 0), ivec2(0), mx), 0).y;
  float d = texelFetch(uT, clamp(c - ivec2(0, 1), ivec2(0), mx), 0).y;
  float u = texelFetch(uT, clamp(c + ivec2(0, 1), ivec2(0), mx), 0).y;
  vec4 base = texelFetch(uT, c, 0);
  vec2 k = vec2(c.x > 0 && c.x < mx.x ? 2.0 : 1.0, c.y > 0 && c.y < mx.y ? 2.0 : 1.0);
  o = vec4(base.x, (r - l) / (k.x * uCell.x), (u - d) / (k.y * uCell.y), base.y);
}
`,tt=b+ie+`
uniform sampler2D uA;
uniform sampler2D uB;
uniform sampler2D uG0;
uniform sampler2D uG1;
uniform int uTW;
uniform vec2 uWorld;
uniform float uDt;
uniform vec4 uGrav;
uniform vec4 uFA[5];
uniform vec4 uFB[5];
uniform uint uFrame;
uniform float uMagnet;
uniform float uTool;
uniform vec4 uSpawn;
uniform vec4 uSpawnConfig;
layout(location = 0) out vec4 oA;
layout(location = 1) out vec4 oB;

vec2 rdir(uint i, uint s) {
  float a = 6.2831853 * rnd3(i, s, uFrame);
  return vec2(cos(a), sin(a));
}

void main() {
  ivec2 pc = ivec2(gl_FragCoord.xy);
  uint idx = uint(pc.y * uTW + pc.x);
  vec4 A = texelFetch(uA, pc, 0);
  vec4 B = texelFetch(uB, pc, 0);
  oA = A; oB = B;
  float slot = mod(float(idx) - uSpawn.x + uSpawnConfig.w, uSpawnConfig.w);
  if (slot < uSpawn.y) {
    float angle = rnd3(idx, 40u, uFrame) * 6.2831853;
    float radius = sqrt(rnd3(idx, 41u, uFrame)) * uSpawnConfig.x;
    vec2 pos = clamp(uSpawn.zw + vec2(cos(angle), sin(angle)) * radius, vec2(0.5), uWorld - 0.5);
    oA = vec4(pos, rdir(idx, 42u) * 4.0);
    oB = vec4(uSpawnConfig.y, -45.0, rnd3(idx, 43u, uFrame), 1.0);
    return;
  }
  if (B.w < 0.5) return;
  if (B.w > 1.0) { oB.w = max(1.0, B.w - uDt); return; }
  Grain g = grain(idx);
  vec2 p = A.xy;
  vec2 v = A.zw;
  float zAir = B.x;
  float vz = B.y;
  float layer = B.z;
  float N = uGrav.z;
  vec2 gt = uGrav.xy;
  vec2 uv = p / uWorld;
  vec4 G0 = texture(uG0, uv);
  vec4 G1 = texture(uG1, uv);
  float h = G0.x;
  vec2 grad = G0.yz;
  vec2 vg = G1.xy;
  float dt = uDt;

  vec2 cs = uWorld / vec2(textureSize(uG0, 0));
  float rk = max(1.0, g.r * 1.3 / cs.x);
  float selfH = 4.18879 * g.r * g.r * g.r / (0.6 * cs.x * cs.y * rk * rk) * (0.172 + 0.85 * (rk - 1.0) / (rk + 0.9));
  float contact = smoothstep(0.2, 0.8, max(h - selfH, 0.0) / max(2.0 * uSize.x, 0.05));
  bool peb = g.kind == 2.0;
  float glue = g.kind == 0.0 ? min(pow(0.1 / g.r, 3.0), 6.0) * (1.0 - smoothstep(0.12, 0.17, g.r)) * (rnd(idx, 22u) < 0.45 ? 1.0 : 1.0 - contact) : 0.0;
  float adh = 9810.0 * glue;

  float shake = smoothstep(1.5, 8.0, uGrav.w);
  bool hang = zAir <= 0.0 && N + adh < -600.0;
  if (hang && (shake > 0.85 || rnd3(idx, 26u, uFrame) < 1.0 - exp(-dt * mix(18.0, 5.0, contact)))) { zAir = 1e-3; vz = 0.0; hang = false; }
  bool gb = zAir <= 0.0;

  if (!gb) {
    vz += -N * dt;
    zAir += vz * dt;
    v *= exp(-dt * 0.4);
    v += gt * dt;
    p += v * dt;
    if (zAir <= 0.0) {
      float impact = max(-vz, 0.0);
      zAir = 0.0;
      vz = 0.0;
      v = v * mix(mix(0.35, 0.75, rnd3(idx, 12u, uFrame)), 0.15, contact) + rdir(idx, 13u) * impact * (peb ? 0.04 : mix(0.1, 0.015, contact)) * rnd3(idx, 14u, uFrame);
      layer = 0.75 + 0.25 * rnd(idx, 15u);
    }
    if (zAir > 120.0 && vz > 0.0) { oB = vec4(zAir, vz, layer, 0.0); oA = vec4(p, v); return; }
  } else {
    float Ns = max(N, 5900.0);
    float sp0 = length(v);
    float mu = mix(g.mu, uFric.z * 0.85, contact * (peb ? 0.2 : 1.0));
    if (sp0 < 0.8) mu *= 1.18;
    vec2 gr = grad * (peb ? 1.4 : 1.0);
    float gl = length(gr);
    float marg = (uFric.z * 0.38 + uMaterial.y * 0.5) * contact;
    float se = max(gl - marg * (1.0 - shake), 0.0);
    vec2 drive = gt - (gl > 1e-5 ? gr / gl : vec2(0.0)) * Ns * se * contact;
    float pic = 1.0 - exp(-dt * 70.0 * contact * (peb ? 0.25 : 1.0) * (1.0 - 0.8 * shake));
    v = mix(v, vg, pic);
    v *= exp(-dt * uMaterial.w * (0.2 + contact));
    v += drive * dt + rdir(idx, 28u) * uGrav.w * 1800.0 * shake * contact * rnd3(idx, 29u, uFrame) * dt;
    v *= exp(-dt * 45.0 * contact * min(max(gl - marg, 0.0), 8.0));
    float sp = length(v);
    float Nf = max(N, contact * 0.6 * length(gt) * (1.0 - smoothstep(2900.0, 6900.0, N)));
    float fr = mu * max(Nf + adh, 0.0) * dt * (1.0 + uMaterial.y * contact);
    if (sp <= fr) v = vec2(0.0);
    else v -= v / sp * fr;
    sp = length(v);
    if (sp > 1.0) {
      v += rdir(idx, 16u) * sp * (0.05 + 0.08 * contact) * rnd3(idx, 17u, uFrame);
      float target = peb ? 1.0 : 0.55 + 0.45 * rnd3(idx, 18u, uFrame);
      layer = mix(layer, target, clamp(sp * dt * 0.8, 0.0, 0.25));
    }
    float vmax = 2500.0;
    sp = length(v);
    if (sp > vmax) v *= vmax / sp;
    if (hang) v = vec2(0.0);
  }

  bool ignoreFinger = (g.kind == 0.0 && rnd(idx, 19u) < 0.012 && g.r < 0.22);
  vec2 q = p + (gb ? v * dt : vec2(0.0));
  if (zAir < 3.0) {
    float zg = clamp(max(h, G0.w), 0.0, 4.0) * (0.35 + 0.65 * layer);
    for (int f = 0; f < 5; f++) {
      vec4 fb = uFB[f];
      if (fb.w < 0.5) continue;
      vec4 fa = uFA[f];
      vec2 a = fa.xy, b = fa.zw;
      vec2 ab = b - a;
      vec2 U = fb.xy;
      if (uTool == 3.0) {
        vec2 segment = b - a;
        vec2 closest = a + segment * clamp(dot(p - a, segment) / max(dot(segment, segment), 1e-6), 0.0, 1.0);
        if (distance(p, closest) < fb.z) { oB.w = 0.0; return; }
        continue;
      }
      if (uMagnet > 0.5 && uMaterial.x == 2.0) {
        vec2 toward = b - p;
        float distanceToMagnet = length(toward);
        float reach = fb.z * 8.0;
        if (distanceToMagnet < reach && distanceToMagnet > 0.1) {
          vec2 direction = toward / distanceToMagnet;
          float force = 44000.0 * pow(1.0 - distanceToMagnet / reach, 2.0);
          v += direction * force * dt * (uTool == 5.0 ? -0.65 : 1.0);
          v *= exp(-dt * 14.0);
          q = p + v * dt;
          // Needles orient along the local magnetic field for rendering.
          layer = clamp(layer, 0.0, 1.0);
        }
        continue;
      }
      float us = length(U);
      float R = min(sqrt(fb.z * fb.z + 18.0 * zg), fb.z + 4.5) + g.r * 0.6;
      if (uTool == 2.0) R = fb.z + g.r * 0.4;
      vec2 c = a + ab * clamp(dot(q - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
      float dist = length(q - c);
      if (gb && us < 8.0 && dist < R + 0.3) {
        v = U;
        q = p + U * dt;
        c = a + ab * clamp(dot(q - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
        dist = length(q - c);
      }
      if (dist < R - (us < 8.0 ? 0.6 : 0.0) && !ignoreFinger) {
        vec2 n = dist > 1e-4 ? (q - c) / dist : rdir(idx, 20u);
        float pen = R - dist;
        q = c + n * (R + (pen > 0.15 ? 1.6 * rnd3(idx, 23u, uFrame) : 0.0));
        float vn = dot(v - U, n);
        if (vn < 0.0) v -= vn * (1.0 + 0.6 * smoothstep(250.0, 2500.0, us)) * n;
        vec2 tng = vec2(-n.y, n.x);
        v -= dot(v - U, tng) * tng * 0.25;
        v += n * us * 0.08 * rnd3(idx, 21u, uFrame);
        if (us >= 8.0) layer = mix(layer, 1.0, 0.2);
        if (us > 250.0 && pen > 0.05 && !peb) {
          float k = rnd3(idx, 27u, uFrame);
          zAir = 0.02;
          vz = us * 0.2 * k * k;
        } else if (zAir > 0.0) { zAir = 0.0; vz = 0.0; }
      }
    }
  }
  p = q;

  float r = g.r;
  float rest = g.kind == 2.0 ? 0.35 : uMaterial.z;
  if (p.x < r) { p.x = r; if (v.x < 0.0) v.x = -v.x * rest; }
  if (p.x > uWorld.x - r) { p.x = uWorld.x - r; if (v.x > 0.0) v.x = -v.x * rest; }
  if (p.y < r) { p.y = r; if (v.y < 0.0) v.y = -v.y * rest; }
  if (p.y > uWorld.y - r) { p.y = uWorld.y - r; if (v.y > 0.0) v.y = -v.y * rest; }

  oA = vec4(p, v);
  oB = vec4(zAir, vz, clamp(layer, 0.0, 1.0), 1.0);
}
`,it=b+`
uniform sampler2D uS;
out vec4 o;
void main() {
  ivec2 ts = textureSize(uS, 0);
  ivec2 blk = (ts + 15) / 16;
  ivec2 base = ivec2(gl_FragCoord.xy) * blk;
  vec4 acc = vec4(0.0);
  for (int y = 0; y < 64; y++) {
    if (y >= blk.y) break;
    for (int x = 0; x < 64; x++) {
      if (x >= blk.x) break;
      ivec2 c = base + ivec2(x, y);
      if (c.x >= ts.x || c.y >= ts.y) continue;
      vec4 s = texelFetch(uS, c, 0);
      acc += vec4(s.x, s.w, step(0.05, s.x), 0.0);
    }
  }
  o = acc;
}
`,A=512,Be=.5;function st(i){return()=>{i|=0,i=i+1831565813|0;let e=Math.imul(i^i>>>15,1|i);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296}}function at(i){const s=new Float32Array(65536);for(let o=0;o<s.length;o++)s[o]=i();const r=(o,u)=>s[(u&255)*256+(o&255)],t=(o,u)=>{const l=Math.floor(o),n=Math.floor(u),h=o-l,d=u-n,y=h*h*(3-2*h),v=d*d*(3-2*d),k=r(l,n),C=r(l+1,n),j=r(l,n+1),X=r(l+1,n+1);return k+(C-k)*y+(j-k)*v+(k-C-j+X)*y*v};return(o,u,l=5)=>{let n=0,h=.5,d=1,y=0;for(let v=0;v<l;v++)n+=h*t(o*d+v*17.3,u*d-v*9.1),y+=h,h*=.5,d*=2.03;return n/y}}class rt{constructor(e,s){this.gl=e,this.floatBlend=s,this.frame=0,this.vao=e.createVertexArray(),this.progs={splat:P(e,Ye,Qe,"splat"),grid:P(e,q,Ze,"grid"),hblur:P(e,q,Je,"hblur"),grad:P(e,q,et,"grad"),step:P(e,q,tt,"step"),stats:P(e,q,it,"stats")},this.statsTex=G(e,16,16,e.RGBA32F,e.RGBA,e.FLOAT,e.NEAREST),this.statsFb=D(e,[this.statsTex]),this.pbo=e.createBuffer(),e.bindBuffer(e.PIXEL_PACK_BUFFER,this.pbo),e.bufferData(e.PIXEL_PACK_BUFFER,256*16,e.STREAM_READ),e.bindBuffer(e.PIXEL_PACK_BUFFER,null),this.statsBuf=new Float32Array(256*4),this.fence=null,this.stats={volume:0,flow:0,coverage:0},this.spawn=[0,0,0,0],this.spawnRadius=4.5,this.spawnHeight=12,this.tool=0}resize(e,s,r){const t=this.gl;if(this.state){for(const n of this.state)t.deleteTexture(n.A),t.deleteTexture(n.B),t.deleteFramebuffer(n.fb);for(const n of["splatTex","G0","G1","T0","T1"])t.deleteTexture(this[n]);for(const n of["splatFb","gridFb","T0fb","T1fb","G0fb"])t.deleteFramebuffer(this[n]);this.fence&&(t.deleteSync(this.fence),this.fence=null)}this.world=[e,s],this.TH=Math.max(1,Math.ceil(r/A)),this.count=A*this.TH,this.active=this.count,this.GW=Math.ceil(e/Be),this.GH=Math.ceil(s/Be),this.cell=[e/this.GW,s/this.GH];const o=()=>G(t,A,this.TH,t.RGBA32F,t.RGBA,t.FLOAT,t.NEAREST);this.state=[0,1].map(()=>{const n=o(),h=o();return{A:n,B:h,fb:D(t,[n,h])}}),this.cur=0;const u=this.floatBlend?t.RGBA32F:t.RGBA16F,l=this.floatBlend?t.FLOAT:t.HALF_FLOAT;this.splatTex=G(t,this.GW,this.GH,u,t.RGBA,l,t.NEAREST),this.splatFb=D(t,[this.splatTex]),this.G0=G(t,this.GW,this.GH,t.RGBA16F,t.RGBA,t.HALF_FLOAT,t.LINEAR),this.G1=G(t,this.GW,this.GH,t.RGBA16F,t.RGBA,t.HALF_FLOAT,t.LINEAR),this.gridFb=D(t,[this.G0,this.G1]),this.T0=G(t,this.GW,this.GH,t.RGBA16F,t.RGBA,t.HALF_FLOAT,t.NEAREST),this.T1=G(t,this.GW,this.GH,t.RGBA16F,t.RGBA,t.HALF_FLOAT,t.NEAREST),this.T0fb=D(t,[this.T0]),this.T1fb=D(t,[this.T1]),this.G0fb=D(t,[this.G0])}setPreset(e){this.preset=e}presetUniforms(){const e=this.preset;return{uPal:{type:"v4a",v:e.palette},uSize:e.size,uSize2:e.size2,uFric:e.fric,uMaterial:e.material||[0,0,.08,0],uMagnet:this.magnet?1:0}}pour({seed:e=1,intro:s=!0,amount:r=1}={}){const t=this.gl,[o,u]=this.world,l=st(e*7919+13),n=at(l),h=96,d=Math.round(96*u/o),y=new Float32Array(h*d),v=.5+(l()-.5)*.1,k=.47+(l()-.5)*.1,C=u/o;let j=0;for(let f=0;f<d;f++)for(let z=0;z<h;z++){const w=(z+.5)/h,F=(f+.5)/d,N=w-v,ne=(F-k)*C,V=Math.sqrt(N*N+ne*ne*.42)/.56,Q=n(w*3.1*o/60,F*3.1*u/60,5),_=n(w*14,F*14*C,3),Z=n(w*o/9+40,F*u/9-20,3);let T=(Q-.47)*3.2+(1-V)*1.9-.25;T=Math.min(1,Math.max(0,T*1.05)),T=T*T*(3-2*T)*(.45+.7*_)*(.35+1.3*Z),T=T+.004+.03*Math.max(0,1-V*.8),y[f*h+z]=T,j+=T}const X=new Float32Array(h*d);let ye=0;for(let f=0;f<X.length;f++)ye+=y[f]/j,X[f]=ye;const ae=this.count,S=new Float32Array(ae*4),re=new Float32Array(ae*4),Ie=Math.floor(ae*r),Y=240,oe=new Float32Array(Y*2),Ue=2.2+l()*.8,Ne=l()*6.283;for(let f=0;f<Y;f++){const z=f/(Y-1),w=.03+.36*Math.sqrt(z),F=Ne+6.283*Ue*z,N=1+.25*(n(z*6,3.7,2)-.5);oe[f*2]=(v+Math.cos(F)*w*N)*o,oe[f*2+1]=(k+Math.sin(F)*w*N*.62/C*1.6)*u}const qe=1.35;for(let f=0;f<ae;f++){if(f>=Ie){re[f*4+3]=0,S[f*4]=-10;continue}const z=l();let w=0,F=X.length-1;for(;w<F;){const _=w+F>>1;X[_]<z?w=_+1:F=_}const N=w%h,ne=w/h|0,V=(N+l())/h*o,Q=(ne+l())/d*u;if(S[f*4]=Math.min(o-.3,Math.max(.3,V)),S[f*4+1]=Math.min(u-.3,Math.max(.3,Q)),S[f*4+2]=0,S[f*4+3]=0,s){let _=0,Z=1e9;for(let J=0;J<Y;J++){const Se=oe[J*2]-V,Ae=oe[J*2+1]-Q,Ee=Se*Se+Ae*Ae;Ee<Z&&(Z=Ee,_=J)}const T=25+l()*65,ce=-200-l()*250,be=(ce+Math.sqrt(ce*ce+2*9810*T))/9810,we=l()*6.283,Te=4+18*l();S[f*4+2]=Math.cos(we)*Te,S[f*4+3]=Math.sin(we)*Te,S[f*4]=V-S[f*4+2]*be,S[f*4+1]=Q-S[f*4+3]*be;const Oe=.05+qe*_/(Y-1)+.18*l()+Math.sqrt(Z)*.012;re.set([T,ce,l(),1+Oe],f*4)}else re.set([0,0,l(),1],f*4)}for(const f of this.state)t.bindTexture(t.TEXTURE_2D,f.A),t.texSubImage2D(t.TEXTURE_2D,0,0,0,A,this.TH,t.RGBA,t.FLOAT,S),t.bindTexture(t.TEXTURE_2D,f.B),t.texSubImage2D(t.TEXTURE_2D,0,0,0,A,this.TH,t.RGBA,t.FLOAT,re)}get A(){return this.state[this.cur].A}get B(){return this.state[this.cur].B}splat(){const e=this.gl,s=this.progs.splat;e.bindFramebuffer(e.FRAMEBUFFER,this.splatFb),e.viewport(0,0,this.GW,this.GH),e.clearColor(0,0,0,0),e.clear(e.COLOR_BUFFER_BIT),e.enable(e.BLEND),e.blendFunc(e.ONE,e.ONE),e.useProgram(s.p),m(e,s,"uA",this.A,0),m(e,s,"uB",this.B,1),I(e,s,{...this.presetUniforms(),uTW:{type:"i",v:A},uWorld:this.world,uGrid:[this.GW,this.GH]}),e.bindVertexArray(this.vao),e.drawArrays(e.POINTS,0,this.active),e.disable(e.BLEND);const r=this.progs.grid;e.bindFramebuffer(e.FRAMEBUFFER,this.gridFb),e.useProgram(r.p),m(e,r,"uS",this.splatTex,0),e.drawArrays(e.TRIANGLES,0,3);const t=this.progs.hblur;e.useProgram(t.p),e.bindFramebuffer(e.FRAMEBUFFER,this.T0fb),m(e,t,"uT",this.G0,0),e.uniform2i(t.u.uDir,1,0),e.drawArrays(e.TRIANGLES,0,3),e.bindFramebuffer(e.FRAMEBUFFER,this.T1fb),m(e,t,"uT",this.T0,0),e.uniform2i(t.u.uDir,0,1),e.drawArrays(e.TRIANGLES,0,3);const o=this.progs.grad;e.useProgram(o.p),e.bindFramebuffer(e.FRAMEBUFFER,this.G0fb),m(e,o,"uT",this.T1,0),I(e,o,{uCell:this.cell}),e.drawArrays(e.TRIANGLES,0,3)}step(e,s,r){const t=this.gl,o=this.progs.step,u=1-this.cur;t.bindFramebuffer(t.FRAMEBUFFER,this.state[u].fb),t.viewport(0,0,A,this.TH),t.useProgram(o.p),m(t,o,"uA",this.A,0),m(t,o,"uB",this.B,1),m(t,o,"uG0",this.G0,2),m(t,o,"uG1",this.G1,3);const l=new Float32Array(20),n=new Float32Array(20);r.slice(0,5).forEach((h,d)=>{l.set([h.x0,h.y0,h.x1,h.y1],d*4),n.set([h.vx,h.vy,h.r,1],d*4)}),I(t,o,{...this.presetUniforms(),uTW:{type:"i",v:A},uWorld:this.world,uDt:e,uGrav:s,uFA:{type:"v4a",v:l},uFB:{type:"v4a",v:n},uFrame:{type:"u",v:this.frame++>>>0},uTool:this.tool,uSpawn:this.spawn,uSpawnConfig:[this.spawnRadius,this.spawnHeight,0,this.count]}),t.bindVertexArray(this.vao),t.drawArrays(t.TRIANGLES,0,3),this.cur=u,this.spawn=[0,0,0,0]}readStats(){const e=this.gl;if(this.fence){if(e.clientWaitSync(this.fence,0,0)===e.TIMEOUT_EXPIRED)return this.stats;e.deleteSync(this.fence),this.fence=null,e.bindBuffer(e.PIXEL_PACK_BUFFER,this.pbo),e.getBufferSubData(e.PIXEL_PACK_BUFFER,0,this.statsBuf),e.bindBuffer(e.PIXEL_PACK_BUFFER,null);let t=0,o=0,u=0;for(let n=0;n<256;n++)t+=this.statsBuf[n*4],o+=this.statsBuf[n*4+1],u+=this.statsBuf[n*4+2];const l=this.cell[0]*this.cell[1];this.stats={volume:t*l,flow:o*l,coverage:u/(this.GW*this.GH)}}const s=this.progs.stats;return e.bindFramebuffer(e.FRAMEBUFFER,this.statsFb),e.viewport(0,0,16,16),e.useProgram(s.p),m(e,s,"uS",this.splatTex,0),e.bindVertexArray(this.vao),e.drawArrays(e.TRIANGLES,0,3),e.bindBuffer(e.PIXEL_PACK_BUFFER,this.pbo),e.bufferData(e.PIXEL_PACK_BUFFER,256*16,e.STREAM_READ),e.readPixels(0,0,16,16,e.RGBA,e.FLOAT,0),e.bindBuffer(e.PIXEL_PACK_BUFFER,null),this.fence=e.fenceSync(e.SYNC_GPU_COMMANDS_COMPLETE,0),e.flush(),this.stats}}const ze=`
uniform vec4 uCamera;
uniform float uCameraScale;
uniform float uCameraDepth;
vec3 cameraRotate(vec3 p) {
  float y = uCamera.y * p.y - uCamera.x * p.z;
  float z = uCamera.x * p.y + uCamera.y * p.z;
  return vec3(uCamera.w * p.x + uCamera.z * z, y, -uCamera.z * p.x + uCamera.w * z);
}
vec3 cameraPoint(vec2 p, float z) { return cameraRotate(vec3(p.x - uWorld.x * 0.5, uWorld.y * 0.5 - p.y, z)); }
vec2 trayUv(vec2 screenUv) {
  float y = (screenUv.y - 0.5) * uWorld.y * uCameraScale / uCamera.y;
  float x = ((screenUv.x - 0.5) * uWorld.x * uCameraScale - uCamera.z * uCamera.x * y) / uCamera.w;
  return vec2(x / uWorld.x + 0.5, 0.5 - y / uWorld.y);
}
`,Pe=`
uniform sampler2D uA;
uniform sampler2D uB;
uniform sampler2D uG0;
uniform int uTW;
uniform vec2 uWorld;
uniform vec3 uL;

float grainHeight(Grain g, vec4 B, float h, out float zTop) {
  float layer = B.z;
  float zc = g.r + layer * max(h - 2.0 * g.r, 0.0);
  if (g.kind == 2.0) zc = max(zc, g.r * 0.8 + h * 0.6);
  zTop = zc + g.r;
  return zc + B.x;
}
`,ot=b+ie+Pe+`
uniform vec2 uRes;
out float vA;
out float vSoft;
out vec2 vPeb;
out vec4 vGeo;
void main() {
  int id = gl_VertexID;
  ivec2 pc = ivec2(id % uTW, id / uTW);
  vec4 A = texelFetch(uA, pc, 0);
  vec4 B = texelFetch(uB, pc, 0);
  if (B.w < 0.5 || B.w > 1.0001) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; }
  Grain g = grain(uint(id));
  float h = texture(uG0, A.xy / uWorld).x;
  float zTop;
  float zc = grainHeight(g, B, h, zTop);
  vec2 off = -uL.xy / max(uL.z, 0.25) * zc;
  vec2 sp = A.xy + off;
  float soft0 = g.kind == 2.0 ? 0.3 * zc + 0.15 : 0.12 * zc + 0.05;
  float soft = min(soft0, 1.2);
  float pxmm = uRes.x / uWorld.x;
  float rm = g.kind == 2.0 && B.x <= 0.0 ? g.r * 1.3 + 0.45 : 0.0;
  float half_ = max(g.r + soft, length(off) + rm);
  gl_PointSize = 2.0 * half_ * pxmm + 1.5;
  vPeb = vec2(rm > 0.0 ? 4.13 * g.r * (1.3 * g.r) * (1.3 * g.r) / (rm * rm) / 16.0 : 0.0, g.r * 0.92 * g.aspect / half_);
  vGeo = vec4((g.r + soft) / half_, vec2(-off.x, off.y) / half_, rm / half_);
  vSoft = clamp(2.0 * soft / (2.0 * g.r + 2.0 * soft), 0.1, 0.95);
  float spread = (2.0 * g.r) / (2.0 * g.r + 2.0 * soft);
  float spread0 = (g.r + soft) / (g.r + soft0);
  vA = min((g.kind == 2.0 ? 1.0 : 0.7) * mix(1.0, spread * spread, 0.6) * exp(-max(B.x, 0.0) / 12.0) * spread0 * spread0, 1.0);
  if (vA < 0.004) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; }
  gl_Position = vec4(sp / uWorld * 2.0 - 1.0, 0.0, 1.0);
}
`,nt=b+`
in float vA;
in float vSoft;
in vec2 vPeb;
in vec4 vGeo;
out vec4 o;
void main() {
  vec2 p = gl_PointCoord * 2.0 - 1.0;
  float a = vA * (1.0 - smoothstep(1.0 - vSoft, 1.0, length(p) / vGeo.x));
  vec2 t = vGeo.w > 0.0 ? abs(p - vGeo.yz) / vGeo.w : vec2(1.0);
  float m = vPeb.x * max(1.0 - t.x, 0.0) * max(1.0 - t.y, 0.0);
  float c = vGeo.w > 0.0 ? 1.0 - smoothstep(0.75, 1.1, length(p - vGeo.yz) / vPeb.y) : 0.0;
  o = vec4(a, m, c, a);
}
`,ct=b+`
uniform sampler2D uT;
uniform vec2 uDir;
out vec4 o;
void main() {
  vec2 ts = vec2(textureSize(uT, 0));
  vec2 uv = gl_FragCoord.xy / ts;
  vec2 d = uDir / ts;
  vec4 s = texture(uT, uv) * 0.2270270270;
  s += (texture(uT, uv + d * 1.3846153846) + texture(uT, uv - d * 1.3846153846)) * 0.3162162162;
  s += (texture(uT, uv + d * 3.2307692308) + texture(uT, uv - d * 3.2307692308)) * 0.0702702703;
  o = s;
}
`,ut=b+ie+`
uniform sampler2D uScreen;
uniform sampler2D uShadow;
uniform sampler2D uG0;
uniform vec2 uRes;
uniform vec2 uWorld;
uniform vec3 uL;
uniform vec4 uBg;
uniform vec3 uRoom;
uniform vec4 uFloor;
uniform vec3 uPm;
${ze}
out vec4 o;
vec3 toLin(vec3 c) { return pow(c, vec3(2.2)); }
float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
vec4 sandFloor(vec2 wp, vec3 n, float h) {
  vec2 p = wp / uFloor.x;
  vec2 b = floor(p);
  float d1 = 9.0, d2 = 9.0;
  vec2 c1 = vec2(0.0);
  vec2 dv = vec2(0.0);
  for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++) {
      vec2 c = b + vec2(i, j);
      vec2 f = c + vec2(hash12(c), hash12(c + 71.3));
      float d = length(p - f);
      if (d < d1) { d2 = d1; d1 = d; c1 = c; dv = p - f; } else if (d < d2) d2 = d;
    }
  uint id = uint(int(c1.x) + 8192) + uint(int(c1.y) + 8192) * 16384u + 2654435769u;
  vec3 alb = pickColor(rnd(id, 5u)) * (0.72 + 0.56 * rnd(id, 6u));
  float t = mix(uFloor.y, uFloor.z, rnd(id, 7u));
  float sc = uFloor.x / 0.304;
  float rad = mix(0.45, 1.3, smoothstep(t + 0.1 * sc, t + 0.9 * sc, h));
  float px = fwidth(d1);
  float m = (1.0 - smoothstep(rad - px, rad + px, d1)) * smoothstep(t - 0.02, t + 0.02, h);
  float crev = smoothstep(0.0, 0.3, d2 - d1);
  vec2 dn = dv / rad;
  vec3 gn = normalize(n + vec3(dn * 0.9, 0.0));
  float diff = max(dot(gn, uL), 0.0);
  float dome = 1.0 - 0.3 * smoothstep(0.3, 0.9, length(dn));
  float deep = mix(1.0, mix(uFloor.w, 0.85, smoothstep(0.1, 0.6, dot(alb, vec3(0.3, 0.55, 0.15)))), smoothstep(t + 0.1 * sc, t + 1.5 * sc, h));
  return vec4(alb * (uLook.x * 0.7 + uLook.y * diff) * mix(0.5, 1.0, crev) * dome * deep, m);
}
void main() {
  vec2 screenUv = gl_FragCoord.xy / uRes;
  vec2 uv = trayUv(screenUv);
  if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) {
    float vignette = 1.0 - 0.25 * length(screenUv - 0.5);
    o = vec4(vec3(0.105, 0.116, 0.132) * vignette, 1.0);
    return;
  }
  vec3 scr = toLin(texture(uScreen, uv).rgb) * uBg.x;
  vec3 sh = texture(uShadow, uv).rgb;
  float occ = sh.x;
  vec4 G = texture(uG0, uv);
  float hb = G.x;
  float ao = 1.0 - uBg.z * smoothstep(0.15, 2.2, hb);
  vec2 rc = (uv - vec2(0.3, 0.12) - uL.xy * vec2(0.6, 0.4)) * vec2(1.0, uRes.y / uRes.x * 0.55);
  float refl = 0.3 + 0.7 * exp(-dot(rc, rc) * 2.2) + 0.25 * smoothstep(0.06, 0.0, abs(dot(uv - vec2(0.75, 0.5), vec2(0.8, 0.6)) - 0.05 * uL.x));
  vec3 filt = mix(vec3(1.0), uPm / max(uPm.r, max(uPm.g, uPm.b)), smoothstep(uFloor.y, uFloor.z, hb) * 0.75);
  vec3 col = (scr * filt * (1.0 - occ * uBg.y) * ao + uRoom * refl * (1.0 - occ * 0.85) * ao) * (1.0 - sh.z * 0.6);
  if (hb > uFloor.y - 0.05) {
    vec2 gs = G.yz / max(1.0, length(G.yz));
    float hs = hb - sh.y * 16.0;
    if (sh.y > 0.004) {
      vec2 o = 3.0 / uWorld;
      float ar = 0.0;
      for (int k = 0; k < 4; k++) {
        vec2 u2 = uv + o * vec2(k == 0 ? 1.0 : k == 1 ? -1.0 : 0.0, k == 2 ? 1.0 : k == 3 ? -1.0 : 0.0);
        ar += texture(uG0, u2).x - texture(uShadow, u2).g * 16.0;
      }
      hs = mix(hs, max(hs, ar * 0.25), smoothstep(0.004, 0.04, sh.y));
    }
    vec4 f = sandFloor(uv * uWorld, normalize(vec3(-gs, 1.0)), hs);
    col = mix(col, f.rgb * (1.0 - occ * 0.5), f.a);
  }
  col = pow(max(col, 0.0), vec3(1.0 / 2.2));
  col += (hash12(gl_FragCoord.xy) - 0.5) / 255.0;
  o = vec4(col, 1.0);
}
`,lt=b+ie+Pe+ze+`
uniform vec2 uRes;
uniform float uMaxPt;
uniform float uCull;
uniform float uSq;
uniform sampler2D uShadow;
uniform float uTime;
out vec3 vAlb;
out vec4 vSeed;
out vec4 vLit;
out vec3 vMacro;
out vec4 vShape;
out vec2 vScreenUv;
out vec3 vDepth;
void main() {
  int id = gl_VertexID;
  uint uid = uint(id);
  ivec2 pc = ivec2(id % uTW, id / uTW);
  vec4 A = texelFetch(uA, pc, 0);
  vec4 B = texelFetch(uB, pc, 0);
  if (B.w < 0.5 || B.w > 1.0001) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; }
  Grain g = grain(uid);
  vec2 uv = A.xy / uWorld;
  vec4 G0 = texture(uG0, uv);
  float h = G0.x;
  if (g.kind != 2.0) h = max(h - texture(uShadow, uv).g * 16.0, 0.0);
  float zTop;
  float z = grainHeight(g, B, h, zTop);
  float persp = 1.0;
  vec3 camera = cameraPoint(A.xy, z);
  vec2 cpos = camera.xy * 2.0 / (uWorld * uCameraScale);
  float pxmm = uRes.x / (uWorld.x * uCameraScale);
  float rpx = g.r * pxmm * persp;
  float size = min(2.0 * rpx * 1.2 + 2.0, uMaxPt);
  gl_PointSize = size;
  if (B.x <= 0.0 && g.kind != 2.0 && h - zTop > uCull) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; }
  float zd = B.x > 0.0 ? z : h - (h - z) * (g.kind == 2.0 ? 1.0 : uSq);
  float cameraDepth = cameraPoint(A.xy, zd).z;
  gl_Position = vec4(cpos, clamp(-cameraDepth / uCameraDepth, -1.0, 1.0), 1.0);
  vDepth = vec3(cameraDepth, g.r * persp, B.x > 0.0 ? 0.0 : 1.0 - smoothstep(0.2, 1.0, h));

  vec3 L = uL;
  float lxy = max(length(L.xy), 1e-3);
  vec2 dir = L.xy / lxy;
  float rise = L.z / lxy;
  float sh = 0.0;
  if (B.x <= 0.0) {
    for (int k = 1; k <= 7; k++) {
      float t = float(k) * 0.45;
      float hq = texture(uG0, (A.xy + dir * t) / uWorld).x;
      sh = max(sh, clamp((hq - (zTop - g.r * 0.5 + t * rise)) / 0.5, 0.0, 1.0));
    }
  }
  float below = max(h - zTop, 0.0);
  float ao = (0.55 + 0.45 * exp(-below * 1.2)) * (B.x > 0.0 ? 1.0 : mix(0.82, 1.0, clamp(z / max(h, 0.01), 0.0, 1.0)));
  float macroW = smoothstep(0.3, 1.2, h);
  vMacro = normalize(vec3(-G0.yz / max(1.0, length(G0.yz)) * macroW, 1.0));
  vAlb = g.col;
  float spin = (A.x * 0.37 - A.y * 0.29) / max(g.r, 0.05);
  vSeed = vec4(rnd(uid, 22u), rnd(uid, 23u), rnd(uid, 24u), rnd(uid, 25u) * 6.2831853 + spin * 0.15);
  float bounce = smoothstep(0.1, 0.6, dot(g.col, vec3(0.3, 0.55, 0.15)));
  vLit = vec4(1.0 - sh * 0.7, mix(ao, 1.0, bounce * 0.5), g.transl * (1.0 - smoothstep(0.25, 0.8, h)) * step(B.x, 0.0), g.sparkle);
  if (uMaterial.x == 2.0 && length(A.zw) > 2.0) vSeed.w = atan(A.w, A.z) + 1.5707963;
  vShape = vec4(size / (2.0 * rpx), g.kind, g.aspect, uMaterial.x);
  vScreenUv = vec2(uv.x, uv.y);
}
`,ht=b+`
uniform sampler2D uScreen;
uniform vec3 uL;
uniform vec3 uViewLight;
uniform float uCameraDepth;
uniform vec4 uLook;
uniform float uScreenGain;
in vec3 vAlb;
in vec4 vSeed;
in vec4 vLit;
in vec3 vMacro;
in vec4 vShape;
in vec2 vScreenUv;
in vec3 vDepth;
out vec4 o;

float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float vnoise(vec2 p) {
  vec2 i = floor(p); vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash12(i), hash12(i + vec2(1, 0)), u.x), mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), u.x), u.y);
}

void main() {
  vec2 q = (gl_PointCoord * 2.0 - 1.0) * vShape.x;
  float ca = cos(vSeed.w), sa = sin(vSeed.w);
  q = mat2(ca, -sa, sa, ca) * q;
  q.x /= vShape.z;
  bool peb = vShape.y == 2.0;
  float ang = atan(q.y, q.x);
  float angular = peb ? 0.03 + 0.15 * vSeed.x * vSeed.x : 0.11;
  float R = 1.0
    + angular * sin(2.0 * ang + vSeed.x * 6.28) * (0.5 + vSeed.y)
    + angular * 0.7 * sin(3.0 * ang + vSeed.y * 6.28)
    + angular * 0.45 * sin(5.0 * ang + vSeed.z * 6.28);
  R *= 1.0 / (1.0 + angular * 0.9);
  float k = 5.0 + floor(vSeed.x * 3.0);
  float su = (ang / 6.2831853 + vSeed.z) * k;
  float sector = floor(su);
  if (!peb) {
    float poly = cos(3.14159 / k) / cos((fract(su) - 0.5) * 6.2831853 / k);
    R *= mix(1.0, poly * (1.0 + 0.12 * (hash12(vec2(sector, vSeed.y * 71.0)) - 0.5)) / 0.93, 0.7);
  }
  if (vShape.w == 1.0 || vShape.w == 2.0) R = 1.0;
  float rho = length(q) / R;
  if (vShape.w == 3.0) rho = max(abs(q.x) * 0.866 + abs(q.y) * 0.5, abs(q.y));
  if (vShape.w == 4.0) rho = max(abs(q.x), abs(q.y));
  float aa = max(fwidth(rho), 0.02);
  float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa * 0.25, rho);
  if (alpha < 0.02) discard;

  vec2 nq = q / R;
  float r2 = min(dot(nq, nq), 0.995);
  gl_FragDepth = clamp(0.5 - (vDepth.x + vDepth.y * sqrt(1.0 - r2)) / (2.0 * uCameraDepth), 0.0, 1.0);
  vec3 L = normalize(uViewLight);
  vec3 alb = vAlb;
  vec3 n;
  float glint = 0.0;
  float spec;
  if (peb) {
    float rr = sqrt(r2);
    float slope = min(rr * rr * pow(max(1.0 - rr * rr * rr, 1e-3), -0.6667), 4.0);
    n = normalize(vec3(nq / max(rr, 1e-3) * slope * (0.45 + 0.3 * vSeed.z), 1.0));
    vec2 np = nq * 1.7 + vSeed.xy * 31.0;
    n.xy += (vec2(vnoise(np + 3.1), vnoise(np - 5.7)) - 0.5) * 0.22;
    n = normalize(n);
    float big = vnoise(nq * 1.6 + vSeed.zx * 17.0);
    float fine = vnoise(nq * 11.0 + vSeed.yz * 41.0);
    float grit = hash12(floor(gl_FragCoord.xy * 0.5) + vSeed.xy * 97.0);
    float fine2 = vnoise(nq * 23.0 - vSeed.zx * 29.0);
    alb *= 0.7 + 0.4 * big + 0.3 * (fine - 0.5) + 0.25 * (fine2 - 0.5) + 0.12 * (grit - 0.5);
    vec2 vd = vec2(cos(vSeed.z * 9.0), sin(vSeed.z * 9.0));
    float vline = dot(nq, vd) + (vSeed.x - 0.5) * 0.9 + (vnoise(nq * 3.0 + vSeed.yx * 13.0) - 0.5) * 0.25;
    float vein = (1.0 - smoothstep(0.008, 0.03, abs(vline))) * step(0.68, vSeed.y);
    alb = mix(alb, alb * 1.4 + 0.05, vein * 0.45);
    float dk = smoothstep(0.78, 0.86, vnoise(nq * 9.0 - vSeed.yx * 23.0)) * step(0.5, vSeed.z);
    alb = mix(alb, alb * 0.35, dk * 0.6);
    spec = pow(max(dot(n, normalize(L + vec3(0.0, 0.0, 1.0))), 0.0), 18.0) * 0.06;
  } else {
    float ac = ((sector + 0.5) / k - vSeed.z) * 6.2831853;
    float sh = hash12(vec2(sector, vSeed.y * 113.0));
    vec3 nf = normalize(vec3(vec2(cos(ac), sin(ac)) * (0.7 + 0.9 * sh), 1.0));
    vec3 nt = normalize(vec3((vSeed.xy - 0.5) * 0.5, 1.0));
    float dA = length(nq - (vSeed.yx - 0.5) * 0.5);
    vec3 nd = normalize(vec3(nq * 0.85, sqrt(1.0 - r2) + 0.25));
    n = normalize(mix(mix(nt, nf, smoothstep(0.05, 0.7, dA)), nd, 0.35));
    alb *= 0.88 + 0.24 * vnoise(q * 1.7 + vSeed.zy * 19.0);
    spec = pow(max(dot(n, normalize(L + vec3(0.0, 0.0, 1.0))), 0.0), 30.0) * 0.05;
    vec3 facet = normalize(vec3((vSeed.yz - 0.5) * 0.9, 1.0));
    float glintA = pow(max(dot(reflect(-L, facet), vec3(0.0, 0.0, 1.0)), 0.0), 220.0);
    vec2 gpos = (vSeed.zy - 0.5) * 0.9;
    glint = glintA * exp(-dot(nq - gpos, nq - gpos) * 9.0) * vLit.w * uLook.z;
  }
  if (vShape.w == 1.0) {
    n = normalize(vec3(nq, sqrt(1.0 - r2)));
    spec = pow(max(dot(n, normalize(L + vec3(0, 0, 1))), 0.0), 75.0) * 1.4;
    alb = mix(alb * 0.65, alb, sqrt(1.0 - r2));
  }
  if (vShape.w == 3.0) {
    n = normalize(vec3((vSeed.xy - 0.5) * 1.2, 1.0));
    spec = pow(max(dot(n, normalize(L + vec3(0, 0, 1))), 0.0), 36.0) * 1.7;
  }
  n = normalize(n + (vMacro - vec3(0.0, 0.0, 1.0)) * 0.9);
  alb = max(alb, 0.0);

  float diff = max(dot(n, L), 0.0) * vLit.x;
  float hemi = 0.5 + 0.5 * n.z;
  float rimOcc = mix(0.7, 1.0, smoothstep(0.05, 0.6, n.z));
  if (peb) rimOcc *= 0.55 + 0.45 * clamp(dot(n, L), 0.0, 1.0);
  float ms = clamp(dot(vMacro, L) / max(L.z, 0.3), 0.62, 1.45);
  float bl = smoothstep(0.1, 0.6, dot(vAlb, vec3(0.3, 0.55, 0.15)));
  ms = ms < 1.0 ? mix(ms, 1.0, bl * 0.6) : ms;
  diff = mix(diff, max(dot(n, L), 0.0), bl * 0.5);
  vec3 col = alb * (uLook.x * hemi * rimOcc + uLook.y * diff) * vLit.y * ms;
  col += vec3(spec * vLit.x * vLit.y) + vec3(1.0, 0.97, 0.92) * glint * vLit.x;

  vec3 scr = pow(texture(uScreen, vScreenUv).rgb, vec3(2.2)) * uScreenGain;
  col += scr * vAlb * (vLit.z * (1.0 - smoothstep(0.35, 0.95, rho)) * 0.25 + vDepth.z * 0.1) * uLook.w;

  col = max(col, 0.0);
  col /= 1.0 + 0.9 * max(max(col.r, max(col.g, col.b)) - 0.55, 0.0);
  col = pow(col, vec3(1.0 / 2.2));
  o = vec4(col, alpha);
}
`;class dt{constructor(e){this.gl=e,this.vao=e.createVertexArray(),this.progs={shadow:P(e,ot,nt,"shadow"),blur:P(e,q,ct,"blur"),bg:P(e,q,ut,"bg"),grain:P(e,lt,ht,"grain")},this.maxPoint=e.getParameter(e.ALIASED_POINT_SIZE_RANGE)[1],this.screenTex=e.createTexture(),e.bindTexture(e.TEXTURE_2D,this.screenTex),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,1,1,0,e.RGBA,e.UNSIGNED_BYTE,new Uint8Array([240,240,240,255])),this.look={screenGain:1,shadowK:.42,aoK:.28,room:[.009,.009,.0102],amb:1.15,key:1,sparkle:1,transl:1,floor:[.3,.85,.5],cull:3,sq:.5}}resize(e,s,r){var o,u;const t=this.gl;(o=this.shadowTex)==null||o.forEach(l=>t.deleteTexture(l)),(u=this.shadowFb)==null||u.forEach(l=>t.deleteFramebuffer(l)),this.w=e,this.h=s,this.sw=Math.max(16,Math.round(e*r)),this.sh=Math.max(16,Math.round(s*r)),this.shadowTex=[0,1].map(()=>G(t,this.sw,this.sh,t.RGBA8,t.RGBA,t.UNSIGNED_BYTE,t.LINEAR)),this.shadowFb=this.shadowTex.map(l=>D(t,[l]))}paletteMean(e){const s=[0,0,0];let r=0;for(let t=0;t<8;t++){const o=Math.max(e[t*4+3]-r,0);r=Math.max(r,e[t*4+3]);for(let u=0;u<3;u++)s[u]+=e[t*4+u]*o}return s}setScreen(e){const s=this.gl;s.bindTexture(s.TEXTURE_2D,this.screenTex),s.pixelStorei(s.UNPACK_FLIP_Y_WEBGL,!1),s.texImage2D(s.TEXTURE_2D,0,s.RGBA,s.RGBA,s.UNSIGNED_BYTE,e)}draw(e,s,r){const t=this.gl,o=s,u={...e.presetUniforms(),uTW:{type:"i",v:A},uWorld:e.world,uL:o,uCamera:r.basis(),uCameraScale:r.scale(e.world),uCameraDepth:Math.max(...e.world)+240,uViewLight:r.rotate([o[0],-o[1],o[2]]).map((k,C)=>C===1?-k:k)};t.bindVertexArray(this.vao);const l=this.progs.shadow;t.bindFramebuffer(t.FRAMEBUFFER,this.shadowFb[0]),t.viewport(0,0,this.sw,this.sh),t.clearColor(0,0,0,0),t.clear(t.COLOR_BUFFER_BIT),t.enable(t.BLEND),t.blendFunc(t.ONE,t.ONE_MINUS_SRC_ALPHA),t.useProgram(l.p),m(t,l,"uA",e.A,0),m(t,l,"uB",e.B,1),m(t,l,"uG0",e.G0,2),I(t,l,{...u,uRes:[this.sw,this.sh]}),t.drawArrays(t.POINTS,0,e.active),t.disable(t.BLEND);const n=this.progs.blur;t.useProgram(n.p),t.bindFramebuffer(t.FRAMEBUFFER,this.shadowFb[1]),m(t,n,"uT",this.shadowTex[0],0),I(t,n,{uDir:[1,0]}),t.drawArrays(t.TRIANGLES,0,3),t.bindFramebuffer(t.FRAMEBUFFER,this.shadowFb[0]),m(t,n,"uT",this.shadowTex[1],0),I(t,n,{uDir:[0,1]}),t.drawArrays(t.TRIANGLES,0,3),t.bindFramebuffer(t.FRAMEBUFFER,null),t.viewport(0,0,this.w,this.h),t.disable(t.DEPTH_TEST),t.depthMask(!1);const h=this.look,d=this.progs.bg,y=Math.pow(e.preset.size[0]/.19,3);t.useProgram(d.p),m(t,d,"uScreen",this.screenTex,0),m(t,d,"uShadow",this.shadowTex[0],1),m(t,d,"uG0",e.G0,2),I(t,d,{...u,uRes:[this.w,this.h],uBg:[h.screenGain,h.shadowK,h.aoK,0],uRoom:h.room,uLook:[h.amb,h.key,h.sparkle,h.transl],uPm:this.paletteMean(e.preset.palette),uFloor:[1.6*e.preset.size[0],h.floor[0]*y,h.floor[1]*y,h.floor[2]]}),t.drawArrays(t.TRIANGLES,0,3),t.enable(t.DEPTH_TEST),t.depthFunc(t.LEQUAL),t.depthMask(!0),t.clearDepth(1),t.clear(t.DEPTH_BUFFER_BIT),t.enable(t.SAMPLE_ALPHA_TO_COVERAGE);const v=this.progs.grain;t.useProgram(v.p),m(t,v,"uA",e.A,0),m(t,v,"uB",e.B,1),m(t,v,"uG0",e.G0,2),m(t,v,"uScreen",this.screenTex,3),m(t,v,"uShadow",this.shadowTex[0],4),I(t,v,{...u,uRes:[this.w,this.h],uMaxPt:this.maxPoint,uCull:h.cull,uSq:h.sq,uLook:[h.amb,h.key,h.sparkle,h.transl],uScreenGain:h.screenGain}),t.drawArrays(t.POINTS,0,e.active),t.disable(t.SAMPLE_ALPHA_TO_COVERAGE),t.disable(t.DEPTH_TEST)}}const ft=i=>{const e=parseInt(i.slice(1),16);return[e>>16&255,e>>8&255,e&255].map(s=>Math.pow(s/255,2.2))};function B(i){const e=i.reduce((t,o)=>t+o[1],0),s=new Float32Array(32);let r=0;for(let t=0;t<8;t++){const o=i[Math.min(t,i.length-1)];t<i.length&&(r+=o[1]/e);const u=ft(o[0]);s.set([u[0],u[1],u[2],t<i.length?r:1],t*4)}return s[(i.length-1)*4+3]=1,s}const O={river:{name:"River",swatch:"#8a7d6b",palette:B([["#9f978b",28],["#877c6c",22],["#6b665f",14],["#363330",8],["#cdc7bb",10],["#86694f",7],["#a8968a",5],["#5f625d",6]]),size:[.19,.36,.02,25e-5],size2:[.7,2.6,.32,.7],fric:[.22,.75,.58,1]},beach:{name:"Beach",swatch:"#d8bf8f",palette:B([["#e3cfa4",30],["#cdb183",24],["#b8935f",14],["#f2e8d4",12],["#8f7454",8],["#3b342c",5],["#c98f6e",4],["#e9ddc7",3]]),size:[.17,.3,.012,12e-5],size2:[.6,1.8,.3,.6],fric:[.2,.7,.55,1.2]},desert:{name:"Desert",swatch:"#c77d45",palette:B([["#c98f5c",30],["#b97c4c",24],["#d6a273",18],["#9e6640",12],["#dfc095",8],["#7a4a2e",5],["#e8d3b0",3]]),size:[.13,.22,0,0],size2:[.6,1,.3,.5],fric:[.25,.7,.56,.5]},volcanic:{name:"Volcanic",swatch:"#2b2a2a",palette:B([["#2b2a29",34],["#1a1919",24],["#45423f",16],["#5a5048",8],["#3c4334",4],["#0d0d0d",7],["#7d756c",4]]),size:[.2,.34,.02,3e-4],size2:[.7,2.4,.32,.7],fric:[.25,.75,.6,1.6]},coral:{name:"Coral",swatch:"#efe4d6",palette:B([["#e6ddcf",32],["#d8cab5",24],["#e9c9bd",12],["#c7b293",13],["#f4eee4",9],["#b58f80",6],["#857868",4]]),size:[.22,.4,.03,25e-5],size2:[.6,2.2,.35,.8],fric:[.25,.75,.6,.8]}};Object.assign(O,{snow:{name:"Powder snow",swatch:"#d9e9f3",palette:B([["#f6fbff",45],["#dcebf4",30],["#c2d9e9",20],["#ffffff",5]]),size:[.23,.36,.012,0],size2:[.6,1,.4,.65],fric:[.28,.88,.76,.9],material:[0,.65,.03,7],description:"Soft, clinging grains. Push slow ridges into a fresh snowfall.",trait:"Soft · cohesive",surface:"slate"},glass:{name:"Glass beads",swatch:"#79c8bd",palette:B([["#70c5bb",25],["#b3e7df",25],["#4e9caa",18],["#e4f9ed",20],["#578795",12]]),size:[.37,.12,0,0],size2:[.6,1,.4,.5],fric:[.035,.13,.13,2.8],material:[1,0,.7,.12],description:"Smooth little spheres. Tilt the tray and watch them roll and rebound.",trait:"Rolling · bouncy",surface:"dark"},iron:{name:"Iron filings",swatch:"#8e98a8",palette:B([["#6f7987",30],["#3d4855",25],["#aeb6c1",20],["#232e3b",15],["#d0d5dc",10]]),size:[.3,.22,0,0],size2:[.6,1,.3,.5],fric:[.23,.65,.52,2.5],material:[2,.06,.15,1.2],description:"Bring a magnet close. Fine metallic needles gather around your touch.",trait:"Magnetic · metallic",surface:"light"},glitter:{name:"Prism glitter",swatch:"#bd85ce",palette:B([["#cc83cb",22],["#71c6ca",22],["#efd48d",18],["#9c95d9",18],["#e4b4d5",12],["#eef2eb",8]]),size:[.27,.15,0,0],size2:[.6,1,.3,.5],fric:[.14,.48,.38,7],material:[3,0,.12,5],description:"Light-catching flakes. Stir a shifting field of pink, gold and teal.",trait:"Faceted · shimmering",surface:"dark"},salt:{name:"Rock salt",swatch:"#efc6b5",palette:B([["#f5e6db",32],["#e4b8a7",25],["#f2d4c6",23],["#bc8e7b",10],["#ffffff",10]]),size:[.34,.25,.025,0],size2:[.6,1,.44,.62],fric:[.35,.82,.68,1.7],material:[4,.08,.22,1],description:"Chunky pink crystals. Rough edges settle into steep, crunchy piles.",trait:"Angular · coarse",surface:"slate"},kinetic:{name:"Kinetic sand",swatch:"#bc8cde",palette:B([["#b18bc7",35],["#9671b3",25],["#ceaadf",25],["#78548f",15]]),size:[.19,.2,0,0],size2:[.6,1,.3,.5],fric:[.4,.94,.92,.25],material:[0,1.5,.015,9],description:"Sculptable, sticky grains. Carve deep channels and leave raised edges.",trait:"Sculptable · sticky",surface:"light"}});const pt={river:["A mix of smooth minerals and tiny pebbles. Draw a path through the riverbed.","Mixed · pebbly"],beach:["Warm quartz grains, a few shells and the quiet pleasure of drawing in sand.","Fine · golden"],desert:["Fine, rounded grains. Tilt gently to build flowing copper-colored dunes.","Fine · flowing"],volcanic:["Dark, rough grains with tiny mineral glints. Sculpt a volcanic shoreline.","Rough · mineral"],coral:["Pale, irregular grains with soft pink shell fragments.","Coarse · shelly"]};for(const[i,[e,s]]of Object.entries(pt))Object.assign(O[i],{description:e,trait:s,surface:i==="coral"?"slate":"light",material:[0,0,.08,0]});const Le=["river","beach","desert","volcanic","coral","snow","glass","iron","glitter","salt","kinetic"];class mt{constructor(){this.pitch=0,this.yaw=0}set(e,s){this.pitch=Math.max(-35,Math.min(35,Number.isFinite(e)?e:0)),this.yaw=Math.max(-35,Math.min(35,Number.isFinite(s)?s:0))}basis(){const e=this.pitch*Math.PI/180,s=this.yaw*Math.PI/180;return[Math.sin(e),Math.cos(e),Math.sin(s),Math.cos(s)]}scale(e){const[s,r,t,o]=this.basis();return Math.max(1,Math.abs(o)+Math.abs(t*s)*e[1]/e[0],Math.abs(r))*(1+.12*Math.max(Math.abs(this.pitch),Math.abs(this.yaw))/35)}rotate([e,s,r]){const[t,o,u,l]=this.basis();return[l*e+u*(t*s+o*r),o*s-t*r,-u*e+l*(t*s+o*r)]}project(e,s,r,t){const o=this.rotate([e-t[0]/2,t[1]/2-s,r]),u=this.scale(t);return[.5+o[0]/(t[0]*u),.5-o[1]/(t[1]*u),o[2]]}unproject(e,s,r){const[t,o,u,l]=this.basis(),n=this.scale(r),h=(.5-s)*r[1]*n/o;return[((e-.5)*r[0]*n-u*t*h)/l+r[0]/2,r[1]/2-h]}}class vt{constructor(e,s,r={}){R(this,"context");R(this,"output");R(this,"session","");R(this,"pending",!1);R(this,"blocked",!1);R(this,"disposed",!1);R(this,"muted",!1);R(this,"volume",1);R(this,"stopped",!1);R(this,"listener");this.media=e,this.title=s,this.options=r}activate(){var e,s;if(!this.media||this.disposed)return!1;this.blocked=!1,this.stopped=!1;try{return this.context||(this.context=((s=(e=this.options).createContext)==null?void 0:s.call(e))??new AudioContext,this.output=this.context.createGain(),this.output.gain.value=0,this.output.connect(this.context.destination),this.context.onstatechange=()=>this.report()),!this.session&&!this.pending&&(this.pending=!0,this.register()),this.context.state==="suspended"&&this.resume(),this.audible}catch{return this.dispose(),!1}}get audible(){var e;return!!this.session&&!this.disposed&&!this.blocked&&!this.muted&&((e=this.context)==null?void 0:e.state)==="running"}setMuted(e){this.muted=e,this.applyGain(),this.report()}applyGain(){this.output&&(this.output.gain.value=this.audible?this.volume:0)}report(){var e,s;if(this.applyGain(),(s=(e=this.options).onChange)==null||s.call(e),!(!this.session||this.disposed))try{this.media.reportState(this.session,{status:this.audible?"playing":this.stopped?"stopped":"paused",volume:this.muted?0:this.volume})}catch{this.dispose()}}async resume(){var e;try{await((e=this.context)==null?void 0:e.resume()),this.report()}catch{this.applyGain()}}async register(){try{const e=await this.media.createSession({owner:"napplet",metadata:{title:this.title,mediaType:"audio"},capabilities:["play","pause","stop","volume"]});if(this.disposed){e.sessionId&&this.media.destroySession(e.sessionId);return}if(e.error||!e.sessionId||e.owner!=="napplet"){e.sessionId&&this.media.destroySession(e.sessionId),this.dispose();return}this.session=e.sessionId,this.listener=this.media.onCommand(this.session,(s,r)=>{var t;this.disposed||(s==="pause"||s==="stop"?(this.blocked=!0,this.stopped=s==="stop",this.applyGain(),(t=this.context)==null||t.suspend().then(()=>this.report()).catch(()=>{})):s==="play"?(this.blocked=!1,this.stopped=!1,this.resume()):s==="volume"&&typeof r=="number"&&Number.isFinite(r)&&(this.volume=Math.max(0,Math.min(1,r))),this.report())}),this.report()}catch{this.dispose()}finally{this.pending=!1}}dispose(){var e,s,r;if(!this.disposed){this.disposed=!0,this.applyGain();try{(e=this.listener)==null||e.close()}catch{}if(this.context&&(this.context.onstatechange=null,this.context.close().catch(()=>{})),this.session)try{this.media.destroySession(this.session)}catch{}this.session="",(r=(s=this.options).onChange)==null||r.call(s)}}}class gt{constructor(e,s,r){this.media=s,this.onAudioChange=r,this.canvas=e;const{gl:t,floatBlend:o}=je(e);this.gl=t,this.sim=new rt(t,o),this.renderer=new dt(t),this.camera=new mt,this.material="beach",this.surface="light",this.amount=.8,this.radius=4.75,this.tool="push",this.speed=1,this.flowRate=12e3,this.pourHeight=12,this.spawnCursor=0,this.spawnRemainder=0,this.undoState=null,this.paused=matchMedia("(prefers-reduced-motion: reduce)").matches,this.keys=new Set,this.pointers=new Map,this.tilt=[0,0],this.pad=[0,0],this.shakeUntil=0,this.last=0,this.raf=0,this.seed=24,this.steps=0,this.quality=0,this.slowFrames=0,this.live=!0,this.visible=!document.hidden,this.skipped=!1,this.sound=null,this.resize(),this.pour(!1,!1),this.draw(),this.schedule()}resize(){var h;const e=Math.max(1,this.canvas.clientWidth),s=Math.max(1,this.canvas.clientHeight);if(e===this.width&&s===this.height&&this.dpr===this.targetDpr())return;this.clearInput(),this.hoverPoint=null;const r=this.sim.world?this.readParticles():null,t=(h=this.sim.world)==null?void 0:h.slice();this.width=e,this.height=s,this.dpr=this.targetDpr(),this.canvas.width=Math.round(e*this.dpr),this.canvas.height=Math.round(s*this.dpr),this.renderer.resize(this.canvas.width,this.canvas.height,Math.min(1,1/this.dpr));const o=matchMedia("(pointer: coarse)").matches?.16:.23;this.mmPerPx=o;const u=Math.max(10,e*o),l=Math.max(10,s*o),n=this.sim.count||(matchMedia("(pointer: coarse)").matches?9e4:18e4);if(this.sim.resize(u,l,n),this.sim.setPreset(O[this.material]),r){for(let d=0;d<this.sim.count;d++)r.A[d*4]*=u/t[0],r.A[d*4+1]*=l/t[1],r.A[d*4+2]=r.A[d*4+3]=0;this.upload(r)}this.setSurface(this.surface),this.draw()}targetDpr(){return Math.min(devicePixelRatio||1,this.quality?1:1.75)}setSurface(e){this.surface=e;const s=document.createElement("canvas");s.width=256,s.height=256;const r=s.getContext("2d"),t={light:["#eeece5","#dcdad2"],dark:["#1b1b20","#0d0e13"],slate:["#526371","#35434f"]},o=t[e]||t.light,u=r.createLinearGradient(0,0,256,256);u.addColorStop(0,o[0]),u.addColorStop(1,o[1]),r.fillStyle=u,r.fillRect(0,0,256,256),this.renderer.setScreen(s),this.renderer.look.screenGain=e==="dark"?.7:1,this.renderer.look.shadowK=e==="light"?.44:.3,this.draw()}setMaterial(e){O[e]&&(this.checkpoint(),this.material=e,this.sim.setPreset(O[e]),this.sim.magnet=!1,(this.tool==="magnet"||this.tool==="repel")&&this.setTool("push"),this.pour(!this.paused,!1))}pour(e=!0,s=!0){s&&this.sim.world&&this.checkpoint(),this.clearInput();const r=this.amount*(this.material==="glass"?.5:this.material==="salt"?.65:1);this.sim.pour({seed:this.seed++,intro:e&&!this.paused,amount:r}),this.spawnCursor=Math.floor(this.sim.count*r)%this.sim.count,this.draw()}clear(){this.checkpoint(),this.clearInput(),this.sim.pour({intro:!1,amount:0}),this.spawnCursor=0,this.draw()}shake(){this.shakeUntil=performance.now()+650}clearInput(){var e,s;(e=this.keys)==null||e.clear(),(s=this.pointers)==null||s.clear(),this.pad=[0,0],this.tilt=[0,0]}setTool(e){["push","pour","rake","erase","magnet","repel","camera"].includes(e)&&((e==="magnet"||e==="repel")&&this.material!=="iron"||(this.clearInput(),this.tool=e,this.sim.tool={push:0,pour:1,rake:2,erase:3,magnet:4,repel:5,camera:6}[e],this.sim.magnet=e==="magnet"||e==="repel"))}setCamera(e,s){this.camera.set(e,s),this.draw()}checkpoint(){this.sim.world&&(this.undoState={...this.readParticles(),world:[...this.sim.world],material:this.material,surface:this.surface,cursor:this.spawnCursor})}undo(){const e=this.undoState;if(!e)return!1;this.clearInput();for(let s=0;s<this.sim.count;s++)e.A[s*4]*=this.sim.world[0]/e.world[0],e.A[s*4+1]*=this.sim.world[1]/e.world[1];return this.material=e.material,this.sim.setPreset(O[e.material]),this.setTool("push"),this.spawnCursor=e.cursor,this.upload(e),this.setSurface(e.surface),this.undoState=null,this.draw(),!0}singleStep(){this.paused&&(this.frame(1/60),this.draw())}pointer(e,s,r,t=!1){if(this.tool==="camera"){const l=this.pointers.get(e);t?this.pointers.set(e,{screen:[s,r],pitch:this.camera.pitch,yaw:this.camera.yaw}):l&&this.setCamera(l.pitch+(r-l.screen[1])*.18,l.yaw+(s-l.screen[0])*.18);return}const o=this.camera.unproject(s/this.width,r/this.height,this.sim.world);if(o[0]<0||o[1]<0||o[0]>this.sim.world[0]||o[1]>this.sim.world[1]){this.pointers.delete(e);return}this.hoverPoint=o;const u=this.pointers.get(e);t?(this.pointers.size||this.checkpoint(),this.pointers.set(e,{last:o,current:o})):u&&(u.current=o)}endPointer(e,s=!1){const r=this.pointers.get(e);r&&(this.tool==="camera"||s?this.pointers.delete(e):r.ended=!0)}frame(e){e*=this.speed;const s=this.keys,r=Number(s.has("ArrowRight")||s.has("d"))-Number(s.has("ArrowLeft")||s.has("a")),t=Number(s.has("ArrowDown")||s.has("s"))-Number(s.has("ArrowUp")||s.has("w"));for(let n=0;n<2;n++){const h=(n?t:r)||this.pad[n];this.tilt[n]+=(h-this.tilt[n])*Math.min(1,e*7)}const o=performance.now()<this.shakeUntil||s.has(" "),u=[this.tilt[0]*8800,this.tilt[1]*8800,9810,o?5:0];o&&(u[0]+=(Math.random()-.5)*22e3,u[1]+=(Math.random()-.5)*22e3);let l=[...this.pointers.values()].filter(n=>n.current).slice(0,5).map(n=>({x0:n.last[0],y0:n.last[1],x1:n.current[0],y1:n.current[1],vx:(n.current[0]-n.last[0])/Math.max(e,.001),vy:(n.current[1]-n.last[1])/Math.max(e,.001),r:this.radius}));if(this.tool==="pour"&&!l.length&&s.has("f")){const n=this.hoverPoint||this.sim.world.map(h=>h/2);l=[{x0:n[0],y0:n[1],x1:n[0],y1:n[1],vx:0,vy:0,r:this.radius}]}if(this.tool==="pour"&&l.length){const n=l[0];this.spawnRemainder+=e*this.flowRate;const h=Math.floor(this.spawnRemainder);this.spawnRemainder-=h,this.sim.spawn=[this.spawnCursor,h,n.x1,n.y1],this.sim.spawnRadius=this.radius*.7,this.sim.spawnHeight=this.pourHeight,this.spawnCursor=(this.spawnCursor+h)%this.sim.count,l=[]}else if(this.tool==="rake"&&l.length){const n=l[0],h=Math.hypot(n.vx,n.vy),d=h>4?-n.vy/h:1,y=h>4?n.vx/h:0;l=[-2,-1,0,1,2].map(v=>({...n,x0:n.x0+d*v*this.radius*.65,x1:n.x1+d*v*this.radius*.65,y0:n.y0+y*v*this.radius*.65,y1:n.y1+y*v*this.radius*.65,r:Math.max(.4,this.radius*.12)}))}this.sim.splat();for(let n=0;n<3;n++)n===2&&this.sim.splat(),this.sim.step(e/3,u,l.map(h=>({...h,x0:h.x0+(h.x1-h.x0)*n/3,y0:h.y0+(h.y1-h.y0)*n/3,x1:h.x0+(h.x1-h.x0)*(n+1)/3,y1:h.y0+(h.y1-h.y0)*(n+1)/3})));for(const[n,h]of this.pointers)h.ended?this.pointers.delete(n):h.current&&(h.last=[...h.current]);if(this.steps++,this.steps%8===0){const n=this.sim.readStats();this.flow=n.flow/Math.max(1,this.sim.world[0]*this.sim.world[1]),this.updateSound()}this.renderer.draw(this.sim,this.light(),this.camera)}light(){const e=[-.32-this.tilt[0]*.3,-.45-this.tilt[1]*.3,1.2],s=Math.hypot(...e);return e.map(r=>r/s)}draw(){var e;!((e=this.sim)!=null&&e.world)||!this.sim.preset||!this.live||(this.sim.splat(),this.renderer.draw(this.sim,this.light(),this.camera))}schedule(){!this.raf&&this.live&&this.visible&&!this.skipped&&!this.paused&&(this.raf=requestAnimationFrame(e=>{this.raf=0;const s=this.last?e-this.last:16.7;this.last=e,s>45?this.slowFrames++:this.slowFrames=Math.max(0,this.slowFrames-1),this.slowFrames>160&&!this.quality&&this.dpr>1&&(this.quality=1,this.resize()),this.frame(Math.min(s/1e3,1/30)),this.schedule()}))}suspend(){cancelAnimationFrame(this.raf),this.raf=0,this.last=0,this.clearInput(),this.sound&&this.sound.gain.gain.setTargetAtTime(0,this.sound.ctx.currentTime,.02)}setPaused(e){this.paused=e,e?this.suspend():this.schedule(),this.draw()}get soundOn(){var e;return!!(this.soundRequested&&((e=this.audio)!=null&&e.audible))}async setSound(e){if(e&&!this.media)throw new Error("Sound needs host media support.");if(this.soundRequested=e,e&&!this.sound){this.audio=new vt(this.media,"Safe Sand",{onChange:()=>{var n;this.updateSound(),(n=this.onAudioChange)==null||n.call(this)}}),this.audio.activate();const s=this.audio.context;if(!s||!this.audio.output)throw new Error("Audio could not start.");const r=s.createBuffer(1,s.sampleRate*2,s.sampleRate),t=r.getChannelData(0);for(let n=0;n<t.length;n++)t[n]=(Math.random()*2-1)*(Math.random()<.12?1:.08);const o=s.createBufferSource();o.buffer=r,o.loop=!0;const u=s.createBiquadFilter();u.type="bandpass",u.frequency.value=3e3,u.Q.value=.6;const l=s.createGain();l.gain.value=0,o.connect(u).connect(l).connect(this.audio.output),o.start(),this.sound={ctx:s,gain:l,filter:u,source:o}}this.sound&&(this.audio.setMuted(!e),e&&this.audio.activate(),this.updateSound())}resetSound(){var e;this.soundRequested=!1,this.sound&&(this.sound.source.stop(),this.sound=null),(e=this.audio)==null||e.dispose(),this.audio=null}updateSound(){if(!this.sound)return;const s=this.soundOn&&!this.paused&&this.visible&&!this.skipped?Math.min(.23,Math.max(0,(this.flow||0)-.1)/180):0;this.sound.gain.gain.setTargetAtTime(s,this.sound.ctx.currentTime,.07),this.sound.filter.frequency.setTargetAtTime(this.material==="glass"||this.material==="salt"?4300:this.material==="snow"?1600:3e3,this.sound.ctx.currentTime,.1)}readParticles(){const e=this.gl,s=this.sim;e.bindFramebuffer(e.FRAMEBUFFER,s.state[s.cur].fb);const r=new Float32Array(s.count*4),t=new Float32Array(s.count*4);return e.readBuffer(e.COLOR_ATTACHMENT0),e.readPixels(0,0,A,s.TH,e.RGBA,e.FLOAT,r),e.readBuffer(e.COLOR_ATTACHMENT1),e.readPixels(0,0,A,s.TH,e.RGBA,e.FLOAT,t),e.readBuffer(e.COLOR_ATTACHMENT0),e.bindFramebuffer(e.FRAMEBUFFER,null),{A:r,B:t}}upload({A:e,B:s}){const r=this.gl;for(const t of this.sim.state)for(const[o,u]of[["A",e],["B",s]])r.bindTexture(r.TEXTURE_2D,t[o]),r.texSubImage2D(r.TEXTURE_2D,0,0,0,A,this.sim.TH,r.RGBA,r.FLOAT,u)}info(){return{material:this.material,paused:this.paused,count:this.sim.count,steps:this.steps,world:[...this.sim.world],surface:this.surface,quality:this.quality,sound:!!this.soundOn,magnet:!!this.sim.magnet,tool:this.tool,speed:this.speed,camera:{pitch:this.camera.pitch,yaw:this.camera.yaw},undo:!!this.undoState}}dispose(){var s,r;this.live=!1,this.suspend(),this.resetSound();const e=this.gl;for(const t of this.sim.state||[])e.deleteTexture(t.A),e.deleteTexture(t.B),e.deleteFramebuffer(t.fb);for(const t of["splatTex","G0","G1","T0","T1","statsTex"])e.deleteTexture(this.sim[t]);for(const t of["splatFb","gridFb","T0fb","T1fb","G0fb","statsFb"])e.deleteFramebuffer(this.sim[t]);this.sim.fence&&e.deleteSync(this.sim.fence),e.deleteBuffer(this.sim.pbo),e.deleteVertexArray(this.sim.vao),(s=this.renderer.shadowTex)==null||s.forEach(t=>e.deleteTexture(t)),(r=this.renderer.shadowFb)==null||r.forEach(t=>e.deleteFramebuffer(t)),e.deleteTexture(this.renderer.screenTex),e.deleteVertexArray(this.renderer.vao);for(const t of[...Object.values(this.sim.progs),...Object.values(this.renderer.progs)])e.deleteProgram(t.p)}}function xt(i,e){const s=JSON.parse(i);if(!s||typeof s!="object")throw new Error("Invalid saved setup.");const r=s;if(r.version!==1||typeof r.material!="string"||!e.includes(r.material)||!["light","slate","dark"].includes(String(r.surface))||typeof r.radius!="number"||!Number.isFinite(r.radius)||r.radius<2||r.radius>10||![.35,.8,1].includes(Number(r.amount))||typeof r.amount!="number")throw new Error("This saved setup is not compatible.");const t={},o={pitch:[-35,35],yaw:[-35,35],flowRate:[2e3,24e3],pourHeight:[2,30]};for(const[u,[l,n]]of Object.entries(o))if(r[u]!==void 0){if(typeof r[u]!="number"||!Number.isFinite(r[u])||r[u]<l||r[u]>n)throw new Error("Invalid saved control value.");Object.assign(t,{[u]:r[u]})}if(r.speed!==void 0){if(typeof r.speed!="number"||![.25,.5,1].includes(r.speed))throw new Error("Invalid saved speed.");t.speed=r.speed}if(r.tool!==void 0){if(typeof r.tool!="string"||!["push","pour","rake","erase","magnet","repel","camera"].includes(r.tool))throw new Error("Invalid saved tool.");t.tool=r.tool}return{version:1,material:r.material,surface:r.surface,radius:r.radius,amount:r.amount,...t}}const Me=O,c=i=>document.getElementById(i),E=c("app"),x=c("sand"),L=c("materialsDialog"),ke="safe-sand.setup.v1";E.dataset.themePolicy="app";let a=null,fe=0,te=!1,de=!1,pe=!1,me=null;function M(i){c("status").textContent=i,clearTimeout(fe),fe=window.setTimeout(()=>{c("status").textContent=""},4500)}function ve(i){c("errorMessage").textContent=i instanceof Error?i.message:String(i),c("errorPanel").hidden=!1,E.dataset.ready="false"}function g(){if(!a)return;const i=Me[a.material];c("materialName").textContent=i.name,c("materialTrait").textContent=i.trait.toUpperCase();const e={push:"Drag a finger through the grains.",pour:"Hold to pour more grains.",rake:"Drag to comb fine lines.",erase:"Brush away a path.",magnet:"Gather iron around your touch.",repel:"Push iron away from your touch.",camera:"Drag to orbit the tray."};c("materialHint").textContent=e[a.tool],c("materialDescription").textContent=i.description,c("currentSwatch").style.backgroundColor=i.swatch,E.dataset.material=a.material,E.dataset.surface=a.surface,E.dataset.tool=a.tool;for(const t of document.querySelectorAll("[data-material]"))t.tagName==="BUTTON"&&t.setAttribute("aria-pressed",String(t.dataset.material===a.material));for(const t of document.querySelectorAll("button[data-surface]"))t.setAttribute("aria-pressed",String(t.dataset.surface===a.surface));c("pauseButton").setAttribute("aria-pressed",String(a.paused)),c("pauseButton").title=`${a.paused?"Resume":"Pause"} simulation (P)`,c("pauseText").textContent=a.paused?"Play":"Pause",c("pauseIcon").textContent=a.paused?"▷":"Ⅱ",c("stateBadge").hidden=!a.paused,c("toolText").textContent=a.tool==="camera"?"Orbit":a.tool[0].toUpperCase()+a.tool.slice(1);for(const t of document.querySelectorAll("button[data-tool]"))t.setAttribute("aria-pressed",String(t.dataset.tool===a.tool)),t.disabled=["magnet","repel"].includes(t.dataset.tool)&&a.material!=="iron";c("undoButton").disabled=!a.undoState,c("stepButton").disabled=!a.paused,c("speed").value=String(a.speed),c("pourRate").value=String(a.flowRate),c("pourRateValue").textContent=`${Math.round(a.flowRate/1e3)}k/s`,c("pourHeight").value=String(a.pourHeight),c("pourHeightValue").textContent=String(a.pourHeight),K(),c("brush").value=String(a.radius),c("brushValue").textContent=String(a.radius),c("amount").value=String(a.amount);const s=!!a.soundOn,r=c("soundButton");r.setAttribute("aria-pressed",String(s)),r.setAttribute("aria-label",s?"Turn sound off":"Turn sound on"),r.title=W("media")?s?"Turn sound off":"Turn sound on":"This host does not support sound",r.querySelector("path").setAttribute("d",s?"M4 9h4l5-4v14l-5-4H4zM17 8q5 4 0 8m3-11q8 7 0 14":"M4 9h4l5-4v14l-5-4H4zM17 9l4 6m0-6-4 6")}function ge(){try{te=!0,a==null||a.dispose(),te=!1,a=new gt(x,W("media")?Ve:void 0,()=>g()),a.radius=4.5,c("errorPanel").hidden=!0,E.dataset.ready="true",g(),$()}catch(i){ve(i)}}for(const[i,e]of Le.entries()){const s=Me[e],r=document.createElement("button");r.className="material",r.dataset.material=e,r.setAttribute("aria-pressed",String(e==="beach")),r.title=s.description;const t=document.createElement("span");t.className="sample",t.style.setProperty("--swatch",s.swatch),t.setAttribute("aria-hidden","true");const o=document.createElement("span");o.textContent=s.name,r.append(t,o),r.addEventListener("click",()=>{a&&(a.setMaterial(e),a.setSurface(s.surface),g(),M(`${s.name} · ${s.trait.toLowerCase()}`))}),c(i<5?"originalMaterials":"newMaterials").append(r)}const le=W("storage");c("soundButton").disabled=!W("media");W("media")||(c("soundButton").title="This host does not support sound");let ue;if(W("identity"))try{ue=Ke.onChanged(()=>{a==null||a.resetSound(),g()})}catch{}c("saveButton").disabled=!le;c("restoreButton").disabled=!le;c("storageStatus").textContent=le?"Setup saves are available in this host.":"This host has no saved setups. All tray tools still work.";c("storageStatus").dataset.domain=le?"storage: available":"storage: absent";async function Ce(i){if(!a||de||!W("storage"))return;de=!0,c("saveButton").disabled=!0,c("restoreButton").disabled=!0,c("storageStatus").textContent=i==="save"?"Saving setup…":"Restoring setup…";let e=0;try{const s=async()=>{if(a)if(i==="save"){const t={version:1,material:a.material,surface:a.surface,radius:a.radius,amount:a.amount,tool:a.tool,speed:a.speed,pitch:a.camera.pitch,yaw:a.camera.yaw,flowRate:a.flowRate,pourHeight:a.pourHeight};await Fe.setItem(ke,JSON.stringify(t))}else{const t=await Fe.getItem(ke);if(!t)throw new Error("No saved setup yet. Choose a material and save it first.");return xt(t,Le)}},r=await Promise.race([s(),new Promise((t,o)=>{e=window.setTimeout(()=>o(new Error("Storage did not respond. Your tray is still usable.")),5e3)})]);r&&(a.radius=r.radius,a.amount=r.amount,a.setMaterial(r.material),a.setSurface(r.surface),a.speed=r.speed??1,a.flowRate=r.flowRate??12e3,a.pourHeight=r.pourHeight??12,a.setCamera(r.pitch??0,r.yaw??0),a.setTool(r.tool??"push"),g()),c("storageStatus").textContent=i==="save"?"Setup saved in this host.":"Saved setup restored."}catch(s){c("storageStatus").textContent=`${i==="save"?"Setup was not saved. ":""}${s instanceof Error?s.message:"Storage is unavailable."}`}finally{clearTimeout(e),de=!1,c("saveButton").disabled=!1,c("restoreButton").disabled=!1}}function he(){L.close()}c("materialsButton").addEventListener("click",()=>{U(),a==null||a.clearInput(),L.showModal()});c("closeMaterials").addEventListener("click",he);c("returnButton").addEventListener("click",he);L.addEventListener("close",()=>{x.focus({preventScroll:!0})});L.addEventListener("click",i=>{if(i.target!==L)return;const e=L.getBoundingClientRect();(i.clientX<e.left||i.clientX>e.right||i.clientY<e.top||i.clientY>e.bottom)&&he()});c("saveButton").addEventListener("click",()=>{Ce("save")});c("restoreButton").addEventListener("click",()=>{Ce("restore")});c("clearButton").addEventListener("click",()=>{a==null||a.clear(),g(),he(),M("An empty tray. Try the Pour tool.")});c("pourButton").addEventListener("click",()=>{a==null||a.pour(),g(),M("Freshly poured. Make a little mess.")});c("pauseButton").addEventListener("click",()=>{a&&a.setPaused(!a.paused),g()});c("shakeButton").addEventListener("click",()=>{a!=null&&a.paused?M("Resume the tray to shake."):a==null||a.shake()});c("toolButton").addEventListener("click",()=>{const i=c("toolsPanel").hidden;U(),c("toolsPanel").hidden=!i,c("toolButton").setAttribute("aria-expanded",String(i))});function U(){for(const[i,e]of[["toolsPanel","toolButton"],["cameraPanel","cameraButton"],["tiltPanel","tiltButton"]])c(i).hidden=!0,c(e).setAttribute("aria-expanded","false");se()}function xe(i){a==null||a.setTool(i),g(),U(),x.focus({preventScroll:!0}),(a==null?void 0:a.tool)==="pour"&&M("Hold to pour, or focus the tray and hold F."),(a==null?void 0:a.tool)==="camera"&&M("Drag to orbit. Select Push to sculpt again.")}for(const i of document.querySelectorAll("button[data-tool]"))i.addEventListener("click",()=>xe(i.dataset.tool));c("closeTools").addEventListener("click",U);c("cameraButton").addEventListener("click",()=>{const i=c("cameraPanel").hidden;U(),c("cameraPanel").hidden=!i,c("cameraButton").setAttribute("aria-expanded",String(i))});c("closeCamera").addEventListener("click",U);function K(){a&&(c("cameraPitch").value=String(Math.round(a.camera.pitch)),c("cameraYaw").value=String(Math.round(a.camera.yaw)),c("pitchValue").textContent=`${Math.round(a.camera.pitch)}°`,c("yawValue").textContent=`${Math.round(a.camera.yaw)}°`)}for(const i of["cameraPitch","cameraYaw"])c(i).addEventListener("input",()=>{a==null||a.setCamera(Number(c("cameraPitch").value),Number(c("cameraYaw").value)),K()});c("overheadButton").addEventListener("click",()=>{a==null||a.setCamera(0,0),K()});c("angledButton").addEventListener("click",()=>{a==null||a.setCamera(28,-18),K()});c("orbitButton").addEventListener("click",()=>xe("camera"));c("undoButton").addEventListener("click",()=>{a!=null&&a.undo()&&M("Last edit undone."),g()});c("stepButton").addEventListener("click",()=>{a==null||a.singleStep(),g()});c("speed").addEventListener("change",()=>{a&&(a.speed=Number(c("speed").value))});c("pourRate").addEventListener("input",()=>{a&&(a.flowRate=Number(c("pourRate").value)),g()});c("pourHeight").addEventListener("input",()=>{a&&(a.pourHeight=Number(c("pourHeight").value)),g()});c("soundButton").addEventListener("click",async()=>{if(a)try{await a.setSound(!a.soundOn),g()}catch{await a.setSound(!1),g(),M("Sound is unavailable in this browser.")}});for(const i of document.querySelectorAll("button[data-surface]"))i.addEventListener("click",()=>{a==null||a.setSurface(i.dataset.surface),g()});c("brush").addEventListener("input",i=>{a&&(a.radius=Number(i.target.value)),g()});c("amount").addEventListener("change",i=>{a&&(a.amount=Number(i.target.value))});function _e(i){i&&U(),c("tiltPanel").hidden=!i,c("tiltButton").setAttribute("aria-expanded",String(i)),se()}c("tiltButton").addEventListener("click",()=>_e(c("tiltPanel").hidden));c("closeTilt").addEventListener("click",()=>_e(!1));const H=c("tiltPad");function se(){me=null,a&&(a.pad=[0,0]),c("tiltDot").style.transform=""}function Ge(i){if(!a)return;const e=H.getBoundingClientRect();let s=(i.clientX-e.left-e.width/2)/(e.width*.37),r=(i.clientY-e.top-e.height/2)/(e.height*.37);const t=Math.max(1,Math.hypot(s,r));s/=t,r/=t,a.pad=[s,r],c("tiltDot").style.transform=`translate(${s*35}px, ${r*35}px)`}H.addEventListener("pointerdown",i=>{me=i.pointerId,H.setPointerCapture(i.pointerId),H.focus(),Ge(i)});H.addEventListener("pointermove",i=>{me===i.pointerId&&Ge(i)});for(const i of["pointerup","pointercancel","lostpointercapture"])H.addEventListener(i,se);x.addEventListener("pointerdown",i=>{if(!a||i.button>0)return;if(i.preventDefault(),x.focus({preventScroll:!0}),a.paused&&a.tool!=="camera"){M("The tray is paused. Press Play to sculpt.");return}x.setPointerCapture(i.pointerId);const e=x.getBoundingClientRect();a.pointer(i.pointerId,i.clientX-e.left,i.clientY-e.top,!0),g()});x.addEventListener("pointermove",i=>{if(!a)return;const e=x.getBoundingClientRect();a.pointer(i.pointerId,i.clientX-e.left,i.clientY-e.top),a.tool==="camera"&&K()});x.addEventListener("pointerup",i=>a==null?void 0:a.endPointer(i.pointerId));x.addEventListener("pointercancel",i=>a==null?void 0:a.endPointer(i.pointerId,!0));x.addEventListener("lostpointercapture",i=>{const e=a==null?void 0:a.pointers.get(i.pointerId);e!=null&&e.ended||a==null||a.endPointer(i.pointerId,!0)});x.addEventListener("contextmenu",i=>i.preventDefault());for(const i of[x,H])i.addEventListener("keydown",e=>{if(!a||L.open)return;const s=e.key.length===1?e.key.toLowerCase():e.key;["ArrowLeft","ArrowRight","ArrowUp","ArrowDown","w","a","s","d","f"," "].includes(s)?(e.preventDefault(),s==="f"&&!e.repeat&&a.tool==="pour"&&!a.paused&&(a.checkpoint(),g()),a.keys.add(s)):!e.repeat&&s==="p"?(a.setPaused(!a.paused),g()):!e.repeat&&s==="r"?(a.pour(),g()):!e.repeat&&s==="v"?(a.setCamera(a.camera.pitch||a.camera.yaw?0:28,a.camera.pitch||a.camera.yaw?0:-18),K()):!e.repeat&&(s==="u"||(e.ctrlKey||e.metaKey)&&s==="z")?(e.preventDefault(),a.undo(),g()):!e.repeat&&/^[1-7]$/.test(s)?xe(["push","pour","rake","erase","magnet","repel","camera"][Number(s)-1]):s==="Escape"&&U()}),i.addEventListener("keyup",e=>{a==null||a.keys.delete(e.key.length===1?e.key.toLowerCase():e.key)}),i.addEventListener("blur",()=>{a==null||a.clearInput(),se()});function $(){a&&(a.visible=!document.hidden,a.skipped=pe||E.clientWidth<280||E.clientHeight<240,(E.clientWidth<280||E.clientHeight<240)&&L.open&&L.close(),!a.visible||a.skipped?a.suspend():a.schedule())}const De=new ResizeObserver(()=>{try{a==null||a.resize(),$()}catch(i){ve(i)}});De.observe(E);c("scene").addEventListener("contentvisibilityautostatechange",i=>{pe=i.skipped,$()});let ee=null;"contentVisibility"in document.documentElement.style||(ee=new IntersectionObserver(i=>{pe=!i[0].isIntersecting,$()}),ee.observe(E));document.addEventListener("visibilitychange",$);window.addEventListener("blur",()=>{a==null||a.suspend(),se()});window.addEventListener("focus",$);x.addEventListener("webglcontextlost",i=>{i.preventDefault(),!te&&(a&&(a.live=!1,a.suspend()),c("errorTitle").textContent="The GPU paused the tray",ve(new Error("The graphics context was lost. Restore the tray with Try again; a fresh pour will replace it.")))});x.addEventListener("webglcontextrestored",()=>{te||ge()});c("retryButton").addEventListener("click",()=>{var e;const i=x.getContext("webgl2");i!=null&&i.isContextLost()?(e=i.getExtension("WEBGL_lose_context"))==null||e.restoreContext():ge()});window.addEventListener("pagehide",()=>{te=!0,clearTimeout(fe),De.disconnect(),ee==null||ee.disconnect(),ue==null||ue.close(),a==null||a.dispose()},{once:!0});Object.assign(window,{sand:{info:()=>a==null?void 0:a.info(),sample:()=>{if(!a)return null;const{A:i,B:e}=a.readParticles();let s=0,r=0,t=0,o=0,u=!0;for(let l=0;l<e.length;l+=4)e[l+3]<.5||(s++,r+=i[l],t+=i[l+1],o+=Math.hypot(i[l+2],i[l+3]),Number.isFinite(i[l]+i[l+1]+e[l])||(u=!1));return{count:s,x:r/(s||1),y:t/(s||1),speed:o/(s||1),finite:u}}}});ge();
</script>
  <style>:root{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#292b2b;background:#e8e6df;font-synthesis:none;--ink: #292b2b;--muted: #62635d;--accent: #e7d5ae;--panel: #25272a;color-scheme:light}*{box-sizing:border-box}html,body,#app{margin:0;width:100%;height:100%}body{overflow:hidden}#app{position:relative;isolation:isolate;container:tray / size}button,select,input{font:inherit}button{border:0;cursor:pointer;touch-action:manipulation;color:inherit}button,select{min-height:44px;min-width:44px}button:disabled{cursor:default;opacity:.42}button:focus-visible,select:focus-visible,input:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid #c39852;outline-offset:4px}button:active:not(:disabled){transform:translateY(1px)}[hidden]{display:none!important}.scene{position:absolute;top:0;right:0;bottom:0;left:0;content-visibility:auto;contain-intrinsic-size:auto 900px auto 600px}canvas{display:block;width:100%;height:100%;touch-action:none;cursor:crosshair}canvas:focus-visible{outline-offset:-5px}.specimen{position:absolute;left:max(17px,env(safe-area-inset-left));top:max(17px,env(safe-area-inset-top));pointer-events:none;max-width:45%;padding:12px 15px;border-radius:18px;background:#eeece5d4;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}.overline{font-size:9px;font-weight:650;letter-spacing:.17em;line-height:1.5}.specimen h1{font-size:clamp(23px,3.4vw,38px);font-weight:380;margin:6px 0;letter-spacing:-.04em}.specimen p{font-size:12px;color:var(--muted);max-width:210px;line-height:1.5;margin:0}#app[data-surface=dark],#app[data-surface=slate]{--ink: #f3f0e8;--muted: #c9cdd0;color:var(--ink)}#app[data-surface=dark] .specimen,#app[data-surface=slate] .specimen{background:#1a1f26d6}.glass{background:#1e2022e8;color:#f0eee8;box-shadow:0 5px 24px #00000024,inset 0 0 0 1px #ffffff14;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.materials-toggle{position:absolute;right:max(24px,env(safe-area-inset-right));top:max(24px,env(safe-area-inset-top));display:flex;align-items:center;gap:10px;border-radius:30px;padding:0 17px 0 11px;font-size:12px;min-height:46px}.mini-swatch{width:25px;height:25px;border-radius:50%;background:#d8bf8f;box-shadow:inset 0 -4px 6px #0003,inset 1px 2px 5px #fff4}.count{opacity:.48;font-size:10px;border-left:1px solid #ffffff24;padding-left:10px}.dock-wrap{position:absolute;bottom:max(16px,env(safe-area-inset-bottom));left:50%;transform:translate(-50%);width:max-content;max-width:calc(100% - 24px);text-align:center}.dock{border-radius:21px;display:flex;align-items:center;padding:6px;gap:2px}.dock button{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:0 13px;font-size:12px;border-radius:15px;background:transparent;white-space:nowrap}.dock button:hover{background:#ffffff13}.dock button.primary{background:#e8d6b1;color:#3e372b;font-weight:600;padding:0 19px}.dock button[aria-pressed=true]{background:#ffffff24}.dock button span[aria-hidden]{font-size:19px;line-height:1}.dock button span.chevron{font-size:12px;margin-left:-4px;opacity:.6}.divider{height:21px;width:1px;background:#ffffff24;margin:0 5px}.icon-button{display:inline-grid;place-items:center;width:44px;height:44px;padding:0!important;background:transparent;border-radius:50%;font-size:25px}.icon-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}.keyboard-hint{margin:12px 0 0;color:var(--muted);font-size:8px;letter-spacing:.13em}.keyboard-hint span{margin:0 10px;opacity:.5}#status{display:block;width:fit-content;max-width:100%;font-size:12px;background:#25272a;color:#fff;padding:9px 14px;border-radius:12px;margin:0 auto 10px}#status:empty{display:none}.state-badge{position:absolute;top:90px;right:25px;font-size:10px;text-transform:uppercase;letter-spacing:.15em;color:var(--muted)}.tilt-panel{position:absolute;bottom:110px;right:24px;width:250px;border-radius:22px;padding:6px 15px 15px}.floating-panel{position:absolute;right:24px;bottom:110px;width:320px;max-width:calc(100% - 24px);max-height:calc(100% - 125px);overflow-y:auto;overscroll-behavior:contain;padding:6px 16px 16px;border-radius:22px;color-scheme:dark;scrollbar-width:thin}.tool-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.tool-grid button{background:#ffffff06;border:1px solid transparent;border-radius:12px;padding:9px 3px;display:flex;flex-direction:column;align-items:center;gap:5px;font-size:11px;min-height:78px}.tool-grid button[aria-pressed=true]{border-color:#e4d4afaa;background:#e8d6b11b}.tool-grid b{font-size:21px;font-weight:400}.tool-grid small{font-size:8px;color:#aeb0ae}.tool-help{color:#b8bcb9;font-size:11px;line-height:1.6;margin:12px 0}.panel-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}.panel-actions button{font-size:11px;background:#ffffff10;border-radius:10px;padding:0 12px;flex:1}.floating-panel output{font-size:10px;color:#b8bcb9;min-width:32px;text-align:right}.floating-panel input[type=range]{width:130px}#app[data-tool=camera] canvas{cursor:grab}#app[data-tool=camera] canvas:active{cursor:grabbing}.panel-heading{display:flex;justify-content:space-between;align-items:center}.tilt-body{display:flex;align-items:center;gap:18px}.tilt-body p{font-size:12px;line-height:1.6;color:#ddd}.tilt-body small{font-size:10px;color:#aaa}#tiltPad{position:relative;width:104px;height:104px;border:1px solid #ffffff24;border-radius:50%;background:radial-gradient(circle,#ffffff0e 0 32%,transparent 33% 65%,#ffffff07 66%);touch-action:none;flex-shrink:0}#tiltDot{position:absolute;left:calc(50% - 8px);top:calc(50% - 8px);width:16px;height:16px;border-radius:50%;background:#e8d6b1;pointer-events:none;z-index:1}.pad-lines:before,.pad-lines:after{content:"";position:absolute;background:#ffffff14}.pad-lines:before{top:50%;left:7%;width:86%;height:1px}.pad-lines:after{left:50%;top:7%;height:86%;width:1px}dialog{color-scheme:dark;color:#eeece6;background:#25272a;border:1px solid #ffffff18;border-radius:26px;padding:0;width:394px;max-width:calc(100% - 24px);max-height:calc(100% - 32px);margin:auto 18px auto auto;box-shadow:0 20px 80px #0005;overflow:hidden}dialog[open]{display:flex;flex-direction:column}dialog::backdrop{background:#10131733}.dialog-heading{display:flex;align-items:center;justify-content:space-between;padding:21px 19px 15px 23px;flex-shrink:0}.dialog-heading .overline{color:#c4bca9;font-size:8px}h2{text-wrap:balance}.dialog-heading h2{font-size:20px;font-weight:450;letter-spacing:-.035em;margin:6px 0 0}.dialog-scroll{overflow-y:auto;overscroll-behavior:contain;padding:0 23px 18px;scrollbar-width:thin}.section-label{color:#a5a7a7;font-size:9px;letter-spacing:.12em;margin:14px 0 12px}.material-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.material{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;border-radius:13px;background:#ffffff05;border:1px solid transparent;min-height:85px;padding:10px 2px}.material:hover{background:#ffffff0c}.material[aria-pressed=true]{border-color:#e4d4afaa;background:#e8d6b10e}.material .sample{width:32px;height:32px;border-radius:50%;background:radial-gradient(circle at 30% 25%,#ffffff44,transparent 55%),var(--swatch);box-shadow:inset 0 -6px 8px #0004;position:relative}.material .sample:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:inherit;background-image:radial-gradient(#fff7 .65px,transparent .7px),radial-gradient(#0005 .55px,transparent .6px);background-size:4px 5px,3px 4px;background-position:0 0,2px 3px}.material span:last-child{font-size:10px;line-height:1.3}.description{font-size:12px;line-height:1.7;color:#c8c9c5;min-height:42px;margin:17px 0}.setting-row{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center;min-height:53px;font-size:11px;border-top:1px solid #ffffff12}.segmented{display:flex;gap:2px}.segmented button{border-radius:9px;font-size:10px;padding:0 9px;background:#ffffff06}.segmented button[aria-pressed=true]{background:#e8d6b1;color:#302b23}input[type=range]{width:135px;height:44px;accent-color:#e8d6b1}#brushValue{width:23px;color:#a9aaa6}select{background:#ffffff0b;color:#eee;border:1px solid #ffffff1a;padding:0 10px;border-radius:9px}.save-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:14px}.save-row button{font-size:10px;padding:0 10px;background:#ffffff0b;border-radius:10px}.footnote{color:#a5a7a4;font-size:10px;line-height:1.6}details{font-size:11px;line-height:1.7;color:#bfc1bd}summary{cursor:pointer;padding:10px 0}.return-button{background:#e8d6b1;color:#353027;margin:0 15px 15px;border-radius:13px;font-size:12px;font-weight:600;flex-shrink:0}.return-button span{margin-left:9px}.error-panel{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;flex-direction:column;padding:30px;text-align:center;background:#e8e6df;color:#292b2b;z-index:9}.error-panel p{max-width:360px;line-height:1.5}.error-panel button{background:#292b2b;color:#fff;border-radius:15px;padding:0 24px}.small-notice{display:none}@container tray (width < 640px){.specimen{top:15px;left:14px;padding:10px 12px;max-width:calc(100% - 155px)}.specimen h1{font-size:25px}.specimen p{display:none}.materials-toggle{right:16px;top:19px;padding-right:12px;gap:7px}.materials-toggle .count{display:none}.dock{flex-wrap:wrap;justify-content:center;max-width:365px;padding:5px;gap:0}.dock button{flex:1 0 23%;padding:0 6px}.dock .icon-button{flex-basis:23%}.divider{display:none}.dock-wrap{bottom:max(14px,env(safe-area-inset-bottom))}.keyboard-hint{display:none}.tilt-panel{right:16px;bottom:119px}.floating-panel{right:12px;bottom:119px;max-height:calc(100% - 135px)}.state-badge{top:80px;right:20px}dialog{margin:auto}}@container tray (height < 420px) and (width >= 640px){.specimen{top:16px;left:20px}.specimen h1{font-size:24px}.specimen p{display:none}.materials-toggle{top:14px}.keyboard-hint{display:none}.dock-wrap{bottom:12px}.tilt-panel{bottom:80px}.floating-panel{bottom:80px;max-height:calc(100% - 94px)}}@container tray (width < 280px) or (height < 240px){.small-notice{position:absolute;top:0;right:0;bottom:0;left:0;background:#e8e6df;color:#32332f;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;padding:12px;z-index:20}.small-notice>span{font-size:23px}.small-notice strong{font-size:13px;margin-top:5px}.small-notice p{font-size:11px;line-height:1.45;margin-bottom:0;max-width:230px}.specimen,.dock-wrap,.materials-toggle,.tilt-panel,.floating-panel,.state-badge{visibility:hidden}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important}}
</style>
  <meta name="napplet-type" content="webgl-sand">
  <meta name="napplet-config-schema" content="{&quot;$schema&quot;:&quot;http://json-schema.org/draft-07/schema#&quot;,&quot;$version&quot;:1,&quot;type&quot;:&quot;object&quot;,&quot;additionalProperties&quot;:false,&quot;properties&quot;:{}}">
</head>
<body>
<main id="app" data-surface="light">
  <div id="scene" class="scene">
    <canvas id="sand" tabindex="0" aria-label="Granular playground. Drag to push grains. Use arrow keys or WASD to tilt, Space to shake, P to pause, R to pour.">This playground needs WebGL2. Use the material and tray controls to explore grains.</canvas>
  </div>
  <div class="specimen" aria-live="polite">
    <span id="materialTrait" class="overline">FINE · GOLDEN</span>
    <h1 id="materialName">Beach</h1>
    <p id="materialHint">Drag a finger through the grains.</p>
  </div>
  <button id="materialsButton" class="glass materials-toggle" aria-haspopup="dialog"><span id="currentSwatch" class="mini-swatch"></span><span>Materials</span><span class="count">11</span></button>
  <div id="stateBadge" class="state-badge" hidden>Paused</div>
  <div class="dock-wrap">
    <output id="status" role="status" aria-live="polite"></output>
    <div class="dock glass" aria-label="Tray tools">
      <button id="pourButton" class="primary" title="Refill the whole tray (R)"><span aria-hidden="true">↻</span> Refill</button>
      <button id="pauseButton" title="Pause simulation (P)" aria-pressed="false"><span id="pauseIcon" aria-hidden="true">Ⅱ</span><span id="pauseText">Pause</span></button>
      <span class="divider"></span>
      <button id="toolButton" title="Choose a tool" aria-expanded="false" aria-controls="toolsPanel"><span aria-hidden="true">◎</span><span id="toolText">Push</span><span class="chevron" aria-hidden="true">⌄</span></button>
      <button id="cameraButton" title="Rotate the view" aria-expanded="false" aria-controls="cameraPanel"><span aria-hidden="true">◇</span> View</button>
      <button id="tiltButton" aria-expanded="false" aria-controls="tiltPanel"><span aria-hidden="true">⤢</span> Tilt</button>
      <button id="shakeButton" title="Shake the tray (Space)"><span aria-hidden="true">≋</span> Shake</button>
      <button id="soundButton" class="icon-button" title="Turn sound on" aria-label="Turn sound on" aria-pressed="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4zM17 9l4 6m0-6-4 6"/></svg></button>
    </div>
    <p class="keyboard-hint">DRAG TO SCULPT <span>·</span> ARROWS TO TILT <span>·</span> SPACE TO SHAKE</p>
  </div>
  <section id="tiltPanel" class="tilt-panel glass" aria-label="Tilt controls" hidden>
    <div class="panel-heading"><span class="overline">TILT THE TRAY</span><button id="closeTilt" class="icon-button" aria-label="Close tilt controls">×</button></div>
    <div class="tilt-body"><div id="tiltPad" role="group" tabindex="0" aria-label="Tilt pad. Drag in a direction, release to level. Arrow keys also work."><span id="tiltDot"></span><span class="pad-lines" aria-hidden="true"></span></div><p>Drag to tilt.<br>Release to level.<br><small>Or hold arrow keys.</small></p></div>
  </section>
  <section id="toolsPanel" class="floating-panel glass" aria-label="Drawing tools" hidden>
    <div class="panel-heading"><span class="overline">MAKE YOUR MARK</span><button id="closeTools" class="icon-button" aria-label="Close drawing tools">×</button></div>
    <div class="tool-grid">
      <button data-tool="push" aria-pressed="true"><b>◎</b><span>Push</span><small>Move grains</small></button>
      <button data-tool="pour" aria-pressed="false"><b>⁙</b><span>Pour</span><small>Add grains</small></button>
      <button data-tool="rake" aria-pressed="false"><b>⋔</b><span>Rake</span><small>Five fine lines</small></button>
      <button data-tool="erase" aria-pressed="false"><b>◌</b><span>Erase</span><small>Clear a path</small></button>
      <button data-tool="magnet" aria-pressed="false"><b>⊕</b><span>Magnet</span><small>Attract iron</small></button>
      <button data-tool="repel" aria-pressed="false"><b>⊖</b><span>Repel</span><small>Scatter iron</small></button>
    </div>
    <p class="tool-help">Magnet and Repel work with iron filings. Select them in Materials.</p>
    <label class="setting-row" for="pourRate"><span>Pour rate</span><input id="pourRate" type="range" min="2000" max="24000" step="1000" value="12000" /><output id="pourRateValue">12k/s</output></label>
    <label class="setting-row" for="pourHeight"><span>Drop height</span><input id="pourHeight" type="range" min="2" max="30" value="12" /><output id="pourHeightValue">12</output></label>
    <label class="setting-row" for="speed"><span>Time</span><select id="speed"><option value="1">Normal</option><option value="0.5">Half speed</option><option value="0.25">Quarter speed</option></select></label>
    <div class="panel-actions"><button id="undoButton" title="Undo the last stroke, refill or material change (U)">↶ Undo</button><button id="stepButton" disabled>Step one frame</button></div>
  </section>
  <section id="cameraPanel" class="floating-panel camera-panel glass" aria-label="View controls" hidden>
    <div class="panel-heading"><span class="overline">A DIFFERENT ANGLE</span><button id="closeCamera" class="icon-button" aria-label="Close view controls">×</button></div>
    <p class="tool-help">Look across the piles. The camera stays above the tray.</p>
    <label class="setting-row" for="cameraPitch"><span>Lean</span><input id="cameraPitch" type="range" min="-35" max="35" value="0" /><output id="pitchValue">0°</output></label>
    <label class="setting-row" for="cameraYaw"><span>Turn</span><input id="cameraYaw" type="range" min="-35" max="35" value="0" /><output id="yawValue">0°</output></label>
    <div class="panel-actions"><button id="overheadButton">Overhead</button><button id="angledButton">Angled view</button><button id="orbitButton">Drag to orbit</button></div>
    <p class="tool-help">Keyboard: focus the canvas, press 7 to orbit or 1 to push. Camera movement also works while paused.</p>
  </section>
  <dialog id="materialsDialog" aria-labelledby="materialsHeading">
    <div class="dialog-heading"><div><span class="overline">MATERIAL STUDIES</span><h2 id="materialsHeading">Pick something to feel.</h2></div><button id="closeMaterials" class="icon-button" aria-label="Close materials">×</button></div>
    <div class="dialog-scroll">
      <p class="section-label">THE ORIGINAL SANDS</p><div id="originalMaterials" class="material-grid"></div>
      <p class="section-label">SOMETHING DIFFERENT</p><div id="newMaterials" class="material-grid"></div>
      <p id="materialDescription" class="description"></p>
      <div class="setting-row"><span>Under the grains</span><div id="surfaces" class="segmented" aria-label="Tray surface"><button data-surface="light" aria-pressed="true">Porcelain</button><button data-surface="slate" aria-pressed="false">Slate</button><button data-surface="dark" aria-pressed="false">Ink</button></div></div>
      <label class="setting-row" for="brush"><span>Brush width</span><input id="brush" type="range" min="2" max="10" step="0.5" value="4.5" /><output id="brushValue" for="brush">4.5</output></label>
      <label class="setting-row" for="amount"><span>Next pour</span><select id="amount"><option value="0.35">A little</option><option value="0.8" selected>A handful</option><option value="1">A full tray</option></select></label>
      <div class="save-row"><button id="saveButton">Save setup</button><button id="restoreButton">Restore setup</button><button id="clearButton">Empty tray</button></div>
      <p id="storageStatus" class="footnote">Setup saves use this host's private storage.</p>
      <details><summary>How to play & credits</summary><p>Drag to sculpt with Push, Pour, Rake or Erase. Iron filings support Magnet and Repel. View rotates the camera up to 35° in each direction; Tilt changes the physical slope of the tray.</p><p>Focus the canvas: arrows / WASD tilt; Space shakes; P pauses; R refills; U undoes; V toggles the angled view; hold F with Pour to pour at the cursor; keys 1–7 select Push, Pour, Rake, Erase, Magnet, Repel and Orbit. Slow motion and single-step are in the tool panel.</p><p>Changing material refills the tray. The Pour tool adds the current material; at capacity it recycles the oldest grain slots. Undo restores the state before the last stroke or refill. Setup saves remember controls, not individual grains.</p><p>Based on Safe Sand by av. Eleven tactile material approximations, simulated and lit on your GPU. Phone sensors are replaced by the tilt pad in this sandbox.</p></details>
    </div>
    <button id="returnButton" class="return-button">Back to the grains <span aria-hidden="true">↗</span></button>
  </dialog>
  <section id="errorPanel" class="error-panel" role="alert" hidden><h2 id="errorTitle">The tray couldn’t start</h2><p id="errorMessage"></p><button id="retryButton">Try again</button></section>
  <div class="small-notice"><span aria-hidden="true">⤢</span><strong>A little more room.</strong><p>Enlarge this napplet to at least 280 × 240 to play. Your tray will stay here.</p></div>
</main>
</body>
</html>