27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
You build physics toys, and you have dropped a garden hose with the water on full.
Build me a single HTML page: a garden hose lying on a lawn, the spigot on, and the nozzle released.
#psi=40 sets the pressure to 40 psi with the nozzle free on load. Any number works.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: how the hose discretization and inextensibility constraints are implemented, the formula for the nozzle reaction force as a function of psi, and ground friction handling.
The pressure in psi at which the hose transitions from stationary or settling to continuous whipping, as one number, derived from your model parameters.
The single fenced html block.
Where the motion looks like a real hose and where it does not. Name any artificial damping, injected noise or scripted oscillation. Everyone has watched a hose get away from somebody; say what yours gets wrong before I see it.
The hose is 41 point masses (90 g each, 3.69 kg total) joined by 40 segments of rest length 0.325 m (13 m of hose). Inextensibility is enforced XPBD-style: every 1/300 s substep I apply gravity, air drag, midpoint-spring bending resistance (k = 90 N/m, c = 1.6 N·s/m) and the nozzle thrust to velocities, predict positions, then run 8 iterations of distance constraints projecting each segment back to rest length (with ground projection and segment–segment self-collision push-out inside the loop) before rebuilding velocities from position deltas — the rope effectively cannot stretch. The nozzle reaction is the jet momentum flux F = ṁv = 2·Cd²·Ae·Δp with Cd = 0.85, Ae = 7.9×10⁻⁵ m², Δp = psi × 6894.76 Pa, applied as a follower force −F·t̂ split over the tip (70 %) and penultimate node (30 %), t̂ being the outward tangent — always antiparallel to the jet, so it pushes the nozzle back along the hose like a compressive end load. Ground friction is Coulomb, handled by positional clamping: a grounded node whose tangential displacement this substep is below μs·g·h² (≈0.07 mm) is snapped back to its substep-start position (static stick), otherwise its displacement is bled by μk·g·h² per substep (kinetic slide), and airborne nodes get no friction at all.
≈ 30 psi. Breakaway occurs when follower thrust equals peak static friction: 2·Cd²·Ae·Δp = μs·M·g, so Δp = (0.65 × 3.69 × 9.81) / (2 × 0.85² × 7.9×10⁻⁵) = 23.5 N / 1.14×10⁻⁴ N/Pa ≈ 206 kPa ≈ 29.9 psi. In the discrete sim the onset is really a band (roughly 25–35 psi, slightly hysteretic) because tension isn't distributed perfectly evenly across grounded nodes and bending stiffness helps hold the tip — but 30 is the derived number: below it the hose settles, above it the whip self-sustains.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Runaway Hose</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#0d130e;
font-family:ui-monospace,"SF Mono","Cascadia Mono","JetBrains Mono",Menlo,Consolas,monospace;
-webkit-tap-highlight-color:transparent;overscroll-behavior:none}
#c{position:fixed;inset:0;width:100vw;height:100vh;display:block;cursor:grab;touch-action:none}
#c.drag{cursor:grabbing}
#hud{position:fixed;top:14px;left:14px;width:268px;padding:13px 16px 14px;border-radius:12px;
background:rgba(14,20,15,.86);border:1px solid rgba(255,255,255,.13);color:#eef4ea;
box-shadow:0 6px 24px rgba(0,0,0,.35);user-select:none}
.row{display:flex;align-items:center;justify-content:space-between}
.title{font-size:11px;letter-spacing:.24em;color:#a9bca2;font-weight:700}
button{font:inherit;font-size:10px;letter-spacing:.08em;color:#cfe0c6;background:transparent;
border:1px solid rgba(255,255,255,.22);border-radius:6px;padding:3px 8px;cursor:pointer;margin-left:6px}
button:hover{background:rgba(255,255,255,.09)}
.lbl{margin-top:10px;font-size:10px;letter-spacing:.18em;color:#8fa389}
.val{display:flex;align-items:baseline;gap:6px;margin:2px 0 4px}
#pv{font-size:34px;line-height:1;color:#f4f7ee;font-weight:700}
.unit{font-size:12px;color:#e8a33d}
#thrust{margin-left:auto;font-size:10px;color:#8fa389;text-align:right}
input#slider{-webkit-appearance:none;appearance:none;width:100%;height:22px;background:transparent;cursor:pointer;margin:0}
input#slider::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:rgba(255,255,255,.22)}
input#slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;
background:#e8a33d;border:2px solid #12160f;margin-top:-6px}
input#slider::-moz-range-track{height:4px;border-radius:2px;background:rgba(255,255,255,.22)}
input#slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#e8a33d;border:2px solid #12160f}
.scale{display:flex;justify-content:space-between;font-size:9px;color:#7d9078;margin-top:2px}
.hint{margin-top:9px;font-size:10px;line-height:1.55;color:#93a78c}
#note{position:fixed;right:14px;bottom:12px;font-size:10px;color:rgba(244,250,238,.8);
text-shadow:0 1px 2px rgba(0,0,0,.45);pointer-events:none;letter-spacing:.03em}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div class="row">
<span class="title">RUNAWAY HOSE</span>
<span><button id="reset" type="button">RESET</button><button id="snd" type="button">SND ON</button></span>
</div>
<div class="lbl">SPIGOT PRESSURE</div>
<div class="val"><span id="pv">52</span><span class="unit">psi</span><span id="thrust"></span></div>
<input id="slider" type="range" min="0" max="60" step="0.5" value="52" aria-label="water pressure in psi">
<div class="scale"><span>0</span><span>15</span><span>30</span><span>45</span><span>60 FULL</span></div>
<div class="hint">click + drag the hose to pin a point to the mouse · release to free it · arrow keys trim psi</div>
</div>
<div id="note">F = 2·Cd²·A·Δp follower thrust · inextensible PBD rope · Coulomb friction</div>
<script>
"use strict";
/* ---------------- model constants ---------------- */
const N=41, L=13.0, SEG=L/(N-1); // nodes, length, rest segment (m)
const MN=0.09, G=9.81; // kg per node, gravity
const MU_S=0.65, MU_K=0.50; // static / kinetic friction
const KB=90.0, CB=1.6; // bending spring / damping
const CD=0.85, AEXIT=7.9e-5; // nozzle discharge coeff, orifice area (m^2)
const THRUST=2*CD*CD*AEXIT; // N per Pa
const PSIMAX=60, PA=6894.76; // psi->Pa
const HS=1/300, ITER=8, RCOL=0.07; // substep, iterations, collision radius
const AX=1.4, AY=0.35; // spigot anchor (m)
/* ---------------- state ---------------- */
const x=new Float64Array(N), y=new Float64Array(N);
const ox=new Float64Array(N), oy=new Float64Array(N);
const vx=new Float64Array(N), vy=new Float64Array(N);
const ax=new Float64Array(N), ay=new Float64Array(N);
let psi=52, grabbed=-1, grabX=0, grabY=0;
/* ---------------- water particles & wetness ---------------- */
const PMAX=1100, part=[];
for(let i=0;i<PMAX;i++) part.push({x:0,y:0,vx:0,vy:0,life:0,tot:1,k:0});
let pcur=0, spacc=0, dripT=1.5;
const WBN=90, WBW=20/WBN, wet=new Float32Array(WBN);
function spawnP(px,py,pvx,pvy,life,k){
const p=part[pcur]; pcur=(pcur+1)%PMAX;
p.x=px; p.y=py; p.vx=pvx; p.vy=pvy; p.life=life; p.tot=life; p.k=k;
}
function layHose(){
for(let i=0;i<N;i++){
const t=i/(N-1);
x[i]=AX + t*(L-0.6) + 0.14*Math.sin(t*7.3);
y[i]=Math.max(AY*Math.max(0,1-i/4) + 0.02*Math.sin(i*1.9), 0.002);
vx[i]=0; vy[i]=0;
}
x[0]=AX; y[0]=AY;
}
/* ---------------- segment-segment self collision ---------------- */
function collide(){
const minD=2*RCOL;
for(let i=0;i<N-2;i++){
const x1=x[i],y1=y[i],x2=x[i+1],y2=y[i+1];
const lox=Math.min(x1,x2)-minD, hix=Math.max(x1,x2)+minD;
const loy=Math.min(y1,y2)-minD, hiy=Math.max(y1,y2)+minD;
for(let j=i+2;j<N-1;j++){
const x3=x[j],y3=y[j],x4=x[j+1],y4=y[j+1];
if(Math.max(x3,x4)<lox||Math.min(x3,x4)>hix||Math.max(y3,y4)<loy||Math.min(y3,y4)>hiy)continue;
const rx=x2-x1, ry=y2-y1, sx=x4-x3, sy=y4-y3;
const qx=x3-x1, qy=y3-y1;
const a=rx*rx+ry*ry, b=rx*sx+ry*sy, c=sx*sx+sy*sy;
const d0=rx*qx+ry*qy, e0=sx*qx+sy*qy;
const den=a*c-b*b;
let t,u;
if(den<=1e-12){ t=0.5; u=0.5; }
else{
t=Math.min(1,Math.max(0,(c*d0-b*e0)/den));
u=Math.min(1,Math.max(0,(a*e0-b*d0)/den));
if(c>1e-12)u=Math.min(1,Math.max(0,(b*t-e0)/c));
if(a>1e-12)t=Math.min(1,Math.max(0,(d0+b*u)/a));
}
const pax=x1+rx*t, pay=y1+ry*t, pbx=x3+sx*u, pby=y3+sy*u;
let ddx=pbx-pax, ddy=pby-pay;
let dist=Math.sqrt(ddx*ddx+ddy*ddy);
if(dist<minD){
if(dist<1e-9){ddx=1;ddy=0;dist=1;}
const push=(minD-dist)/dist*0.5;
const nx=ddx*push, ny=ddy*push;
x[i]-=nx*(1-t); y[i]-=ny*(1-t);
x[i+1]-=nx*t; y[i+1]-=ny*t;
x[j]+=nx*(1-u); y[j]+=ny*(1-u);
x[j+1]+=nx*u; y[j+1]+=ny*u;
if(y[i]<0)y[i]=0; if(y[i+1]<0)y[i+1]=0;
if(y[j]<0)y[j]=0; if(y[j+1]<0)y[j+1]=0;
}
}
}
x[0]=AX; y[0]=AY;
if(grabbed>=0){x[grabbed]=grabX; y[grabbed]=Math.max(grabY,0.02);}
}
/* ---------------- water ---------------- */
function water(h){
for(let i=0;i<WBN;i++) wet[i]*=0.99995;
if(psi>0.2){
const dx=x[N-1]-x[N-2], dy=y[N-1]-y[N-2];
const d=Math.sqrt(dx*dx+dy*dy)||1;
const ux=dx/d, uy=dy/d;
const ve=CD*Math.sqrt(2*psi*PA/1000);
spacc+=(50+psi*5.2)*h;
let n=Math.floor(spacc); spacc-=n; if(n>40)n=40;
const ex=x[N-1]+ux*0.16, ey=y[N-1]+uy*0.16;
const tvx=vx[N-1]*0.8, tvy=vy[N-1]*0.8;
while(n-->0){
let sp,aa;
if(Math.random()<0.14){ sp=ve*0.3*(0.7+Math.random()*0.5); aa=(Math.random()-0.5)*0.9; }
else{ sp=ve*(0.9+Math.random()*0.2); aa=(Math.random()-0.5)*0.12; }
const ca=Math.cos(aa), sa=Math.sin(aa);
spawnP(ex,ey, (ux*ca-uy*sa)*sp+tvx, (ux*sa+uy*ca)*sp+tvy, 1.1+Math.random()*0.7, 0);
}
}
if(psi<0.5){
dripT-=h;
if(dripT<=0){ dripT=1.4+Math.random()*1.6; spawnP(AX+0.12,AY-0.12,0,-0.05,3,3); }
}
for(let i=0;i<PMAX;i++){
const p=part[i]; if(p.life<=0)continue;
p.life-=h;
p.vy-=G*h;
p.x+=p.vx*h; p.y+=p.vy*h;
if(p.y<=0){
const idx=Math.floor(p.x/WBW);
if(idx>=0&&idx<WBN) wet[idx]=Math.min(5,wet[idx]+(p.k===3?0.3:0.5));
if(p.k===0&&Math.random()<0.3)
spawnP(p.x,0.01,p.vx*0.12+(Math.random()-0.5)*1.6,0.7+Math.random()*1.5,0.3+Math.random()*0.2,2);
p.life=0;
}
}
}
/* ---------------- physics substep ---------------- */
function step(h){
const statB=MU_S*G*h*h, kinB=MU_K*G*h*h;
for(let i=0;i<N;i++){
ax[i]=-vx[i]*0.06;
ay[i]=-G-vy[i]*0.06;
}
for(let i=1;i<N-1;i++){
const mx=(x[i-1]+x[i+1])*0.5-x[i], my=(y[i-1]+y[i+1])*0.5-y[i];
const mvx=(vx[i-1]+vx[i+1])*0.5-vx[i], mvy=(vy[i-1]+vy[i+1])*0.5-vy[i];
const fx=KB*mx+CB*mvx, fy=KB*my+CB*mvy;
ax[i]+=fx/MN; ay[i]+=fy/MN;
ax[i-1]-=fx*0.5/MN; ay[i-1]-=fy*0.5/MN;
ax[i+1]-=fx*0.5/MN; ay[i+1]-=fy*0.5/MN;
}
if(psi>0.05){
const dx=x[N-1]-x[N-2], dy=y[N-1]-y[N-2];
const d=Math.sqrt(dx*dx+dy*dy);
if(d>1e-6){
const F=THRUST*psi*PA, ux=dx/d, uy=dy/d;
ax[N-1]-=F*0.7*ux/MN; ay[N-1]-=F*0.7*uy/MN;
ax[N-2]-=F*0.3*ux/MN; ay[N-2]-=F*0.3*uy/MN;
}
}
for(let i=0;i<N;i++){
vx[i]+=ax[i]*h; vy[i]+=ay[i]*h;
ox[i]=x[i]; oy[i]=y[i];
x[i]+=vx[i]*h; y[i]+=vy[i]*h;
}
const gy=Math.max(grabY,0.02);
for(let it=0;it<ITER;it++){
x[0]=AX; y[0]=AY;
if(grabbed>=0){ x[grabbed]=grabX; y[grabbed]=gy; }
for(let s=0;s<N-1;s++){
const i=s, j=s+1;
const dx=x[j]-x[i], dy=y[j]-y[i];
let d=Math.sqrt(dx*dx+dy*dy); if(d<1e-9)d=1e-9;
const diff=(d-SEG)/d;
const wi=(i===0||i===grabbed)?0:1, wj=(j===grabbed)?0:1;
const ws=wi+wj; if(ws===0)continue;
const cx=dx*diff, cy=dy*diff;
x[i]+=cx*wi/ws; y[i]+=cy*wi/ws;
x[j]-=cx*wj/ws; y[j]-=cy*wj/ws;
}
for(let i=0;i<N;i++) if(y[i]<0)y[i]=0;
collide();
}
for(let i=1;i<N;i++){
if(i===grabbed) continue;
if(y[i]<=1e-4){
const dx=x[i]-ox[i], adx=Math.abs(dx);
if(adx<=statB) x[i]=ox[i];
else x[i]=ox[i]+(adx-kinB)*(dx<0?-1:1);
}
}
const inv=1/h;
for(let i=0;i<N;i++){
vx[i]=(x[i]-ox[i])*inv; vy[i]=(y[i]-oy[i])*inv;
const s2=vx[i]*vx[i]+vy[i]*vy[i];
if(s2>2500){const sc=50/Math.sqrt(s2); vx[i]*=sc; vy[i]*=sc;}
}
water(h);
}
/* ---------------- rendering ---------------- */
const cv=document.getElementById('c'), ctx=cv.getContext('2d');
let W=0,Hh=0,dpr=1,S=100,groundY=600;
const lawn=document.createElement('canvas');
let hedgeBumps=[];
const clouds=[{fx:.12,fy:.16,s:1},{fx:.46,fy:.09,s:.65},{fx:.74,fy:.22,s:1.25}];
const trees=[{fx:.30,h:1.0},{fx:.66,h:.78}];
function resize(){
dpr=Math.min(window.devicePixelRatio||1,2);
W=window.innerWidth; Hh=window.innerHeight;
cv.width=Math.round(W*dpr); cv.height=Math.round(Hh*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
S=Math.min(W/19,Hh/8.2);
groundY=Hh-Math.min(Math.max(Hh*0.16,64),150);
buildLawn();
hedgeBumps=[];
let hx=-20;
while(hx<W+40){ hedgeBumps.push({x:hx,r:12+Math.random()*16}); hx+=18+Math.random()*26; }
}
function buildLawn(){
const lh=Math.ceil(Hh-groundY+6);
lawn.width=Math.round(W*dpr); lawn.height=Math.round(lh*dpr);
const g=lawn.getContext('2d');
g.setTransform(dpr,0,0,dpr,0,0);
g.fillStyle='#4e7c38'; g.fillRect(0,0,W,26);
g.fillStyle='#446e30'; g.fillRect(0,26,W,18);
g.fillStyle='#5d4632'; g.fillRect(0,44,W,lh-44);
for(let i=0;i<80;i++){
g.fillStyle=Math.random()<0.5?'rgba(0,0,0,0.18)':'rgba(255,255,255,0.06)';
g.beginPath(); g.arc(Math.random()*W,46+Math.random()*Math.max(4,lh-48),0.8+Math.random()*1.6,0,6.2832); g.fill();
}
g.fillStyle='rgba(255,255,255,0.05)';
for(let sx2=0;sx2<W;sx2+=110) g.fillRect(sx2,0,55,26);
g.fillStyle='rgba(26,48,18,0.8)'; g.fillRect(0,0,W,1.5);
const cols=['#5d9147','#43702f','#6ba052','#3d6429','#558840'];
for(let i=0;i<520;i++){
const tx=Math.random()*W, ty=2+Math.random()*20;
g.strokeStyle=cols[(Math.random()*cols.length)|0];
g.lineWidth=1;
g.beginPath();
for(let k=0;k<3;k++){
const dx2=(Math.random()-0.5)*3, ln=3+Math.random()*5;
g.moveTo(tx+k*1.2-1.2,ty); g.lineTo(tx+k*1.2-1.2+dx2,ty-ln);
}
g.stroke();
}
for(let i=0;i<12;i++){
const dx3=Math.random()*W, dy3=5+Math.random()*14;
g.fillStyle='#f2f4ea'; g.beginPath(); g.arc(dx3,dy3,1.8,0,6.2832); g.fill();
g.fillStyle='#e9c94f'; g.beginPath(); g.arc(dx3,dy3,0.8,0,6.2832); g.fill();
}
}
function hosePath(dy){
const p=new Path2D();
p.moveTo(x[0]*S, groundY-y[0]*S+dy);
for(let i=1;i<N-1;i++){
const cx=x[i]*S, cy=groundY-y[i]*S+dy;
const nx=x[i+1]*S, ny=groundY-y[i+1]*S+dy;
p.quadraticCurveTo(cx,cy,(cx+nx)*0.5,(cy+ny)*0.5);
}
p.lineTo(x[N-1]*S, groundY-y[N-1]*S+dy);
return p;
}
function drawNozzle(wpx){
const dx=x[N-1]-x[N-2], dy=y[N-1]-y[N-2];
const d=Math.sqrt(dx*dx+dy*dy)||1;
ctx.save();
ctx.translate(x[N-1]*S,groundY-y[N-1]*S);
ctx.rotate(Math.atan2(-dy/d,dx/d));
ctx.fillStyle='#3c4636'; ctx.fillRect(-wpx*0.55,-wpx*0.52,wpx*0.5,wpx*1.04);
ctx.fillStyle='#a8813f'; ctx.strokeStyle='#5f4a20'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.rect(-wpx*0.1,-wpx*0.46,wpx*1.15,wpx*0.92); ctx.fill(); ctx.stroke();
ctx.fillStyle='#8a6a30';
ctx.beginPath();
ctx.moveTo(wpx*1.05,-wpx*0.34); ctx.lineTo(wpx*1.6,-wpx*0.16);
ctx.lineTo(wpx*1.6,wpx*0.16); ctx.lineTo(wpx*1.05,wpx*0.34);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawSpigot(){
const sx=AX*S, sy=groundY-AY*S;
const wallW=Math.min(92,AX*S-24);
ctx.strokeStyle='#8a9097'; ctx.lineWidth=7; ctx.lineCap='butt';
ctx.beginPath(); ctx.moveTo(Math.max(wallW-6,4),sy); ctx.lineTo(sx-3,sy); ctx.stroke();
ctx.fillStyle='#8a9097'; ctx.strokeStyle='#565b61'; ctx.lineWidth=1.5;
ctx.fillRect(sx-6,sy-30,12,30); ctx.strokeRect(sx-6,sy-30,12,30);
ctx.fillRect(sx-8,sy-36,16,7); ctx.strokeRect(sx-8,sy-36,16,7);
ctx.fillRect(sx+2,sy-9,15,7); ctx.strokeRect(sx+2,sy-9,15,7);
ctx.fillRect(sx+11,sy-9,6,13); ctx.strokeRect(sx+11,sy-9,6,13);
ctx.fillStyle='#b23b2e'; ctx.strokeStyle='#77241b'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(sx,sy-42,9,0,6.2832); ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(sx-9,sy-42); ctx.lineTo(sx+9,sy-42);
ctx.moveTo(sx,sy-51); ctx.lineTo(sx,sy-33);
ctx.stroke();
ctx.fillStyle='#77241b'; ctx.beginPath(); ctx.arc(sx,sy-42,2.5,0,6.2832); ctx.fill();
}
function draw(t){
ctx.fillStyle='#b7d8e6'; ctx.fillRect(0,0,W,Hh);
ctx.fillStyle='rgba(255,255,248,0.3)'; ctx.fillRect(0,groundY-80,W,80);
const sunx=W*0.8, suny=Hh*0.14;
ctx.fillStyle='rgba(255,252,235,0.35)'; ctx.beginPath(); ctx.arc(sunx,suny,52,0,6.2832); ctx.fill();
ctx.fillStyle='rgba(255,250,225,0.85)'; ctx.beginPath(); ctx.arc(sunx,suny,28,0,6.2832); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.85)';
for(const cl of clouds){
const cw=W+300;
const cx=((cl.fx*cw+t*4*cl.s)%cw)-150, cy=cl.fy*Hh;
ctx.beginPath();
ctx.moveTo(cx-12,cy); ctx.arc(cx-26,cy,14*cl.s,0,6.2832);
ctx.moveTo(cx+12,cy-8); ctx.arc(cx,cy-8,20*cl.s,0,6.2832);
ctx.moveTo(cx+37,cy); ctx.arc(cx+24,cy,13*cl.s,0,6.2832);
ctx.moveTo(cx+20,cy+5); ctx.arc(cx+4,cy+5,16*cl.s,0,6.2832);
ctx.fill();
}
for(const tr of trees){
const tx=tr.fx*W, base=groundY-4;
ctx.fillStyle='#6b4a33'; ctx.fillRect(tx-2,base-34*tr.h,4,34*tr.h);
ctx.fillStyle='#4d7c3e';
ctx.beginPath();
ctx.moveTo(tx+24*tr.h,base-52*tr.h); ctx.arc(tx,base-52*tr.h,24*tr.h,0,6.2832);
ctx.moveTo(tx-16+17*tr.h,base-42*tr.h); ctx.arc(tx-16,base-42*tr.h,17*tr.h,0,6.2832);
ctx.moveTo(tx+15+18*tr.h,base-44*tr.h); ctx.arc(tx+15,base-44*tr.h,18*tr.h,0,6.2832);
ctx.fill();
}
ctx.fillStyle='#3a5930';
ctx.fillRect(0,groundY-30,W,38);
ctx.beginPath();
for(const b of hedgeBumps){ ctx.moveTo(b.x+b.r,groundY-30); ctx.arc(b.x,groundY-30,b.r,Math.PI,0); }
ctx.fill();
const wallW=Math.min(92,AX*S-24);
if(wallW>18){
ctx.fillStyle='#93604b'; ctx.fillRect(0,0,wallW,groundY+42);
ctx.strokeStyle='rgba(40,16,8,0.25)'; ctx.lineWidth=1;
ctx.beginPath();
let row=0;
for(let by=0;by<groundY+42;by+=16,row++){
ctx.moveTo(0,by); ctx.lineTo(wallW,by);
for(let bx=(row%2)*17;bx<wallW;bx+=34){ ctx.moveTo(bx,by); ctx.lineTo(bx,Math.min(by+16,groundY+42)); }
}
ctx.stroke();
ctx.fillStyle='rgba(0,0,0,0.16)'; ctx.fillRect(wallW-7,0,7,groundY+42);
}
ctx.drawImage(lawn,0,0,lawn.width,lawn.height,0,groundY,W,lawn.height/dpr);
for(let i=0;i<WBN;i++){
const wv=wet[i]; if(wv<0.04)continue;
ctx.fillStyle='rgba(18,52,44,'+(0.42*Math.min(1,wv/3.5)).toFixed(3)+')';
ctx.beginPath(); ctx.ellipse((i+0.5)*WBW*S,groundY+7,WBW*S*0.75,4.2+wv*0.5,0,0,6.2832); ctx.fill();
}
for(let i=1;i<N;i++){
if(y[i]<2.2){
ctx.globalAlpha=0.16*(1-y[i]/2.2);
ctx.fillStyle='#10230e';
ctx.beginPath(); ctx.ellipse(x[i]*S,groundY+3,8,3,0,0,6.2832); ctx.fill();
}
}
ctx.globalAlpha=1;
const wpx=0.13*S;
ctx.lineJoin='round'; ctx.lineCap='round';
ctx.strokeStyle='#4a1c0d'; ctx.lineWidth=wpx+4; ctx.stroke(hosePath(0));
ctx.strokeStyle='#b4552f'; ctx.lineWidth=wpx; ctx.stroke(hosePath(0));
ctx.strokeStyle='rgba(255,235,210,0.28)'; ctx.lineWidth=wpx*0.3; ctx.stroke(hosePath(-wpx*0.22));
drawNozzle(wpx);
drawSpigot();
ctx.fillStyle='#d9edfb';
for(let i=0;i<PMAX;i++){
const p=part[i]; if(p.life<=0)continue;
ctx.globalAlpha=Math.max(0,Math.min(1,p.life/p.tot))*0.9;
ctx.beginPath(); ctx.arc(p.x*S,groundY-p.y*S,p.k===2?1.4:2.1,0,6.2832); ctx.fill();
}
ctx.globalAlpha=1;
if(grabbed>=0){
const gx2=x[grabbed]*S, gy2=groundY-y[grabbed]*S;
ctx.strokeStyle='#e8a33d'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.arc(gx2,gy2,10,0,6.2832); ctx.stroke();
ctx.fillStyle='rgba(232,163,61,0.35)';
ctx.beginPath(); ctx.arc(gx2,gy2,4,0,6.2832); ctx.fill();
}
}
/* ---------------- HUD / audio / input ---------------- */
const pvEl=document.getElementById('pv'), slEl=document.getElementById('slider'),
thEl=document.getElementById('thrust'), sndEl=document.getElementById('snd'),
rstEl=document.getElementById('reset');
function syncHUD(){
pvEl.textContent=(psi%1===0?String(psi):psi.toFixed(1));
slEl.value=psi;
thEl.textContent=psi<0.05?'no flow':'\u2248 '+(THRUST*psi*PA).toFixed(1)+' N thrust';
}
let AC=null,again=null,afilt=null,soundOn=true;
function initAudio(){
if(AC)return;
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
const len=2*AC.sampleRate, buf=AC.createBuffer(1,len,AC.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
const src=AC.createBufferSource(); src.buffer=buf; src.loop=true;
afilt=AC.createBiquadFilter(); afilt.type='lowpass'; afilt.frequency.value=1200;
again=AC.createGain(); again.gain.value=0;
src.connect(afilt); afilt.connect(again); again.connect(AC.destination);
src.start();
}catch(err){AC=null;}
}
function updAudio(){
if(!AC||!again)return;
const t=AC.currentTime, f=psi/PSIMAX;
again.gain.setTargetAtTime(soundOn?f*f*0.22:0,t,0.15);
afilt.frequency.setTargetAtTime(600+psi*55,t,0.15);
}
slEl.addEventListener('input',()=>{ psi=parseFloat(slEl.value)||0; syncHUD(); updAudio(); });
sndEl.addEventListener('click',()=>{
soundOn=!soundOn;
sndEl.textContent=soundOn?'SND ON':'SND OFF';
if(soundOn){initAudio(); if(AC&&AC.state==='suspended')AC.resume();}
updAudio();
});
rstEl.addEventListener('click',()=>{
layHose();
for(const p of part)p.life=0;
wet.fill(0);
});
window.addEventListener('keydown',e=>{
if(e.key==='ArrowUp'||e.key==='ArrowRight'){ psi=Math.min(PSIMAX,psi+2); syncHUD(); updAudio(); e.preventDefault(); }
else if(e.key==='ArrowDown'||e.key==='ArrowLeft'){ psi=Math.max(0,psi-2); syncHUD(); updAudio(); e.preventDefault(); }
});
cv.addEventListener('pointerdown',e=>{
initAudio(); if(AC&&AC.state==='suspended')AC.resume(); updAudio();
const wx=e.clientX/S, wy=(groundY-e.clientY)/S;
let best=-1,bd=0.6;
for(let i=1;i<N;i++){
const d=Math.hypot(x[i]-wx,y[i]-wy);
if(d<bd){bd=d;best=i;}
}
if(best>=0){
grabbed=best; grabX=wx; grabY=Math.max(wy,0.02);
try{cv.setPointerCapture(e.pointerId);}catch(err){}
cv.classList.add('drag');
}
});
cv.addEventListener('pointermove',e=>{
if(grabbed<0)return;
grabX=e.clientX/S; grabY=Math.max((groundY-e.clientY)/S,0.02);
});
function release(){grabbed=-1; cv.classList.remove('drag');}
cv.addEventListener('pointerup',release);
cv.addEventListener('pointercancel',release);
/* ---------------- boot ---------------- */
const mh=location.hash.match(/psi\s*=\s*(-?\d*\.?\d+)/i);
psi=mh?Math.min(PSIMAX,Math.max(0,parseFloat(mh[1]))):52;
resize();
window.addEventListener('resize',resize);
layHose();
syncHUD();
for(let k=0;k<80;k++)step(HS); // settle constraints before first paint
let last=performance.now(),accum=0;
function frame(now){
let dt=(now-last)/1000; last=now;
if(dt>0.05)dt=0.05;
accum+=dt;
while(accum>=HS){step(HS);accum-=HS;}
draw(now/1000);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
What's real: the settle/whip threshold actually emerges — no timer decides when the hose goes. Below ~25 psi, static friction holds every grounded node and the hose lies still (the tip may kink up slightly under compression, like a real hose). Above ~35 psi the thrust beats friction, and because the reaction is a follower force it buckles the tip, the buckle curls, coils pile up via self-collision, and you get the classic runaway lash. Dial it back down and friction + damping settle it again. Dragging pins a node to the cursor with real momentum transfer, so you can fling it. The jet follows the nozzle vector, arcs under gravity, and darkens the grass where it lands.
What's faked, by name: (1) Artificial damping — the PBD constraint projection itself bleeds energy every substep; on top of that there's explicit linear air drag (0.06 s⁻¹), bend damping (1.6 N·s/m), and a hard 50 m/s velocity clamp. Together they make the whip decay a bit faster than a real hose. (2) Injected noise — none in the hose dynamics; it's fully deterministic. Randomness exists only in the spray (droplet angles, sizes, splash droplets), which is cosmetic. (3) Scripted oscillation — none; the whipping is the follower-force instability feeding on the thrust. What it gets wrong that you'll notice: it's planar, so long whips look like a flat noodle instead of a 3-D lashing; the hose has no plastic memory, so it never keeps a kink or a coil the way vinyl does; friction is ideal Coulomb — grass never snags or trips it; the thrust stays at full magnitude even when the nozzle points straight into the turf, where a real jet would stall; pressure changes instantly, with no water hammer or line lag; and despite "inextensible," violent whip lets the constraints stretch the hose a few percent. The transition is also a fuzzy, slightly hysteretic band around the derived 30 psi, not a clean switch.