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 $4.0\text{ m}$ hose is discretized into $N = 41$ lumped-mass nodes ($40$ segments of $ds = 0.10\text{ m}$, linear density $\lambda = 0.42\text{ kg/m}$, plus a $0.095\text{ kg}$ brass nozzle at the free tip) with the first segment clamped horizontally at the spigot. Bending elasticity and internal rubber damping are computed explicitly from discrete joint angles $\theta_i$ via Kirchhoff joint torques $M_i = \frac{EI}{ds}(\theta_i - \theta_{0,i}) + \frac{\eta_b}{ds}\dot{\theta}i$ ($EI = 0.75\text{ N}\cdot\text{m}^2$, $\eta_b = 0.0007\text{ N}\cdot\text{m}^2\cdot\text{s}$, with static reel-curl memory $\theta{0,i}$). Integration runs at $16$ substeps per $1/60\text{ s}$ frame using a Projected Gauss-Seidel (PGS) sequential-impulse velocity solver that enforces segment inextensibility $(\vec{v}{j+1} - \vec{v}j)\cdot\hat{t}j = 0$, followed by position-only length and minimum bend-radius ($78^\circ$/segment) drift stabilization so geometric projection never injects ghost velocities. Converting gauge pressure $P{\text{psi}}$ to Pascals ($P = 6894.76,P{\text{psi}}$) for a $d_n = 6.0\text{ mm}$ nozzle orifice ($C_d = 0.92$) on a $d_h = 16.0\text{ mm}$ hose (area ratio $\beta = A_n/A_h = 0.1406$), the total jet momentum flux is $F(P) = 2 C_d A_n P = 0.3587,P{\text{psi}}\text{ N}$; by control-volume momentum balance this acts as a compressive follower thrust $-(1 - \beta)F(P)\hat{t}{N-1}$ on the nozzle node together with centrifugal bend forces $\beta F(P)(\hat{t}{i-1} - \hat{t}i)$ and fluid Coriolis forces $-2\dot{m}[(\vec{v}i - \vec{v}{i-1})\cdot\hat{n}{i-1}]\hat{n}{i-1}$ along interior joints. Ground friction is solved simultaneously inside the PGS velocity iterations by clamping each free node's accumulated friction impulse to the isotropic dry Coulomb disk $|\vec{p}{f,i}| \le \mu m_i g,dt$ ($\mu = 0.48$, giving exact zero-creep static stick below yield and kinetic sliding above), supplemented by quadratic turf/air drag $c_d = 0.38\text{ N}\cdot\text{s}^2/\text{m}^3$ at high whipping speeds.
Balancing the non-conservative follower-force work of a traveling Beck flutter wave above $F_{\text{Beck}}(\ell) = \frac{20.05,EI}{\ell^2}$ against per-cycle Coulomb grass dissipation ($q = \mu\lambda g = 1.98\text{ N/m}$, $f_{\text{noz}} = \mu m_{\text{noz}} g = 0.45\text{ N}$) and viscoelastic bending loss yields a minimum critical thrust $F_{\text{crit}} \approx 7.9\text{ N}$ at an active tail length $\ell^* \approx 2.0\text{ m}$; dividing by $0.3587\text{ N/psi}$ gives the transition pressure:
22 psi
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dropped Garden Hose</title>
<style>
* { box-sizing: border-box; user-select: none; -webkit-user-select: none; }
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #1f3b18;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#sim {
display: block;
width: 100vw;
height: 100vh;
cursor: grab;
touch-action: none;
}
#sim.grabbing {
cursor: grabbing;
}
#hud {
position: fixed;
left: 20px;
bottom: 20px;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 18px 12px 14px;
background: rgba(18, 28, 16, 0.84);
border: 1px solid rgba(198, 218, 178, 0.24);
border-radius: 16px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
color: #eef5e8;
z-index: 10;
}
#gauge {
width: 76px;
height: 76px;
flex-shrink: 0;
}
.controls {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 215px;
}
.top-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #a9c49a;
}
.readout {
font-variant-numeric: tabular-nums;
font-size: 20px;
font-weight: 700;
color: #fff8e6;
}
.readout span {
font-size: 12px;
font-weight: 600;
color: #c9b37e;
margin-left: 2px;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
border-radius: 999px;
background: linear-gradient(90deg, #2b4a34 0%, #3d85ab 55%, #e07a3c 100%);
outline: none;
margin: 4px 0;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff6d6, #d4a44c 68%, #8f6319 100%);
border: 2px solid #231a0b;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff6d6, #d4a44c 68%, #8f6319 100%);
border: 2px solid #231a0b;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
.ticks {
display: flex;
justify-content: space-between;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
color: #8fa982;
}
.ticks button {
background: none;
border: none;
color: inherit;
font: inherit;
padding: 0;
cursor: pointer;
transition: color 0.15s;
}
.ticks button:hover {
color: #fff4d2;
}
#hint {
position: fixed;
top: 16px;
right: 20px;
padding: 7px 13px;
border-radius: 999px;
background: rgba(18, 28, 16, 0.68);
border: 1px solid rgba(198, 218, 178, 0.18);
color: #cfe0c4;
font-size: 12px;
letter-spacing: 0.02em;
pointer-events: none;
}
@media (max-width: 600px) {
#hud { left: 12px; right: 12px; bottom: 12px; gap: 12px; }
.controls { min-width: 0; flex: 1; }
#hint { top: 10px; right: 12px; font-size: 11px; }
}
</style>
</head>
<body>
<canvas id="sim"></canvas>
<div id="hint">Click & drag the hose to grab it · Release to drop</div>
<div id="hud">
<canvas id="gauge" width="152" height="152"></canvas>
<div class="controls">
<div class="top-row">
<div class="label">Water Pressure</div>
<div class="readout"><strong id="psiText">100</strong><span>psi</span></div>
</div>
<input id="psiSlider" type="range" min="0" max="100" step="0.5" value="100" aria-label="Water pressure in psi">
<div class="ticks">
<button type="button" data-psi="0">0 OFF</button>
<button type="button" data-psi="15">15</button>
<button type="button" data-psi="40">40</button>
<button type="button" data-psi="70">70</button>
<button type="button" id="fullBtn" data-psi="100">100 FULL</button>
</div>
</div>
</div>
<script>
(() => {
'use strict';
// --- Physical constants (SI units: m, kg, s, N, Pa) ---
const PSI_TO_PA = 6894.76;
const RHO_WATER = 1000.0;
const G = 9.81;
const N_SEG = 40;
const N_NODES = N_SEG + 1;
const DS = 0.10; // 4.0 m total inextensible hose length
const LAMBDA = 0.42; // kg/m (water-filled 5/8" rubber hose)
const M_NODE = LAMBDA * DS; // 0.042 kg per interior node
const M_NOZZLE = 0.095; // 0.095 kg solid brass twist nozzle
const EI = 0.75; // N*m^2 flexural rigidity
const ETA_BEND = 0.0007; // N*m^2*s viscoelastic rubber damping
const MU_FRIC = 0.48; // Coulomb friction coefficient on grass
const C_DRAG = 0.38; // N*s^2/m^3 high-speed turf/air drag
const MIN_SPAN = 1.55 * DS; // 78.5 deg max joint bend (prevents kinks)
const D_HOSE = 0.016; // 16 mm inner diameter
const D_NOZZLE = 0.006; // 6.0 mm nozzle orifice
const A_HOSE = Math.PI * 0.25 * D_HOSE * D_HOSE;
const A_NOZZLE = Math.PI * 0.25 * D_NOZZLE * D_NOZZLE;
const AREA_RATIO = A_NOZZLE / A_HOSE; // ~0.1406
const C_DISCHARGE = 0.92;
// F(psi) = 2 * C_d * A_n * P_Pa = 0.35870 N/psi
const BASE_DT = 1 / 60;
const BASE_SUBSTEPS = 16;
const PGS_ITERS = 16;
const POS_ITERS = 8;
// --- Hose state arrays ---
const x = new Float64Array(N_NODES);
const y = new Float64Array(N_NODES);
const vx = new Float64Array(N_NODES);
const vy = new Float64Array(N_NODES);
const fx = new Float64Array(N_NODES);
const fy = new Float64Array(N_NODES);
const mass = new Float64Array(N_NODES);
const invMass = new Float64Array(N_NODES);
const theta0 = new Float64Array(N_NODES);
const pFricX = new Float64Array(N_NODES);
const pFricY = new Float64Array(N_NODES);
const segTx = new Float64Array(N_SEG);
const segTy = new Float64Array(N_SEG);
const segLen = new Float64Array(N_SEG);
for (let i = 0; i < N_NODES; i++) {
mass[i] = (i === N_NODES - 1) ? M_NOZZLE : M_NODE;
invMass[i] = (i <= 1) ? 0.0 : 1.0 / mass[i];
// Subtle coiled-reel rest curvature (static property of rubber, zero runtime noise)
theta0[i] = 0.42 * DS * Math.sin((1.5 * Math.PI * i) / N_SEG);
}
function initHoseShape() {
x[0] = 0.0; y[0] = 0.0;
x[1] = DS; y[1] = 0.0;
vx[0] = vy[0] = vx[1] = vy[1] = 0.0;
for (let j = 1; j < N_SEG; j++) {
const s = j / N_SEG;
const phi = 0.56 * Math.sin(j * 0.115) - 0.24 * s * s;
x[j + 1] = x[j] + DS * Math.cos(phi);
y[j + 1] = y[j] + DS * Math.sin(phi);
vx[j + 1] = 0.0;
vy[j + 1] = 0.0;
}
}
initHoseShape();
// --- Pressure control & URL hash (#psi=...) ---
const slider = document.getElementById('psiSlider');
const psiText = document.getElementById('psiText');
const fullBtn = document.getElementById('fullBtn');
const gaugeCanvas = document.getElementById('gauge');
const gaugeCtx = gaugeCanvas.getContext('2d');
let psi = 100.0;
let sliderMax = 100.0;
let valveAngle = 0.0;
let needlePsi = 100.0;
// Interaction state
let grabIdx = -1;
let mouseWorldX = 0.0;
let mouseWorldY = 0.0;
let hoverIdx = -1;
function setPressure(val) {
const clean = Number.isFinite(val) ? Math.max(0, Math.min(500, val)) : 0;
psi = clean;
if (clean > sliderMax) {
sliderMax = Math.ceil(clean / 10) * 10;
slider.max = String(sliderMax);
fullBtn.dataset.psi = String(sliderMax);
fullBtn.textContent = sliderMax + ' FULL';
}
slider.value = String(clean);
psiText.textContent = Number.isInteger(Math.round(clean * 10) / 10)
? String(Math.round(clean))
: clean.toFixed(1);
}
function applyHashIfPresent() {
const match = window.location.hash.match(/psi=([+-]?\d*\.?\d+)/i);
if (match) {
const parsed = parseFloat(match[1]);
if (Number.isFinite(parsed)) {
grabIdx = -1; // Nozzle explicitly free on hash load
canvas.classList.remove('grabbing');
setPressure(Math.max(0, parsed));
needlePsi = psi;
return true;
}
}
return false;
}
slider.addEventListener('input', () => {
setPressure(parseFloat(slider.value));
});
document.querySelectorAll('.ticks button').forEach((btn) => {
btn.addEventListener('click', () => {
setPressure(parseFloat(btn.dataset.psi));
});
});
// --- Canvas, Camera & Procedural Lawn / Wetness ---
const canvas = document.getElementById('sim');
const ctx = canvas.getContext('2d');
let viewW = 1200;
let viewH = 800;
let dpr = 1;
let ppm = 115; // pixels per meter
let originX = 240; // spigot screen X in CSS px
let originY = 400; // spigot screen Y in CSS px
// Wetness grid in fixed world bounds: x in [-2.2, 9.8] (12m), y in [-4.5, 4.5] (9m)
const WET_X0 = -2.2, WET_W_M = 12.0;
const WET_Y0 = -4.5, WET_H_M = 9.0;
const WET_COLS = 240, WET_ROWS = 180;
const wetGrid = new Float32Array(WET_COLS * WET_ROWS);
const wetCanvas = document.createElement('canvas');
wetCanvas.width = WET_COLS;
wetCanvas.height = WET_ROWS;
const wetCtx = wetCanvas.getContext('2d');
const wetImg = wetCtx.createImageData(WET_COLS, WET_ROWS);
// Procedural grass tile (baked once offline at startup)
const grassTile = document.createElement('canvas');
grassTile.width = 256;
grassTile.height = 256;
let grassPattern = null;
function buildGrassTile() {
const gctx = grassTile.getContext('2d');
gctx.fillStyle = '#366428';
gctx.fillRect(0, 0, 256, 256);
// Deterministic PRNG solely for static grass blade texture at load
let seed = 133742;
const rand = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
for (let i = 0; i < 4200; i++) {
const gx = rand() * 256;
const gy = rand() * 256;
const len = 4 + rand() * 7;
const ang = -1.35 + (rand() - 0.5) * 0.7;
const shade = rand();
if (shade < 0.33) gctx.strokeStyle = 'rgba(34, 68, 23, 0.45)';
else if (shade < 0.72) gctx.strokeStyle = 'rgba(72, 124, 48, 0.38)';
else gctx.strokeStyle = 'rgba(112, 162, 66, 0.28)';
gctx.lineWidth = 1.1;
gctx.beginPath();
gctx.moveTo(gx, gy);
gctx.lineTo(gx + Math.cos(ang) * len, gy + Math.sin(ang) * len);
gctx.stroke();
}
// Tiny clover / wild lawn flecks
for (let i = 0; i < 90; i++) {
const cx = rand() * 256;
const cy = rand() * 256;
gctx.fillStyle = rand() > 0.15 ? 'rgba(44, 84, 30, 0.35)' : 'rgba(235, 238, 195, 0.28)';
gctx.beginPath();
gctx.arc(cx, cy, 1.2 + rand() * 1.1, 0, Math.PI * 2);
gctx.fill();
}
}
buildGrassTile();
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
viewW = window.innerWidth;
viewH = window.innerHeight;
canvas.width = Math.round(viewW * dpr);
canvas.height = Math.round(viewH * dpr);
// Fit ~9.6 m horizontally and ~6.4 m vertically so the 4 m hose + water arc fit comfortably
ppm = Math.min(viewW / 9.4, viewH / 6.2);
ppm = Math.max(54, Math.min(165, ppm));
originX = Math.max(115, viewW * 0.21);
originY = viewH * 0.48;
grassPattern = ctx.createPattern(grassTile, 'repeat');
}
window.addEventListener('resize', resize);
resize();
if (!applyHashIfPresent()) {
setPressure(100);
needlePsi = 100;
}
window.addEventListener('hashchange', applyHashIfPresent);
function worldToScreenX(wx) { return originX + wx * ppm; }
function worldToScreenY(wy) { return originY + wy * ppm; }
function screenToWorldX(sx) { return (sx - originX) / ppm; }
function screenToWorldY(sy) { return (sy - originY) / ppm; }
// --- Water Droplet & Ground Splash Particle Pools ---
const MAX_DROPS = 1800;
const dropActive = new Uint8Array(MAX_DROPS);
const dropX = new Float32Array(MAX_DROPS);
const dropY = new Float32Array(MAX_DROPS);
const dropZ = new Float32Array(MAX_DROPS);
const dropVX = new Float32Array(MAX_DROPS);
const dropVY = new Float32Array(MAX_DROPS);
const dropVZ = new Float32Array(MAX_DROPS);
const dropSize = new Float32Array(MAX_DROPS);
let dropCursor = 0;
let emitCarry = 0;
let dropDetSeq = 1; // deterministic sequence for spray cone micro-fan
const MAX_SPLASH = 420;
const splashActive = new Uint8Array(MAX_SPLASH);
const splashX = new Float32Array(MAX_SPLASH);
const splashY = new Float32Array(MAX_SPLASH);
const splashAge = new Float32Array(MAX_SPLASH);
const splashLife = new Float32Array(MAX_SPLASH);
const splashRad = new Float32Array(MAX_SPLASH);
let splashCursor = 0;
function nextSpraySample() {
// Low-discrepancy deterministic Halton-style sequence for spray fan (not applied to hose!)
dropDetSeq = (dropDetSeq + 1) % 1024;
let i = dropDetSeq, f = 1, r2 = 0;
while (i > 0) { f *= 0.5; r2 += f * (i & 1); i >>= 1; }
let j = dropDetSeq, g = 1, r3 = 0;
while (j > 0) { g /= 3; r3 += g * (j % 3); j = (j / 3) | 0; }
return [r2 * 2 - 1, r3 * 2 - 1];
}
function addWetness(wx, wy, amount) {
const gx = ((wx - WET_X0) / WET_W_M) * WET_COLS;
const gy = ((wy - WET_Y0) / WET_H_M) * WET_ROWS;
const cx = gx | 0;
const cy = gy | 0;
if (cx < 2 || cx >= WET_COLS - 2 || cy < 2 || cy >= WET_ROWS - 2) return;
for (let dy = -2; dy <= 2; dy++) {
const rowOff = (cy + dy) * WET_COLS;
for (let dx = -2; dx <= 2; dx++) {
const d2 = dx * dx + dy * dy;
if (d2 <= 5) {
const w = amount * (1 - d2 / 6);
const idx = rowOff + cx + dx;
const nv = wetGrid[idx] + w;
wetGrid[idx] = nv > 1.0 ? 1.0 : nv;
}
}
}
}
function spawnSplash(wx, wy, speed) {
const idx = splashCursor;
splashCursor = (splashCursor + 1) % MAX_SPLASH;
splashActive[idx] = 1;
splashX[idx] = wx;
splashY[idx] = wy;
splashAge[idx] = 0;
splashLife[idx] = 0.22 + Math.min(0.25, speed * 0.012);
splashRad[idx] = 0.04 + Math.min(0.11, speed * 0.0045);
}
function spawnDroplets(dt) {
if (psi <= 0.15) return;
const pPa = psi * PSI_TO_PA;
const vJet = Math.sqrt((2.0 * pPa) / RHO_WATER);
// Nozzle tip position & exit tangent vector
const tx = x[N_NODES - 1] - x[N_NODES - 2];
const ty = y[N_NODES - 1] - y[N_NODES - 2];
const len = Math.hypot(tx, ty) || DS;
const ux = tx / len;
const uy = ty / len;
const nx = -uy;
const ny = ux;
// Brass nozzle orifice extends 6 cm past the last joint center
const nozTipX = x[N_NODES - 1] + ux * 0.055;
const nozTipY = y[N_NODES - 1] + uy * 0.055;
const nozVX = vx[N_NODES - 1];
const nozVY = vy[N_NODES - 1];
const rate = 45 + Math.min(460, psi * 4.4); // droplets per second
emitCarry += rate * dt;
const count = emitCarry | 0;
emitCarry -= count;
for (let k = 0; k < count; k++) {
const frac = (k + 0.5) / count;
const [s1, s2] = nextSpraySample();
const spreadAz = s1 * (0.025 + 0.032 * Math.min(1, psi / 90));
const speedScale = 0.88 + 0.22 * (0.5 + 0.5 * s2);
const speed = vJet * speedScale;
const cosA = Math.cos(spreadAz);
const sinA = Math.sin(spreadAz);
const dirX = ux * cosA - uy * sinA;
const dirY = ux * sinA + uy * cosA;
const idx = dropCursor;
dropCursor = (dropCursor + 1) % MAX_DROPS;
dropActive[idx] = 1;
const dvx = dirX * speed + nozVX * 0.35;
const dvy = dirY * speed + nozVY * 0.35;
// Nozzle collar rests slightly tilted off turf (~5 deg mean upward pitch)
const dvz = speed * (0.082 + 0.032 * s2);
// Sub-frame interpolation along jet so high-rate streams have no frame banding
const subT = (1 - frac) * dt;
dropX[idx] = nozTipX + dvx * subT + nx * (s1 * 0.005);
dropY[idx] = nozTipY + dvy * subT + ny * (s1 * 0.005);
dropZ[idx] = 0.038 + dvz * subT;
dropVX[idx] = dvx;
dropVY[idx] = dvy;
dropVZ[idx] = dvz;
dropSize[idx] = 1.4 + (0.5 + 0.5 * Math.abs(s2)) * 1.9;
}
}
function updateParticles(dt) {
const airDrag = 0.46; // m^-1 aerodynamic breakup drag on droplets in air
for (let i = 0; i < MAX_DROPS; i++) {
if (!dropActive[i]) continue;
const vx0 = dropVX[i];
const vy0 = dropVY[i];
const vz0 = dropVZ[i];
const spd = Math.hypot(vx0, vy0, vz0);
const decel = 1.0 / (1.0 + airDrag * spd * dt);
const vx1 = vx0 * decel;
const vy1 = vy0 * decel;
const vz1 = vz0 * decel - G * dt;
dropX[i] += 0.5 * (vx0 + vx1) * dt;
dropY[i] += 0.5 * (vy0 + vy1) * dt;
const prevZ = dropZ[i];
const nextZ = prevZ + 0.5 * (vz0 + vz1) * dt;
dropVX[i] = vx1;
dropVY[i] = vy1;
dropVZ[i] = vz1;
dropZ[i] = nextZ;
if (nextZ <= 0.0) {
dropActive[i] = 0;
addWetness(dropX[i], dropY[i], 0.085);
if ((i & 3) === 0) {
spawnSplash(dropX[i], dropY[i], spd);
}
}
}
for (let i = 0; i < MAX_SPLASH; i++) {
if (!splashActive[i]) continue;
splashAge[i] += dt;
if (splashAge[i] >= splashLife[i]) splashActive[i] = 0;
}
// Gradual evaporation / soaking into the turf
const decay = Math.max(0, 1.0 - 0.075 * dt);
const data = wetImg.data;
for (let i = 0, p = 0; i < wetGrid.length; i++, p += 4) {
let w = wetGrid[i] * decay;
if (w < 0.002) w = 0;
wetGrid[i] = w;
data[p] = 14;
data[p + 1] = 34;
data[p + 2] = 18;
data[p + 3] = (w * 135) | 0;
}
wetCtx.putImageData(wetImg, 0, 0);
}
// --- Hose Physics Step (PGS Inextensibility + Coulomb Friction + Kirchhoff Bending) ---
function stepPhysics(frameDt) {
// Scale substeps up cleanly if user sets extreme #psi (>120) via URL hash
const substeps = psi > 110
? Math.min(40, Math.ceil(BASE_SUBSTEPS * Math.sqrt(psi / 100)))
: BASE_SUBSTEPS;
const dt = frameDt / substeps;
const pPa = psi * PSI_TO_PA;
const fTotal = 2.0 * C_DISCHARGE * A_NOZZLE * pPa; // 0.3587 * psi (Newtons)
const vJet = Math.sqrt((2.0 * pPa) / RHO_WATER);
const mDot = RHO_WATER * C_DISCHARGE * A_NOZZLE * vJet;
// Clamped mouse pin target (hose cannot stretch past arc-length to spigot)
let pinX = mouseWorldX;
let pinY = mouseWorldY;
if (grabIdx >= 2) {
const maxReach = (grabIdx - 1) * DS;
const dx = pinX - x[1];
const dy = pinY - y[1];
const dist = Math.hypot(dx, dy);
if (dist > maxReach && dist > 1e-6) {
pinX = x[1] + (dx / dist) * maxReach;
pinY = y[1] + (dy / dist) * maxReach;
}
}
for (let sub = 0; sub < substeps; sub++) {
// 1. Clear forces and compute current segment unit tangents
for (let i = 0; i < N_NODES; i++) {
fx[i] = 0.0;
fy[i] = 0.0;
invMass[i] = (i <= 1 || i === grabIdx) ? 0.0 : 1.0 / mass[i];
}
for (let j = 0; j < N_SEG; j++) {
const dx = x[j + 1] - x[j];
const dy = y[j + 1] - y[j];
const len = Math.hypot(dx, dy) || DS;
segLen[j] = len;
segTx[j] = dx / len;
segTy[j] = dy / len;
}
// 2. Discrete Kirchhoff elastic bending + viscoelastic rubber damping
for (let i = 1; i < N_NODES - 1; i++) {
const tx0 = segTx[i - 1], ty0 = segTy[i - 1], l0 = segLen[i - 1];
const tx1 = segTx[i], ty1 = segTy[i], l1 = segLen[i];
const nx0 = -ty0, ny0 = tx0;
const nx1 = -ty1, ny1 = tx1;
const cross = tx0 * ty1 - ty0 * tx1;
const dot = tx0 * tx1 + ty0 * ty1;
const theta = Math.atan2(cross, dot);
const omega1 = ((vx[i + 1] - vx[i]) * nx1 + (vy[i + 1] - vy[i]) * ny1) / l1;
const omega0 = ((vx[i] - vx[i - 1]) * nx0 + (vy[i] - vy[i - 1]) * ny0) / l0;
let thetaDot = omega1 - omega0;
if (thetaDot > 75.0) thetaDot = 75.0;
else if (thetaDot < -75.0) thetaDot = -75.0;
const moment = (EI / DS) * (theta - theta0[i]) + (ETA_BEND / DS) * thetaDot;
const f1x = -moment * (nx1 / l1);
const f1y = -moment * (ny1 / l1);
const f0x = -moment * (nx0 / l0);
const f0y = -moment * (ny0 / l0);
fx[i + 1] += f1x;
fy[i + 1] += f1y;
fx[i - 1] += f0x;
fy[i - 1] += f0y;
fx[i] -= (f1x + f0x);
fy[i] -= (f1y + f0y);
}
// 3. Fluid momentum-flux reaction forces (centrifugal + Coriolis + nozzle thrust)
if (fTotal > 0.0) {
const fBend = AREA_RATIO * fTotal;
for (let i = 2; i < N_NODES - 1; i++) {
fx[i] += fBend * (segTx[i - 1] - segTx[i]);
fy[i] += fBend * (segTy[i - 1] - segTy[i]);
// Internal flow Coriolis reaction: -2 * mDot * ((v_i - v_{i-1}) . n) * n
const nx = -segTy[i - 1];
const ny = segTx[i - 1];
const vRelN = (vx[i] - vx[i - 1]) * nx + (vy[i] - vy[i - 1]) * ny;
const fCor = -2.0 * mDot * vRelN;
fx[i] += 0.5 * fCor * nx;
fy[i] += 0.5 * fCor * ny;
fx[i - 1] += 0.5 * fCor * nx;
fy[i - 1] += 0.5 * fCor * ny;
}
// Nozzle follower thrust opposite to exit tangent
const fTip = (1.0 - AREA_RATIO) * fTotal;
fx[N_NODES - 1] -= fTip * segTx[N_SEG - 1];
fy[N_NODES - 1] -= fTip * segTy[N_SEG - 1];
}
// 4. Quadratic high-speed turf/air drag
for (let i = 2; i < N_NODES; i++) {
const spd = Math.hypot(vx[i], vy[i]);
if (spd > 1e-6) {
const drag = C_DRAG * DS * spd;
fx[i] -= drag * vx[i];
fy[i] -= drag * vy[i];
}
}
// 5. Unconstrained velocity step + pin velocity setup
vx[0] = vy[0] = vx[1] = vy[1] = 0.0;
for (let i = 2; i < N_NODES; i++) {
if (i === grabIdx) {
let tvx = (pinX - x[i]) / dt;
let tvy = (pinY - y[i]) / dt;
const tspd = Math.hypot(tvx, tvy);
const maxPinSpd = 14.0;
if (tspd > maxPinSpd) {
tvx = (tvx / tspd) * maxPinSpd;
tvy = (tvy / tspd) * maxPinSpd;
}
vx[i] = tvx;
vy[i] = tvy;
} else {
vx[i] += fx[i] * invMass[i] * dt;
vy[i] += fy[i] * invMass[i] * dt;
}
pFricX[i] = 0.0;
pFricY[i] = 0.0;
}
// 6. Projected Gauss-Seidel (PGS) solver for inextensibility + dry Coulomb friction
for (let iter = 0; iter < PGS_ITERS; iter++) {
// Alternate sweep order so axial impulses propagate symmetrically
if ((iter & 1) === 0) {
for (let j = 0; j < N_SEG; j++) {
const wSum = invMass[j] + invMass[j + 1];
if (wSum === 0.0) continue;
const tx = segTx[j], ty = segTy[j];
const cDot = (vx[j + 1] - vx[j]) * tx + (vy[j + 1] - vy[j]) * ty;
const imp = -cDot / wSum;
vx[j] -= invMass[j] * imp * tx;
vy[j] -= invMass[j] * imp * ty;
vx[j + 1] += invMass[j + 1] * imp * tx;
vy[j + 1] += invMass[j + 1] * imp * ty;
}
} else {
for (let j = N_SEG - 1; j >= 0; j--) {
const wSum = invMass[j] + invMass[j + 1];
if (wSum === 0.0) continue;
const tx = segTx[j], ty = segTy[j];
const cDot = (vx[j + 1] - vx[j]) * tx + (vy[j + 1] - vy[j]) * ty;
const imp = -cDot / wSum;
vx[j] -= invMass[j] * imp * tx;
vy[j] -= invMass[j] * imp * ty;
vx[j + 1] += invMass[j + 1] * imp * tx;
vy[j + 1] += invMass[j + 1] * imp * ty;
}
}
// Coulomb friction disk projection on free nodes
for (let i = 2; i < N_NODES; i++) {
if (invMass[i] === 0.0) continue;
const m = mass[i];
const pMax = MU_FRIC * m * G * dt;
let nx = pFricX[i] - m * vx[i];
let ny = pFricY[i] - m * vy[i];
const mag = Math.hypot(nx, ny);
if (mag > pMax) {
const s = pMax / mag;
nx *= s;
ny *= s;
}
const dpx = nx - pFricX[i];
const dpy = ny - pFricY[i];
pFricX[i] = nx;
pFricY[i] = ny;
vx[i] += dpx * invMass[i];
vy[i] += dpy * invMass[i];
}
}
// 7. Advance positions
for (let i = 2; i < N_NODES; i++) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
}
// 8. Position-only geometric drift projection (preserves velocities without creep)
for (let iter = 0; iter < POS_ITERS; iter++) {
x[0] = 0.0; y[0] = 0.0;
x[1] = DS; y[1] = 0.0;
for (let j = 0; j < N_SEG; j++) {
const w0 = invMass[j];
const w1 = invMass[j + 1];
const wSum = w0 + w1;
if (wSum === 0.0) continue;
const dx = x[j + 1] - x[j];
const dy = y[j + 1] - y[j];
const len = Math.hypot(dx, dy);
if (len < 1e-9) continue;
const corr = (len - DS) / (len * wSum);
x[j] += w0 * corr * dx;
y[j] += w0 * corr * dy;
x[j + 1] -= w1 * corr * dx;
y[j + 1] -= w1 * corr * dy;
}
// Minimum bend-radius constraint across 2-segment spans
for (let i = 1; i < N_NODES - 1; i++) {
const w0 = invMass[i - 1];
const w2 = invMass[i + 1];
const wSum = w0 + w2;
if (wSum === 0.0) continue;
const dx = x[i + 1] - x[i - 1];
const dy = y[i + 1] - y[i - 1];
const span = Math.hypot(dx, dy);
if (span < MIN_SPAN && span > 1e-9) {
const corr = (span - MIN_SPAN) / (span * wSum);
x[i - 1] += w0 * corr * dx;
y[i - 1] += w0 * corr * dy;
x[i + 1] -= w2 * corr * dx;
y[i + 1] -= w2 * corr * dy;
}
}
}
}
}
// --- Pointer / Touch Interaction ---
function findNearestNode(wx, wy, maxPx) {
const maxDist = maxPx / ppm;
let bestIdx = -1;
let bestD2 = maxDist * maxDist;
for (let i = 2; i < N_NODES; i++) {
const dx = x[i] - wx;
const dy = y[i] - wy;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2) {
bestD2 = d2;
bestIdx = i;
}
}
return bestIdx;
}
canvas.addEventListener('pointerdown', (e) => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
mouseWorldX = screenToWorldX(sx);
mouseWorldY = screenToWorldY(sy);
const hit = findNearestNode(mouseWorldX, mouseWorldY, 42);
if (hit >= 2) {
grabIdx = hit;
hoverIdx = hit;
canvas.classList.add('grabbing');
if (canvas.setPointerCapture) {
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
}
}
});
window.addEventListener('pointermove', (e) => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
mouseWorldX = screenToWorldX(sx);
mouseWorldY = screenToWorldY(sy);
if (grabIdx < 0) {
hoverIdx = findNearestNode(mouseWorldX, mouseWorldY, 36);
}
});
function releaseGrab() {
grabIdx = -1;
canvas.classList.remove('grabbing');
}
window.addEventListener('pointerup', releaseGrab);
window.addEventListener('pointercancel', releaseGrab);
window.addEventListener('blur', releaseGrab);
// --- Rendering ---
function traceHosePath() {
ctx.beginPath();
const sx0 = worldToScreenX(x[0]);
const sy0 = worldToScreenY(y[0]);
ctx.moveTo(sx0, sy0);
for (let i = 1; i < N_NODES - 1; i++) {
const mx = worldToScreenX(0.5 * (x[i] + x[i + 1]));
const my = worldToScreenY(0.5 * (y[i] + y[i + 1]));
ctx.quadraticCurveTo(worldToScreenX(x[i]), worldToScreenY(y[i]), mx, my);
}
ctx.lineTo(worldToScreenX(x[N_NODES - 1]), worldToScreenY(y[N_NODES - 1]));
}
function drawScene() {
ctx.save();
ctx.scale(dpr, dpr);
// 1. Mowed lawn background with alternating stripes
ctx.fillStyle = '#356327';
ctx.fillRect(0, 0, viewW, viewH);
const stripeW = 0.85 * ppm;
ctx.fillStyle = 'rgba(128, 178, 72, 0.085)';
const firstStripe = Math.floor((-originX) / (stripeW * 2)) * (stripeW * 2);
for (let sx = originX + firstStripe; sx < viewW + stripeW; sx += stripeW * 2) {
ctx.fillRect(sx, 0, stripeW, viewH);
}
if (grassPattern) {
ctx.fillStyle = grassPattern;
ctx.fillRect(0, 0, viewW, viewH);
}
// 2. Wet grass darkening map from landed water droplets
const wetSX = worldToScreenX(WET_X0);
const wetSY = worldToScreenY(WET_Y0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(wetCanvas, wetSX, wetSY, WET_W_M * ppm, WET_H_M * ppm);
ctx.restore();
// Subtle sunlit vignette
const vig = ctx.createRadialGradient(
viewW * 0.52, viewH * 0.48, Math.min(viewW, viewH) * 0.25,
viewW * 0.5, viewH * 0.5, Math.max(viewW, viewH) * 0.78
);
vig.addColorStop(0, 'rgba(255, 250, 215, 0.04)');
vig.addColorStop(1, 'rgba(8, 18, 8, 0.34)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, viewW, viewH);
// 3. Garden stone edging & standpipe wall on the left behind spigot
const wallX = worldToScreenX(-0.34);
if (wallX > 0) {
// Mulch bed strip along house wall
const bedX = worldToScreenX(-0.12);
ctx.fillStyle = 'rgba(42, 28, 18, 0.72)';
ctx.fillRect(0, 0, bedX, viewH);
// Stone border blocks
const stoneW = Math.max(12, 0.14 * ppm);
const stoneH = Math.max(24, 0.38 * ppm);
for (let sy = -stoneH; sy < viewH + stoneH; sy += stoneH + 3) {
ctx.fillStyle = '#8d897f';
ctx.strokeStyle = '#4e4b44';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(bedX - stoneW * 0.5, sy, stoneW, stoneH, 4);
ctx.fill();
ctx.stroke();
}
// Concrete foundation edge
ctx.fillStyle = '#6f6d66';
ctx.fillRect(0, 0, Math.max(0, wallX), viewH);
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.fillRect(Math.max(0, wallX), 0, 7, viewH);
}
// 4. Ground splash rings where droplets land
for (let i = 0; i < MAX_SPLASH; i++) {
if (!splashActive[i]) continue;
const t = splashAge[i] / splashLife[i];
const r = splashRad[i] * (0.35 + 0.75 * t) * ppm;
const alpha = (1 - t) * 0.48;
ctx.strokeStyle = `rgba(218, 244, 255, ${alpha.toFixed(3)})`;
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.arc(worldToScreenX(splashX[i]), worldToScreenY(splashY[i]), r, 0, Math.PI * 2);
ctx.stroke();
}
// 5. Airborne droplet shadows on the turf
ctx.fillStyle = 'rgba(12, 26, 10, 0.22)';
ctx.beginPath();
for (let i = 0; i < MAX_DROPS; i++) {
if (!dropActive[i]) continue;
const z = dropZ[i];
const shX = worldToScreenX(dropX[i] + z * 0.28);
const shY = worldToScreenY(dropY[i] + z * 0.38);
const rad = Math.max(1.0, dropSize[i] * (ppm / 115) * 0.72);
ctx.moveTo(shX + rad, shY);
ctx.arc(shX, shY, rad, 0, Math.PI * 2);
}
ctx.fill();
// 6. Hose drop shadow on the grass
const hosePx = Math.max(6.5, 0.082 * ppm);
ctx.save();
ctx.translate(3.5, 5.5);
traceHosePath();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(10, 22, 8, 0.44)';
ctx.lineWidth = hosePx + 3.0;
ctx.stroke();
ctx.restore();
// 7. Hose body (layered heavy-duty contractor ochre-terracotta rubber tube)
traceHosePath();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#3d1e10';
ctx.lineWidth = hosePx + 2.4;
ctx.stroke();
ctx.strokeStyle = '#d96528';
ctx.lineWidth = hosePx;
ctx.stroke();
// Subtle cylindrical highlight ridge
ctx.save();
ctx.translate(-hosePx * 0.14, -hosePx * 0.16);
traceHosePath();
ctx.strokeStyle = 'rgba(255, 206, 148, 0.42)';
ctx.lineWidth = Math.max(1.5, hosePx * 0.26);
ctx.stroke();
ctx.restore();
// 8. Solid brass twist nozzle at free end
const nIdx = N_NODES - 1;
const tx = x[nIdx] - x[nIdx - 1];
const ty = y[nIdx] - y[nIdx - 1];
const nozAng = Math.atan2(ty, tx);
const nozSX = worldToScreenX(x[nIdx]);
const nozSY = worldToScreenY(y[nIdx]);
ctx.save();
ctx.translate(nozSX, nozSY);
ctx.rotate(nozAng);
const nw = hosePx * 1.18;
const nl = Math.max(13, 0.14 * ppm);
// Nozzle shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
ctx.fillRect(-nl * 0.25 + 2, -nw * 0.55 + 3, nl, nw * 1.1);
// Knurled brass collar + tapered barrel
const brassGrad = ctx.createLinearGradient(0, -nw * 0.6, 0, nw * 0.6);
brassGrad.addColorStop(0, '#7c5a1a');
brassGrad.addColorStop(0.35, '#f5d98b');
brassGrad.addColorStop(0.65, '#cfa03c');
brassGrad.addColorStop(1, '#5d410f');
ctx.fillStyle = '#222';
ctx.fillRect(-nl * 0.34, -nw * 0.56, nl * 0.26, nw * 1.12);
ctx.fillStyle = brassGrad;
ctx.strokeStyle = '#3a2808';
ctx.lineWidth = 1.2;
ctx.begin