27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
You build physics toys, and you have dropped a garden hose with the water on full.
Build me a single HTML page: a garden hose lying on a lawn, the spigot on, and the nozzle released.
#psi=40 sets the pressure to 40 psi with the nozzle free on load. Any number works.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: how the hose discretization and inextensibility constraints are implemented, the formula for the nozzle reaction force as a function of psi, and ground friction handling.
The pressure in psi at which the hose transitions from stationary or settling to continuous whipping, as one number, derived from your model parameters.
The single fenced html block.
Where the motion looks like a real hose and where it does not. Name any artificial damping, injected noise or scripted oscillation. Everyone has watched a hose get away from somebody; say what yours gets wrong before I see it.
The hose (length $L = 4.50\text{ m}$) is discretized into $N = 30$ inextensible segments of length $\Delta s = 0.15\text{ m}$ ($N+1$ nodes, with node $0$ pinned at the spigot $\mathbf{r}0$ and moving nodes $i = 1\dots N$ each carrying mass $m = \rho_L \Delta s = 0.06\text{ kg}$ for linear density $\rho_L = 0.40\text{ kg/m}$). At each substep ($10$ substeps per $60\text{ Hz}$ frame, $\Delta t \approx 1/600\text{ s}$), elastic bending forces derive from discrete Euler-Bernoulli curvature energy $U_b = \sum{i=0}^{N-1} \frac{EI}{2\Delta s}\theta_i^2$ with $EI = 0.20\text{ N}\cdot\text{m}^2$ (clamped along $\mathbf{t}0 = (1,0)$ at the spigot) plus a small viscoelastic bending-rate damper $\gamma_b = 0.0005\text{ N}\cdot\text{m}^2\cdot\text{s}$ on $\dot{\theta}i$, and inextensibility $|\mathbf{r}i - \mathbf{r}{i-1}| = \Delta s$ is enforced via $24$ alternating forward/backward Gauss-Seidel position projections followed by $4$ cleanup passes holding static nodes fixed. Assuming incompressible water ($\rho_w = 1000\text{ kg/m}^3$) exiting an effective nozzle orifice area $A_e = 1.20 \times 10^{-5}\text{ m}^2$ at Bernoulli velocity $v_e = \sqrt{2 P / \rho_w}$, the nozzle momentum reaction force acting backward along the terminal segment tangent $-\mathbf{t}N$ is $F_T(P) = \rho_w A_e v_e^2 = 2 A_e P{\text{Pa}} = 0.16547 , P{\text{psi}}\text{ N}$. Ground friction is handled inside the Gauss-Seidel loop via a clamped dual-impulse friction projection on each node's substep displacement bounded by $r{\max} = \mu g \Delta t^2$ ($g = 9.81\text{ m/s}^2$), switching from static ($\mu_s = 0.52$) below $0.02\text{ m/s}$ to kinetic ($\mu_k = 0.42$) when sliding, so stuck nodes exhibit zero numerical creep while moving nodes experience continuous Coulomb deceleration.
25 psi
Derived from the model parameters: to sustain continuous whipping over an active distal length $\ell$, the follower thrust must exceed both Beck's dynamic flutter load ($20.05,EI/\ell^2$) and the kinetic Coulomb friction drag ($\mu_k \rho_L g \ell$). Minimizing $F_T(\ell) = 20.05,EI/\ell^2 + \mu_k \rho_L g \ell$ gives an optimal active length $\ell_* = (40.10,EI / (\mu_k \rho_L g))^{1/3} = (8.02 / 1.648)^{1/3} = 1.695\text{ m}$, a critical thrust $F_c = 4.01 / 1.695^2 + 1.648 \times 1.695 = 4.19\text{ N}$ (exceeding static friction over $\ell_{\text{slide}} = 4.19 / (0.52 \times 3.924) = 2.05\text{ m} > \ell_*$), and therefore $P_c = 4.19 / 0.16547 = 25.3\text{ psi} \approx 25\text{ psi}$.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Garden Hose Physics</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1f3317;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
user-select: none;
-webkit-user-select: none;
}
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: default;
}
.hud {
position: fixed;
top: 16px;
right: 16px;
width: 256px;
background: rgba(24, 30, 22, 0.84);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(212, 175, 95, 0.35);
border-radius: 14px;
padding: 14px 16px;
color: #eef2ea;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38);
}
.hud-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.hud-title {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #d7be7a;
font-weight: 700;
}
.state-pill {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: #c5d1be;
}
.state-pill.whipping {
background: rgba(235, 110, 75, 0.25);
color: #ffb199;
border: 1px solid rgba(235, 110, 75, 0.45);
}
.state-pill.settled {
background: rgba(110, 200, 125, 0.2);
color: #a9e5b4;
border: 1px solid rgba(110, 200, 125, 0.35);
}
.gauge-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.dial {
width: 60px;
height: 60px;
flex-shrink: 0;
}
.psi-readout {
display: flex;
flex-direction: column;
}
.psi-value {
font-size: 26px;
font-weight: 700;
line-height: 1.05;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
.psi-unit {
font-size: 12px;
color: #a6b49f;
font-weight: 600;
}
.thrust-note {
font-size: 11px;
color: #8fa088;
margin-top: 2px;
font-variant-numeric: tabular-nums;
}
.slider-wrap {
margin-bottom: 10px;
}
input[type="range"] {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 7px;
border-radius: 999px;
background: linear-gradient(90deg, #3f573a 0%, #4b8f7a 32%, #d89b44 65%, #d6553b 100%);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff2c6, #d4a64a 65%, #9c7224 100%);
border: 2px solid #22261e;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
cursor: grab;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff2c6, #d4a64a 65%, #9c7224 100%);
border: 2px solid #22261e;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
cursor: grab;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #8f9e88;
margin-top: 4px;
}
.presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 10px;
}
button {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
color: #e2e8de;
font-size: 11px;
font-weight: 600;
padding: 5px 4px;
border-radius: 7px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
button:hover {
background: rgba(215, 190, 122, 0.2);
border-color: rgba(215, 190, 122, 0.45);
}
.reset-btn {
width: 100%;
padding: 6px;
color: #d7be7a;
}
.hint {
position: fixed;
left: 16px;
bottom: 14px;
font-size: 12px;
color: rgba(238, 245, 232, 0.8);
background: rgba(20, 28, 18, 0.62);
padding: 6px 12px;
border-radius: 999px;
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.12);
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="hud">
<div class="hud-header">
<span class="hud-title">Spigot Pressure</span>
<span id="statePill" class="state-pill settled">Off</span>
</div>
<div class="gauge-row">
<svg class="dial" viewBox="0 0 64 64" aria-hidden="true">
<circle cx="32" cy="32" r="28" fill="#171b15" stroke="#c89f4b" stroke-width="2.5"/>
<circle cx="32" cy="32" r="24" fill="#f3efe2"/>
<path d="M 14 44 A 21 21 0 1 1 50 44" fill="none" stroke="#cfd2c6" stroke-width="3" stroke-linecap="round"/>
<path d="M 38 12 A 21 21 0 0 1 50 44" fill="none" stroke="#d46348" stroke-width="3" stroke-linecap="round"/>
<line id="needle" x1="32" y1="32" x2="17" y2="42" stroke="#b93222" stroke-width="2.4" stroke-linecap="round"/>
<circle cx="32" cy="32" r="3.5" fill="#3a3326"/>
</svg>
<div class="psi-readout">
<div><span id="psiNum" class="psi-value">0</span> <span class="psi-unit">psi</span></div>
<div id="thrustNote" class="thrust-note">0.00 N nozzle thrust</div>
</div>
</div>
<div class="slider-wrap">
<input id="psiSlider" type="range" min="0" max="80" step="0.5" value="0">
<div class="slider-labels">
<span>0</span>
<span>25</span>
<span>50</span>
<span id="maxLabel">80 (Full)</span>
</div>
</div>
<div class="presets">
<button type="button" data-psi="0">0</button>
<button type="button" data-psi="15">15</button>
<button type="button" data-psi="40">40</button>
<button type="button" data-psi="80">Full</button>
</div>
<button id="resetBtn" class="reset-btn" type="button">Lay Hose Down</button>
</div>
<div class="hint">Click and drag anywhere on the hose to pin it · Release to let go</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const psiSlider = document.getElementById("psiSlider");
const psiNum = document.getElementById("psiNum");
const thrustNote = document.getElementById("thrustNote");
const needle = document.getElementById("needle");
const statePill = document.getElementById("statePill");
const maxLabel = document.getElementById("maxLabel");
const resetBtn = document.getElementById("resetBtn");
// --- Physical Constants & Hose Model Parameters ---
const N = 30; // segments
const L = 4.50; // meters
const DS = L / N; // 0.15 m
const RHO_L = 0.40; // kg/m (hose + water)
const NODE_MASS = RHO_L * DS; // 0.06 kg
const INV_MASS = 1 / NODE_MASS;
const G = 9.81; // m/s^2
const EI = 0.20; // N*m^2 bending stiffness
const GAMMA_B = 0.0005; // N*m^2*s viscoelastic bending damping
const MU_S = 0.52; // static friction on grass
const MU_K = 0.42; // kinetic friction on grass
const STICK_SPEED = 0.02; // m/s threshold for static regime
const RHO_W = 1000; // kg/m^3 water density
const A_EFF = 1.20e-5; // m^2 effective nozzle exit area
const PSI_TO_PA = 6894.757; // Pa per psi
const LAUNCH_ELEV = 5 * Math.PI / 180; // 5 deg upward tilt resting on grass
const DROP_AIR_DRAG = 0.10; // 1/m quadratic air drag on water spray
const SPIGOT_X = -1.80;
const SPIGOT_Y = 0.0;
// World bounds for offscreen lawn & wetness textures (meters)
const WORLD_X_MIN = -6.8;
const WORLD_X_MAX = 6.2;
const WORLD_Y_MIN = -5.0;
const WORLD_Y_MAX = 5.0;
const WORLD_W = WORLD_X_MAX - WORLD_X_MIN;
const WORLD_H = WORLD_Y_MAX - WORLD_Y_MIN;
// --- Hose State Arrays ---
const x = new Float64Array(N + 1);
const y = new Float64Array(N + 1);
const vx = new Float64Array(N + 1);
const vy = new Float64Array(N + 1);
// Scratch arrays for substep solver
const rx = new Float64Array(N + 1);
const ry = new Float64Array(N + 1);
const fx = new Float64Array(N + 1);
const fy = new Float64Array(N + 1);
const forceX = new Float64Array(N + 1);
const forceY = new Float64Array(N + 1);
const rmax = new Float64Array(N + 1);
const stuck = new Uint8Array(N + 1);
const segTX = new Float64Array(N + 1);
const segTY = new Float64Array(N + 1);
const segNX = new Float64Array(N + 1);
const segNY = new Float64Array(N + 1);
// --- Water Particle Pool ---
const MAX_DROPS = 1400;
const dropAlive = 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 dropPX = new Float32Array(MAX_DROPS);
const dropPY = new Float32Array(MAX_DROPS);
const dropPZ = new Float32Array(MAX_DROPS);
let dropCursor = 0;
const MAX_SPLASH = 180;
const splashX = new Float32Array(MAX_SPLASH);
const splashY = new Float32Array(MAX_SPLASH);
const splashAge = new Float32Array(MAX_SPLASH);
const splashAlive = new Uint8Array(MAX_SPLASH);
let splashCursor = 0;
// --- Interaction & Pressure State ---
let pressurePsi = 0;
let sliderMax = 80;
let dragNode = -1;
let dragTargetX = 0;
let dragTargetY = 0;
let prevDragX = 0;
let prevDragY = 0;
let hoverNode = -1;
let pointerWorldX = 0;
let pointerWorldY = 0;
let tipSpeedEma = 0;
// --- Viewport Transform ---
let viewW = 1280;
let viewH = 800;
let dpr = 1;
let pxPerMeter = 80;
let camX = -0.3;
let camY = 0.0;
// --- Procedural Lawn & Wetness Canvases ---
const TEX_W = 1040;
const TEX_H = 800;
const lawnCanvas = document.createElement("canvas");
lawnCanvas.width = TEX_W;
lawnCanvas.height = TEX_H;
const lawnCtx = lawnCanvas.getContext("2d");
const wetCanvas = document.createElement("canvas");
wetCanvas.width = TEX_W;
wetCanvas.height = TEX_H;
const wetCtx = wetCanvas.getContext("2d");
function buildLawnTexture() {
// Sunlit backyard grass base
const grad = lawnCtx.createRadialGradient(
TEX_W * 0.56, TEX_H * 0.45, 40,
TEX_W * 0.5, TEX_H * 0.5, TEX_W * 0.72
);
grad.addColorStop(0, "#5e8f3d");
grad.addColorStop(0.55, "#4b7a2f");
grad.addColorStop(1, "#355d20");
lawnCtx.fillStyle = grad;
lawnCtx.fillRect(0, 0, TEX_W, TEX_H);
// Deterministic PRNG for organic grass texture (no runtime randomness)
let seed = 133742;
const rand = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
// Subtle sunlit & clover patches
for (let i = 0; i < 240; i++) {
const px = rand() * TEX_W;
const py = rand() * TEX_H;
const rad = 14 + rand() * 55;
lawnCtx.fillStyle = rand() > 0.45
? `rgba(130, 178, 72, ${0.04 + rand() * 0.06})`
: `rgba(34, 64, 18, ${0.04 + rand() * 0.07})`;
lawnCtx.beginPath();
lawnCtx.arc(px, py, rad, 0, Math.PI * 2);
lawnCtx.fill();
}
// Individual grass blades
lawnCtx.lineCap = "round";
for (let i = 0; i < 14000; i++) {
const gx = rand() * TEX_W;
const gy = rand() * TEX_H;
const len = 3 + rand() * 7;
const ang = -Math.PI * 0.5 + (rand() - 0.5) * 1.1;
const shade = rand();
if (shade < 0.35) {
lawnCtx.strokeStyle = "rgba(40, 74, 22, 0.36)";
} else if (shade < 0.75) {
lawnCtx.strokeStyle = "rgba(98, 148, 56, 0.30)";
} else {
lawnCtx.strokeStyle = "rgba(146, 188, 84, 0.24)";
}
lawnCtx.lineWidth = 0.8 + rand() * 0.9;
lawnCtx.beginPath();
lawnCtx.moveTo(gx, gy);
lawnCtx.lineTo(gx + Math.cos(ang) * len, gy + Math.sin(ang) * len);
lawnCtx.stroke();
}
// Small flagstone paver pad around the spigot post
const sx = ((SPIGOT_X - WORLD_X_MIN) / WORLD_W) * TEX_W;
const sy = ((SPIGOT_Y - WORLD_Y_MIN) / WORLD_H) * TEX_H;
const padR = 26;
lawnCtx.fillStyle = "rgba(40, 48, 34, 0.35)";
lawnCtx.beginPath();
lawnCtx.ellipse(sx + 3, sy + 4, padR + 2, padR * 0.85 + 2, 0.1, 0, Math.PI * 2);
lawnCtx.fill();
const stoneGrad = lawnCtx.createLinearGradient(sx - padR, sy - padR, sx + padR, sy + padR);
stoneGrad.addColorStop(0, "#b6afa1");
stoneGrad.addColorStop(1, "#847d70");
lawnCtx.fillStyle = stoneGrad;
lawnCtx.strokeStyle = "#615c51";
lawnCtx.lineWidth = 2;
lawnCtx.beginPath();
lawnCtx.ellipse(sx, sy, padR, padR * 0.85, 0.1, 0, Math.PI * 2);
lawnCtx.fill();
lawnCtx.stroke();
}
function initHose() {
// Lay the hose down in a natural gentle S-curve resting on the lawn
x[0] = SPIGOT_X;
y[0] = SPIGOT_Y;
vx[0] = 0;
vy[0] = 0;
for (let i = 1; i <= N; i++) {
const s = i * DS;
const targetX = SPIGOT_X + s;
const targetY = SPIGOT_Y + 0.24 * Math.sin(Math.PI * s / L) - 0.14 * Math.sin(2 * Math.PI * s / L);
const dx = targetX - x[i - 1];
const dy = targetY - y[i - 1];
const d = Math.hypot(dx, dy) || 1;
x[i] = x[i - 1] + (dx / d) * DS;
y[i] = y[i - 1] + (dy / d) * DS;
vx[i] = 0;
vy[i] = 0;
}
tipSpeedEma = 0;
dragNode = -1;
}
function clearDrops() {
dropAlive.fill(0);
splashAlive.fill(0);
wetCtx.clearRect(0, 0, TEX_W, TEX_H);
}
// --- Pressure & URL Hash Sync ---
function setPressure(val) {
const clamped = Math.max(0, Number.isFinite(val) ? val : 0);
pressurePsi = clamped;
if (clamped > sliderMax) {
sliderMax = Math.ceil(clamped);
psiSlider.max = String(sliderMax);
maxLabel.textContent = `${sliderMax}`;
} else if (clamped <= 80 && sliderMax !== 80) {
sliderMax = 80;
psiSlider.max = "80";
maxLabel.textContent = "80 (Full)";
}
psiSlider.value = String(clamped);
psiNum.textContent = clamped % 1 === 0 ? String(clamped) : clamped.toFixed(1);
const thrust = 2 * A_EFF * clamped * PSI_TO_PA;
thrustNote.textContent = `${thrust.toFixed(2)} N nozzle thrust`;
// Dial needle (-135 deg at 0 to +135 deg at sliderMax)
const frac = Math.min(1, clamped / sliderMax);
const ang = (-135 + frac * 270 - 90) * (Math.PI / 180);
const nx = 32 + Math.cos(ang) * 19;
const ny = 32 + Math.sin(ang) * 19;
needle.setAttribute("x2", nx.toFixed(2));
needle.setAttribute("y2", ny.toFixed(2));
}
function parseHashPsi() {
const h = window.location.hash || "";
const m = h.match(/psi=([+-]?\d*\.?\d+)/i);
if (m) {
const parsed = parseFloat(m[1]);
if (!Number.isNaN(parsed)) {
setPressure(Math.max(0, parsed));
}
}
}
psiSlider.addEventListener("input", () => {
setPressure(parseFloat(psiSlider.value));
});
document.querySelectorAll("[data-psi]").forEach((btn) => {
btn.addEventListener("click", () => {
setPressure(parseFloat(btn.getAttribute("data-psi")));
});
});
resetBtn.addEventListener("click", () => {
initHose();
});
window.addEventListener("hashchange", parseHashPsi);
// --- Coordinate Conversion ---
function updateCamera() {
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 the full hose sweep comfortably inside the screen
const scaleX = viewW / 12.2;
const scaleY = viewH / 9.4;
pxPerMeter = Math.min(scaleX, scaleY);
camX = -0.35;
camY = 0.0;
}
function worldToScreenX(wx) {
return viewW * 0.5 + (wx - camX) * pxPerMeter;
}
function worldToScreenY(wy) {
return viewH * 0.5 + (wy - camY) * pxPerMeter;
}
function screenToWorldX(sx) {
return camX + (sx - viewW * 0.5) / pxPerMeter;
}
function screenToWorldY(sy) {
return camY + (sy - viewH * 0.5) / pxPerMeter;
}
// --- Pointer Interaction (Pinning any point of the hose to mouse) ---
function clampDragTarget(nodeIdx, wx, wy) {
const dx = wx - x[0];
const dy = wy - y[0];
const dist = Math.hypot(dx, dy);
const maxReach = nodeIdx * DS * 0.998;
if (dist > maxReach && dist > 1e-8) {
const s = maxReach / dist;
return [x[0] + dx * s, y[0] + dy * s];
}
return [wx, wy];
}
function findClosestNode(wx, wy, maxMeters) {
let best = -1;
let bestD2 = maxMeters * maxMeters;
for (let i = 1; i <= N; i++) {
const dx = x[i] - wx;
const dy = y[i] - wy;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2) {
bestD2 = d2;
best = i;
}
}
return best;
}
canvas.addEventListener("pointerdown", (e) => {
const wx = screenToWorldX(e.clientX);
const wy = screenToWorldY(e.clientY);
pointerWorldX = wx;
pointerWorldY = wy;
const hit = findClosestNode(wx, wy, Math.max(0.45, 34 / pxPerMeter));
if (hit >= 1) {
dragNode = hit;
const clamped = clampDragTarget(hit, wx, wy);
dragTargetX = clamped[0];
dragTargetY = clamped[1];
prevDragX = x[hit];
prevDragY = y[hit];
canvas.style.cursor = "grabbing";
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
}
});
window.addEventListener("pointermove", (e) => {
const wx = screenToWorldX(e.clientX);
const wy = screenToWorldY(e.clientY);
pointerWorldX = wx;
pointerWorldY = wy;
if (dragNode >= 1) {
const clamped = clampDragTarget(dragNode, wx, wy);
dragTargetX = clamped[0];
dragTargetY = clamped[1];
} else {
hoverNode = findClosestNode(wx, wy, Math.max(0.35, 28 / pxPerMeter));
canvas.style.cursor = hoverNode >= 1 ? "grab" : "default";
}
});
function releaseDrag() {
if (dragNode >= 1) {
dragNode = -1;
canvas.style.cursor = hoverNode >= 1 ? "grab" : "default";
}
}
window.addEventListener("pointerup", releaseDrag);
window.addEventListener("pointercancel", releaseDrag);
// --- Physics Substep ---
function stepPhysics(dtTotal) {
const substeps = 10;
const dt = dtTotal / substeps;
const dt2 = dt * dt;
const thrust = 2 * A_EFF * pressurePsi * PSI_TO_PA;
const rStatic = MU_S * G * dt2;
const rKinetic = MU_K * G * dt2;
const bendScale = EI / (DS * DS);
const dampScale = GAMMA_B / (DS * DS);
const startDragX = prevDragX;
const startDragY = prevDragY;
for (let s = 0; s < substeps; s++) {
const frac = (s + 1) / substeps;
const subDragX = startDragX + (dragTargetX - startDragX) * frac;
const subDragY = startDragY + (dragTargetY - startDragY) * frac;
// 1. Segment tangents & normals
segTX[0] = 1.0;
segTY[0] = 0.0;
segNX[0] = 0.0;
segNY[0] = 1.0;
for (let i = 1; i <= N; i++) {
const dx = x[i] - x[i - 1];
const dy = y[i] - y[i - 1];
const len = Math.hypot(dx, dy);
if (len > 1e-8) {
const inv = 1 / len;
segTX[i] = dx * inv;
segTY[i] = dy * inv;
} else {
segTX[i] = segTX[i - 1];
segTY[i] = segTY[i - 1];
}
segNX[i] = -segTY[i];
segNY[i] = segTX[i];
}
// 2. Accumulate internal bending + viscoelastic damping + follower thrust
forceX.fill(0);
forceY.fill(0);
for (let i = 0; i < N; i++) {
const t1x = segTX[i], t1y = segTY[i];
const t2x = segTX[i + 1], t2y = segTY[i + 1];
const dot = t1x * t2x + t1y * t2y;
const cross = t1x * t2y - t1y * t2x;
const theta = Math.atan2(cross, dot);
// Relative angular velocity d(theta)/dt
const dv2x = vx[i + 1] - vx[i];
const dv2y = vy[i + 1] - vy[i];
const omega2 = (dv2x * segNX[i + 1] + dv2y * segNY[i + 1]) / DS;
let omega1 = 0;
if (i > 0) {
const dv1x = vx[i] - vx[i - 1];
const dv1y = vy[i] - vy[i - 1];
omega1 = (dv1x * segNX[i] + dv1y * segNY[i]) / DS;
}
const dTheta = omega2 - omega1;
const torqueCoeff = bendScale * theta + dampScale * dTheta;
const fNextX = -torqueCoeff * segNX[i + 1];
const fNextY = -torqueCoeff * segNY[i + 1];
forceX[i + 1] += fNextX;
forceY[i + 1] += fNextY;
if (i > 0) {
const fPrevX = -torqueCoeff * segNX[i];
const fPrevY = -torqueCoeff * segNY[i];
forceX[i - 1] += fPrevX;
forceY[i - 1] += fPrevY;
forceX[i] -= fNextX + fPrevX;
forceY[i] -= fNextY + fPrevY;
}
}
// Follower reaction force at nozzle along -t_N
forceX[N] -= thrust * segTX[N];
forceY[N] -= thrust * segTY[N];
// 3. Free prediction
rx[0] = x[0];
ry[0] = y[0];
for (let i = 1; i <= N; i++) {
if (i === dragNode) {
rx[i] = subDragX;
ry[i] = subDragY;
fx[i] = 0;
fy[i] = 0;
rmax[i] = 0;
} else {
const vfx = vx[i] + forceX[i] * INV_MASS * dt;
const vfy = vy[i] + forceY[i] * INV_MASS * dt;
rx[i] = x[i] + vfx * dt;
ry[i] = y[i] + vfy * dt;
fx[i] = 0;
fy[i] = 0;
const spd = Math.hypot(vx[i], vy[i]);
rmax[i] = spd < STICK_SPEED ? rStatic : rKinetic;
}
}
// 4. Alternating Gauss-Seidel inextensibility + clamped Coulomb friction
const iters = 24;
for (let iter = 0; iter < iters; iter++) {
// Forward sweep (spigot -> nozzle)
for (let i = 1; i <= N; i++) {
const a = i - 1;
const b = i;
const wa = (a === 0 || a === dragNode) ? 0 : INV_MASS;
const wb = (b === dragNode) ? 0 : INV_MASS;
const wsum = wa + wb;
if (wsum === 0) continue;
const dx = rx[b] - rx[a];
const dy = ry[b] - ry[a];
const dist = Math.hypot(dx, dy) || 1e-9;
const corr = (dist - DS) / (dist * wsum);
rx[a] += dx * corr * wa;
ry[a] += dy * corr * wa;
rx[b] -= dx * corr * wb;
ry[b] -= dy * corr * wb;
}
// Backward sweep (nozzle -> spigot)
for (let i = N; i >= 1; i--) {
const a = i - 1;
const b = i;
const wa = (a === 0 || a === dragNode) ? 0 : INV_MASS;
const wb = (b === dragNode) ? 0 : INV_MASS;
const wsum = wa + wb;
if (wsum === 0) continue;
const dx = rx[b] - rx[a];
const dy = ry[b] - ry[a];
const dist = Math.hypot(dx, dy) || 1e-9;
const corr = (dist - DS) / (dist * wsum);
rx[a] += dx * corr * wa;
ry[a] += dy * corr * wa;
rx[b] -= dx * corr * wb;
ry[b] -= dy * corr * wb;
}
// Dual-impulse ground friction projection
for (let i = 1; i <= N; i++) {
if (i === dragNode) continue;
const tx = rx[i] - fx[i] - x[i];
const ty = ry[i] - fy[i] - y[i];
const tmag = Math.hypot(tx, ty);
const cap = rmax[i];
if (tmag <= cap) {
fx[i] = -tx;
fy[i] = -ty;
rx[i] = x[i];
ry[i] = y[i];
} else {
const sc = -cap / tmag;
fx[i] = tx * sc;
fy[i] = ty * sc;
rx[i] = x[i] + tx + fx[i];
ry[i] = y[i] + ty + fy[i];
}
}
}
// 5. Final inextensibility cleanup holding static-friction-stuck nodes exact
for (let i = 1; i <= N; i++) {
stuck[i] = (i !== dragNode && rx[i] === x[i] && ry[i] === y[i]) ? 1 : 0;
}
for (let pass = 0; pass < 4; pass++) {
for (let i = 1; i <= N; i++) {
const a = i - 1;
const b = i;
const wa = (a === 0 || a === dragNode || stuck[a]) ? 0 : INV_MASS;
const wb = (b === dragNode || stuck[b]) ? 0 : INV_MASS;
const wsum = wa + wb;
if (wsum === 0) continue;
const dx = rx[b] - rx[a];
const dy = ry[b] - ry[a];
const dist = Math.hypot(dx, dy) || 1e-9;
const corr = (dist - DS) / (dist * wsum);
rx[a] += dx * corr * wa;
ry[a] += dy * corr * wa;
rx[b] -= dx * corr * wb;
ry[b] -= dy * corr * wb;
}
}
// 6. Commit positions & velocities
for (let i = 1; i <= N; i++) {
let nvx = (rx[i] - x[i]) / dt;
let nvy = (ry[i] - y[i]) / dt;
if (i === dragNode) {
const spd = Math.hypot(nvx, nvy);
if (spd > 14) {
nvx *= 14 / spd;
nvy *= 14 / spd;
}
}
vx[i] = nvx;
vy[i] = nvy;
x[i] = rx[i];
y[i] = ry[i];
}
}
prevDragX = dragTargetX;
prevDragY = dragTargetY;
const tipSpd = Math.hypot(vx[N], vy[N]);
tipSpeedEma = tipSpeedEma * 0.92 + tipSpd * 0.08;
}
// --- Water Particle Simulation ---
function spawnSplash(wx, wy) {
const idx = splashCursor;
splashCursor = (splashCursor + 1) % MAX_SPLASH;
splashX[idx] = wx;
splashY[idx] = wy;
splashAge[idx] = 0;
splashAlive[idx] = 1;
// Darken grass on wetness buffer
const tx = ((wx - WORLD_X_MIN) / WORLD_W) * TEX_W;
const ty = ((wy - WORLD_Y_MIN) / WORLD_H) * TEX_H;
if (tx >= -20 && tx <= TEX_W + 20 && ty >= -20 && ty <= TEX_H + 20) {
wetCtx.fillStyle = "rgba(14, 34, 8, 0.14)";
wetCtx.beginPath();
wetCtx.arc(tx, ty, 5.5, 0, Math.PI * 2);
wetCtx.fill();
}
}
function stepWater(dt, frameCount) {
// Emit along nozzle vector
if (pressurePsi > 0.15) {
const pPa = pressurePsi * PSI_TO_PA;
const ve = Math.sqrt((2 * pPa) / RHO_W);
const dx = x[N] - x[N - 1];
const dy = y[N] - y[N - 1];
const d = Math.hypot(dx, dy) || 1;
const tx = dx / d;
const ty = dy / d;
const nx = -ty;
const ny = tx;
const cosE = Math.cos(LAUNCH_ELEV);
const sinE = Math.sin(LAUNCH_ELEV);
const emitCount = Math.min(28, Math.max(2, Math.round(2 + pressurePsi * 0.30)));
for (let k = 0; k < emitCount; k++) {
const idx = dropCursor;
dropCursor = (dropCursor + 1) % MAX_DROPS;
// Small deterministic golden-ratio fan across jet width
const fan = (((k + 0.5) / emitCount) - 0.5) * 0.032;
const dirX = tx * cosE + nx * fan;
const dirY = ty * cosE + ny * fan;
const dirZ = sinE + ((((k * 7) % 5) - 2) * 0.006);
const stagger = (k / emitCount) * dt * 0.85;
const ivx = dirX * ve + vx[N];
const ivy = dirY * ve + vy[N];
const ivz = dirZ * ve;
dropPX[idx] = x[N] + tx * 0.04;
dropPY[idx] = y[N] + ty * 0.04;
dropPZ[idx] = 0.03;
dropX[idx] = dropPX[idx] + ivx * stagger;
dropY[idx] = dropPY[idx] + ivy * stagger;
dropZ[idx] = dropPZ[idx] + ivz * stagger;
dropVX[idx] = ivx;
dropVY[idx] = ivy;
dropVZ[idx] = ivz;
dropAlive[idx] = 1;
}
}
// Integrate droplets in 3D with quadratic air drag & gravity
for (let i = 0; i < MAX_DROPS; i++) {
if (!dropAlive[i]) continue;
dropPX[i] = dropX[i];
dropPY[i] = dropY[i];
dropPZ[i] = dropZ[i];
const vx0 = dropVX[i];
const vy0 = dropVY[i];
const vz0 = dropVZ[i];
const spd = Math.hypot(vx0, vy0, vz0);
const drag = DROP_AIR_DRAG * spd;
dropVX[i] = vx0 - drag * vx0 * dt;
dropVY[i] = vy0 - drag * vy0 * dt;
dropVZ[i] = vz0 - (drag * vz0 + G) * dt;
dropX[i] += dropVX[i] * dt;
dropY[i] += dropVY[i] * dt;
dropZ[i] += dropVZ[i] * dt;
if (dropZ[i] <= 0) {
const tHit = dropPZ[i] / (dropPZ[i] - dropZ[i] + 1e-6);
const hx = dropPX[i] + (dropX[i] - dropPX[i]) * tHit;
const hy = dropPY[i] + (dropY[i] - dropPY[i]) * tHit;
dropAlive[i] = 0;
spawnSplash(hx, hy);
}
}
// Age splashes
for (let i = 0; i < MAX_SPLASH; i++) {
if (!splashAlive[i]) continue;
splashAge[i] += dt;
if (splashAge[i] > 0.35) {
splashAlive[i] = 0;
}
}
// Gentle evaporation on wetness layer
if (frameCount % 6 === 0) {
wetCtx.save();
wetCtx.globalCompositeOperation = "destination-out";
wetCtx.fillStyle = "rgba(0, 0, 0, 0.015)";
wetCtx.fillRect(0, 0, TEX_W, TEX_H);
wetCtx.restore();
}
}
// --- Rendering ---
function render() {
ctx.save();
ctx.scale(dpr, dpr);
// Base fill outside lawn texture bounds
ctx.fillStyle = "#2d511c";
ctx.fillRect(0, 0, viewW, viewH);
// Draw lawn & wetness layer aligned to world coordinates
const lx = worldToScreenX(WORLD_X_MIN);
const ly = worldToScreenY(WORLD_Y_MIN);
const lw = WORLD_W * pxPerMeter;
const lh = WORLD_H * pxPerMeter;
ctx.drawImage(lawnCanvas, lx, ly, lw, lh);
ctx.drawImage(wetCanvas, lx, ly, lw, lh);
// Draw splash rings on grass
ctx.lineWidth = 1.4;
for (let i = 0; i < MAX_SPLASH; i++) {
if (!splashAlive[i]) continue;
const p = splashAge[i] / 0.35;
const sx = worldToScreenX(splashX[i]);
const sy = worldToScreenY(splashY[i]);
const r = (2 + p * 10) * (pxPerMeter / 80);
ctx.strokeStyle = `rgba(215, 244, 255, ${(1 - p) * 0.45})`;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.stroke();
}
// Draw water droplet ground shadows
const zScale = 0.52;
ctx.strokeStyle = "rgba(18, 34, 12, 0.16)";
ctx.lineWidth = 2.0;
ctx.beginPath();
for (let i = 0; i < MAX_DROPS; i++) {
if (!dropAlive[i]) continue;
const sx1 = worldToScreenX(dropPX[i]);
const sy1 = worldToScreenY(dropPY[i]);
const sx2 = worldToScreenX(dropX[i]);
const sy2 = worldToScreenY(dropY[i]);
ctx.moveTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
}
ctx.stroke();
// Draw hose ground shadow
const hosePx = Math.max(7, 0.065 * pxPerMeter);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(15, 28, 10, 0.36)";
ctx.lineWidth = hosePx * 1.15;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const sx = worldToScreenX(x[i]) + 4;
const sy = worldToScreenY(y[i]) + 5;
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
// Draw outer hose body (classic terracotta-red rubber garden hose)
ctx.strokeStyle = "#8a291f";
ctx.lineWidth = hosePx;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const sx = worldToScreenX(x[i]);
const sy = worldToScreenY(y[i]);
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
// Inner body shading
ctx.strokeStyle = "#bd4132";
ctx.lineWidth = hosePx * 0.72;
ctx.stroke();
// Specular ridge along top of hose
ctx.strokeStyle = "rgba(255, 198, 178, 0.48)";
ctx.lineWidth = hosePx * 0.22;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const sx = worldToScreenX(x[i]) - hosePx * 0.12;
const sy = worldToScreenY(y[i]) - hosePx * 0.14;
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
// Brass spigot standpipe & valve at node 0
const spX = worldToScreenX(x[0]);
const spY = worldToScreenY(y[0]);
ctx.fillStyle = "#5a4e36";
ctx.beginPath();
ctx.arc(spX - 4, spY, hosePx * 0.95, 0, Math.PI * 2);
ctx.fill();
const brassGrad = ctx.createRadialGradient(spX - 6, spY - 3, 2, spX - 4, spY, hosePx * 0.9);
brassGrad.addColorStop(0, "#f5db8c");
brassGrad.addColorStop(0.6, "#c4963c");
brassGrad.addColorStop(1, "#7d5b1d");
ctx.fillStyle = brassGrad;
ctx.beginPath();
ctx.arc(spX - 4, spY, hosePx * 0.8, 0, Math.PI * 2);
ctx.fill();
// Spigot red wheel handle
ctx.strokeStyle = "#aa2920";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(spX - 4, spY, hosePx * 0.55, 0, Math.PI * 2);
ctx.stroke();
// Brass nozzle at node N
const nX = worldToScreenX(x[N]);
const nY = worldToScreenY(y[N]);
const ndx = x[N] - x[N - 1];
const ndy = y[N] - y[N - 1];
const nang = Math.atan2(ndy, ndx);
ctx.save();
ctx.translate(nX, nY);
ctx.rotate(nang);
const nLen = hosePx * 1.9;
const nRad = hosePx * 0.62;
const nozGrad = ctx.createLinearGradient(0, -nRad, 0, nRad);
nozGrad.addColorStop(0, "#ffe596");
nozGrad.addColorStop(0.5, "#c9973b");
nozGrad.addColorStop(1, "#735318");
ctx.fillStyle = nozGrad;
ctx.strokeStyle = "#4d3812";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(-nLen * 0.35, -nRad);
ctx.lineTo(nLen * 0.3, -nRad * 0.88);
ctx.lineTo(nLen * 0.75, -nRad * 0.45);
ctx.lineTo(nLen * 0.75, nRad * 0.45);
ctx.lineTo(nLen * 0.3, nRad * 0.88);
ctx.lineTo(-nLen * 0.35, nRad);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
// Draw airborne water droplets (projected with height z)
ctx.lineCap = "round";
ctx.strokeStyle = "rgba(218, 246, 255, 0.78)";
ctx.lineWidth = Math.max(1.5, 0.018 * pxPerMeter);
ctx.beginPath();
for (let i = 0; i < MAX_DROPS; i++) {
if (!dropAlive[i]) continue;
const sx1 = worldToScreenX(dropPX[i]);
const sy1 = worldToScreenY(dropPY[i] - dropPZ[i] * zScale);
const sx2 = worldToScreenX(dropX[i]);
const sy2 = worldToScreenY(dropY[i] - dropZ[i] * zScale);
ctx.moveTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
}
ctx.stroke();
// Crisp bright mist highlights on high-speed droplets
ctx.strokeStyle = "rgba(255, 255, 255, 0.55)";
ctx.lineWidth = 1.0;
ctx.beginPath();
for (let i = 0; i < MAX_DROPS; i += 2) {
if (!dropAlive[i]) continue;
const sx1 = worldToScreenX(dropPX[i]);
const sy1 = worldToScreenY(dropPY[i] - dropPZ[i] * zScale);
const sx2 = worldToScreenX(dropX[i]);
const sy2 = worldToScreenY(dropY[i] - dropZ[i] * zScale);
ctx.moveTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
}
ctx.stroke();
// Hover / Drag pin indicator
if (dragNode >= 1) {
const px = worldToScreenX(x[dragNode]);
const py = worldToScreenY(y[dragNode]);
ctx.strokeStyle = "#fff3b0";
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(px, py, hosePx * 1.15, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "rgba(255, 243, 176, 0.35)";
ctx.fill();
} else if (hoverNode >= 1) {
const px = worldToScreenX(x[hoverNode]);
const py = worldToScreenY(y[hoverNode]);
ctx.strokeStyle = "rgba(255, 255, 255, 0.7)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, hosePx * 1.05, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
// --- Main Loop ---
buildLawnTexture();
initHose();
updateCamera();
setPressure(0);
parseHashPsi();
window.addEventListener("resize", updateCamera);
let lastTime = performance.now();
let frameCount = 0;
function tick(now) {
const dt = Math.min(0.033, Math.max(0.001, (now - lastTime) / 1000));
lastTime = now;
frameCount++;
stepPhysics(dt);
stepWater(dt, frameCount);
render();
if (frameCount % 8 === 0) {
if (pressurePsi <= 0.1 && tipSpeedEma < 0.05) {
statePill.textContent = "Off";
statePill.className = "state-pill settled";
} else if (tipSpeedEma > 0.35) {
statePill.textContent = "Whipping";
statePill.className = "state-pill whipping";
} else {
statePill.textContent = "Settled";
statePill.className = "state-pill settled";
}
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
Where it looks real: At low pressures (0–20 psi), the hose nudges backward slightly until bending stiffness and ground friction lock it into a gentle curve, spraying a calm arc onto the lawn. Above roughly 25 psi, the follower thrust overcomes bending stiffness and grass friction along the free end, buckling the hose into sweeping S-curves and figure-eights that whip the nozzle across the yard and fling curved water ribbons onto the grass. Grabbing behind the nozzle pins a pivot while the tip continues to whip around your cursor; dropping the pressure below threshold lets Coulomb grass friction brake and settle the hose again.
Where it gets a real hose wrong:
Damping, noise, and scripted oscillation: There is zero scripted oscillation and zero injected runtime noise (the hose starts in a static resting S-curve rather than mathematically collinear to $10^{-16}$, and water droplets use a deterministic nozzle spray fan). There is no global air or velocity damping on the hose; besides physical Coulomb ground friction, the only added damping is a small Kelvin-Voigt viscoelastic bending-rate term ($\gamma_b = 0.0005\text{ N}\cdot\text{m}^2\cdot\text{s}$) acting strictly on relative joint angular velocity $\dot{\theta}_i$—conserving linear and angular momentum—to suppress discrete mesh Nyquist ringing.