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.
The lowest pull speed at which every object stays on the table is 15.89 ft/s, and the speed below which no object stays on the table is 2.78 ft/s. In single-body Coulomb sliding with $g = 32.174\text{ ft/s}^2$, each object experiences cloth friction $F_f = \mu_c m g$ and accelerates at $a_c = F_f / m = \mu_c g$ (so mass cancels during unobstructed sliding while governing collision momentum transfer and rotational inertia). For the rightmost object, the Salad Plate ($m_7 = 0.50\text{ kg}$, base half-width $b_7 = 0.24\text{ ft}$, CoM height $h_7 = 0.045\text{ ft}$, $\mu_{c,7} = 0.22$, $\mu_{t,7} = 0.28$), its center sits at $x_7 = 4.88\text{ ft}$ on a $5.50\text{ ft}$ table with the cloth's trailing edge at $x_{c0} = 0.25\text{ ft}$, giving cloth length $D_7 = 4.63\text{ ft}$ behind it and table runway $R_7 = 0.62\text{ ft}$ ahead; its cloth-capture speed is $v_{sync,7} = \sqrt{2 \mu_{c,7} g D_7} = \sqrt{2(0.22)(32.174)(4.63)} = 8.10\text{ ft/s}$, the maximum cloth-exit speed that still stops within $R_7$ is $v_{exit,max,7} = \sqrt{2 g R_7 / (1/\mu_{c,7} + 1/\mu_{t,7})} = \sqrt{39.896 / 8.117} = 2.217\text{ ft/s}$, and solving $v_p - \sqrt{v_p^2 - v_{sync,7}^2} = v_{exit,max,7}$ yields $v_{all} = (v_{sync,7}^2 + v_{exit,max,7}^2)/(2 v_{exit,max,7}) = (65.545 + 4.915)/4.434 = \mathbf{15.89\text{ ft/s}}$. For the leftmost object, the Bread Plate ($m_1 = 0.40\text{ kg}$, $x_1 = 0.85\text{ ft}$, $D_1 = 0.60\text{ ft}$, $\mu_{c,1} = 0.20$, $\mu_{t,1} = 0.28$, runway $R_1 = 4.65\text{ ft}$), its sync speed is $v_{none} = \sqrt{2 \mu_{c,1} g D_1} = \sqrt{2(0.20)(32.174)(0.60)} = \mathbf{2.78\text{ ft/s}}$; below $2.78\text{ ft/s}$, every object (from $m_1 = 0.40\text{ kg}$ up to the $m_4 = 0.95\text{ kg}$ Dinner Plate) accelerates up to the cloth's speed before the trailing edge passes underneath, locks to the cloth, and rides off the edge.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Tablecloth Trick — Rigid-Body Physics Toy</title>
<style>
:root {
--bg: #17131a;
--panel: #231d27;
--panel-border: #3a3040;
--text: #f3ece0;
--muted: #b5a999;
--accent: #e09f4e;
--accent-hover: #f0b261;
--danger: #d95d4e;
--ok: #6dbd7a;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: radial-gradient(circle at 50% 15%, #2a212e 0%, var(--bg) 70%);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Georgia, serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.wrap {
width: 100%;
max-width: 1060px;
padding: 16px 18px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px 18px;
}
h1 {
margin: 0;
font-size: 1.45rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #f9f2e6;
}
.subtitle {
font-size: 0.88rem;
color: var(--muted);
}
.stage-card {
position: relative;
background: #141017;
border: 1px solid var(--panel-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.55);
}
canvas {
display: block;
width: 100%;
height: auto;
user-select: none;
}
.hud-badge {
position: absolute;
top: 12px;
left: 14px;
padding: 6px 11px;
border-radius: 999px;
background: rgba(23, 19, 26, 0.82);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 0.82rem;
color: var(--muted);
letter-spacing: 0.02em;
pointer-events: none;
}
.hud-badge strong { color: var(--text); }
.result-banner {
position: absolute;
top: 12px;
right: 14px;
padding: 10px 15px;
border-radius: 10px;
background: rgba(25, 20, 29, 0.92);
border: 1px solid var(--accent);
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
display: none;
gap: 16px;
align-items: center;
font-size: 0.92rem;
}
.result-banner.visible { display: flex; }
.result-metric {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.result-metric span {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.result-metric strong {
font-size: 1.15rem;
color: #fff7ea;
}
.controls {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px 20px;
}
.speed-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
flex: 1 1 360px;
}
.speed-group label {
font-size: 0.92rem;
color: var(--muted);
white-space: nowrap;
}
input[type="range"] {
flex: 1 1 170px;
accent-color: var(--accent);
cursor: pointer;
height: 6px;
}
.num-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
background: #17131a;
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 4px 10px;
}
input[type="number"] {
width: 68px;
background: transparent;
border: none;
color: var(--text);
font-size: 1rem;
font-weight: 600;
outline: none;
text-align: right;
}
.unit {
font-size: 0.85rem;
color: var(--muted);
}
.btn-group {
display: flex;
gap: 10px;
align-items: center;
}
button {
border: none;
border-radius: 8px;
padding: 10px 18px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.08s ease, background 0.15s ease, opacity 0.15s ease;
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-yank {
background: var(--accent);
color: #1a1209;
}
.btn-yank:hover:not(:disabled) { background: var(--accent-hover); }
.btn-reset {
background: #362d3c;
color: var(--text);
border: 1px solid #4a3f52;
}
.btn-reset:hover:not(:disabled) { background: #43394a; }
.legend {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: 8px;
}
.item-card {
background: rgba(35, 29, 39, 0.72);
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 0.78rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.item-card .name {
font-weight: 600;
color: var(--text);
display: flex;
justify-content: space-between;
align-items: center;
}
.item-card .meta { color: var(--muted); }
.pill {
font-size: 0.68rem;
padding: 1px 6px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: var(--muted);
}
.pill.upright { background: rgba(109, 189, 122, 0.2); color: var(--ok); }
.pill.tipped { background: rgba(224, 159, 78, 0.22); color: var(--accent-hover); }
.pill.fell { background: rgba(217, 93, 78, 0.22); color: #f08a7e; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>The Tablecloth Trick</h1>
<div class="subtitle">Rigid-body Coulomb friction, tipping inertia, and collision dynamics · 5.5 ft oak table</div>
</header>
<div class="stage-card">
<canvas id="scene" width="1020" height="500"></canvas>
<div class="hud-badge" id="statusBadge">Status: <strong>Ready</strong></div>
<div class="result-banner" id="resultBanner">
<div class="result-metric">
<span>On Table</span>
<strong id="onTableCount">7 / 7</strong>
</div>
<div class="result-metric">
<span>Upright</span>
<strong id="uprightCount">7 / 7</strong>
</div>
</div>
</div>
<div class="controls">
<div class="speed-group">
<label for="speedSlider">Pull speed</label>
<input id="speedSlider" type="range" min="0" max="25" step="0.1" value="12" />
<div class="num-wrap">
<input id="speedNumber" type="number" min="0" step="0.1" value="12" />
<span class="unit">ft/s</span>
</div>
</div>
<div class="btn-group">
<button class="btn-yank" id="yankBtn">Yank Cloth</button>
<button class="btn-reset" id="resetBtn">Reset</button>
</div>
</div>
<div class="legend" id="legend"></div>
</div>
<script>
(() => {
"use strict";
// --- Physical Constants (Feet, Kilograms, Seconds) ---
const G = 32.174; // ft/s^2
const TABLE_LEFT = 0.0; // ft
const TABLE_RIGHT = 5.50; // ft
const TABLE_Y = 2.50; // ft above floor
const TABLE_THICK = 0.16; // ft
const CLOTH_INIT_LEFT = 0.25; // ft
const CLOTH_INIT_RIGHT = 6.15; // ft (drapes off right edge)
const FLOOR_FRICTION = 0.42;
const OBJ_OBJ_FRICTION = 0.24;
const RESTITUTION = 0.14;
const RESTITUTION_THRESHOLD = 0.85; // ft/s
const SLOP = 0.0004; // ft
const BAUMGARTE = 0.22;
const SETTLE_TIME_REQ = 0.35; // s
// --- Canvas & Viewport Mapping ---
const canvas = document.getElementById("scene");
const ctx = canvas.getContext("2d");
const PX_PER_FT = 132;
const ORIGIN_X = 76; // screen px for x = 0 ft
const FLOOR_Y = 446; // screen px for y = 0 ft
const toSX = (wx) => ORIGIN_X + wx * PX_PER_FT;
const toSY = (wy) => FLOOR_Y - wy * PX_PER_FT;
// --- Table Setting Definitions ---
// Local origin (0,0) = Center of Mass. Base lies at y = -h, spanning [-b, +b].
const OBJECT_SPECS = [
{
id: "bread",
name: "Bread Plate",
x0: 0.85,
mass: 0.40,
b: 0.22,
h: 0.04,
muCloth: 0.20,
muTable: 0.28,
inertiaFactor: 0.34, // relative to m*(b^2+h^2)
boxes: [{ ox: 0, oy: 0, hx: 0.22, hy: 0.04 }],
kind: "plate-small"
},
{
id: "bowl",
name: "Soup Bowl",
x0: 1.55,
mass: 0.70,
b: 0.20,
h: 0.14,
muCloth: 0.28,
muTable: 0.34,
inertiaFactor: 0.36,
boxes: [
{ ox: 0, oy: -0.08, hx: 0.20, hy: 0.06 },
{ ox: 0, oy: 0.04, hx: 0.26, hy: 0.06 }
],
kind: "bowl"
},
{
id: "wine",
name: "Wine Glass",
x0: 2.18,
mass: 0.22,
b: 0.10,
h: 0.42,
muCloth: 0.34,
muTable: 0.35,
inertiaFactor: 0.25,
boxes: [
{ ox: 0, oy: -0.40, hx: 0.10, hy: 0.02 },
{ ox: 0, oy: -0.14, hx: 0.018, hy: 0.24 },
{ ox: 0, oy: 0.22, hx: 0.11, hy: 0.12 }
],
kind: "wine"
},
{
id: "dinner",
name: "Dinner Plate",
x0: 2.95,
mass: 0.95,
b: 0.36,
h: 0.05,
muCloth: 0.22,
muTable: 0.30,
inertiaFactor: 0.34,
boxes: [{ ox: 0, oy: 0, hx: 0.36, hy: 0.05 }],
kind: "plate-large"
},
{
id: "candle",
name: "Candlestick",
x0: 3.72,
mass: 0.85,
b: 0.14,
h: 0.55,
muCloth: 0.34,
muTable: 0.38,
inertiaFactor: 0.25,
boxes: [
{ ox: 0, oy: -0.51, hx: 0.14, hy: 0.04 },
{ ox: 0, oy: -0.05, hx: 0.042, hy: 0.42 },
{ ox: 0, oy: 0.42, hx: 0.065, hy: 0.05 }
],
kind: "candle"
},
{
id: "tumbler",
name: "Water Glass",
x0: 4.30,
mass: 0.35,
b: 0.13,
h: 0.28,
muCloth: 0.26,
muTable: 0.32,
inertiaFactor: 0.30,
boxes: [
{ ox: 0, oy: -0.02, hx: 0.13, hy: 0.26 }
],
kind: "tumbler"
},
{
id: "salad",
name: "Salad Plate",
x0: 4.88,
mass: 0.50,
b: 0.24,
h: 0.045,
muCloth: 0.22,
muTable: 0.28,
inertiaFactor: 0.34,
boxes: [{ ox: 0, oy: 0, hx: 0.24, hy: 0.045 }],
kind: "plate-med"
}
];
// Static table OBB for SAT collision
const TABLE_BOX = {
cx: (TABLE_LEFT + TABLE_RIGHT) * 0.5,
cy: TABLE_Y - TABLE_THICK * 0.5,
hx: (TABLE_RIGHT - TABLE_LEFT) * 0.5,
hy: TABLE_THICK * 0.5,
ux: { x: 1, y: 0 },
uy: { x: 0, y: 1 }
};
// --- Simulation State ---
let bodies = [];
let cloth = {
left: CLOTH_INIT_LEFT,
right: CLOTH_INIT_RIGHT,
speed: 0,
pulling: false,
cleared: false
};
let simState = "ready"; // "ready" | "running" | "done"
let settleTimer = 0;
let simTime = 0;
let autoPullTimer = null;
let lastFrameTs = null;
let pullSpeedSetting = 12;
// --- UI Elements ---
const speedSlider = document.getElementById("speedSlider");
const speedNumber = document.getElementById("speedNumber");
const yankBtn = document.getElementById("yankBtn");
const resetBtn = document.getElementById("resetBtn");
const statusBadge = document.getElementById("statusBadge");
const resultBanner = document.getElementById("resultBanner");
const onTableCountEl = document.getElementById("onTableCount");
const uprightCountEl = document.getElementById("uprightCount");
const legendEl = document.getElementById("legend");
function createBodies() {
bodies = OBJECT_SPECS.map((spec) => {
const I = spec.inertiaFactor * spec.mass * (spec.b * spec.b + spec.h * spec.h);
return {
spec,
x: spec.x0,
y: TABLE_Y + spec.h,
vx: 0,
vy: 0,
angle: 0,
omega: 0,
mass: spec.mass,
invMass: 1 / spec.mass,
I,
invI: 1 / I,
flameAlive: spec.kind === "candle",
smokePuff: 0
};
});
}
function wrapAngle(a) {
let r = a % (Math.PI * 2);
if (r > Math.PI) r -= Math.PI * 2;
if (r < -Math.PI) r += Math.PI * 2;
return r;
}
function setPullSpeedUI(val) {
const num = Math.max(0, Number.isFinite(val) ? val : 0);
pullSpeedSetting = num;
if (num > Number(speedSlider.max)) {
speedSlider.max = String(Math.ceil(num / 5) * 5);
}
speedSlider.value = String(num);
speedNumber.value = String(Math.round(num * 100) / 100);
}
function updateStatusBadge(text) {
statusBadge.innerHTML = `Status: <strong>${text}</strong>`;
}
function buildLegend() {
legendEl.innerHTML = "";
for (const b of bodies) {
const card = document.createElement("div");
card.className = "item-card";
card.id = `card-${b.spec.id}`;
card.innerHTML = `
<div class="name">
<span>${b.spec.name}</span>
<span class="pill" id="pill-${b.spec.id}">Ready</span>
</div>
<div class="meta">${b.mass.toFixed(2)} kg · base ${(b.spec.b * 2).toFixed(2)} ft · μ=${b.spec.muCloth.toFixed(2)}</div>
`;
legendEl.appendChild(card);
}
}
function updateLegendAndSummary(isFinal) {
let onTable = 0;
let upright = 0;
for (const b of bodies) {
const isOnTable = b.y > TABLE_Y - 0.1 && b.x > TABLE_LEFT - 0.35 && b.x < TABLE_RIGHT + 0.35;
const isUpright = isOnTable && Math.abs(wrapAngle(b.angle)) < 0.25;
if (isOnTable) onTable++;
if (isUpright) upright++;
const pill = document.getElementById(`pill-${b.spec.id}`);
if (!pill) continue;
if (!isFinal && simState === "ready") {
pill.className = "pill";
pill.textContent = "Ready";
} else if (!isFinal) {
pill.className = "pill";
pill.textContent = isOnTable ? (isUpright ? "On table" : "Tipping") : "Falling";
} else {
if (!isOnTable) {
pill.className = "pill fell";
pill.textContent = "Fell";
} else if (isUpright) {
pill.className = "pill upright";
pill.textContent = "Upright";
} else {
pill.className = "pill tipped";
pill.textContent = "Tipped";
}
}
}
if (isFinal) {
onTableCountEl.textContent = `${onTable} / ${bodies.length}`;
uprightCountEl.textContent = `${upright} / ${bodies.length}`;
resultBanner.classList.add("visible");
} else {
resultBanner.classList.remove("visible");
}
}
function resetSim() {
if (autoPullTimer !== null) {
clearTimeout(autoPullTimer);
autoPullTimer = null;
}
createBodies();
cloth = {
left: CLOTH_INIT_LEFT,
right: CLOTH_INIT_RIGHT,
speed: 0,
pulling: false,
cleared: false
};
simState = "ready";
settleTimer = 0;
simTime = 0;
yankBtn.disabled = false;
speedSlider.disabled = false;
speedNumber.disabled = false;
updateStatusBadge("Ready");
updateLegendAndSummary(false);
}
function startPull() {
if (simState !== "ready") return;
if (autoPullTimer !== null) {
clearTimeout(autoPullTimer);
autoPullTimer = null;
}
cloth.speed = pullSpeedSetting;
cloth.pulling = true;
cloth.cleared = pullSpeedSetting <= 0;
simState = "running";
settleTimer = 0;
yankBtn.disabled = true;
speedSlider.disabled = true;
speedNumber.disabled = true;
updateStatusBadge(pullSpeedSetting > 0 ? `Pulling at ${pullSpeedSetting.toFixed(1)} ft/s` : "No pull (0 ft/s)");
}
// --- 2D OBB Geometry & Contact Generation ---
function getWorldOBB(body, localBox) {
const c = Math.cos(body.angle);
const s = Math.sin(body.angle);
return {
cx: body.x + localBox.ox * c - localBox.oy * s,
cy: body.y + localBox.ox * s + localBox.oy * c,
hx: localBox.hx,
hy: localBox.hy,
ux: { x: c, y: s },
uy: { x: -s, y: c }
};
}
function getOBBCorners(obb) {
const exX = obb.ux.x * obb.hx, exY = obb.ux.y * obb.hx;
const eyX = obb.uy.x * obb.hy, eyY = obb.uy.y * obb.hy;
return [
{ x: obb.cx - exX - eyX, y: obb.cy - exY - eyY },
{ x: obb.cx + exX - eyX, y: obb.cy + exY - eyY },
{ x: obb.cx + exX + eyX, y: obb.cy + exY + eyY },
{ x: obb.cx - exX + eyX, y: obb.cy - exY + eyY }
];
}
function projectRadius(obb, axis) {
return obb.hx * Math.abs(obb.ux.x * axis.x + obb.ux.y * axis.y) +
obb.hy * Math.abs(obb.uy.x * axis.x + obb.uy.y * axis.y);
}
function getFaceVertices(obb, normal) {
// Pick face whose outward normal aligns best with `normal`
const d0 = obb.ux.x * normal.x + obb.ux.y * normal.y;
const d1 = obb.uy.x * normal.x + obb.uy.y * normal.y;
if (Math.abs(d0) > Math.abs(d1)) {
const sign = d0 >= 0 ? 1 : -1;
const fx = obb.cx + sign * obb.hx * obb.ux.x;
const fy = obb.cy + sign * obb.hx * obb.ux.y;
return {
v1: { x: fx - obb.hy * obb.uy.x, y: fy - obb.hy * obb.uy.y },
v2: { x: fx + obb.hy * obb.uy.x, y: fy + obb.hy * obb.uy.y },
sideAxis: obb.uy,
sideExtent: obb.hy,
faceCenter: { x: fx, y: fy },
faceNormal: { x: sign * obb.ux.x, y: sign * obb.ux.y }
};
} else {
const sign = d1 >= 0 ? 1 : -1;
const fx = obb.cx + sign * obb.hy * obb.uy.x;
const fy = obb.cy + sign * obb.hy * obb.uy.y;
return {
v1: { x: fx - obb.hx * obb.ux.x, y: fy - obb.hx * obb.ux.y },
v2: { x: fx + obb.hx * obb.ux.x, y: fy + obb.hx * obb.ux.y },
sideAxis: obb.ux,
sideExtent: obb.hx,
faceCenter: { x: fx, y: fy },
faceNormal: { x: sign * obb.uy.x, y: sign * obb.uy.y }
};
}
}
function clipSegmentToLine(v1, v2, n, offset) {
const out = [];
const d1 = v1.x * n.x + v1.y * n.y - offset;
const d2 = v2.x * n.x + v2.y * n.y - offset;
if (d1 <= 0) out.push(v1);
if (d2 <= 0) out.push(v2);
if (d1 * d2 < 0) {
const t = d1 / (d1 - d2);
out.push({
x: v1.x + t * (v2.x - v1.x),
y: v1.y + t * (v2.y - v1.y)
});
}
return out;
}
function collideOBB(obbA, obbB) {
const dx = obbB.cx - obbA.cx;
const dy = obbB.cy - obbA.cy;
const axes = [obbA.ux, obbA.uy, obbB.ux, obbB.uy];
let minOverlap = Infinity;
let bestAxis = null;
for (let i = 0; i < 4; i++) {
const ax = axes[i];
const dist = Math.abs(dx * ax.x + dy * ax.y);
const overlap = projectRadius(obbA, ax) + projectRadius(obbB, ax) - dist;
if (overlap <= 0) return [];
// Slight bias toward A's axes for stability
const tol = i < 2 ? 0.9995 : 1.0;
if (overlap * tol < minOverlap) {
minOverlap = overlap;
bestAxis = { x: ax.x, y: ax.y, owner: i < 2 ? "A" : "B" };
}
}
// Normal points from A to B
if (dx * bestAxis.x + dy * bestAxis.y < 0) {
bestAxis.x = -bestAxis.x;
bestAxis.y = -bestAxis.y;
}
let refOBB, incOBB, refDir;
if (bestAxis.owner === "A") {
refOBB = obbA;
incOBB = obbB;
refDir = { x: bestAxis.x, y: bestAxis.y };
} else {
refOBB = obbB;
incOBB = obbA;
refDir = { x: -bestAxis.x, y: -bestAxis.y };
}
const refFace = getFaceVertices(refOBB, refDir);
const incFace = getFaceVertices(incOBB, { x: -refDir.x, y: -refDir.y });
const sidePos = refFace.faceCenter.x * refFace.sideAxis.x + refFace.faceCenter.y * refFace.sideAxis.y;
let clipped = clipSegmentToLine(incFace.v1, incFace.v2, refFace.sideAxis, sidePos + refFace.sideExtent);
if (clipped.length < 2) return [];
clipped = clipSegmentToLine(
clipped[0],
clipped[1],
{ x: -refFace.sideAxis.x, y: -refFace.sideAxis.y },
-sidePos + refFace.sideExtent
);
if (clipped.length < 2) return [];
const contacts = [];
const refPlaneOffset = refFace.faceCenter.x * refFace.faceNormal.x + refFace.faceCenter.y * refFace.faceNormal.y;
for (const pt of clipped) {
const sep = pt.x * refFace.faceNormal.x + pt.y * refFace.faceNormal.y - refPlaneOffset;
if (sep <= 0.002) {
contacts.push({
x: pt.x,
y: pt.y,
nx: bestAxis.x,
ny: bestAxis.y,
depth: Math.max(0, -sep)
});
}
}
return contacts;
}
// --- Sequential Impulse Physics Substep ---
function stepPhysics(dt) {
// 1. Advance cloth
if (cloth.pulling && !cloth.cleared) {
cloth.left += cloth.speed * dt;
cloth.right += cloth.speed * dt;
if (cloth.left >= TABLE_RIGHT + 0.35) {
cloth.cleared = true;
}
}
// 2. Integrate gravity
for (const b of bodies) {
b.vy -= G * dt;
if (b.kind === "candle" || b.spec.kind === "candle") {
if (b.flameAlive && (Math.abs(wrapAngle(b.angle)) > 0.72 || b.y < TABLE_Y - 0.1)) {
b.flameAlive = false;
b.smokePuff = 1.0;
}
}
if (b.smokePuff > 0) b.smokePuff = Math.max(0, b.smokePuff - dt * 1.4);
}
// 3. Collect all contacts
const contacts = [];
// 3a. Body vs Floor (y = 0) and Body vs Table
for (const b of bodies) {
for (const box of b.spec.boxes) {
const obb = getWorldOBB(b, box);
// Floor corners
const corners = getOBBCorners(obb);
for (const c of corners) {
if (c.y < 0.002) {
contacts.push({
bodyA: null,
bodyB: b,
x: c.x,
y: Math.min(c.y, 0),
nx: 0,
ny: 1,
depth: Math.max(0, -c.y),
mu: FLOOR_FRICTION,
surfVx: 0,
jn: 0,
jt: 0
});
}
}
// Table OBB (A = static table, B = body; normal points table -> body)
const tContacts = collideOBB(TABLE_BOX, obb);
for (const tc of tContacts) {
// Determine whether contact sits on cloth or bare table
const onTopFace = tc.ny > 0.75 && tc.y > TABLE_Y - 0.03;
const overCloth = onTopFace && !cloth.cleared && cloth.pulling &&
tc.x >= cloth.left && tc.x <= TABLE_RIGHT;
const overClothReady = onTopFace && !cloth.pulling &&
tc.x >= cloth.left && tc.x <= TABLE_RIGHT;
const isCloth = overCloth || overClothReady;
contacts.push({
bodyA: null,
bodyB: b,
x: tc.x,
y: tc.y,
nx: tc.nx,
ny: tc.ny,
depth: tc.depth,
mu: isCloth ? b.spec.muCloth : b.spec.muTable,
surfVx: (overCloth && !cloth.cleared) ? cloth.speed : 0,
jn: 0,
jt: 0
});
}
}
}
// 3b. Body vs Body
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const bA = bodies[i];
const bB = bodies[j];
// Quick broadphase AABB distance check
if (Math.abs(bA.x - bB.x) > 1.3 || Math.abs(bA.y - bB.y) > 1.3) continue;
for (const boxA of bA.spec.boxes) {
const obbA = getWorldOBB(bA, boxA);
for (const boxB of bB.spec.boxes) {
const obbB = getWorldOBB(bB, boxB);
const pContacts = collideOBB(obbA, obbB);
for (const pc of pContacts) {
contacts.push({
bodyA: bA,
bodyB: bB,
x: pc.x,
y: pc.y,
nx: pc.nx,
ny: pc.ny,
depth: pc.depth,
mu: OBJ_OBJ_FRICTION,
surfVx: 0,
jn: 0,
jt: 0
});
}
}
}
}
}
// Precompute contact solver properties
for (const c of contacts) {
const bA = c.bodyA;
const bB = c.bodyB;
c.rAx = bA ? c.x - bA.x : 0;
c.rAy = bA ? c.y - bA.y : 0;
c.rBx = c.x - bB.x;
c.rBy = c.y - bB.y;
// Tangent vector (rotated -90 deg from normal: for n=(0,1), t=(1,0))
c.tx = c.ny;
c.ty = -c.nx;
const invMA = bA ? bA.invMass : 0;
const invIA = bA ? bA.invI : 0;
const invMB = bB.invMass;
const invIB = bB.invI;
const rAxN = c.rAx * c.ny - c.rAy * c.nx;
const rBxN = c.rBx * c.ny - c.rBy * c.nx;
c.effN = 1 / (invMA + invMB + invIA * rAxN * rAxN + invIB * rBxN * rBxN);
const rAxT = c.rAx * c.ty - c.rAy * c.tx;
const rBxT = c.rBx * c.ty - c.rBy * c.tx;
c.effT = 1 / (invMA + invMB + invIA * rAxT * rAxT + invIB * rBxT * rBxT);
// Initial relative normal velocity for restitution
const vAx = bA ? bA.vx - bA.omega * c.rAy : c.surfVx;
const vAy = bA ? bA.vy + bA.omega * c.rAx : 0;
const vBx = bB.vx - bB.omega * c.rBy;
const vBy = bB.vy + bB.omega * c.rBx;
const vn0 = (vBx - vAx) * c.nx + (vBy - vAy) * c.ny;
c.restitutionBias = vn0 < -RESTITUTION_THRESHOLD ? -RESTITUTION * vn0 : 0;
c.posBias = (BAUMGARTE / dt) * Math.max(0, c.depth - SLOP);
}
// 4. Sequential Impulse Solver Iterations
const ITERATIONS = 12;
for (let iter = 0; iter < ITERATIONS; iter++) {
for (const c of contacts) {
const bA = c.bodyA;
const bB = c.bodyB;
// Current relative velocity
let vAx = bA ? bA.vx - bA.omega * c.rAy : c.surfVx;
let vAy = bA ? bA.vy + bA.omega * c.rAx : 0;
let vBx = bB.vx - bB.omega * c.rBy;
let vBy = bB.vy + bB.omega * c.rBx;
// Normal impulse
const vn = (vBx - vAx) * c.nx + (vBy - vAy) * c.ny;
let dJn = c.effN * (-vn + c.posBias + c.restitutionBias);
const oldJn = c.jn;
c.jn = Math.max(0, oldJn + dJn);
dJn = c.jn - oldJn;
const pnx = dJn * c.nx;
const pny = dJn * c.ny;
if (bA) {
bA.vx -= pnx * bA.invMass;
bA.vy -= pny * bA.invMass;
bA.omega -= (c.rAx * pny - c.rAy * pnx) * bA.invI;
}
bB.vx += pnx * bB.invMass;
bB.vy += pny * bB.invMass;
bB.omega += (c.rBx * pny - c.rBy * pnx) * bB.invI;
// Recompute relative velocity for friction
vAx = bA ? bA.vx - bA.omega * c.rAy : c.surfVx;
vAy = bA ? bA.vy + bA.omega * c.rAx : 0;
vBx = bB.vx - bB.omega * c.rBy;
vBy = bB.vy + bB.omega * c.rBx;
const vt = (vBx - vAx) * c.tx + (vBy - vAy) * c.ty;
let dJt = -c.effT * vt;
const maxJt = c.mu * c.jn;
const oldJt = c.jt;
c.jt = Math.max(-maxJt, Math.min(maxJt, oldJt + dJt));
dJt = c.jt - oldJt;
const ptx = dJt * c.tx;
const pty = dJt * c.ty;
if (bA) {
bA.vx -= ptx * bA.invMass;
bA.vy -= pty * bA.invMass;
bA.omega -= (c.rAx * pty - c.rAy * ptx) * bA.invI;
}
bB.vx += ptx * bB.invMass;
bB.vy += pty * bB.invMass;
bB.omega += (c.rBx * pty - c.rBy * ptx) * bB.invI;
}
}
// 5. Integrate positions
for (const b of bodies) {
b.x += b.vx * dt;
b.y += b.vy * dt;
b.angle += b.omega * dt;
}
}
function checkSettled(frameDt) {
if (cloth.pulling && !cloth.cleared) {
settleTimer = 0;
return false;
}
let allSlow = true;
for (const b of bodies) {
if (Math.abs(b.vx) > 0.01 || Math.abs(b.vy) > 0.01 || Math.abs(b.omega) > 0.025) {
allSlow = false;
break;
}
}
if (allSlow) {
settleTimer += frameDt;
if (settleTimer >= SETTLE_TIME_REQ) {
for (const b of bodies) {
b.vx = 0;
b.vy = 0;
b.omega = 0;
}
return true;
}
} else {
settleTimer = 0;
}
return false;
}
// --- Rendering ---
function drawBackground() {
// Dining room wall
const wallGrad = ctx.createLinearGradient(0, 0, 0, FLOOR_Y);
wallGrad.addColorStop(0, "#1c1721");
wallGrad.addColorStop(0.6, "#271f2c");
wallGrad.addColorStop(1, "#1e1722");
ctx.fillStyle = wallGrad;
ctx.fillRect(0, 0, canvas.width, FLOOR_Y);
// Warm overhead chandelier pool
const glow = ctx.createRadialGradient(
toSX(2.75), toSY(4.2), 20,
toSX(2.75), toSY(2.6), 470
);
glow.addColorStop(0, "rgba(255, 210, 145, 0.16)");
glow.addColorStop(0.55, "rgba(230, 165, 95, 0.06)");
glow.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, canvas.width, FLOOR_Y);
// Subtle chair-rail molding
ctx.fillStyle = "rgba(255, 240, 220, 0.04)";
ctx.fillRect(0, toSY(2.9), canvas.width, 6);
ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
ctx.fillRect(0, toSY(2.9) + 6, canvas.width, 3);
// Hardwood floor
const floorGrad = ctx.createLinearGradient(0, FLOOR_Y, 0, canvas.height);
floorGrad.addColorStop(0, "#312118");
floorGrad.addColorStop(1, "#1c120d");
ctx.fillStyle = floorGrad;
ctx.fillRect(0, FLOOR_Y, canvas.width, canvas.height - FLOOR_Y);
// Floor plank seams & edge highlight
ctx.fillStyle = "rgba(255, 215, 175, 0.1)";
ctx.fillRect(0, FLOOR_Y, canvas.width, 2);
ctx.strokeStyle = "rgba(0, 0, 0, 0.28)";
ctx.lineWidth = 1;
for (let x = 20; x < canvas.width; x += 110) {
ctx.beginPath();
ctx.moveTo(x, FLOOR_Y);
ctx.lineTo(x - 16, canvas.height);
ctx.stroke();
}
// Subtle scale ticks along the floor
ctx.fillStyle = "rgba(243, 236, 224, 0.25)";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
for (let ft = 0; ft <= 6; ft++) {
const sx = toSX(ft);
ctx.fillRect(sx, FLOOR_Y + 3, 1, 6);
ctx.fillText(`${ft} ft`, sx, FLOOR_Y + 21);
}
}
function drawTable() {
const tx = toSX(TABLE_LEFT);
const tw = (TABLE_RIGHT - TABLE_LEFT) * PX_PER_FT;
const ty = toSY(TABLE_Y);
const th = TABLE_THICK * PX_PER_FT;
// Legs & apron
const legW = 24;
const legH = FLOOR_Y - (ty + th);
for (const lx of [tx + 34, tx + tw - 34 - legW]) {
const legGrad = ctx.createLinearGradient(lx, 0, lx + legW, 0);
legGrad.addColorStop(0, "#4a2a14");
legGrad.addColorStop(0.45, "#6b3f20");
legGrad.addColorStop(1, "#3b200e");
ctx.fillStyle = legGrad;
ctx.fillRect(lx, ty + th, legW, legH);
// Floor contact shadow under legs
ctx.fillStyle = "rgba(0,0,0,0.45)";
ctx.fillRect(lx - 4, FLOOR_Y - 2, legW + 8, 4);
}
// Apron rail
ctx.fillStyle = "#4d2c15";
ctx.fillRect(tx + 34, ty + th, tw - 68, 18);
// Table top slab
const topGrad = ctx.createLinearGradient(0, ty, 0, ty + th);
topGrad.addColorStop(0, "#8d562d");
topGrad.addColorStop(0.25, "#744421");
topGrad.addColorStop(1, "#4f2c13");
ctx.fillStyle = topGrad;
ctx.beginPath();
ctx.roundRect(tx, ty, tw, th, 3);
ctx.fill();
// Specular edge on tabletop
ctx.fillStyle = "rgba(255, 224, 185, 0.22)";
ctx.fillRect(tx + 2, ty, tw - 4, 2);
}
function drawTablecloth() {
if (cloth.left > TABLE_RIGHT + 1.8) return;
const ty = toSY(TABLE_Y);
const leftPx = toSX(cloth.left);
const rightEdgePx = toSX(TABLE_RIGHT);
const clothThickPx = 4;
// Portion resting flat on the table
const flatStart = leftPx;
const flatEnd = Math.min(toSX(cloth.right), rightEdgePx);
if (flatEnd > flatStart) {
ctx.fillStyle = "#f4ede0";
ctx.fillRect(flatStart, ty - clothThickPx, flatEnd - flatStart, clothThickPx);
// Subtle decorative border near trailing left edge
if (flatEnd - flatStart > 16) {
ctx.fillStyle = "#c9a679";
ctx.fillRect(flatStart + 6, ty - clothThickPx, 6, clothThickPx);
}
// Soft contact shadow on wood
ctx.fillStyle = "rgba(0, 0, 0, 0.25)";
ctx.fillRect(flatStart, ty, flatEnd - flatStart, 2);
}
// Overhang / pulled tail on the right
const overhangLen = Math.max(0, toSX(cloth.right) - Math.max(rightEdgePx, leftPx));
if (overhangLen > 2) {
const startX = Math.max(rightEdgePx, leftPx);
const startY = ty - clothThickPx * 0.5;
// When stationary, cloth drapes down; when pulled fast, it streams horizontally!
const pullRatio = cloth.pulling ? Math.min(1, cloth.speed / 10) : 0;
const dropY = (1 - pullRatio) * 68 + pullRatio * 8;
const endX = startX + overhangLen * (0.35 + 0.65 * pullRatio);
const endY = startY + dropY;
ctx.save();
ctx.strokeStyle = "#efe6d5";
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.quadraticCurveTo(
startX + (endX - startX) * 0.55,
startY + dropY * 0.2,
endX,
endY
);
ctx.stroke();
// Stylized grip / pull handle at the right end
ctx.fillStyle = cloth.pulling ? "#e09f4e" : "#b89f82";
ctx.beginPath();
ctx.arc(endX, endY, 6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function drawObject(b, nowSec) {
const sx = toSX(b.x);
const sy = toSY(b.y);
const s = PX_PER_FT;
ctx.save();
ctx.translate(sx, sy);
ctx.rotate(-b.angle); // canvas y is down, physics angle is CCW (y-up)
const k = b.spec.kind;
if (k === "plate-small" || k === "plate-med" || k === "plate-large") {
const hw = b.spec.b * s;
const hh = b.spec.h * s;
// Porcelain plate cross-section
ctx.fillStyle = "#f7f3eb";
ctx.strokeStyle = "#cfc5b4";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(-hw, hh);
ctx.lineTo(-hw * 1.12, -hh * 0.5);
ctx.lineTo(-hw * 1.04, -hh);
ctx.lineTo(hw * 1.04, -hh);
ctx.lineTo(hw * 1.12, -hh * 0.5);
ctx.lineTo(hw, hh);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Gold rim stripe
ctx.fillStyle = "#c89b54";
ctx.fillRect(-hw * 1.05, -hh, hw * 2.1, 2);
if (k === "plate-large") {
ctx.fillStyle = "#dfd7c8";
ctx.fillRect(-hw * 0.72, -hh + 3, hw * 1.44, 2);
}
} else if (k === "bowl") {
const hwBase = b.spec.b * s;
const hh = b.spec.h * s;
const hwTop = hwBase * 1.32;
const grad = ctx.createLinearGradient(-hwTop, 0, hwTop, 0);
grad.addColorStop(0, "#2b466b");
grad.addColorStop(0.35, "#426796");
grad.addColorStop(0.7, "#32517a");
grad.addColorStop(1, "#223959");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(-hwBase, hh);
ctx.quadraticCurveTo(-hwTop, hh * 0.2, -hwTop, -hh);
ctx.lineTo(hwTop, -hh);
ctx.quadraticCurveTo(hwTop, hh * 0.2, hwBase, hh);
ctx.closePath();
ctx.fill();
// Cream rim
ctx.fillStyle = "#efe7d6";
ctx.fillRect(-hwTop, -hh, hwTop * 2, 3.5);
} else if (k === "wine") {
const footW = b.spec.b * s;
const hh = b.spec.h * s;
const bowlW = 0.11 * s;
// Crystal foot & stem
ctx.fillStyle = "rgba(225, 242, 255, 0.55)";
ctx.strokeStyle = "rgba(235, 248, 255, 0.85)";
ctx.lineWidth = 1.3;
// Foot
ctx.beginPath();
ctx.moveTo(-footW, hh);
ctx.lineTo(footW, hh);
ctx.lineTo(footW * 0.3, hh - 4);
ctx.lineTo(-footW * 0.3, hh - 4);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Stem
ctx.fillRect(-2.2, -0.10 * s, 4.4, hh - 4 + 0.10 * s);
// Bowl
ctx.beginPath();
ctx.moveTo(-bowlW * 0.92, -0.34 * s);
ctx.quadraticCurveTo(-bowlW * 1.15, -0.11 * s, 0, -0.10 * s);
ctx.quadraticCurveTo(bowlW * 1.15, -0.11 * s, bowlW * 0.92, -0.34 * s);
ctx.closePath();
ctx.fillStyle = "rgba(220, 240, 255, 0.28)";
ctx.fill();
ctx.stroke();
// Red wine inside
ctx.beginPath();
ctx.moveTo(-bowlW * 0.88, -0.22 * s);
ctx.quadraticCurveTo(-bowlW * 0.95, -0.12 * s, 0, -0.11 * s);
ctx.quadraticCurveTo(bowlW * 0.95, -0.12 * s, bowlW * 0.88, -0.22 * s);
ctx.closePath();
ctx.fillStyle = "rgba(155, 22, 42, 0.85)";
ctx.fill();
// Glass highlight
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fillRect(-bowlW * 0.65, -0.31 * s, 2.5, 0.14 * s);
} else if (k === "tumbler") {
const hw = b.spec.b * s;
const hh = b.spec.h * s;
// Glass body
ctx.fillStyle = "rgba(210, 238, 250, 0.26)";
ctx.strokeStyle = "rgba(225, 245, 255, 0.78)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.rect(-hw, -hh, hw * 2, hh * 2);
ctx.fill();
ctx.stroke();
// Water inside
ctx.fillStyle = "rgba(115, 195, 225, 0.35)";
ctx.fillRect(-hw + 2, -hh * 0.55, hw * 2 - 4, hh * 1.45 - 6);
// Thick glass bottom
ctx.fillStyle = "rgba(225, 245, 255, 0.45)";
ctx.fillRect(-hw + 1, hh - 7, hw * 2 - 2, 6);
// Specular streak
ctx.fillStyle = "rgba(255,255,255,0.4)";
ctx.fillRect(-hw + 4, -hh + 4, 3, hh * 1.7);
} else if (k === "candle") {
const footW = b.spec.b * s;
const hh = b.spec.h * s;
const brassGrad = ctx.createLinearGradient(-footW, 0, footW, 0);
brassGrad.addColorStop(0, "#8f6926");
brassGrad.addColorStop(0.35, "#e6c16c");
brassGrad.addColorStop(0.65, "#b98a35");
brassGrad.addColorStop(1, "#705019");
ctx.fillStyle = brassGrad;
// Brass foot
ctx.beginPath();
ctx.moveTo(-footW, hh);
ctx.lineTo(footW, hh);
ctx.lineTo(footW * 0.55, hh - 10);
ctx.lineTo(-footW * 0.55, hh - 10);
ctx.closePath();
ctx.fill();
// Brass shaft & collar
ctx.fillRect(-5.5, -0.02 * s, 11, hh - 10 + 0.02 * s);
ctx.beginPath();
ctx.roundRect(-9, -0.05 * s, 18, 9, 3);
ctx.fill();
// Wax taper candle
ctx.fillStyle = "#f7eedb";
ctx.fillRect(-5.2, -0.46 * s, 10.4, 0.41 * s);
ctx.fillStyle = "rgba(210, 195, 170, 0.5)";
ctx.fillRect(1, -0.46 * s, 4.2, 0.41 * s);
// Wick
ctx.strokeStyle = "#2b201b";
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(0, -0.46 * s);
ctx.lineTo(0, -0.49 * s);
ctx.stroke();
// Flame (if lit)
if (b.flameAlive) {
const flicker = Math.sin(nowSec * 19 + b.x * 5) * 1.5;
const fx = flicker * 0.5;
const fy = -0.52 * s;
// Halo
const halo = ctx.createRadialGradient(fx, fy - 4, 2, fx, fy - 4, 26);
halo.addColorStop(0, "rgba(255, 210, 100, 0.55)");
halo.addColorStop(1, "rgba(255, 160, 40, 0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(fx, fy - 4, 26, 0, Math.PI * 2);
ctx.fill();
// Outer flame
ctx.fillStyle = "#ff9d2e";
ctx.beginPath();
ctx.moveTo(0, fy + 4);
ctx.quadraticCurveTo(-5, fy - 4, fx, fy - 14);
ctx.quadraticCurveTo(5, fy - 4, 0, fy + 4);
ctx.fill();
// Inner core
ctx.fillStyle = "#fff5ba";
ctx.beginPath();
ctx.moveTo(0, fy + 3);
ctx.quadraticCurveTo(-2.5, fy - 2, fx * 0.5, fy - 8);
ctx.quadraticCurveTo(2.5, fy - 2, 0, fy + 3);
ctx.fill();
} else if (b.smokePuff > 0) {
ctx.fillStyle = `rgba(210, 205, 215, ${b.smokePuff * 0.45})`;
const r = (1 - b.smokePuff) * 18 + 4;
ctx.beginPath();
ctx.arc(0, -0.53 * s - (1 - b.smokePuff) * 24, r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
function render(nowMs) {
const nowSec = nowMs * 0.001;
drawBackground();
drawTable();
drawTablecloth();
for (const b of bodies) {
drawObject(b, nowSec);
}
}
// --- Main Loop ---
function loop(ts) {
if (lastFrameTs === null) lastFrameTs = ts;
const rawDt = Math.min(0.05, Math.max(0.001, (ts - lastFrameTs) / 1000));
lastFrameTs = ts;
if (simState === "running") {
// Adaptive substep count so fast cloth pulls never skip across bases
const minSubsteps = 12;
const clothSubsteps = cloth.pulling && !cloth.cleared
? Math.ceil((cloth.speed * rawDt) / 0.04)
: minSubsteps;
const substeps = Math.max(minSubsteps, Math.min(180, clothSubsteps));
const dt = rawDt / substeps;
for (let i = 0; i < substeps; i++) {
stepPhysics(dt);
}
simTime += rawDt;
if (checkSettled(rawDt)) {
simState = "done";
updateStatusBadge("Settled");
updateLegendAndSummary(true);
} else {
updateLegendAndSummary(false);
}
}
render(ts);
requestAnimationFrame(loop);
}
// --- Controls & Hash Bootstrapping ---
speedSlider.addEventListener("input", () => {
setPullSpeedUI(parseFloat(speedSlider.value));
});
speedNumber.addEventListener("input", () => {
const v = parseFloat(speedNumber.value);
if (!Number.isNaN(v) && v >= 0) {
pullSpeedSetting = v;
if (v > Number(speedSlider.max)) {
speedSlider.max = String(Math.ceil(v / 5) * 5);
}
speedSlider.value = String(v);
}
});
speedNumber.addEventListener("blur", () => {
setPullSpeedUI(parseFloat(speedNumber.value));
});
yankBtn.addEventListener("click", startPull);
resetBtn.addEventListener("click", resetSim);
function applyHashAndMaybePull() {
const match = window.location.hash.match(/speed=([-+]?\d*\.?\d+)/i);
if (match) {
const parsed = Math.max(0, parseFloat(match[1]));
if (Number.isFinite(parsed)) {
resetSim();
setPullSpeedUI(parsed);
autoPullTimer = setTimeout(() => {
autoPullTimer = null;
startPull();
}, 1000);
}
}
}
window.addEventListener("hashchange", applyHashAndMaybePull);
// Initialize
createBodies();
buildLegend();
setPullSpeedUI(12);
updateLegendAndSummary(false);
applyHashAndMaybePull();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
The simulation uses a custom 2D Sequential Impulse rigid-body solver (oriented bounding boxes with SAT + clipped-segment manifolds, 12+ substeps per frame, 12 solver iterations per substep) with Coulomb friction at every contact point. Slow pulls below 2.78 ft/s carry every item off the table; fast pulls at or above 15.89 ft/s leave every item on the table; and intermediate speeds separate the objects: wide plates slide cleanly, the tall wine glass ($b=0.10\text{ ft}, h=0.42\text{ ft}, \mu_c h > b$) and candlestick ($b=0.14\text{ ft}, h=0.55\text{ ft}, \mu_c h > b$) tip backward unless the cloth escapes quickly enough for them to wobble and settle back upright, and objects closer to the right edge run out of table and tumble onto the floor.
Where it differs from a real dinner table, plus its special cases and failure modes:
pullSpeed with no hand acceleration ramp, no fabric stretch or wrinkles, no hem lip to catch flat plates, and no downward drag over the right table edge.0.0004 ft, bounce only triggers above 0.85 ft/s impact speed, and simulation concludes once the cloth clears x = 5.85 ft (or pullSpeed <= 0) and all objects remain below 0.01 ft/s linear and 0.025 rad/s angular speed for 0.35 s. Counts check y > 2.40 ft (within table bounds) for "on table" and |angle| < 0.25 rad (~14.3°) for "upright." The candlestick flame extinguishing with a fading smoke puff when tilted past 0.72 rad is visual only.#speed=12), the rightmost Salad Plate runs out of table and crashes to the floor while the candlestick tips backward; around 5–10 ft/s, backward-tipping glassware knocks into neighbors and creates chain collisions; and during sharp multi-corner impacts on the table edge or floor, Baumgarte position correction can add slight extra bounce or spin.