Back to Powder Tool
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool

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

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" />
    <meta name="theme-color" content="#15191d" />
    <title>Powder Tool</title>
    <script type="module" crossorigin>var Ht=Object.defineProperty;var Vt=(e,t,i)=>t in e?Ht(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i;var M=(e,t,i)=>Vt(e,typeof t!="symbol"?t+"":t,i);function _t(){const e=new Map;function t(n,s){if(e.has(n))throw new Error(`NAP domain "${n}" is already registered`);e.set(n,s)}function i(n){const s=n.type.indexOf(".");if(s<=0)return!1;const l=n.type.slice(0,s),a=e.get(l);return a?(a(n),!0):!1}function r(){return Array.from(e.keys())}return{registerNap:t,dispatch:i,getRegisteredDomains:r}}var Qt=_t(),Jt=Qt.registerNap,Zt="theme";function Rt(){var t;const e=window;if(!((t=e.napplet)!=null&&t.theme))throw new Error("window.napplet.theme is unavailable -- runtime did not inject this domain");return e.napplet.theme}function Kt(){return Rt().get()}function te(e){return Rt().onChanged(e)}Jt(Zt,e=>{});function ee(){const e=window;if(!e.napplet)throw new Error("window.napplet is unavailable -- runtime did not inject the napplet namespace");return e.napplet}function I(e){const i=ee()[e];if(!i)throw new Error(`window.napplet.${String(e)} is unavailable -- runtime did not inject this domain`);return i}var $t={getItem(e){return I("storage").getItem(e)},setItem(e,t){return I("storage").setItem(e,t)},removeItem(e){return I("storage").removeItem(e)},keys(){return I("storage").keys()},instance:{getItem(e){return I("storage").instance.getItem(e)},setItem(e,t){return I("storage").instance.setItem(e,t)},removeItem(e){return I("storage").instance.removeItem(e)},keys(){return I("storage").instance.keys()}}},ht={get(){return I("config").get()},subscribe(e){return I("config").subscribe(e)},openSettings(e){I("config").openSettings(e)},registerSchema(e,t){return I("config").registerSchema(e,t)},onSchemaError(e){return I("config").onSchemaError(e)},get schema(){return I("config").schema}};function ie(e,t){return!!(e!=null&&e[t])}function vt(e){return ie(window.napplet,e)}const ne="http://json-schema.org/draft-07/schema#",re=1,se="object",oe=!1,ae={textSize:{type:"integer",title:"Text size",description:"Base text size in pixels. Changes apply while the napplet is running.",minimum:10,maximum:20,default:13,"x-napplet-section":"Appearance","x-napplet-order":1},controlSize:{type:"integer",title:"Control height",description:"Choose compact controls or larger touch targets, in pixels.",minimum:24,maximum:44,default:28,"x-napplet-section":"Appearance","x-napplet-order":2},selectText:{type:"boolean",title:"Allow text selection",description:"Allow selecting interface text. Text inputs always remain selectable.",default:!1,"x-napplet-section":"Interaction"}},le={$schema:ne,$version:re,type:se,additionalProperties:oe,properties:ae};vt("config")&&(async()=>{ht.schema||await ht.registerSchema(le);const e=ht.subscribe(t=>{const i=document.documentElement.style,r=typeof t.textSize=="number"?t.textSize:13,n=typeof t.controlSize=="number"?t.controlSize:28;i.fontSize=`${r}px`,i.setProperty("--control-h",`${n}px`),i.setProperty("--napplet-text-selection",t.selectText===!0?"text":"none")});window.addEventListener("pagehide",()=>e.close(),{once:!0})})().catch(()=>{});const o={empty:0,sand:1,water:2,oil:3,fire:4,steam:5,lava:6,wood:7,wall:8,smoke:9,hydrogen:10,helium:11,co2:12},xt=[o.steam,o.smoke,o.hydrogen,o.helium,o.co2],Ot=[o.sand,o.water,o.oil,o.fire,o.steam,o.lava,o.smoke,o.hydrogen,o.helium,o.co2],Ct=e=>e===o.fire||xt.includes(e),X=[0,4,2,1,-.7,-.4,3,9,9,-.2,-.95,-.85,.3],D=e=>e===o.wall||e===o.wood,j=(e,t)=>e.x*t.y-e.y*t.x,q=(e,t)=>e.x*t.x+e.y*t.y,S=(e,t,i)=>Math.max(t,Math.min(i,e));function N(e){const t=Math.cos(e.angle),i=Math.sin(e.angle);return e.local.map(r=>({x:e.x+r.x*t-r.y*i,y:e.y+r.x*i+r.y*t}))}function it(e,t,i){for(let r=0;r<e.length;r++){const n=e[r],s=e[(r+1)%e.length];if((s.x-n.x)*(i-n.y)-(s.y-n.y)*(t-n.x)<-.001)return!1}return!0}function B(e){const t=Math.min(...e.map(s=>s.x)),i=Math.max(...e.map(s=>s.x)),r=Math.min(...e.map(s=>s.y)),n=Math.max(...e.map(s=>s.y));return{vertices:e,left:t,right:i,top:r,bottom:n,x:(t+i)/2,y:(r+n)/2}}function Q(e,t,i,r){return B([{x:e,y:t},{x:e+i,y:t},{x:e+i,y:t+r},{x:e,y:t+r}])}function ut(e,t){if(e.left>=t.right||e.right<=t.left||e.top>=t.bottom||e.bottom<=t.top)return null;let i=1/0,r={x:0,y:0};for(const a of[e.vertices,t.vertices])for(let d=0;d<a.length;d++){const m=a[d],f=a[(d+1)%a.length],g=Math.hypot(f.x-m.x,f.y-m.y),y={x:-(f.y-m.y)/g,y:(f.x-m.x)/g},k=e.vertices.map(L=>q(L,y)),v=t.vertices.map(L=>q(L,y)),x=Math.min(Math.max(...k)-Math.min(...v),Math.max(...v)-Math.min(...k));if(x<=0)return null;x<i&&(i=x,r=y)}q({x:e.x-t.x,y:e.y-t.y},r)<0&&(r={x:-r.x,y:-r.y});const n=Math.min(...e.vertices.map(a=>q(a,r))),s=e.vertices.filter(a=>q(a,r)<n+.35),l={x:s.reduce((a,d)=>a+d.x,0)/s.length,y:s.reduce((a,d)=>a+d.y,0)/s.length};return l.x=S(l.x,t.left,t.right),l.y=S(l.y,t.top,t.bottom),{normal:r,depth:i,point:l}}function Tt(e,t,i){const r=i.normal,n=e.invMass+((t==null?void 0:t.invMass)??0),s=Math.max(0,i.depth-.025)*.7/n;e.x+=r.x*s*e.invMass,e.y+=r.y*s*e.invMass,t&&(t.x-=r.x*s*t.invMass,t.y-=r.y*s*t.invMass);const l={x:i.point.x-e.x,y:i.point.y-e.y},a={x:i.point.x-((t==null?void 0:t.x)??i.point.x),y:i.point.y-((t==null?void 0:t.y)??i.point.y)},d={x:e.vx-e.omega*l.y-(t?t.vx-t.omega*a.y:0),y:e.vy+e.omega*l.x-(t?t.vy+t.omega*a.x:0)},m=q(d,r);if(m>=0)return;const f=n+j(l,r)**2*e.invInertia+j(a,r)**2*((t==null?void 0:t.invInertia)??0),g=-(1+(m<-8?.12:0))*m/f,y={x:-r.y,y:r.x},k=n+j(l,y)**2*e.invInertia+j(a,y)**2*((t==null?void 0:t.invInertia)??0),v=S(-q(d,y)/k,-g*.55,g*.55),x={x:g*r.x+v*y.x,y:g*r.y+v*y.y};e.vx+=x.x*e.invMass,e.vy+=x.y*e.invMass,e.omega+=j(l,x)*e.invInertia,t&&(t.vx-=x.x*t.invMass,t.vy-=x.y*t.invMass,t.omega-=j(a,x)*t.invInertia)}const O="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";function Nt(e){let t="";for(let i=0;i<e.length;i+=3){const r=e[i]<<16|(e[i+1]??0)<<8|(e[i+2]??0);t+=O[r>>>18&63]+O[r>>>12&63]+(i+1<e.length?O[r>>>6&63]:"=")+(i+2<e.length?O[r&63]:"=")}return t}function de(e){if(e.length%4||!/^[A-Za-z0-9+/]*={0,2}$/.test(e))throw new Error("Invalid scene encoding");const t=[];for(let i=0;i<e.length;i+=4){const r=O.indexOf(e[i])<<18|O.indexOf(e[i+1])<<12|Math.max(0,O.indexOf(e[i+2]))<<6|Math.max(0,O.indexOf(e[i+3]));t.push(r>>>16&255),e[i+2]!=="="&&t.push(r>>>8&255),e[i+3]!=="="&&t.push(r&255)}return new Uint8Array(t)}function Pt(e){const t=[];for(let r=0;r<e.length;){const n=e[r];let s=r+1;for(;s<e.length&&e[s]===n&&s-r<65535;)s++;t.push(n&255,n>>>8,s-r&255,s-r>>>8),r=s}if(t.length<e.length*e.BYTES_PER_ELEMENT)return"r"+Nt(t);const i=[];for(const r of e)i.push(r&255),e.BYTES_PER_ELEMENT===2&&i.push(r>>>8);return"b"+Nt(i)}function Dt(e,t,i){const r=new Uint16Array(t);let n=0;const s=(l,a)=>{if(!Number.isInteger(l)||l<0||l>i||!Number.isInteger(a)||a<1||n+a>t)throw new Error("Invalid scene data");r.fill(l,n,n+a),n+=a};if(Array.isArray(e)){if(e.length%2||e.length>t*2)throw new Error("Invalid scene data");for(let l=0;l<e.length;l+=2)s(e[l],e[l+1])}else if(typeof e=="string"&&e.length<=t*6+16){const l=de(e.slice(1));if(e[0]==="r"&&l.length%4===0)for(let a=0;a<l.length;a+=4)s(l[a]|l[a+1]<<8,l[a+2]|l[a+3]<<8);else if(e[0]==="b"){const a=i>255?2:1;if(l.length!==t*a)throw new Error("Invalid scene data");for(let d=0;d<l.length;d+=a)s(l[d]|(a===2?l[d+1]<<8:0),1)}else throw new Error("Invalid scene encoding")}else throw new Error("Invalid scene data");if(n!==t)throw new Error("Incomplete scene data");return r}function mt(e){return!!e&&Ot.includes(e.material)&&Number.isInteger(e.rate)&&e.rate>=1&&e.rate<=2400&&Number.isInteger(e.radius)&&e.radius>=1&&e.radius<=24&&["up","down","left","right"].includes(e.direction)&&typeof e.enabled=="boolean"}class ${constructor(t=300,i=200,r=82371){M(this,"width");M(this,"height");M(this,"cells");M(this,"life");M(this,"moved");M(this,"occupancy");M(this,"bodies",[]);M(this,"emitters",[]);M(this,"nextEmitterId",1);M(this,"gravity",1);M(this,"tick",0);M(this,"seed");M(this,"nextId",1);M(this,"terrainDirty",!0);M(this,"terrain",[]);M(this,"grab",null);this.width=t,this.height=i,this.seed=r,this.cells=new Uint8Array(t*i),this.life=new Uint16Array(t*i),this.moved=new Uint32Array(t*i),this.occupancy=new Int32Array(t*i)}get units(){return Math.max(1,this.width/300)}random(){let t=this.seed|0;return t^=t<<13,t^=t>>>17,t^=t<<5,this.seed=t>>>0,this.seed/4294967296}get(t,i){return t=Math.floor(t),i=Math.floor(i),t<0||t>=this.width||i<0||i>=this.height?o.wall:this.cells[i*this.width+t]}set(t,i,r,n=0){if(t=Math.floor(t),i=Math.floor(i),t<0||t>=this.width||i<0||i>=this.height)return;const s=i*this.width+t;(D(r)||D(this.cells[s]))&&(this.terrainDirty=!0),this.cells[s]=r,this.life[s]=n||(r===o.fire?40+Math.floor(this.random()*80):r===o.steam?360+Math.floor(this.random()*240):r===o.smoke?420+Math.floor(this.random()*180):0)}paint(t,i,r,n){const s=Math.max(.5,r);for(let l=Math.floor(i-s);l<=i+s;l++)for(let a=Math.floor(t-s);a<=t+s;a++){if((a-t)**2+(l-i)**2>s*s||a<0||a>=this.width||l<0||l>=this.height)continue;const d=l*this.width+a;(n===o.empty||!this.occupancy[d]&&(!this.cells[d]||n===o.fire))&&(n===o.empty||D(n)||this.random()<.6)&&this.set(a,l,n)}n===o.empty&&(this.bodies=this.bodies.filter(l=>!it(N(l),t,i)),this.emitters=this.emitters.filter(l=>Math.hypot(l.x-t,l.y-i)>Math.max(s,6*this.units)))}addBody(t,i,r,n=12){if(this.bodies.length>=48)return null;n=S(n,4,24*this.units);const s=t==="box"?[{x:-n,y:-n},{x:n,y:-n},{x:n,y:n},{x:-n,y:n}]:Array.from({length:7},(m,f)=>{const g=f*Math.PI*2/7,y=n*(f%2?.91:1);return{x:Math.cos(g)*y,y:Math.sin(g)*y}}),l=n*n*(t==="box"?.55:2.8),a={id:this.nextId++,kind:t,x:S(i,n*1.5,this.width-n*1.5),y:S(r,n*1.5,this.height-n*1.5),vx:0,vy:0,angle:t==="box"?-.08:.2,omega:0,size:n,heat:0,mass:l,invMass:1/l,invInertia:1/(l*n*n*.67),local:s},d=B(N(a));for(let m=Math.max(0,Math.floor(d.top));m<Math.min(this.height,d.bottom);m++)for(let f=Math.max(0,Math.floor(d.left));f<Math.min(this.width,d.right);f++)if(D(this.get(f,m))&&it(d.vertices,f+.5,m+.5))return null;return this.bodies.some(m=>ut(d,B(N(m))))?null:(this.bodies.push(a),this.rasterBodies(),a)}bodyAt(t,i){return[...this.bodies].reverse().find(r=>it(N(r),t,i))}swap(t,i){const r=this.cells[i],n=this.life[i];this.cells[i]=this.cells[t],this.life[i]=this.life[t],this.cells[t]=r,this.life[t]=n,this.moved[i]=this.tick,this.moved[t]=this.tick}move(t,i,r,n=!1){if(i<0||i>=this.width||r<0||r>=this.height)return!1;const s=r*this.width+i;if(this.occupancy[s]||D(this.cells[s]))return!1;const l=this.cells[t],a=this.cells[s];return!a||!n&&X[l]>X[a]&&a!==o.sand?(this.swap(t,s),!0):!1}react(t,i,r){const n=this.cells[t];if(n!==o.fire&&n!==o.lava)return;const s=[[i-1,r],[i+1,r],[i,r-1],[i,r+1]];for(const[l,a]of s){const d=this.get(l,a);if(d===o.co2&&n===o.fire){this.set(i,r,o.smoke,60);return}if(d===o.water){this.set(l,a,o.steam),this.set(i,r,n===o.lava?o.wall:o.steam);return}d===o.hydrogen&&this.set(l,a,o.fire,30+Math.floor(this.random()*40)),(d===o.oil||d===o.wood)&&this.random()<(d===o.oil?.24:.025)&&this.set(l,a,o.fire,d===o.wood?180:100),n===o.lava&&d===o.empty&&this.random()<.004&&this.set(l,a,o.fire,35)}}updateCell(t,i,r){const n=i*this.width+t,s=this.cells[n];if(!s||D(s)||this.occupancy[n]||this.moved[n]===this.tick||r!==Ct(s))return;const l=this.random()<.5?-1:1;if(s===o.hydrogen||s===o.co2)for(const[a,d]of[[t-1,i],[t+1,i],[t,i-1],[t,i+1]]){const m=this.get(a,d);if(s===o.hydrogen&&(m===o.fire||m===o.lava)){this.set(t,i,o.fire,50);return}s===o.co2&&m===o.fire&&this.set(a,d,o.smoke,60)}if((s===o.fire||s===o.lava)&&this.react(n,t,i),this.cells[n]===s){if(r){const a=s===o.fire||s===o.steam||s===o.smoke;if(a&&this.life[n]&&this.life[n]--,a&&!this.life[n]){this.set(t,i,s===o.steam&&this.random()<.15?o.water:s===o.fire&&this.random()<.6?o.smoke:o.empty);return}if(i===0&&s!==o.co2){this.set(t,i,o.empty);return}const d=s===o.co2?1:-1,m=s===o.hydrogen?.97:s===o.helium?.9:s===o.smoke?.3:s===o.fire?.45:.7,f=(g,y)=>{if(g<0||g>=this.width||y<0||y>=this.height)return!1;const k=y*this.width+g,v=this.cells[k];return this.occupancy[k]?!1:!v||Ct(v)&&v!==s&&(d<0&&X[s]<X[v]||d>0&&X[s]>X[v])?(this.swap(n,k),!0):!1};if(this.gravity>0&&this.random()<m*Math.min(1,this.gravity)&&(f(t,i+d)||f(t+l,i+d)||f(t-l,i+d)))return;this.random()<.45&&(f(t+l,i)||f(t-l,i));return}if(!(s===o.lava&&this.random()>.2)&&!(this.gravity>0&&this.random()<Math.min(1,this.gravity)&&(this.move(n,t,i+1)||this.move(n,t+l,i+1)||this.move(n,t-l,i+1)))&&s!==o.sand){const a=Math.round((s===o.water?5:s===o.oil?3:1)*this.units);for(const d of[l,-l]){let m=t;for(let f=1;f<=a;f++){const g=t+d*f;if(g<0||g>=this.width||this.cells[i*this.width+g]||this.occupancy[i*this.width+g]||(m=g,this.gravity>0&&this.get(g,i+1)===o.empty))break}if(m!==t){this.move(n,m,i);return}}}}}addEmitter(t,i,r){if(this.emitters.length>=24||!mt(r)||!Number.isFinite(t)||!Number.isFinite(i)||t<0||t>=this.width||i<0||i>=this.height||D(this.get(t,i)))return null;const n={...r,id:this.nextEmitterId++,x:t,y:i,carry:0};return this.emitters.push(n),n}emitterAt(t,i,r=8*this.units){return[...this.emitters].reverse().find(n=>Math.hypot(n.x-t,n.y-i)<=r)}updateEmitter(t,i){const r=this.emitters.find(n=>n.id===t);return!r||!mt(i)?!1:(Object.assign(r,i),!0)}emit(){for(const t of this.emitters){if(!t.enabled)continue;t.carry+=t.rate/60;const i=Math.floor(t.carry);t.carry-=i;const r=t.direction==="right"?1:t.direction==="left"?-1:0,n=t.direction==="down"?1:t.direction==="up"?-1:0;for(let s=0;s<i;s++)for(let l=0;l<6;l++){const a=(this.random()*2-1)*t.radius,d=2*this.units+this.random()*(t.radius+1),m=Math.floor(t.x+r*d-n*a),f=Math.floor(t.y+n*d+r*a);if(m<0||m>=this.width||f<0||f>=this.height)continue;const g=f*this.width+m;if(!this.cells[g]&&!this.occupancy[g]){this.set(m,f,t.material);break}}}}rebuildTerrain(){this.terrain=[Q(-20,-20,20,this.height+40),Q(this.width,-20,20,this.height+40),Q(0,this.height,this.width,20),Q(0,-20,this.width,20)];let t=new Map;for(let i=0;i<this.height;i++){const r=new Map;for(let n=0;n<this.width;n++){if(!D(this.cells[i*this.width+n]))continue;const s=n;for(;n+1<this.width&&D(this.cells[i*this.width+n+1]);)n++;const l=`${s}:${n}`,a=t.get(l);if(a)a.bottom=i+1,a.y=(a.top+a.bottom)/2,a.vertices[2].y=i+1,a.vertices[3].y=i+1,r.set(l,a);else{const d=Q(s,i,n-s+1,1);this.terrain.push(d),r.set(l,d)}}t=r}this.terrainDirty=!1}updateBodies(){var r;if(!this.bodies.length)return;this.terrainDirty&&this.rebuildTerrain();const t=1/120;for(let n=0;n<2;n++){for(const s of this.bodies){const l=N(s),a=B(l);let d=0,m=0,f=0,g=0;for(let v=Math.ceil(a.top);v<a.bottom;v+=2){const x=this.get(a.left-2,v),L=this.get(a.right+1,v);d+=+(x===o.water||x===o.oil)+ +(L===o.water||L===o.oil),m+=+(x===o.fire||x===o.lava)+ +(L===o.fire||L===o.lava),g+=2}for(let v=a.left;v<=a.right;v+=2)f+=+(this.get(v,a.bottom+1)===o.sand);const y=d/Math.max(1,g);s.vy+=75*this.units*this.gravity*(1-y*(s.kind==="box"?1.9:.32))*t;const k=Math.exp(-(y*3+.08)*t);if(s.vx*=k,s.vy*=k,s.omega*=Math.exp(-(y*4+.7)*t),f>s.size*.3&&s.vy>0&&(s.vy*=s.kind==="box"?.15:.8,s.vx*=.93,s.omega*=.88),s.kind==="box"&&(s.heat=S(s.heat+(m?1.5:-1)*t,0,10)),((r=this.grab)==null?void 0:r.id)===s.id){const v=Math.cos(s.angle),x=Math.sin(s.angle),L=this.grab.localX*v-this.grab.localY*x,ct=this.grab.localX*x+this.grab.localY*v,At=S((this.grab.x-s.x-L)*75-(s.vx-s.omega*ct)*12,-2500,2500),Lt=S((this.grab.y-s.y-ct)*75-(s.vy+s.omega*L)*12,-2500,2500);s.vx+=At*t,s.vy+=Lt*t,s.omega+=(L*Lt-ct*At)*t/(s.size*s.size*1.5)}s.vx=S(s.vx,-160*this.units,160*this.units),s.vy=S(s.vy,-160*this.units,160*this.units),s.omega=S(s.omega,-8,8),s.x+=s.vx*t,s.y+=s.vy*t,s.angle+=s.omega*t}for(let s=0;s<4;s++){for(const l of this.bodies){let a=B(N(l));for(const d of this.terrain){const m=ut(a,d);m&&(Tt(l,null,m),a=B(N(l)))}}for(let l=0;l<this.bodies.length;l++)for(let a=l+1;a<this.bodies.length;a++){const d=this.bodies[l],m=this.bodies[a];if(Math.abs(d.x-m.x)>(d.size+m.size)*1.5||Math.abs(d.y-m.y)>(d.size+m.size)*1.5)continue;const f=ut(B(N(d)),B(N(m)));f&&Tt(d,m,f)}}}const i=this.bodies.filter(n=>n.heat>3);this.bodies=this.bodies.filter(n=>n.heat<=3),this.rasterBodies();for(const n of i)this.paint(n.x,n.y,n.size,o.fire)}rasterBodies(){this.occupancy.fill(0);for(const t of this.bodies){const i=N(t),r=B(i);for(let n=Math.max(0,Math.floor(r.top));n<Math.min(this.height,r.bottom);n++)for(let s=Math.max(0,Math.floor(r.left));s<Math.min(this.width,r.right);s++)it(i,s+.5,n+.5)&&(this.occupancy[n*this.width+s]=t.id)}for(const t of this.bodies){const i=B(N(t));for(let r=Math.max(0,Math.floor(i.top));r<Math.min(this.height,i.bottom);r++)for(let n=Math.max(0,Math.floor(i.left));n<Math.min(this.width,i.right);n++){const s=r*this.width+n;if(this.occupancy[s]!==t.id||!this.cells[s]||D(this.cells[s]))continue;let l=!1;for(let a=1;a<=t.size*3&&!l;a++){const d=n<t.x?-1:1;for(const[m,f]of[[d*a,-Math.floor(a/3)],[d*a,0],[-d*a,0],[0,-a]]){const g=n+m,y=r+f;if(g<0||g>=this.width||y<0||y>=this.height)continue;const k=y*this.width+g;if(!this.occupancy[k]&&!this.cells[k]){this.swap(s,k),l=!0;break}}}}}}step(){this.tick++,this.updateBodies(),this.emit();for(let t=this.height-1;t>=0;t--){const i=(this.tick+t)%2;for(let r=0;r<this.width;r++)this.updateCell(i?this.width-1-r:r,t,!1)}for(let t=0;t<this.height;t++){const i=(this.tick+t)%2;for(let r=0;r<this.width;r++)this.updateCell(i?this.width-1-r:r,t,!0)}}snapshot(){return{version:2,emitters:this.emitters.map(t=>({...t})),width:this.width,height:this.height,gravity:this.gravity,seed:this.seed,cells:Pt(this.cells),life:Pt(this.life),bodies:this.bodies.map(({kind:t,x:i,y:r,vx:n,vy:s,angle:l,omega:a,size:d,heat:m})=>({kind:t,x:i,y:r,vx:n,vy:s,angle:l,omega:a,size:d,heat:m}))}}static restore(t){if(!t||typeof t!="object")throw new Error("Invalid scene");const i=t;if(i.version!==1&&i.version!==2||!Number.isInteger(i.width)||!Number.isInteger(i.height)||i.width<60||i.width>720||i.height<60||i.height>720||!Number.isFinite(i.gravity)||i.gravity<0||i.gravity>2||!Number.isInteger(i.seed)||i.seed<=0||i.seed>4294967295)throw new Error("Unsupported scene");const r=new $(i.width,i.height,i.seed);if(r.cells=new Uint8Array(Dt(i.cells,i.width*i.height,i.version===1?o.wall:o.co2)),r.life=Dt(i.life,i.width*i.height,i.version===1?255:65535),r.gravity=i.gravity,!Array.isArray(i.bodies)||i.bodies.length>48)throw new Error("Invalid bodies");for(const n of i.bodies){if(!n||n.kind!=="box"&&n.kind!=="stone"||!["x","y","vx","vy","angle","omega","size","heat"].every(a=>Number.isFinite(n[a]))||n.size<4||n.size>24*r.units||n.x<0||n.x>i.width||n.y<0||n.y>i.height||Math.abs(n.vx)>200*r.units||Math.abs(n.vy)>200*r.units||Math.abs(n.omega)>20||Math.abs(n.angle)>1e8||n.heat<0||n.heat>10)throw new Error("Invalid body");const l=new $(i.width,i.height).addBody(n.kind,n.x,n.y,n.size);Object.assign(l,n,{id:r.nextId++}),r.bodies.push(l)}if(i.version===2){if(!Array.isArray(i.emitters)||i.emitters.length>24)throw new Error("Invalid emitters");const n=new Set;for(const s of i.emitters){if(!mt(s)||!Number.isSafeInteger(s.id)||s.id<1||n.has(s.id)||!Number.isFinite(s.x)||!Number.isFinite(s.y)||s.x<0||s.x>=i.width||s.y<0||s.y>=i.height||!Number.isFinite(s.carry)||s.carry<0||s.carry>=1)throw new Error("Invalid emitter");n.add(s.id),r.emitters.push({id:s.id,x:s.x,y:s.y,material:s.material,rate:s.rate,radius:s.radius,direction:s.direction,enabled:s.enabled,carry:s.carry}),r.nextEmitterId=Math.max(r.nextEmitterId,s.id+1)}}return r.rasterBodies(),r}resized(t,i){if(!Number.isInteger(t)||!Number.isInteger(i)||t<60||i<60||t>720||i>720)throw new Error("Invalid stage resolution");const r=new $(t,i,this.seed);r.gravity=this.gravity;const n=t/this.width,s=i/this.height,l=Math.min(n,s);for(let d=0;d<i;d++)for(let m=0;m<t;m++){const f=Math.min(this.height-1,Math.floor(d/s))*this.width+Math.min(this.width-1,Math.floor(m/n));r.cells[d*t+m]=this.cells[f],r.life[d*t+m]=this.life[f]}const a=r.snapshot();return a.bodies=this.snapshot().bodies.map(d=>({...d,x:d.x*n,y:d.y*s,vx:d.vx*n,vy:d.vy*s,size:S(d.size*l,4,24*r.units)})),a.emitters=this.emitters.map(d=>({...d,x:d.x*n,y:d.y*s,radius:S(Math.round(d.radius*l),1,24),rate:d.rate})),$.restore(a)}}function bt(e,t=300,i=200){const r=new $(t,i),n=(l,a,d,m,f)=>{for(let g=Math.floor(a*i);g<(a+m)*i;g++)for(let y=Math.floor(l*t);y<(l+d)*t;y++)r.set(y,g,f)},s=(l,a,d,m)=>r.addBody(l,a*t,d*i,m*Math.min(t,i));if(e==="empty")return r;if(e==="gaslab"){n(.08,.86,.84,.025,o.wall),n(.08,.54,.014,.32,o.wall),n(.9,.54,.02,.32,o.wall),n(.48,.58,.014,.28,o.wall);for(const[l,a,d,m]of[[o.helium,.2,.76,"up"],[o.smoke,.38,.76,"up"],[o.co2,.65,.34,"down"],[o.hydrogen,.81,.76,"up"]])r.addEmitter(a*t,d*i,{material:l,rate:Math.round(100*r.units),radius:Math.round(4*r.units),direction:m,enabled:!0});s("box",.58,.74,.04)}else if(e==="waterworks")n(.08,.42,.3,.025,o.wall),n(.07,.18,.015,.27,o.wall),n(.08,.25,.19,.16,o.water),n(.58,.64,.32,.025,o.wall),n(.885,.43,.015,.23,o.wall),n(.05,.88,.9,.035,o.wall),n(.05,.65,.012,.24,o.wall),n(.94,.65,.012,.24,o.wall),n(.065,.78,.87,.1,o.water),s("box",.5,.52,.045),s("stone",.3,.56,.045);else if(e==="furnace")n(.12,.85,.76,.04,o.wall),n(.12,.55,.02,.3,o.wall),n(.86,.55,.02,.3,o.wall),n(.14,.76,.72,.09,o.lava),n(.3,.55,.36,.035,o.wood),n(.32,.37,.045,.18,o.wood),n(.59,.37,.045,.18,o.wood),n(.32,.31,.32,.055,o.oil),s("box",.45,.2,.055),s("stone",.75,.3,.05);else{n(.055,.86,.89,.035,o.wall),n(.055,.57,.015,.29,o.wall),n(.93,.55,.015,.31,o.wall),n(.39,.735,.54,.125,o.water);for(let l=Math.floor(i*.57);l<i*.86;l++){const a=(l/i-.57)*t*.6;for(let d=Math.max(t*.07,t*.25-a);d<t*.25+a;d++)r.set(d,l,o.sand)}n(.11,.38,.19,.02,o.wall),n(.108,.27,.012,.13,o.wall),n(.285,.27,.015,.13,o.wall),n(.123,.31,.16,.07,o.oil),n(.45,.56,.19,.02,o.wood),n(.455,.58,.016,.14,o.wood),n(.617,.58,.016,.14,o.wood),s("box",.51,.47,.047),s("box",.58,.35,.034),s("box",.8,.64,.047),s("stone",.73,.38,.045),s("stone",.87,.5,.032)}return r.rasterBodies(),r}function u(e){const t=document.getElementById(e);if(!t)throw new Error(`Missing control: ${e}`);return t}const w=u("canvas"),h=w.getContext("2d",{alpha:!1}),ot=document.createElement("canvas"),ce=ot.getContext("2d"),Mt=u("app"),ft=u("brush"),gt=u("gravity"),at=u("scene");let c=bt("playground"),pt=new ImageData(c.width,c.height),z="sand",T=16,C=!1,nt=0,H=0,b={width:1,height:1,scale:1,x:0,y:0,dpr:1},rt=null,R={bg:"#15191d",fg:"#e6e9e8",primary:"#e8bc72",light:!1},p=null,J=null,Z=[],yt=0,zt=0,wt=60,G=!1,K=0,st=600,E=null,lt=null,Et={material:o.water,rate:240,radius:6,direction:"down",enabled:!0};const dt=vt("storage"),W=[{id:"sand",name:"Sand",color:"#dbb777",value:o.sand,kind:"GRANULAR",detail:"Falls, piles up, and sinks through water."},{id:"water",name:"Water",color:"#5a9ed1",value:o.water,kind:"LIQUID",detail:"Flows into gaps. Floats crates, puts out fire, and cools lava."},{id:"oil",name:"Oil",color:"#b49c56",value:o.oil,kind:"FLAMMABLE LIQUID",detail:"Floats on water. Add fire and watch it ignite."},{id:"fire",name:"Fire",color:"#ed8850",value:o.fire,kind:"REACTION",detail:"Ignites oil and wood. Rises, flickers, and eventually burns out."},{id:"steam",name:"Steam",color:"#a3bec5",value:o.steam,kind:"GAS",detail:"Drifts upward and dissipates. Some condenses back into water."},{id:"lava",name:"Lava",color:"#d65638",value:o.lava,kind:"MOLTEN LIQUID",detail:"Flows slowly and ignites fuel. Water cools it into solid wall."},{id:"wood",name:"Wood",color:"#a78058",value:o.wood,kind:"FIXED SOLID",detail:"Build bridges and platforms. Stays fixed until it burns."},{id:"wall",name:"Wall",color:"#77848a",value:o.wall,kind:"FIXED SOLID",detail:"An immovable, fireproof barrier. Draw containers for your experiments."},{id:"smoke",name:"Smoke",color:"#899099",value:o.smoke,kind:"SLOW RISING GAS",detail:"Rises slowly, spreads, and fades. Fire produces smoke as it burns out."},{id:"hydrogen",name:"Hydrogen",color:"#e4b4cf",value:o.hydrogen,kind:"FLAMMABLE GAS",detail:"A very light gas. Rises quickly and ignites when it touches fire or lava."},{id:"helium",name:"Helium",color:"#ab9bda",value:o.helium,kind:"INERT GAS",detail:"Rises rapidly and stays unburned near fire. Collect it under a ceiling."},{id:"co2",name:"CO₂",color:"#81ac91",value:o.co2,kind:"HEAVY GAS",detail:"Sinks and collects in low places. Smothers fire on contact."},{id:"emitter",name:"Emitter",color:"#d4b57b",value:-4,kind:"CONTINUOUS SOURCE",detail:"Place a configurable source. Set its material, flow rate, width, direction, and power."},{id:"box",name:"Crate",color:"#c39962",value:-1,kind:"RIGID BODY",detail:"Tap to drop a wooden crate. It tumbles, stacks, floats, and burns. Use Grab to throw it."},{id:"stone",name:"Stone",color:"#89989b",value:-2,kind:"RIGID BODY",detail:"Tap to drop a heavy stone. It rolls, collides, and sinks. Brush size sets its size."},{id:"grab",name:"Grab",color:"",value:-3,kind:"PHYSICS TOOL",detail:"Drag a crate or stone. Grab near a corner to spin it, then release to throw."},{id:"erase",name:"Erase",color:"",value:o.empty,kind:"EDITING TOOL",detail:"Brush away particles and walls. Touch a rigid body to remove it. Right-drag also erases."}];for(const e of W){const t=document.createElement("button");t.type="button",t.dataset.material=e.id,t.title=`${e.name} — ${e.detail}`,t.setAttribute("aria-pressed",String(e.id===z));const i=["grab","erase","emitter"].includes(e.id);if(!i){t.className="material";const n=document.createElement("span");n.className=`swatch ${e.id}`,n.style.setProperty("--swatch",e.color),n.setAttribute("aria-hidden","true"),t.append(n)}const r=document.createElement("span");r.textContent=e.name,t.append(r),t.addEventListener("click",()=>et(e.id)),u(i?"tools":xt.includes(e.value)?"gases":e.value<0?"bodies":"materials").append(t)}function V(){Mt.classList.remove("palette-open"),u("palette-toggle").setAttribute("aria-expanded","false")}function et(e){P(),z=e;const t=W.find(i=>i.id===e);document.querySelectorAll("[data-material]").forEach(i=>i.setAttribute("aria-pressed",String(i.dataset.material===e))),u("emitter-editor").hidden=e!=="emitter",u("quick-emitter").setAttribute("aria-pressed",String(e==="emitter")),u("quick-grab").setAttribute("aria-pressed",String(e==="grab")),u("quick-erase").setAttribute("aria-pressed",String(e==="erase")),u("detail-kind").textContent=t.kind,u("detail-name").textContent=t.name,u("detail-text").textContent=t.detail,u("palette-toggle").title=`Materials and tools · ${t.name} selected`,w.style.cursor=e==="grab"?"grab":"crosshair",u("hint").textContent=e==="emitter"?"Tap to place a source · Tap an existing source to edit it":e==="grab"?"Drag a body or emitter · Release a body to throw":e==="box"||e==="stone"?`Tap to place a ${t.name.toLowerCase()} · Brush size controls its size`:`Drag to paint ${t.name.toLowerCase()} · Right-drag to erase`,e==="emitter"?(F(),Mt.classList.add("palette-open"),u("palette-toggle").setAttribute("aria-expanded","true"),u("emitter-editor").scrollIntoView({block:"nearest"})):V()}function A(e){const t=u("toast");t.textContent=e,t.hidden=!1,window.clearTimeout(yt),yt=window.setTimeout(()=>{t.hidden=!0},3600)}function Y(){K++,Z.push(c.snapshot()),Z.length>8&&Z.shift(),u("undo").disabled=!1}function _(e){P(),c=e,E=null,F(),st=c.width,he(),pt=new ImageData(c.width,c.height),ot.width=c.width,ot.height=c.height,gt.value=String(Math.round(c.gravity*10)),u("gravity-value").textContent=`${c.gravity.toFixed(1)}×`,Yt(),U()}function he(){var t;const e=u("resolution");if((t=e.querySelector("option[data-custom]"))==null||t.remove(),![300,450,600].includes(c.width)){const i=new Option(`Loaded · ${c.width} wide`,String(c.width));i.dataset.custom="true",e.add(i)}e.value=String(c.width),u("stage-size").textContent=`${c.width} × ${c.height}`}function F(){var r;const e=c.emitters.find(n=>n.id===E);e||(E=null);const t=e??Et,i=u("emitter-list");i.replaceChildren(new Option("New emitter","new"));for(const n of c.emitters)i.add(new Option(`#${n.id} · ${((r=W.find(s=>s.value===n.material))==null?void 0:r.name)??"Source"}${n.enabled?"":" · off"}`,String(n.id)));i.value=e?String(e.id):"new",u("emitter-id").textContent=e?`#${e.id}`:"NEW",u("emitter-material").value=String(t.material),u("emitter-rate").value=String(t.rate),u("emitter-radius").value=String(t.radius),u("emitter-direction").value=t.direction,u("emitter-enabled").checked=t.enabled,u("emitter-rate-value").textContent=`${t.rate} /s`,u("emitter-radius-value").textContent=`${t.radius*2} px`,u("emitter-remove").disabled=!e,u("emitter-tip").textContent=e?"Changes apply immediately. Grab moves this source; Erase removes it.":"Configure a source, then tap the stage to place it.",u("emitter-place").textContent=e?"Back to stage":"Place on stage"}for(const e of W.filter(t=>Ot.includes(t.value)))u("emitter-material").add(new Option(e.name,String(e.value)));function ue(){const e={material:Number(u("emitter-material").value),rate:Number(u("emitter-rate").value),radius:Number(u("emitter-radius").value),direction:u("emitter-direction").value,enabled:u("emitter-enabled").checked};E!==null&&(Y(),c.updateEmitter(E,e)),Et={...e},F(),U()}for(const e of["emitter-material","emitter-rate","emitter-radius","emitter-direction","emitter-enabled"])u(e).addEventListener("change",ue);for(const e of["emitter-rate","emitter-radius"])u(e).addEventListener("input",()=>{u(e+"-value").textContent=e==="emitter-rate"?`${u(e).value} /s`:`${Number(u(e).value)*2} px`});u("emitter-list").addEventListener("change",()=>{const e=u("emitter-list").value;E=e==="new"?null:Number(e),F()});u("emitter-new").addEventListener("click",()=>{E=null,F()});u("emitter-place").addEventListener("click",V);u("emitter-remove").addEventListener("click",()=>{E!==null&&(Y(),c.emitters=c.emitters.filter(e=>e.id!==E),E=null,F(),U())});u("resolution").addEventListener("change",()=>{const e=Number(u("resolution").value);if(e===c.width)return;Y();const t=c.width;_(c.resized(e,Math.max(60,Math.min(720,Math.round(c.height*e/c.width))))),T=Math.max(1,Math.min(48,Math.round(T*e/t))),ft.value=String(T),u("brush-value").textContent=`${T} px`,A(`Stage: ${c.width} × ${c.height}. Undo restores the previous detail.`)});function Ft(){const e=w.getBoundingClientRect();return[st,Math.max(Math.round(st*.53),Math.min(720,Math.round((e.height-70)/Math.max(1,e.width-32)*st)))]}function Ut(e){Y();const[t,i]=Ft();_(bt(e,t,i)),u("scene-name").textContent=at.selectedOptions[0].textContent,u("hint").textContent=e==="gaslab"?"Use Emitter to edit a source · Add fire to hydrogen":e==="empty"?"Choose a material, then drag to draw":"Drag to pour sand · Try dropping a stone into the water",V()}function kt(e){C=e,H=0,P();const t=u("pause");t.setAttribute("aria-label",C?"Resume simulation":"Pause simulation"),t.title=C?"Resume simulation":"Pause simulation",t.querySelector("path").setAttribute("d",C?"m6 4 9 6-9 6Z":"M7 5v10M13 5v10"),t.querySelector(".button-label").textContent=C?"Play":"Pause",u("live-state").innerHTML=C?"<i></i>PAUSED":"<i></i>LIVE",u("live-state").classList.toggle("is-paused",C),u("paused-badge").hidden=!C,U()}u("pause").addEventListener("click",()=>kt(!C));u("step").addEventListener("click",()=>{kt(!0),c.step(),U()});u("palette-toggle").addEventListener("click",()=>{const e=Mt.classList.toggle("palette-open");u("palette-toggle").setAttribute("aria-expanded",String(e))});u("quick-emitter").addEventListener("click",()=>et("emitter"));u("quick-grab").addEventListener("click",()=>et("grab"));u("quick-erase").addEventListener("click",()=>et("erase"));at.addEventListener("change",()=>Ut(at.value));for(const e of["reset","drawer-reset"])u(e).addEventListener("click",()=>Ut(at.value));u("clear").addEventListener("click",()=>{Y(),_(new $(c.width,c.height)),u("scene-name").textContent="Empty canvas",u("hint").textContent="Choose a material, then drag to draw",V(),A("Canvas cleared. Undo restores your scene.")});u("undo").disabled=!0;u("undo").addEventListener("click",()=>{const e=Z.pop();e&&(K++,_($.restore(e)),A("Previous edit restored")),u("undo").disabled=!Z.length});ft.addEventListener("input",()=>{T=Number(ft.value),u("brush-value").textContent=`${T} px`});gt.addEventListener("input",()=>{c.gravity=Number(gt.value)/10,u("gravity-value").textContent=`${c.gravity.toFixed(1)}×`,K++});async function qt(e){let t=0;try{return await Promise.race([e,new Promise((i,r)=>{t=window.setTimeout(()=>r(new Error("The host did not respond")),6e3)})])}finally{window.clearTimeout(t)}}function tt(){for(const e of["save","load","drawer-save","drawer-load"]){const t=u(e);t.disabled=!dt||G,dt||(t.title="This host does not provide scene storage")}}async function me(){if(!(!dt||G)){G=!0,tt();try{const e=JSON.stringify(c.snapshot());if(new TextEncoder().encode(e).length>48e4)throw new Error("Scene is too detailed to save; erase some material first");await qt($t.setItem("powder-scene-v1",e)),A("Scene saved")}catch(e){A(`Scene was not saved: ${e instanceof Error?e.message:"storage unavailable"}`)}finally{G=!1,tt()}}}async function fe(){if(!dt||G)return;G=!0,tt();const e=K;try{const t=await qt($t.getItem("powder-scene-v1"));if(!t){A("No saved scene yet");return}const i=$.restore(JSON.parse(t));if(e!==K){A("Canvas changed while loading. Load again to replace it.");return}Y(),_(i),u("scene-name").textContent="Saved scene",V(),A("Saved scene restored")}catch(t){A(`Could not load scene: ${t instanceof Error?t.message:"storage unavailable"}`)}finally{G=!1,tt()}}for(const e of["save","drawer-save"])u(e).addEventListener("click",()=>{me()});for(const e of["load","drawer-load"])u(e).addEventListener("click",()=>{fe()});function Gt(e){const t=w.getBoundingClientRect();return{x:(e.clientX-t.left-b.x)/b.scale,y:(e.clientY-t.top-b.y)/b.scale}}function Wt(e){return e.x>=0&&e.y>=0&&e.x<c.width&&e.y<c.height}function St(){if(!p||["grab","box","stone","emitter"].includes(p.mode))return;const e=W.find(n=>n.id===p.mode).value,t=p.x-p.lastX,i=p.y-p.lastY,r=Math.min(600,Math.max(1,Math.ceil(Math.hypot(t,i)/Math.max(1,T/3))));for(let n=1;n<=r;n++)c.paint(p.lastX+t*n/r,p.lastY+i*n/r,T/2,e);p.lastX=p.x,p.lastY=p.y,e===o.empty&&(c.emitters=c.emitters.filter(n=>Math.hypot(n.x-p.x,n.y-p.y)>Math.max(T/2,6*c.units)),E!==null&&!c.emitters.some(n=>n.id===E)&&(E=null,F()),c.rasterBodies())}w.addEventListener("pointerdown",e=>{if(p||![0,2].includes(e.button))return;const t=Gt(e);if(!Wt(t))return;e.preventDefault(),V(),w.focus({preventScroll:!0}),Y();const i=e.button===2?"erase":z;if(p={id:e.pointerId,...t,lastX:t.x,lastY:t.y,mode:i},w.setPointerCapture(e.pointerId),i==="emitter"){const r=c.emitterAt(t.x,t.y,Math.max(8*c.units,14/b.scale));if(r)E=r.id,P(),et("emitter");else{const n=c.addEmitter(t.x,t.y,Et);n?(E=n.id,F(),A("Emitter placed. Tap it to edit; use Grab to move it.")):A(c.emitters.length>=24?"24 emitters is the limit. Remove one to add another.":"Place emitters in an open space")}}else if(i==="box"||i==="stone")c.addBody(i,t.x,t.y,Math.max(4*c.units,T*.65+3*c.units))||A(c.bodies.length>=48?"48 bodies is the limit. Erase one to make room.":"Place bodies in an open space");else if(i==="grab"){const r=c.emitterAt(t.x,t.y,Math.max(8*c.units,14/b.scale)),n=c.bodyAt(t.x,t.y);if(r)lt=r.id,E=r.id;else if(n){const s=t.x-n.x,l=t.y-n.y,a=Math.cos(n.angle),d=Math.sin(n.angle);c.grab={id:n.id,...t,localX:s*a+l*d,localY:-s*d+l*a},w.style.cursor="grabbing",C&&A("Press Play to move and throw the body")}}else St();U()});w.addEventListener("pointermove",e=>{const t=Gt(e);if(J=Wt(t)?t:null,!(!p||p.id!==e.pointerId))if(p.x=Math.max(-24,Math.min(c.width+24,t.x)),p.y=Math.max(-24,Math.min(c.height+24,t.y)),lt!==null){const i=c.emitters.find(r=>r.id===lt);i&&(i.x=Math.max(0,Math.min(c.width-1,t.x)),i.y=Math.max(0,Math.min(c.height-1,t.y)))}else c.grab?(c.grab.x=p.x,c.grab.y=p.y):St()});function P(){const e=p==null?void 0:p.id;p=null,c.grab=null,lt=null,e!==void 0&&w.hasPointerCapture(e)&&w.releasePointerCapture(e),w.style.cursor=z==="grab"?"grab":"crosshair"}w.addEventListener("pointerup",P);w.addEventListener("pointercancel",P);w.addEventListener("lostpointercapture",P);w.addEventListener("pointerleave",()=>{J=null});w.addEventListener("contextmenu",e=>e.preventDefault());window.addEventListener("blur",P);document.addEventListener("visibilitychange",()=>{P(),nt=0,H=0});function Yt(){const e=w.getBoundingClientRect(),t=Math.min(window.devicePixelRatio||1,2),i=e.width<240||e.height<200?6:16,r=e.height<200?8:48,n=e.height<200?8:46,s=Math.max(.01,Math.min((e.width-i*2)/c.width,(e.height-r-n)/c.height));b={width:e.width,height:e.height,dpr:t,scale:s,x:(e.width-c.width*s)/2,y:r+(Math.max(1,e.height-r-n)-c.height*s)/2},w.width=Math.max(1,Math.round(e.width*t)),w.height=Math.max(1,Math.round(e.height*t))}const ge=[[0,0,0],[211,175,108],[59,132,185],[156,130,65],[247,134,46],[164,189,197],[220,70,32],[140,103,65],[91,108,116],[121,130,143],[224,167,205],[164,142,219],[111,164,130]];function pe(e){var t;if(h.save(),h.translate(e.x,e.y),h.rotate(e.angle),h.lineWidth=.55,e.kind==="box"){const i=e.size;h.fillStyle=e.heat>.5?"#83522e":"#967044",h.fillRect(-i,-i,i*2,i*2),h.strokeStyle="#553e28";for(let r=1;r<4;r++){const n=-i+r*i/2;h.beginPath(),h.moveTo(-i,n),h.lineTo(i,n),h.stroke()}h.strokeStyle="#d3ad72",h.lineWidth=1.3,h.strokeRect(-i+.8,-i+.8,2*i-1.6,2*i-1.6),h.beginPath(),h.moveTo(-i+1.5,-i+1.5),h.lineTo(i-1.5,i-1.5),h.moveTo(i-1.5,-i+1.5),h.lineTo(-i+1.5,i-1.5),h.stroke(),h.fillStyle="#4d4030";for(const r of[-i+2,i-2])for(const n of[-i+2,i-2])h.fillRect(r-.4,n-.4,.8,.8)}else{h.beginPath(),e.local.forEach((i,r)=>r?h.lineTo(i.x,i.y):h.moveTo(i.x,i.y)),h.closePath(),h.fillStyle="#78888e",h.fill();for(let i=0;i<e.local.length;i++){const r=e.local[i],n=e.local[(i+1)%e.local.length];h.beginPath(),h.moveTo(-e.size*.15,-e.size*.2),h.lineTo(r.x,r.y),h.lineTo(n.x,n.y),h.closePath(),h.fillStyle=["#83969c","#64777e","#596b73","#778a91","#a0b1b4","#aebcbf","#95a6aa"][i],h.fill()}h.beginPath(),e.local.forEach((i,r)=>r?h.lineTo(i.x,i.y):h.moveTo(i.x,i.y)),h.closePath(),h.strokeStyle="#c2cece",h.lineWidth=.45,h.stroke()}((t=c.grab)==null?void 0:t.id)===e.id&&(h.strokeStyle=R.primary,h.lineWidth=.7,h.strokeRect(-e.size-2,-e.size-2,e.size*2+4,e.size*2+4)),h.restore()}function ye(){var e;for(const t of c.emitters){const i=Math.max(5*c.units,8/b.scale),r=t.direction==="up"?-Math.PI/2:t.direction==="down"?Math.PI/2:t.direction==="left"?Math.PI:0;h.save(),h.translate(t.x,t.y),h.rotate(r),h.fillStyle=R.bg,h.strokeStyle=((e=W.find(n=>n.value===t.material))==null?void 0:e.color)||R.primary,h.lineWidth=(t.id===E?2:1.3)/b.scale,h.globalAlpha=t.enabled?1:.45,h.beginPath(),h.arc(0,0,i,0,Math.PI*2),h.fill(),h.stroke(),h.beginPath(),h.moveTo(-i*.35,-i*.45),h.lineTo(i*.4,0),h.lineTo(-i*.35,i*.45),h.stroke(),h.restore()}}function U(){var i;h.setTransform(b.dpr,0,0,b.dpr,0,0),h.fillStyle=R.bg,h.fillRect(0,0,b.width,b.height),h.save(),h.translate(b.x,b.y),h.scale(b.scale,b.scale),h.fillStyle=R.fg,h.globalAlpha=.12;for(let r=0;r<=c.height;r+=10)for(let n=0;n<=c.width;n+=10)h.fillRect(n,r,.45,.45);h.globalAlpha=.16,h.strokeStyle=R.fg,h.lineWidth=.4,h.strokeRect(0,0,c.width,c.height),h.globalAlpha=1;const e=pt.data;let t=0;for(let r=0;r<c.cells.length;r++){const n=c.cells[r],s=r*4;if(!n||c.occupancy[r]){e[s+3]=0;continue}t++;const l=ge[n],a=r%c.width,d=Math.floor(r/c.width),m=(r*113+(r>>>5)*73)%29-14,f=n===o.water?m*.25:n===o.wall?m*.4:m;e[s]=l[0]+f,e[s+1]=l[1]+f,e[s+2]=l[2]+f,e[s+3]=xt.includes(n)?n===o.smoke||n===o.steam?Math.min(n===o.smoke?170:160,c.life[r]):190:255,n===o.water&&c.get(a,d-1)!==o.water&&(e[s]+=40,e[s+1]+=45,e[s+2]+=35),n===o.fire&&(e[s+1]=70+c.life[r]*1.2,e[s+2]=c.life[r]/2),n===o.wall&&c.get(a,d-1)!==o.wall&&(e[s]+=38,e[s+1]+=38,e[s+2]+=38)}ce.putImageData(pt,0,0),h.imageSmoothingEnabled=!1,h.drawImage(ot,0,0);for(const r of c.bodies)pe(r);if(ye(),c.grab){const r=c.bodies.find(n=>n.id===c.grab.id);r&&(h.strokeStyle=R.primary,h.lineWidth=.7,h.setLineDash([2,2]),h.beginPath(),h.moveTo(r.x,r.y),h.lineTo(c.grab.x,c.grab.y),h.stroke(),h.setLineDash([]))}J&&!p&&!matchMedia("(pointer: coarse)").matches&&(h.strokeStyle=((i=W.find(r=>r.id===z))==null?void 0:i.color)||R.fg,h.globalAlpha=.7,h.lineWidth=1/b.scale,h.beginPath(),h.arc(J.x,J.y,["box","stone"].includes(z)?T*.65+3*c.units:z==="emitter"?7*c.units:z==="grab"?3:T/2,0,Math.PI*2),h.stroke(),h.globalAlpha=1),h.restore(),(zt%10===0||C)&&(u("particles").textContent=t.toLocaleString(),u("body-count").textContent=String(c.bodies.length),u("fps").textContent=String(Math.round(wt)),w.dataset.tick=String(c.tick),w.dataset.particles=String(t),w.dataset.bodies=String(c.bodies.length),w.dataset.emitters=String(c.emitters.length),w.dataset.width=String(c.width),w.dataset.height=String(c.height))}function Bt(e){var n;const t=document.documentElement.style;t.setProperty("--bg",e.colors.background),t.setProperty("--fg",e.colors.text),t.setProperty("--primary",e.colors.primary);const i=e.colors.background.replace("#",""),r=((n=(i.length===3?i.split("").map(s=>s+s).join(""):i).match(/.{2}/g))==null?void 0:n.map(s=>parseInt(s,16)))??[20,20,20];R={bg:e.colors.background,fg:e.colors.text,primary:e.colors.primary,light:r[0]*.299+r[1]*.587+r[2]*.114>145},t.colorScheme=R.light?"light":"dark"}if(vt("theme"))try{Kt().then(Bt).catch(()=>{}),rt=te(Bt)}catch{}tt();const Xt=new ResizeObserver(()=>{P(),Yt(),U()});Xt.observe(w);const[we,ve]=Ft();_(bt("playground",we,ve));matchMedia("(prefers-reduced-motion: reduce)").matches&&kt(!0);let It=0;function jt(e){const t=nt?Math.min(100,e-nt):16.67;if(nt=e,wt=wt*.95+1e3/Math.max(1,t)*.05,!document.hidden){if(p&&St(),!C){H+=t;let i=0;for(;H>=1e3/60&&i<3;)c.step(),H-=1e3/60,i++;i===3&&(H=0)}zt++,U()}It=requestAnimationFrame(jt)}It=requestAnimationFrame(jt);window.addEventListener("pagehide",()=>{P(),rt==null||rt.close(),Xt.disconnect(),cancelAnimationFrame(It),window.clearTimeout(yt)},{once:!0});
</script>
    <style>:root{--bg: #15191d;--fg: #e6e9e8;--primary: #e8bc72;--surface: color-mix(in srgb, var(--bg) 95%, var(--fg));--raised: color-mix(in srgb, var(--bg) 89%, var(--fg));--line: color-mix(in srgb, var(--fg) 12%, transparent);--muted: color-mix(in srgb, var(--fg) 58%, var(--bg));--selected: color-mix(in srgb, var(--primary) 12%, var(--bg));--control-h: 28px;--napplet-text-selection: none;font:13px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color-scheme:dark}*{box-sizing:border-box}html,body,#app{height:100%;margin:0;background:var(--bg);color:var(--fg)}body{overflow:hidden;-webkit-user-select:var(--napplet-text-selection);user-select:var(--napplet-text-selection)}button,select,input{font:inherit}button,select{color:var(--fg);border:1px solid var(--line);background:transparent;border-radius:5px;min-height:var(--control-h)}button{display:inline-flex;gap:6px;align-items:center;justify-content:center;padding:3px 8px;cursor:pointer}button:hover:not(:disabled),select:hover{background:var(--raised);border-color:var(--muted)}button:active:not(:disabled){transform:translateY(1px)}button:disabled{opacity:.35;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,canvas:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}select{padding:3px 24px 3px 8px;max-width:100%;background:var(--surface)}option{background:var(--bg);color:var(--fg)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}#app{display:flex;flex-direction:column;container-type:inline-size}.toolbar{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid var(--line);background:var(--surface);flex-shrink:0;z-index:3}.primary{color:var(--primary);background:var(--selected);border-color:color-mix(in srgb,var(--primary) 40%,transparent)}.icon-button{width:var(--control-h);padding:4px;flex-shrink:0}.divider{width:1px;align-self:stretch;margin:3px 2px;background:var(--line)}.toolbar-spacer{flex:1}.live-state{display:flex;gap:6px;align-items:center;font:10px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:1px;margin-right:8px;color:var(--muted)}.live-state i{width:5px;height:5px;border-radius:50%;background:#8ec59c}.live-state.is-paused i{background:var(--primary)}.workbench{display:flex;flex:1;min-height:0;position:relative}#palette{width:clamp(172px,20vw,208px);flex-shrink:0;overflow-y:auto;padding:12px;border-right:1px solid var(--line);background:var(--surface);scrollbar-width:thin;scrollbar-color:var(--line) transparent}.section-label{display:flex;justify-content:space-between;color:var(--muted);font:9px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:1.3px;margin:4px 0 8px}.section-label span{opacity:.5;letter-spacing:.2px}.material-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:18px}.material{justify-content:flex-start;padding:8px;gap:8px;min-height:36px;font-size:12px;border-color:transparent;background:color-mix(in srgb,var(--fg) 3%,transparent)}.material[aria-pressed=true],.tool-grid button[aria-pressed=true],.canvas-tools button[aria-pressed=true]{color:var(--primary);background:var(--selected);border-color:color-mix(in srgb,var(--primary) 50%,transparent)}.swatch{width:13px;height:13px;background:var(--swatch);flex-shrink:0;border-radius:2px;box-shadow:inset 0 0 0 1px #ffffff18;position:relative}.swatch.sand{background:repeating-conic-gradient(#d9b36e,#d9b36e 25%,#b88b49 0%,#b88b49 50%) 50% / 5px 5px}.swatch.water{border-radius:7px 7px 6px 2px;transform:rotate(-35deg)}.swatch.fire,.swatch.lava{border-radius:3px 8px 4px}.swatch.box{background:#b99058;border:2px solid #d4b17c}.swatch.box:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(45deg,transparent 40%,#e0bb8388 42% 58%,transparent 60%)}.swatch.stone{clip-path:polygon(25% 0,80% 10%,100% 60%,70% 100%,10% 80%,0 35%)}.tool-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}.tool-grid button{min-width:0;font-size:12px}.brush-control{margin-top:18px}.gravity-control{margin-top:14px}.brush-control label,.gravity-control label{display:flex;justify-content:space-between;align-items:center;font-size:11px}output{color:var(--muted);font:10px ui-monospace,SFMono-Regular,Consolas,monospace}input[type=range]{width:100%;margin:8px 0 0;accent-color:var(--primary);cursor:pointer;height:15px}.range-labels{display:flex;justify-content:space-between;color:var(--muted);font-size:9px}.edit-controls{display:flex;gap:6px;margin-top:18px}.edit-controls button{flex:1;font-size:11px}.material-detail{margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}.material-detail>span{color:var(--muted);font:9px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:1px}.material-detail strong{display:block;font-size:16px;font-weight:500;margin:6px 0}.material-detail p{color:var(--muted);font-size:11px;line-height:1.65;margin:0}.viewport{flex:1;min-width:0;min-height:0;position:relative;overflow:hidden;background:var(--bg)}canvas{display:block;width:100%;height:100%;touch-action:none;cursor:crosshair}.scene-badge{position:absolute;left:16px;top:14px;pointer-events:none;display:flex;gap:12px;align-items:center;color:var(--muted);font-size:11px}.scene-mode{padding-left:12px;border-left:1px solid var(--line);font:9px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:1px;opacity:.7}.canvas-hint{position:absolute;top:43px;left:16px;color:var(--muted);font-size:10px;pointer-events:none;opacity:.75}.canvas-hint span{margin:0 4px}.telemetry{position:absolute;bottom:12px;right:14px;display:flex;gap:16px;pointer-events:none;color:var(--muted);font:9px ui-monospace,SFMono-Regular,Consolas,monospace}.telemetry b{font-weight:400;color:var(--fg)}.canvas-tools{display:flex;gap:5px;position:absolute;bottom:10px;left:12px}.canvas-tools button{background:var(--surface);width:30px;height:30px;padding:5px}#paused-badge{position:absolute;top:12px;right:12px;color:var(--primary);background:var(--surface);border:1px solid var(--line);padding:4px 8px;border-radius:4px;font:10px ui-monospace,SFMono-Regular,Consolas,monospace;pointer-events:none}#toast{position:absolute;left:50%;bottom:52px;transform:translate(-50%);max-width:calc(100% - 24px);padding:8px 12px;background:var(--surface);color:var(--fg);border:1px solid var(--line);border-radius:5px;font-size:12px;text-align:center;pointer-events:none}#palette-toggle,.drawer-actions{display:none}@media(min-width:900px){.material{min-height:40px}.material-grid{gap:6px}#palette{padding:16px 12px}}@media(max-width:599px){#palette-toggle{display:inline-flex}#palette{position:absolute;top:0;left:0;bottom:0;z-index:5;width:min(252px,100%);display:none;border-bottom:1px solid var(--line)}#app.palette-open #palette{display:block}.toolbar{gap:4px;padding:6px}.button-label,.live-state,.secondary-control,.divider{display:none}.scene-field{flex:1;min-width:0}.scene-field select{width:100%}.toolbar-spacer,.scene-mode,.canvas-hint{display:none}.scene-badge{left:12px;top:12px}.telemetry{gap:8px;bottom:19px}.fps{display:none}.drawer-actions{display:flex;flex-wrap:wrap;gap:5px;margin-top:10px}.drawer-actions button{flex:1 0 auto;font-size:11px}}@media(max-width:239px){.toolbar{padding:4px;gap:3px}select{padding-left:4px;font-size:11px}.scene-badge,.telemetry,.canvas-tools,#paused-badge{display:none!important}#palette{width:100%;padding:8px}.material-grid{margin-bottom:10px}}@media(max-height:380px){.canvas-hint,.scene-mode{display:none}.scene-badge{top:8px}.toolbar{padding-top:4px;padding-bottom:4px}.material-detail{display:none}}@media(pointer:coarse){:root{--control-h: 44px !important}button,select{min-height:44px}button,.icon-button{min-width:44px}.material{min-height:44px}input[type=range]{height:36px;margin-top:0}.canvas-tools button{width:44px;height:44px}.telemetry{bottom:25px}.canvas-tools{bottom:max(8px,env(safe-area-inset-bottom))}#palette{padding-bottom:max(12px,env(safe-area-inset-bottom))}}@media(prefers-reduced-motion:reduce){button:active:not(:disabled){transform:none}}.tool-grid{grid-template-columns:repeat(3,minmax(0,1fr));margin-bottom:14px}.tool-grid button{padding-inline:3px;font-size:11px}.material-grid{margin-bottom:12px}.material{padding:6px;gap:6px;font-size:11px}#emitter-editor{border-block:1px solid var(--line);padding:10px 0 12px;margin-bottom:12px}#emitter-editor[hidden]{display:none}#emitter-editor>label,.resolution-control{display:flex;justify-content:space-between;gap:4px;font-size:11px;margin-top:8px}#emitter-editor>select,.resolution-control select{width:100%;font-size:11px;min-width:0}#emitter-editor .emitter-enabled{justify-content:flex-start;align-items:center;min-height:var(--control-h)}.emitter-enabled input{accent-color:var(--primary)}.emitter-tip{font-size:10px;color:var(--muted);margin:10px 0}#emitter-place{width:100%}.resolution-control{flex-direction:column;margin-top:14px}.swatch.smoke,.swatch.steam,.swatch.helium,.swatch.hydrogen,.swatch.co2{border-radius:50%;opacity:.8;box-shadow:3px -2px 0 -2px var(--swatch),-3px 3px 0 -2px var(--swatch)}@media(max-width:400px){.telemetry>span:nth-child(2){display:none}}@media(pointer:coarse){.emitter-enabled{min-height:44px}}
</style>
    <meta name="napplet-type" content="powder-tool">
    <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;:{&quot;textSize&quot;:{&quot;type&quot;:&quot;integer&quot;,&quot;title&quot;:&quot;Text size&quot;,&quot;description&quot;:&quot;Base text size in pixels. Changes apply while the napplet is running.&quot;,&quot;minimum&quot;:10,&quot;maximum&quot;:20,&quot;default&quot;:13,&quot;x-napplet-section&quot;:&quot;Appearance&quot;,&quot;x-napplet-order&quot;:1},&quot;controlSize&quot;:{&quot;type&quot;:&quot;integer&quot;,&quot;title&quot;:&quot;Control height&quot;,&quot;description&quot;:&quot;Choose compact controls or larger touch targets, in pixels.&quot;,&quot;minimum&quot;:24,&quot;maximum&quot;:44,&quot;default&quot;:28,&quot;x-napplet-section&quot;:&quot;Appearance&quot;,&quot;x-napplet-order&quot;:2},&quot;selectText&quot;:{&quot;type&quot;:&quot;boolean&quot;,&quot;title&quot;:&quot;Allow text selection&quot;,&quot;description&quot;:&quot;Allow selecting interface text. Text inputs always remain selectable.&quot;,&quot;default&quot;:false,&quot;x-napplet-section&quot;:&quot;Interaction&quot;}}}">
  </head>
  <body>
    <main id="app">
      <div class="toolbar" aria-label="Simulation controls">
        <button id="palette-toggle" class="icon-button" title="Materials and tools" aria-label="Materials and tools" aria-expanded="false" aria-controls="palette"><svg viewBox="0 0 20 20"><path d="M3 4h14M3 10h14M3 16h14"/></svg></button>
        <button id="pause" class="primary" title="Pause simulation" aria-label="Pause simulation"><svg viewBox="0 0 20 20"><path d="M7 5v10M13 5v10"/></svg><span class="button-label">Pause</span></button>
        <button id="step" class="icon-button" title="Advance one frame" aria-label="Advance one frame"><svg viewBox="0 0 20 20"><path d="m5 4 8 6-8 6ZM15 4v12"/></svg></button>
        <span class="divider"></span>
        <label class="scene-field"><span class="sr-only">Example scene</span><select id="scene"><option value="playground">Playground</option><option value="waterworks">Waterworks</option><option value="furnace">Furnace</option><option value="gaslab">Gas lab</option><option value="empty">Empty canvas</option></select></label>
        <button id="reset" class="icon-button secondary-control" title="Restart scene" aria-label="Restart scene"><svg viewBox="0 0 20 20"><path d="M4 7a6 6 0 1 1-1 5M4 3v5h5"/></svg></button>
        <span class="toolbar-spacer"></span>
        <span id="live-state" class="live-state"><i></i>LIVE</span>
        <button id="save" class="secondary-control" title="Save this scene"><svg viewBox="0 0 20 20"><path d="M4 3h10l3 3v11H3V3ZM6 3v5h7V3M6 17v-6h8v6"/></svg><span class="button-label">Save</span></button>
        <button id="load" class="icon-button secondary-control" title="Load saved scene" aria-label="Load saved scene"><svg viewBox="0 0 20 20"><path d="M3 16V5h5l2 2h7v9ZM3 10h14"/></svg></button>
      </div>
      <div class="workbench">
        <aside id="palette" aria-label="Materials and tools">
          <div class="section-label">TOOLS</div>
          <div id="tools" class="tool-grid" role="group" aria-label="Drawing tools"></div>
          <section id="emitter-editor" hidden aria-label="Emitter configuration">
            <div class="section-label">EMITTER <span id="emitter-id">NEW</span></div>
            <label for="emitter-list">Source</label><select id="emitter-list" aria-label="Select emitter"><option value="new">New emitter</option></select>
            <label for="emitter-material">Material</label><select id="emitter-material" aria-label="Emitter material"></select>
            <label for="emitter-rate">Flow rate <output id="emitter-rate-value">240 /s</output></label>
            <input id="emitter-rate" type="range" min="30" max="2400" step="30" value="240" aria-label="Emitter flow rate" />
            <label for="emitter-radius">Outlet width <output id="emitter-radius-value">12 px</output></label>
            <input id="emitter-radius" type="range" min="1" max="24" value="6" aria-label="Emitter outlet radius" />
            <label for="emitter-direction">Outlet direction</label><select id="emitter-direction"><option value="down">↓ Down</option><option value="up">↑ Up</option><option value="left">← Left</option><option value="right">→ Right</option></select>
            <label class="emitter-enabled"><input id="emitter-enabled" type="checkbox" checked /> Enabled</label>
            <div class="edit-controls"><button id="emitter-new">New</button><button id="emitter-remove" disabled>Remove</button></div>
            <p class="emitter-tip" id="emitter-tip">Configure a source, then tap the stage to place it.</p>
            <button id="emitter-place">Place on stage</button>
          </section>
          <div class="section-label">MATERIALS <span>7</span></div>
          <div id="materials" class="material-grid" role="group" aria-label="Choose a material"></div>
          <div class="section-label">GASES <span>5</span></div>
          <div id="gases" class="material-grid" role="group" aria-label="Choose a gas"></div>
          <div class="section-label">RIGID BODIES <span>2</span></div>
          <div id="bodies" class="material-grid" role="group" aria-label="Choose a rigid body"></div>
          <div class="brush-control">
            <label for="brush">Brush size <output id="brush-value">16 px</output></label>
            <input id="brush" type="range" min="1" max="48" value="16" aria-label="Brush size" />
            <div class="range-labels"><span>Fine</span><span>Wide</span></div>
          </div>
          <div class="gravity-control">
            <label for="gravity">Gravity <output id="gravity-value">1.0×</output></label>
            <input id="gravity" type="range" min="0" max="20" value="10" aria-label="Gravity" />
          </div>
          <label class="resolution-control" for="resolution">Stage detail <select id="resolution"><option value="300">Standard · 300 wide</option><option value="450">Fine · 450 wide</option><option value="600" selected>Ultra · 600 wide</option></select></label>
          <div class="edit-controls"><button id="undo" title="Undo the last edit">↶ Undo</button><button id="clear" title="Clear the canvas">Clear</button></div>
          <div class="drawer-actions"><button id="drawer-save">Save scene</button><button id="drawer-load">Load scene</button><button id="drawer-reset">Restart scene</button></div>
          <div class="material-detail"><span id="detail-kind">GRANULAR</span><strong id="detail-name">Sand</strong><p id="detail-text">Falls, piles up, and sinks through water.</p></div>
        </aside>
        <section class="viewport" aria-label="Physics workspace">
          <canvas id="canvas" tabindex="0" aria-label="Physics sandbox. Paint with the selected material, or select Grab to move rigid bodies."></canvas>
          <div class="scene-badge"><span id="scene-name">Playground</span><span id="stage-size" class="scene-mode">600 × 400</span></div>
          <div id="paused-badge" hidden>Ⅱ PAUSED</div>
          <div id="hint" class="canvas-hint">Drag to pour sand <span>·</span> Try dropping a stone into the water</div>
          <div id="toast" role="status" aria-live="polite" hidden></div>
          <div class="canvas-tools"><button id="quick-emitter" title="Configure and place an emitter" aria-label="Configure and place an emitter"><svg viewBox="0 0 20 20"><path d="M4 7h12v8H4ZM7 4v-2M10 4v-3M13 4v-2M7 18h6"/></svg></button><button id="quick-grab" title="Grab and throw rigid bodies" aria-label="Grab and throw rigid bodies"><svg viewBox="0 0 20 20"><path d="M6 10V6a1.5 1.5 0 0 1 3 0v3-5a1.5 1.5 0 0 1 3 0v5-3a1.5 1.5 0 0 1 3 0v4-2a1.5 1.5 0 0 1 3 0v5c0 4-3 5-6 5-2 0-3-1-5-4l-3-3a1.5 1.5 0 0 1 2-2l2 2"/></svg></button><button id="quick-erase" title="Erase material or rigid bodies" aria-label="Erase material or rigid bodies"><svg viewBox="0 0 20 20"><path d="m3 11 8-8 6 6-8 8H6ZM7 7l6 6M9 17h8"/></svg></button></div>
          <div class="telemetry" aria-label="Simulation statistics"><span><b id="particles">0</b> particles</span><span><b id="body-count">0</b> bodies</span><span class="fps"><b id="fps">60</b> fps</span></div>
        </section>
      </div>
    </main>
  </body>
</html>