27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 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 watched someone try the tablecloth trick at a family dinner.
Build me a single HTML page: a table set for dinner, and a tablecloth I can yank out from under it.
#speed=12 sets the pull speed to 12 ft/s and pulls one second after load. Any number works.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: the lowest pull speed at which every object stays on the table, and the speed below which no object stays on the table, as two numbers in ft/s. State the arithmetic using your masses, friction coefficients, and cloth length.
The single fenced html block.
Where it behaves like the physical trick and where it does not. Name any special cases, scripted wobbles, or fall thresholds. Everyone has seen this trick fail; say how yours fails before I find out.
Everything should stay on the table at about 17.3 ft/s and above. Nothing should stay below about 4.6 ft/s.
These numbers come from a single-body Coulomb analysis that uses the page's own parameters:
The binding objects:
The others fall between those two:
| Object | x | μc | μt | Threshold |
|---|---|---|---|---|
| Plate | 1.4 | 0.22 | 0.20 | 5.19 ft/s (riding threshold) |
| Bowl | 3.1 | 0.25 | 0.22 | 8.54 ft/s |
| Wine glass | 2.2 | 0.42 | 0.22 | 9.11 ft/s |
| Second plate | 4.0 | 0.22 | 0.20 | 10.52 ft/s |
The wine glass's centre of mass is at 0.336 ft with a 0.11 ft half-base, so its tip threshold is μ ≈ 0.33. Under cloth friction of 0.42 it should also topple during the pull, by a rough energy estimate, somewhere up to about 11–12 ft/s.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>The Tablecloth Trick</title>
<style>
:root{--bg:#17130f;--panel:#241d17;--ink:#f3e9da;--dim:#b4a48e;--acc:#e3a54c;--line:#3b3129}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 Georgia,"Times New Roman",serif}
header{padding:12px 16px 6px;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
h1{margin:0;font-size:21px;font-weight:normal;letter-spacing:.04em}
header span{color:var(--dim);font-style:italic}
.bar{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;padding:9px 16px;background:var(--panel);border-top:1px solid var(--line);border-bottom:1px solid var(--line);font-family:system-ui,sans-serif;font-size:13px}
.bar label{display:flex;align-items:center;gap:8px}
#spd{width:220px;accent-color:var(--acc)}
#spdNum{width:72px;background:#120f0c;color:var(--ink);border:1px solid var(--line);padding:3px 5px;font:inherit}
#spdLbl{font:600 18px/1 ui-monospace,Menlo,monospace;color:var(--acc);min-width:150px}
button{font:600 13px system-ui,sans-serif;padding:7px 16px;border-radius:4px;border:1px solid #7a5a2a;background:#3a2a17;color:var(--ink);cursor:pointer}
button#yank{background:#8c2f22;border-color:#c0503c}
button:disabled{opacity:.45;cursor:default}
select{background:#120f0c;color:var(--ink);border:1px solid var(--line);font:inherit;padding:2px}
#wrap{width:100%;max-width:1280px;margin:0 auto}
canvas{display:block;width:100%}
#status{padding:8px 16px;font:13px ui-monospace,Menlo,monospace;color:var(--dim);min-height:2.6em}
#result{padding:4px 16px 10px;font-size:17px;color:var(--acc);min-height:1.6em}
table{border-collapse:collapse;font:12.5px system-ui,sans-serif;margin:0 16px 22px;color:var(--ink)}
th,td{padding:4px 9px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
th{color:var(--dim);font-weight:600}
td:first-child,th:first-child,td:last-child,th:last-child{text-align:left}
.ok{color:#9fd28b}.bad{color:#f08a74}
.note{color:var(--dim);font:12px system-ui,sans-serif;padding:0 16px 24px;max-width:900px}
</style>
</head>
<body>
<div id="wrap">
<header><h1>The Tablecloth Trick</h1><span>friction, inertia, and a little nerve</span></header>
<div class="bar">
<label>Pull speed <input id="spd" type="range" min="0.5" max="40" step="0.5" value="12"></label>
<input id="spdNum" type="number" step="0.1" value="12" title="any number, ft/s">
<span id="spdLbl">12.0 ft/s</span>
<button id="yank">Yank the cloth</button>
<button id="reset">Reset table</button>
<label>Sim speed <select id="tscale"><option value="0.1">0.1×</option><option value="0.25">0.25×</option><option value="1" selected>1×</option><option value="4">4×</option></select></label>
<label><input id="hulls" type="checkbox"> show collision hulls</label>
</div>
<canvas id="c"></canvas>
<div id="status"></div>
<div id="result"></div>
<table>
<thead><tr><th>Object</th><th>mass (lb)</th><th>base (in)</th><th>μ cloth</th><th>μ table</th><th>cloth behind (ft)</th><th>to edge (ft)</th><th>single-body: stays if v ≥</th><th>what happened</th></tr></thead>
<tbody id="tb"></tbody>
</table>
<p class="note" id="pred"></p>
</div>
<script>
(function(){
'use strict';
const G=32.2, TOP=2.5, W=6, O=0.5;
const VIEW={x0:-1.8,x1:9.2,y0:-0.45,y1:4.55};
const UPRIGHT=0.30; // rad (~17°)
const SLOP=0.002;
function rect(x0,y0,x1,y1){return [[x0,y0],[x1,y0],[x1,y1],[x0,y1]];}
function trap(y0,w0,y1,w1){return [[-w0/2,y0],[w0/2,y0],[w1/2,y1],[-w1/2,y1]];}
const PLATE_HULL=[[-0.25,0],[0.25,0],[0.425,0.055],[0.425,0.07],[-0.425,0.07],[-0.425,0.055]];
const BOWL_HULL=[[-0.15,0],[0.15,0],[0.29,0.17],[0.31,0.26],[-0.31,0.26],[-0.29,0.17]];
const DEFS=[
{id:'candle',name:'Brass candlestick',x:0.6,m:2.2,muC:0.30,muT:0.28,base:0.30,
hull:[[-0.15,0],[0.15,0],[0.15,0.06],[0.035,1.15],[-0.035,1.15],[-0.15,0.06]],
parts:[{p:rect(-0.15,0,0.15,0.06),d:6},{p:trap(0.06,0.06,0.85,0.04),d:3},{p:rect(-0.035,0.85,0.035,1.15),d:1}],draw:drawCandle},
{id:'plate1',name:'Dinner plate',x:1.4,m:1.4,muC:0.22,muT:0.20,base:0.5,hull:PLATE_HULL,parts:[{p:PLATE_HULL,d:1}],draw:drawPlate},
{id:'wine',name:'Wine glass',x:2.2,m:0.45,muC:0.42,muT:0.22,base:0.22,
hull:[[-0.11,0],[0.11,0],[0.135,0.5],[0.125,0.72],[-0.125,0.72],[-0.135,0.5]],
parts:[{p:rect(-0.11,0,0.11,0.015),d:4},{p:rect(-0.01,0.015,0.01,0.32),d:4},{p:trap(0.32,0.2,0.72,0.25),d:0.5}],draw:drawWine},
{id:'bowl',name:'Serving bowl',x:3.1,m:1.1,muC:0.25,muT:0.22,base:0.30,hull:BOWL_HULL,parts:[{p:BOWL_HULL,d:1}],draw:drawBowl},
{id:'plate2',name:'Dinner plate',x:4.0,m:1.4,muC:0.22,muT:0.20,base:0.5,hull:PLATE_HULL,parts:[{p:PLATE_HULL,d:1}],draw:drawPlate},
{id:'water',name:'Water glass',x:4.8,m:0.7,muC:0.30,muT:0.26,base:0.20,hull:trap(0,0.2,0.55,0.25),
parts:[{p:rect(-0.1,0,0.1,0.05),d:5},{p:trap(0.05,0.19,0.38,0.22),d:1},{p:trap(0.38,0.22,0.55,0.25),d:0.15}],draw:drawWater}
];
// ---------- single-body prediction (same parameters the sim uses) ----------
function predict(d){
const a=d.muC*G, L=d.x+O, D=W-d.x, vr=Math.sqrt(2*a*L);
const um=Math.sqrt(2*G*D/(1/d.muC+1/d.muT));
return um>=vr? vr : a*L/um+um/2;
}
// ---------- mass properties ----------
function polyMP(v){
let A=0,cx=0,cy=0,I=0;const n=v.length;
for(let i=0;i<n;i++){const x0=v[i][0],y0=v[i][1],x1=v[(i+1)%n][0],y1=v[(i+1)%n][1];
const c=x0*y1-x1*y0;A+=c;cx+=(x0+x1)*c;cy+=(y0+y1)*c;I+=c*(x0*x0+x0*x1+x1*x1+y0*y0+y0*y1+y1*y1);}
A/=2;cx/=(6*A);cy/=(6*A);I/=12;return {A,cx,cy,I};
}
function makeBody(def){
let M=0,mx=0,my=0,Io=0;
for(const p of def.parts){const q=polyMP(p.p);const mm=q.A*p.d;M+=mm;mx+=mm*q.cx;my+=mm*q.cy;Io+=q.I*p.d;}
const cx=mx/M,cy=my/M;let Ic=(Io-M*(cx*cx+cy*cy))*(def.m/M);
const verts=def.hull.map(([x,y])=>[x-cx,y-cy]);
return {def,name:def.name,verts,wv:verts.map(v=>v.slice()),com:[cx,cy],m:def.m,im:1/def.m,iI:1/Ic,I:Ic,
muC:def.muC,muT:def.muT,x:def.x+cx,y:TOP+cy,a:0,vx:0,vy:0,w:0,x0:def.x+cx,
lit:true,spilled:false,cracked:false,cracks:null,static:false};
}
function makeStatic(kind,verts){
const s={static:true,kind,verts,wv:verts.map(v=>v.slice()),im:0,iI:0,vx:0,vy:0,w:0,x:0,y:0};
s.minx=Math.min(...verts.map(v=>v[0]));s.maxx=Math.max(...verts.map(v=>v[0]));
s.miny=Math.min(...verts.map(v=>v[1]));s.maxy=Math.max(...verts.map(v=>v[1]));return s;
}
const statics=[
makeStatic('table',rect(0,2.2,W,TOP)),
makeStatic('floor',rect(-30,-3,40,0)),
makeStatic('wall',rect(-8,-3,VIEW.x0,14)),
makeStatic('wall',rect(VIEW.x1,-3,16,14))
];
// ---------- state ----------
let bodies=[],speed=12,vPull=12,pulling=false,tPull=0,done=false,still=0,acc=0,simT=0,timeScale=1,showHulls=false;
let lastFrame=performance.now(),settleT=0;
function clothTrail(){return -O+(pulling?vPull*tPull:0);}
function upd(b){
const c=Math.cos(b.a),s=Math.sin(b.a);let a=1e9,bb=1e9,cc=-1e9,dd=-1e9;
for(let i=0;i<b.verts.length;i++){const lx=b.verts[i][0],ly=b.verts[i][1];
const x=b.x+c*lx-s*ly,y=b.y+s*lx+c*ly;b.wv[i][0]=x;b.wv[i][1]=y;
if(x<a)a=x;if(x>cc)cc=x;if(y<bb)bb=y;if(y>dd)dd=y;}
b.minx=a-0.01;b.miny=bb-0.01;b.maxx=cc+0.01;b.maxy=dd+0.01;
}
function inside(P,Q,out){
const q=Q.wv,n=q.length;
for(const p of P.wv){
let best=-1e9,bnx=0,bny=0,ok=true;
for(let i=0;i<n;i++){const a=q[i],b=q[(i+1)%n];const ex=b[0]-a[0],ey=b[1]-a[1];const L=Math.hypot(ex,ey);
const nx=ey/L,ny=-ex/L;const d=(p[0]-a[0])*nx+(p[1]-a[1])*ny;
if(d>=0){ok=false;break;} if(d>best){best=d;bnx=nx;bny=ny;}}
if(ok) out.push({A:P,B:Q,px:p[0],py:p[1],nx:bnx,ny:bny,depth:-best});
}
}
function pair(A,B,out){
if(A.maxx<B.minx||B.maxx<A.minx||A.maxy<B.miny||B.maxy<A.miny)return;
inside(A,B,out);inside(B,A,out);
}
function gather(out){
out.length=0;const n=bodies.length;
for(let i=0;i<n;i++)for(let j=i+1;j<n;j++)pair(bodies[i],bodies[j],out);
for(let i=0;i<n;i++)for(const s of statics)pair(bodies[i],s,out);
}
const C=[],C2=[];
function relv(c){
const A=c.A,B=c.B;
return [ (A.vx-A.w*c.ray+c.sAx)-(B.vx-B.w*c.rby+c.sBx),
(A.vy+A.w*c.rax+c.sAy)-(B.vy+B.w*c.rbx+c.sBy) ];
}
function imp(c,px,py){
const A=c.A,B=c.B;
A.vx+=px*A.im;A.vy+=py*A.im;A.w+=A.iI*(c.rax*py-c.ray*px);
B.vx-=px*B.im;B.vy-=py*B.im;B.w-=B.iI*(c.rbx*py-c.rby*px);
}
function makeCracks(b){
const xs=b.verts.map(v=>v[0]+b.com[0]),ys=b.verts.map(v=>v[1]+b.com[1]);
const x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys);
const cx=x0+(x1-x0)*(0.3+0.4*Math.random()),cy=y0+(y1-y0)*(0.3+0.4*Math.random());const lines=[];
for(let k=0;k<5;k++){const ang=Math.random()*6.283;let px=cx,py=cy;const l=[[px,py]];
for(let s=0;s<4;s++){px+=Math.cos(ang+(Math.random()-0.5))*(x1-x0)*0.12;py+=Math.sin(ang+(Math.random()-0.5))*(y1-y0)*0.18;l.push([px,py]);}
lines.push(l);}
return lines;
}
function step(h){
for(const b of bodies)upd(b);
gather(C);
const trail=clothTrail(), vc=pulling?vPull:0, clothOn=trail<W;
for(const c of C){
const A=c.A,B=c.B,S=A.static?A:(B.static?B:null);
c.sAx=c.sAy=c.sBx=c.sBy=0;
if(!S){c.mu=0.3;c.e=0.1;}
else{
const D=S===A?B:A;
if(S.kind==='table'){
const onCloth=clothOn&&c.py>TOP-0.04&&c.px>=trail&&c.px<=W+0.03;
c.mu=onCloth?D.muC:D.muT;c.e=0.1;
const sv=onCloth?vc:0;if(S===A)c.sAx=sv;else c.sBx=sv;
}else if(S.kind==='floor'){c.mu=0.45;c.e=0.25;}
else {c.mu=0.3;c.e=0.2;}
c.S=S;c.D=D;
}
c.rax=c.px-A.x;c.ray=c.py-A.y;c.rbx=c.px-B.x;c.rby=c.py-B.y;
const rnA=c.rax*c.ny-c.ray*c.nx,rnB=c.rbx*c.ny-c.rby*c.nx;
c.kn=A.im+B.im+A.iI*rnA*rnA+B.iI*rnB*rnB;
c.tx=-c.ny;c.ty=c.nx;
const rtA=c.rax*c.ty-c.ray*c.tx,rtB=c.rbx*c.ty-c.rby*c.tx;
c.kt=A.im+B.im+A.iI*rtA*rtA+B.iI*rtB*rtB;
c.jn=0;c.jt=0;
}
for(const b of bodies)b.vy-=G*h;
for(const c of C){
const r=relv(c),vn=r[0]*c.nx+r[1]*c.ny;
c.bias=vn<-1.5?-c.e*vn:0;
if(c.S&&c.S.kind==='floor'&&vn<-9&&!c.D.cracked&&c.D.def.id!=='candle'){c.D.cracked=true;c.D.cracks=makeCracks(c.D);}
}
for(let it=0;it<12;it++){
for(const c of C){
if(c.kn<=0)continue;
let r=relv(c);const vn=r[0]*c.nx+r[1]*c.ny;
let dj=(c.bias-vn)/c.kn;const nj=Math.max(c.jn+dj,0);dj=nj-c.jn;c.jn=nj;
imp(c,dj*c.nx,dj*c.ny);
r=relv(c);const vt=r[0]*c.tx+r[1]*c.ty;
let dt=-vt/c.kt;const mx=c.mu*c.jn;const nt=Math.max(-mx,Math.min(mx,c.jt+dt));dt=nt-c.jt;c.jt=nt;
imp(c,dt*c.tx,dt*c.ty);
}
}
for(const b of bodies){
b.x+=b.vx*h;b.y+=b.vy*h;b.a+=b.w*h;b.w*=(1-0.02*h);
}
for(const b of bodies)upd(b);
gather(C2);
for(const c of C2){
if(c.depth>SLOP){const k=c.A.im+c.B.im;if(k>0){const m=(c.depth-SLOP)*0.4/k;
c.A.x+=c.nx*m*c.A.im;c.A.y+=c.ny*m*c.A.im;c.B.x-=c.nx*m*c.B.im;c.B.y-=c.ny*m*c.B.im;}}
}
// cosmetic flags only (no effect on motion)
for(const b of bodies){
const na=Math.abs(norm(b.a));
if(b.def.id==='candle'&&b.lit&&na>0.7)b.lit=false;
if((b.def.id==='wine'||b.def.id==='water')&&!b.spilled&&na>1.0)b.spilled=true;
}
if(pulling){
tPull+=h;simT+=h;
const gone=clothTrail()>=W||vPull===0;
let moving=false;
for(const b of bodies)if(Math.hypot(b.vx,b.vy)>0.05||Math.abs(b.w)>0.15){moving=true;break;}
if(gone&&!moving)still+=h;else still=0;
const timeout=vPull>0?(W+O)/vPull+30:5;
if(still>0.5||tPull>timeout)finish(still>0.5);
}
}
function norm(a){return Math.atan2(Math.sin(a),Math.cos(a));}
function onTable(b){return b.y>TOP&&b.x>0&&b.x<W;}
function isUp(b){return Math.abs(norm(b.a))<UPRIGHT;}
function curH(){return (pulling&&vPull>0&&clothTrail()<W)?Math.min(1/1000,0.01/vPull):1/1000;}
// ---------- control ----------
function reset(){
bodies=DEFS.map(makeBody);pulling=false;tPull=0;done=false;still=0;acc=0;simT=0;
for(let i=0;i<300;i++)step(1/1000);
for(const b of bodies){b.x0=b.x;}
document.getElementById('yank').disabled=false;
document.getElementById('result').textContent='';
fillTable(false);
}
function yank(){
if(pulling)return;
vPull=speed;pulling=true;tPull=0;still=0;acc=0;done=false;
document.getElementById('yank').disabled=true;
document.getElementById('result').textContent='';
}
function finish(settled){
done=true;settleT=tPull;
let on=0,up=0,upOn=0;
for(const b of bodies){const o=onTable(b),u=isUp(b);if(o)on++;if(u)up++;if(o&&u)upOn++;}
const n=bodies.length;
document.getElementById('result').textContent=
(settled?`Everything stopped at t = ${tPull.toFixed(2)} s. `:`Gave up waiting at t = ${tPull.toFixed(1)} s. `)+
`On the table: ${on} of ${n}. Upright: ${up} of ${n} (${upOn} on the table, ${up-upOn} on the floor).`;
fillTable(true);
}
function fate(b){
const na=Math.abs(norm(b.a));let s=onTable(b)?'on table':'on floor';
if(na<UPRIGHT)s+=', upright';else if(na>Math.PI-0.45)s+=', upside down';else s+=', on its side';
if(onTable(b))s+=`, moved ${(b.x-b.x0).toFixed(2)} ft`;
if(b.cracked)s+=' (cracked)';
return s;
}
function fillTable(withFate){
const tb=document.getElementById('tb');tb.innerHTML='';
bodies.forEach(b=>{const d=b.def,thr=predict(d);
let f='—';
if(withFate){const predStay=vPull>=thr,st=onTable(b);
f=`<span class="${predStay===st?'ok':'bad'}">${fate(b)}${predStay===st?'':' — prediction said '+(predStay?'stay':'fall')}</span>`;}
const tr=document.createElement('tr');
tr.innerHTML=`<td>${d.name}</td><td>${d.m.toFixed(2)}</td><td>${(d.base*12).toFixed(1)}</td><td>${d.muC.toFixed(2)}</td><td>${d.muT.toFixed(2)}</td><td>${(d.x+O).toFixed(1)}</td><td>${(W-d.x).toFixed(1)}</td><td>${thr.toFixed(2)} ft/s</td><td>${f}</td>`;
tb.appendChild(tr);});
}
function setSpeed(v){
if(!isFinite(v))v=(v===Infinity||v===-Infinity)?1e9:12;
v=Math.min(Math.abs(v),1e9);speed=v;
const s=document.getElementById('spd'),nb=document.getElementById('spdNum');
s.value=Math.max(0.5,Math.min(40,v));
if(document.activeElement!==nb)nb.value=+v.toFixed(3);
document.getElementById('spdLbl').textContent=`${v<1000?v.toFixed(1):v.toExponential(2)} ft/s (${(v*0.6818).toFixed(1)} mph)`;
}
document.getElementById('spd').addEventListener('input',e=>setSpeed(parseFloat(e.target.value)));
document.getElementById('spdNum').addEventListener('change',e=>{const v=parseFloat(e.target.value);if(!isNaN(v))setSpeed(v);});
document.getElementById('yank').addEventListener('click',yank);
document.getElementById('reset').addEventListener('click',reset);
document.getElementById('tscale').addEventListener('change',e=>{timeScale=parseFloat(e.target.value);});
document.getElementById('hulls').addEventListener('change',e=>{showHulls=e.target.checked;});
function readHash(){
const m=/speed=([^&]*)/i.exec(location.hash);if(!m)return null;
const v=parseFloat(decodeURIComponent(m[1]));return isNaN(v)?null:v;
}
let hashTimer=null;
function fromHash(){const v=readHash();if(v===null)return;reset();setSpeed(v);clearTimeout(hashTimer);hashTimer=setTimeout(yank,1000);}
window.addEventListener('hashchange',fromHash);
// ---------- rendering ----------
const cv=document.getElementById('c'),ctx=cv.getContext('2d');
let S=100,CW=1000,CH=450,dpr=1;
function resize(){
dpr=window.devicePixelRatio||1;CW=cv.parentElement.clientWidth;S=CW/(VIEW.x1-VIEW.x0);
CH=Math.round(S*(VIEW.y1-VIEW.y0));cv.width=Math.round(CW*dpr);cv.height=Math.round(CH*dpr);cv.style.height=CH+'px';
}
window.addEventListener('resize',resize);
let now=0;
function drawRoom(){
// wall
ctx.fillStyle='#2b3a31';ctx.fillRect(VIEW.x0,0,VIEW.x1-VIEW.x0,VIEW.y1);
ctx.fillStyle='#31433a';
for(let x=Math.floor(VIEW.x0);x<VIEW.x1;x+=0.5)ctx.fillRect(x,1.25,0.22,VIEW.y1);
ctx.fillStyle='rgba(212,180,110,0.07)';
for(let x=Math.floor(VIEW.x0)+0.25;x<VIEW.x1;x+=0.5)for(let y=1.6;y<VIEW.y1;y+=0.5){ctx.beginPath();ctx.arc(x,y,0.035,0,6.283);ctx.fill();}
// wainscot
const wg=ctx.createLinearGradient(0,0,0,1.25);wg.addColorStop(0,'#3a2417');wg.addColorStop(1,'#56361f');
ctx.fillStyle=wg;ctx.fillRect(VIEW.x0,0,VIEW.x1-VIEW.x0,1.25);
ctx.strokeStyle='rgba(0,0,0,0.35)';ctx.lineWidth=0.02;
for(let x=VIEW.x0+0.15;x<VIEW.x1;x+=1.1)ctx.strokeRect(x,0.3,0.9,0.8);
ctx.fillStyle='#6b4527';ctx.fillRect(VIEW.x0,1.22,VIEW.x1-VIEW.x0,0.06);
ctx.fillStyle='#24160d';ctx.fillRect(VIEW.x0,0,VIEW.x1-VIEW.x0,0.22);
// window
const wx0=6.9,wx1=8.7,wy0=2.35,wy1=4.25;
const sky=ctx.createLinearGradient(0,wy0,0,wy1);sky.addColorStop(0,'#1b2a4a');sky.addColorStop(1,'#0a1022');
ctx.fillStyle=sky;ctx.fillRect(wx0,wy0,wx1-wx0,wy1-wy0);
ctx.fillStyle='#f4ecc8';ctx.beginPath();ctx.arc(8.15,3.85,0.16,0,6.283);ctx.fill();
ctx.fillStyle='#1b2a4a';ctx.beginPath();ctx.arc(8.22,3.9,0.14,0,6.283);ctx.fill();
ctx.fillStyle='rgba(255,255,230,0.8)';
[[7.2,4.0],[7.5,3.6],[7.9,4.1],[8.5,3.4],[7.3,3.0],[8.3,2.9]].forEach(p=>ctx.fillRect(p[0],p[1],0.025,0.025));
ctx.strokeStyle='#d8cdb5';ctx.lineWidth=0.06;ctx.strokeRect(wx0,wy0,wx1-wx0,wy1-wy0);
ctx.lineWidth=0.035;ctx.beginPath();ctx.moveTo((wx0+wx1)/2,wy0);ctx.lineTo((wx0+wx1)/2,wy1);ctx.moveTo(wx0,(wy0+wy1)/2);ctx.lineTo(wx1,(wy0+wy1)/2);ctx.stroke();
ctx.fillStyle='#cfc2a6';ctx.fillRect(wx0-0.1,wy0-0.09,wx1-wx0+0.2,0.06);
// painting
ctx.fillStyle='#b08a3e';ctx.fillRect(0.9,3.1,1.5,1.05);
const pg=ctx.createLinearGradient(1,3.2,2.3,4.05);pg.addColorStop(0,'#5c3a2a');pg.addColorStop(1,'#a8683a');
ctx.fillStyle=pg;ctx.fillRect(1.0,3.2,1.3,0.85);
ctx.fillStyle='#e7cf8e';ctx.beginPath();ctx.ellipse(1.65,3.55,0.28,0.12,0,0,6.283);ctx.fill();
ctx.fillStyle='#7a2b22';ctx.beginPath();ctx.arc(1.55,3.62,0.07,0,6.283);ctx.arc(1.75,3.6,0.06,0,6.283);ctx.fill();
// floor
const fg=ctx.createLinearGradient(0,VIEW.y0,0,0);fg.addColorStop(0,'#3a2414');fg.addColorStop(1,'#6a4426');
ctx.fillStyle=fg;ctx.fillRect(VIEW.x0,VIEW.y0,VIEW.x1-VIEW.x0,-VIEW.y0);
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=0.012;
for(let y=-0.12;y>VIEW.y0;y-=0.13){ctx.beginPath();ctx.moveTo(VIEW.x0,y);ctx.lineTo(VIEW.x1,y);ctx.stroke();}
// table shadow
ctx.fillStyle='rgba(0,0,0,0.35)';ctx.beginPath();ctx.ellipse(3,-0.03,3.4,0.07,0,0,6.283);ctx.fill();
}
function drawTable(){
const lg=ctx.createLinearGradient(0,0,0.3,0);lg.addColorStop(0,'#3d220f');lg.addColorStop(0.5,'#6e4220');lg.addColorStop(1,'#3d220f');
ctx.fillStyle=lg;
[[0.22,0.5],[5.5,5.78]].forEach(([a,b])=>{
ctx.beginPath();ctx.moveTo(a,2.0);ctx.lineTo(b,2.0);ctx.lineTo(b-0.05,0.15);ctx.lineTo(b-0.02,0);ctx.lineTo(a+0.02,0);ctx.lineTo(a+0.05,0.15);ctx.closePath();ctx.fill();
});
ctx.fillStyle='#4a2a13';ctx.fillRect(0.15,1.98,W-0.3,0.24);
ctx.fillStyle='rgba(0,0,0,0.25)';ctx.fillRect(0.15,1.98,W-0.3,0.03);
const tg=ctx.createLinearGradient(0,2.2,0,TOP);tg.addColorStop(0,'#4f2d14');tg.addColorStop(0.6,'#7c4b24');tg.addColorStop(1,'#9a6233');
ctx.fillStyle=tg;ctx.fillRect(0,2.2,W,TOP-2.2);
ctx.strokeStyle='rgba(30,15,5,0.35)';ctx.lineWidth=0.008;
for(let i=0;i<4;i++){ctx.beginPath();ctx.moveTo(0,2.25+i*0.06);for(let x=0;x<=W;x+=0.5)ctx.lineTo(x,2.25+i*0.06+Math.sin(x*1.7+i)*0.008);ctx.stroke();}
}
const CLOTH='#f2ece0',HEM='#a8322a';
function handPos(){
if(!pulling)return [W+0.28,TOP-0.95];
const d=vPull*tPull,k=Math.min(d,0.6);return [W+0.28+d,TOP-0.95+k*0.7];
}
function drawCloth(){
const tr=clothTrail();const [hx,hy]=handPos();
ctx.lineCap='round';ctx.lineJoin='round';
if(tr<W){
const x0=Math.max(tr,0);
ctx.fillStyle=CLOTH;ctx.fillRect(x0,TOP-0.035,W-x0,0.035);
ctx.fillStyle='rgba(0,0,0,0.12)';ctx.fillRect(x0,TOP-0.035,W-x0,0.008);
if(tr<0){
const L=-tr;ctx.fillStyle=CLOTH;ctx.fillRect(-0.035,TOP-L,0.035,L);
ctx.fillStyle=HEM;ctx.fillRect(-0.035,TOP-L,0.035,Math.min(0.05,L));
}else{ctx.fillStyle=HEM;ctx.fillRect(x0,TOP-0.035,0.04,0.035);}
ctx.strokeStyle=CLOTH;ctx.lineWidth=0.05;
ctx.beginPath();ctx.moveTo(W+0.015,TOP-0.02);
const cxp=pulling?W+0.05+(hx-W)*0.3:W+0.035,cyp=pulling?TOP-0.08:TOP-0.5;
ctx.quadraticCurveTo(cxp,cyp,hx,hy);ctx.stroke();
ctx.strokeStyle='rgba(150,140,120,0.5)';ctx.lineWidth=0.012;ctx.stroke();
}else{
ctx.strokeStyle=CLOTH;ctx.lineWidth=0.06;ctx.beginPath();ctx.moveTo(hx,hy);
for(let i=1;i<=12;i++){ctx.lineTo(hx-i*0.11,hy-i*0.035+Math.sin(i*0.9-now*0.02)*0.035*i/12);}
ctx.stroke();
ctx.strokeStyle=HEM;ctx.lineWidth=0.02;ctx.stroke();
}
// arm and hand
ctx.strokeStyle='#4f2328';ctx.lineWidth=0.17;
ctx.beginPath();ctx.moveTo(hx+0.14,hy+0.06);ctx.lineTo(hx+1.4,hy+0.75);ctx.stroke();
ctx.strokeStyle='#efe9df';ctx.lineWidth=0.18;ctx.beginPath();ctx.moveTo(hx+0.12,hy+0.05);ctx.lineTo(hx+0.17,hy+0.08);ctx.stroke();
ctx.fillStyle='#e6b48f';ctx.beginPath();ctx.ellipse(hx+0.02,hy,0.095,0.075,0.5,0,6.283);ctx.fill();
}
// ----- object art, drawn in each body's base frame (y up, ft) -----
function drawCandle(c,b){
if(b.lit){
const fl=1+0.08*Math.sin(now*0.021)+0.06*Math.sin(now*0.053);
const g=c.createRadialGradient(0,1.24,0,0,1.24,0.4);g.addColorStop(0,'rgba(255,205,110,0.35)');g.addColorStop(1,'rgba(255,205,110,0)');
c.fillStyle=g;c.beginPath();c.arc(0,1.24,0.4,0,6.283);c.fill();
c.fillStyle='#ffd46b';c.beginPath();c.moveTo(0,1.165);c.quadraticCurveTo(0.045,1.21,0,1.165+0.13*fl);c.quadraticCurveTo(-0.045,1.21,0,1.165);c.fill();
c.fillStyle='#fff6d8';c.beginPath();c.moveTo(0,1.17);c.quadraticCurveTo(0.018,1.2,0,1.17+0.06*fl);c.quadraticCurveTo(-0.018,1.2,0,1.17);c.fill();
}
c.strokeStyle='#222';c.lineWidth=0.008;c.beginPath();c.moveTo(0,1.15);c.lineTo(0,1.175);c.stroke();
c.fillStyle='#efe5cc';c.fillRect(-0.035,0.85,0.07,0.3);
c.fillStyle='#e3d6b6';c.fillRect(0.015,0.85,0.02,0.3);
c.fillStyle='#f6efdc';c.beginPath();c.ellipse(-0.035,1.06,0.008,0.04,0,0,6.283);c.fill();
const g=c.createLinearGradient(-0.15,0,0.15,0);
g.addColorStop(0,'#6e4f18');g.addColorStop(0.35,'#d7b25a');g.addColorStop(0.5,'#fff0b0');g.addColorStop(0.7,'#b88a2e');g.addColorStop(1,'#5e4313');
c.fillStyle=g;c.beginPath();
c.moveTo(-0.15,0);c.lineTo(0.15,0);c.lineTo(0.15,0.03);c.quadraticCurveTo(0.13,0.06,0.05,0.075);c.lineTo(0.025,0.1);
c.lineTo(0.02,0.3);c.quadraticCurveTo(0.065,0.34,0.02,0.38);c.lineTo(0.02,0.78);c.lineTo(0.06,0.82);c.lineTo(0.06,0.85);
c.lineTo(-0.06,0.85);c.lineTo(-0.06,0.82);c.lineTo(-0.02,0.78);c.lineTo(-0.02,0.38);c.quadraticCurveTo(-0.065,0.34,-0.02,0.3);
c.lineTo(-0.025,0.1);c.lineTo(-0.05,0.075);c.quadraticCurveTo(-0.13,0.06,-0.15,0.03);c.closePath();c.fill();
c.strokeStyle='rgba(60,40,10,0.6)';c.lineWidth=0.006;c.stroke();
}
function drawPlate(c,b){
const g=c.createLinearGradient(0,0,0,0.07);g.addColorStop(0,'#bdb8ae');g.addColorStop(1,'#fbf9f4');
c.fillStyle=g;c.beginPath();c.moveTo(-0.25,0);c.lineTo(0.25,0);c.lineTo(0.27,0.015);c.quadraticCurveTo(0.35,0.045,0.425,0.056);
c.lineTo(0.425,0.07);c.lineTo(-0.425,0.07);c.lineTo(-0.425,0.056);c.quadraticCurveTo(-0.35,0.045,-0.27,0.015);c.closePath();c.fill();
c.strokeStyle='#8f8a80';c.lineWidth=0.005;c.stroke();
c.strokeStyle='#2f5d8a';c.lineWidth=0.009;c.beginPath();c.moveTo(-0.415,0.064);c.lineTo(0.415,0.064);c.stroke();
c.fillStyle='#9c978c';c.fillRect(-0.25,0,0.5,0.008);
if(b.cracked)drawCracks(c,b,'rgba(80,80,80,0.9)');
}
function wineBowlPath(c){
c.beginPath();c.moveTo(-0.01,0.32);c.quadraticCurveTo(-0.14,0.36,-0.135,0.5);c.quadraticCurveTo(-0.132,0.62,-0.125,0.72);
c.lineTo(0.125,0.72);c.quadraticCurveTo(0.132,0.62,0.135,0.5);c.quadraticCurveTo(0.14,0.36,0.01,0.32);c.closePath();
}
function drawWine(c,b){
if(!b.spilled){c.save();wineBowlPath(c);c.clip();c.fillStyle='#6a0e1d';c.fillRect(-0.2,0.3,0.4,0.22);
c.fillStyle='#8f2233';c.fillRect(-0.2,0.505,0.4,0.015);c.restore();}
wineBowlPath(c);c.fillStyle='rgba(215,238,255,0.16)';c.fill();
c.strokeStyle='rgba(235,248,255,0.75)';c.lineWidth=0.006;c.stroke();
c.fillStyle='rgba(225,242,255,0.55)';
c.beginPath();c.moveTo(-0.11,0);c.lineTo(0.11,0);c.lineTo(0.1,0.014);c.quadraticCurveTo(0.02,0.02,0.011,0.045);
c.lineTo(0.009,0.32);c.lineTo(-0.009,0.32);c.lineTo(-0.011,0.045);c.quadraticCurveTo(-0.02,0.02,-0.1,0.014);c.closePath();c.fill();
c.strokeStyle='rgba(255,255,255,0.6)';c.lineWidth=0.008;c.beginPath();c.moveTo(-0.09,0.45);c.quadraticCurveTo(-0.1,0.58,-0.09,0.66);c.stroke();
if(b.cracked)drawCracks(c,b,'rgba(255,255,255,0.9)');
}
function drawBowl(c,b){
const g=c.createLinearGradient(-0.3,0,0.3,0.2);g.addColorStop(0,'#174a52');g.addColorStop(0.5,'#2f8790');g.addColorStop(1,'#123a40');
c.fillStyle=g;c.beginPath();c.moveTo(-0.15,0);c.lineTo(0.15,0);c.lineTo(0.16,0.025);c.quadraticCurveTo(0.3,0.07,0.31,0.26);
c.lineTo(-0.31,0.26);c.quadraticCurveTo(-0.3,0.07,-0.16,0.025);c.closePath();c.fill();
c.strokeStyle='#f2ead6';c.lineWidth=0.012;c.beginPath();
for(let x=-0.27;x<=0.27;x+=0.01){const y=0.17+Math.sin(x*60)*0.012;x===-0.27?c.moveTo(x,y):c.lineTo(x,y);}c.stroke();
c.fillStyle='#e8dcc0';c.fillRect(-0.31,0.245,0.62,0.015);
c.fillStyle='#0d2a2e';c.fillRect(-0.15,0,0.3,0.012);
if(b.cracked)drawCracks(c,b,'rgba(240,230,210,0.9)');
}
function drawWater(c,b){
if(!b.spilled){c.fillStyle='rgba(140,195,235,0.42)';c.beginPath();c.moveTo(-0.093,0.05);c.lineTo(0.093,0.05);c.lineTo(0.11,0.38);c.lineTo(-0.11,0.38);c.closePath();c.fill();
c.fillStyle='rgba(235,248,255,0.7)';c.save();c.translate(-0.03,0.33);c.rotate(0.3);c.fillRect(-0.035,-0.035,0.07,0.07);c.restore();
c.save();c.translate(0.045,0.3);c.rotate(-0.4);c.fillRect(-0.03,-0.03,0.06,0.06);c.restore();
c.fillStyle='rgba(200,230,250,0.8)';c.fillRect(-0.11,0.372,0.22,0.01);}
c.fillStyle='rgba(220,240,255,0.14)';c.beginPath();c.moveTo(-0.1,0);c.lineTo(0.1,0);c.lineTo(0.125,0.55);c.lineTo(-0.125,0.55);c.closePath();c.fill();
c.strokeStyle='rgba(235,248,255,0.75)';c.lineWidth=0.007;c.stroke();
c.fillStyle='rgba(230,245,255,0.35)';c.fillRect(-0.1,0,0.2,0.05);
c.strokeStyle='rgba(255,255,255,0.55)';c.lineWidth=0.01;c.beginPath();c.moveTo(-0.08,0.1);c.lineTo(-0.098,0.48);c.stroke();
if(b.cracked)drawCracks(c,b,'rgba(255,255,255,0.9)');
}
function drawCracks(c,b,col){
if(!b.cracks)return;c.strokeStyle=col;c.lineWidth=0.006;
for(const l of b.cracks){c.beginPath();c.moveTo(l[0][0],l[0][1]);for(let i=1;i<l.length;i++)c.lineTo(l[i][0],l[i][1]);c.stroke();}
}
function drawBody(b){
ctx.save();ctx.translate(b.x,b.y);ctx.rotate(b.a);ctx.translate(-b.com[0],-b.com[1]);
b.def.draw(ctx,b);ctx.restore();
if(showHulls){
ctx.strokeStyle='#ff3df0';ctx.lineWidth=0.008;ctx.beginPath();
b.wv.forEach((p,i)=>i?ctx.lineTo(p[0],p[1]):ctx.moveTo(p[0],p[1]));ctx.closePath();ctx.stroke();
ctx.fillStyle='#ff3df0';ctx.beginPath();ctx.arc(b.x,b.y,0.02,0,6.283);ctx.fill();
}
}
function draw(){
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,cv.width,cv.height);
ctx.setTransform(S*dpr,0,0,-S*dpr,-VIEW.x0*S*dpr,(CH+VIEW.y0*S)*dpr);
drawRoom();drawTable();drawCloth();
for(const b of bodies)drawBody(b);
if(showHulls){ctx.strokeStyle='#3df0ff';ctx.lineWidth=0.01;const tr=Math.max(0,clothTrail());
if(tr<W){ctx.beginPath();ctx.moveTo(tr,TOP+0.01);ctx.lineTo(W,TOP+0.01);ctx.stroke();}}
}
function status(){
const el=document.getElementById('status');
if(!pulling){el.textContent=`Ready. Cloth: ${(W+2*O).toFixed(1)} ft over a ${W} ft table, ${O} ft hanging off the far end. Press "Yank" (or load with #speed=N).`;return;}
const tr=clothTrail(),d=vPull*tPull;
const cl=tr>=W?`cloth clear of the table (took ${((W+O)/vPull).toFixed(3)} s)`:`cloth pulled ${d.toFixed(2)} ft of ${(W+O).toFixed(1)}`;
let mv=0;for(const b of bodies)if(Math.hypot(b.vx,b.vy)>0.05||Math.abs(b.w)>0.15)mv++;
el.textContent=`t = ${tPull.toFixed(3)} s · pull ${vPull<1000?vPull.toFixed(1):vPull.toExponential(2)} ft/s · ${cl} · ${done?'settled':mv+' object(s) moving'}`;
}
function frame(ts){
now=ts;const rdt=Math.min((ts-lastFrame)/1000,0.05);lastFrame=ts;
if(pulling&&!done){
acc+=rdt*timeScale;let n=0;
while(acc>0&&n<1500&&!done){const h=curH();step(h);acc-=h;n++;}
if(n>=1500)acc=0;
}
draw();status();requestAnimationFrame(frame);
}
// prediction note
(function(){
const th=DEFS.map(predict);const hi=Math.max(...th),lo=Math.min(...th);
document.getElementById('pred').textContent=
`Single-body prediction from these parameters (g = ${G} ft/s², Coulomb friction, cloth snapped to full speed instantly, object falls when its centre of mass passes the edge): `+
`everything stays at ≥ ${hi.toFixed(2)} ft/s; nothing stays below ${lo.toFixed(2)} ft/s. `+
`Tipping and collisions are not in that formula — the simulation includes them, so the "what happened" column can disagree, and it says so in red.`;
})();
resize();setSpeed(12);reset();
fromHash();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
I have not run this file. What follows describes how it is built and where I expect it to diverge. The table under the canvas puts each object's single-body threshold next to its actual outcome, and turns red when they disagree, so you can check me.
Where it behaves like the physical trick:
Where it does not behave like the physical trick:
One friction coefficient, no static/kinetic split. Under Coulomb friction, mass and contact area cancel out of sliding acceleration. So in this model they matter only through tipping (base width against centre-of-mass height) and through collisions. Real felt and linen are not this tidy.
The cloth is idealised.
Real attempts usually fail because a hem or wrinkle snags a plate, or because the pull angle lifts the cloth. This version cannot fail that way. Above the threshold it is more forgiving than reality: a clean, fast pull always succeeds.
Single-body arithmetic. The prediction treats each base as a point at its centre. The sim spreads friction over two base corners, and tipping moves load onto the rear corner, which leaves the cloth earlier. Expect real thresholds to differ from the printed ones by a few tenths of a ft/s, more for the wine glass.
It is 2D. Glasses can't roll sideways, and plates are profile polygons. Contacts are found by vertex-in-polygon tests, so two thin bodies crossing edge-to-edge (an X with no vertex inside the other) are missed. Penetration is fixed by a linear position nudge (40%, 0.002 ft slop). Restitution switches on only above 1.5 ft/s approach speed.
Materials and timestep:
Thresholds and special cases you would otherwise discover:
#speed=N accepts any number and pulls one second after load.Infinity is clamped to 1e9 ft/s.0 leaves the cloth still; the run "settles" with everything in place.