HardYards/web/world/js/sail.js
m3ultra 2034593a30 Re-run the §7 gate against real storm wind; stop allocating per face
B-4. The gate used to run on my own stub wind — uniform, horizontal,
tuned by nobody — so it proved the cloth was self-consistent, not that
the game works. It now loads the shipped storm JSON and drives the cloth
through weather.core.js (pure and import-free, so the same path runs in
node and in selftest.html; weather.js's own loadStorm can't help because
its STORM_DIR is a file:// URL that node's fetch won't open).

All three halves of §7 now run on real storm_02 and the real yard:
a flat drum-tight carabiner rig cascades 4/4; a well-twisted mixed rig
holds all four; and a twisted rig with one dodgy corner blows it and
finishes 4/4 intact after a single repair — the DoD scenario, in an
assert.

Perf: Lane C added an `out` param to wind.sample specifically so sail.js
wouldn't allocate, and I wasn't passing it — 162 faces at 60 Hz is ~9.7k
throwaway Vector3s a second. Now passing a scratch vector. Stub winds
that ignore `out` still work; we read the return value.

Two tests skip rather than pass vacuously while Lane C's downdraft is
unmerged: a "nothing broke" repair scenario would go green forever and
check nothing. Both light up by themselves on merge, and the repair one
hard-fails if a downdraft IS present and still can't threaten the rig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:25:25 +10:00

777 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* sail.js — shade sail cloth simulation, corner loads, hardware failure. [Lane B]
*
* A 3D verlet cloth on a bilinear patch between 4 anchors. Wind pressure is
* applied per FACE, not per node, which is the whole point: a twisted (hypar)
* sail turns most of its faces edge-on to the wind and sheds load, while a flat
* one presents every face square-on and catches everything. That difference is
* the game's thesis and it is asserted in sail.selftest.js.
*
* Units are SI throughout: metres, kilograms, seconds, newtons. Corner loads
* come out in real newtons and hardware ratings are real working load limits,
* so a 5x5 m sail in a 34 m/s storm genuinely puts ~1-4 kN on a corner — which
* is genuinely why real shade sails use 3 kN+ shackles.
*
* The sim core holds no THREE types: nodes are plain Float64Arrays, so the hot
* loop allocates nothing, replays bit-for-bit, and runs headless under node
* (see sail.selftest.js) as well as in Lane A's selftest.html. three.js only
* appears in createSailView(), which is imported lazily.
*/
import * as THREE from '../vendor/three.module.js';
import { Emitter, FIXED_DT, HARDWARE } from './contracts.js';
export { HARDWARE };
/**
* What a carried spare re-rigs a corner with. The prep phase sells exactly one
* kind ("spare shackle, $15"), so repair() has no hardware argument to take.
*/
const SPARE_HW = HARDWARE[1];
// ---------- sim tunables ----------
const SIM_DT = FIXED_DT; // sim always steps at a fixed rate; step() accumulates
const MAX_SUBSTEPS = 5; // spiral-of-death guard when the frame hitches
const RELAX_ITERS = 5; // FABRIC_K is calibrated against this; changing it rescales loads
const GRAVITY = -9.81;
// ---------- aerodynamics ----------
// 0.5 * air density (1.225) * flat-plate drag coefficient (~1.4).
// Newtons per m^2 of face area per (m/s)^2 of normal-on airflow.
const PRESSURE_COEFF = 0.86;
const TANGENT_COEFF = 0.02; // skin friction dragging along the face
const MAX_NORMAL_SPEED = 45; // clamp on the normal-on component, m/s — stability in extreme gusts
// ---------- fabric ----------
const FABRIC_DENSITY = 0.32; // kg/m^2, typical knitted shade cloth
// Axial stiffness of one grid spring, N/m — roughly E*t*width/length for
// knitted HDPE mesh. Fed to the solver as a compliance (1/k), not used to
// convert stretch into force: see _measureLoads for why that distinction is
// the whole ballgame.
const FABRIC_K = 100000;
const K_COMPRESS = 0.08; // cloth resists stretch hard, compression barely (from prototype)
const K_BEND = 0.04;
const COMP_STRETCH = 1 / FABRIC_K;
const COMP_COMPRESS = 1 / (FABRIC_K * K_COMPRESS);
const COMP_BEND = 1 / (FABRIC_K * K_BEND);
const VEL_DAMP = 0.995; // light; relative-wind drag supplies the real damping
// ---------- debris (SPRINT2 decision 5) ----------
const DEBRIS_RESTITUTION = 0.1; // a wheelie bin into shade cloth barely bounces
const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness
// ---------- failure ----------
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
export const TENSION_MIN = 0.6;
export const TENSION_MAX = 1.4;
/**
* How much pre-strain the tension dial actually commands, per unit of dial.
* Dial 1.0 is neutral (rest length = as-cut), 1.4 is drum tight, 0.6 is loose.
*
* The prototype used `rest = rest / tension`, which on its 2D arbitrary scale
* was harmless. In real newtons it is not: it asks for 17% pre-strain at dial
* 1.2 and 29% at 1.4 — i.e. stretching an 18 m sail by three metres — and it
* put 68 kN on a corner of the real yard's biggest quad before any wind blew.
*
* 0.10 puts dial 1.4 at 4% pre-strain. Measured: it swings a 5x5 m rig's peak
* load 2.1x from loose to tight, so the dial is a real decision; and it redlines
* the yard's 192 m2 quad at 8.3 kN drum-tight, which blows even a rated shackle
* — correctly, because you cannot drum-tighten 192 m2 of cloth on $30 of
* hardware. The load bars show that during prep, which is where it should be
* learned.
*/
const PRE_STRAIN = 0.10;
const TRIM_MIN = 0.85;
const TRIM_MAX = 1.15;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Order 4 anchors into a non-self-intersecting ring by angle around their
* centroid, projected onto the ground plane. Ported from the prototype's
* orderRing; without it, picking corners in a silly order knots the sail.
*/
export function orderRing(anchors) {
const n = anchors.length;
let cx = 0, cz = 0;
for (const a of anchors) { cx += a.pos.x; cz += a.pos.z; }
cx /= n; cz /= n;
return [...anchors].sort(
(a, b) => Math.atan2(a.pos.z - cz, a.pos.x - cx) - Math.atan2(b.pos.z - cz, b.pos.x - cx)
);
}
export class SailRig {
/**
* @param {object} opts
* @param {Array} opts.anchors world.anchors — see contracts.js Anchor
* @param {number} opts.gridN nodes per side (default 10)
* @param {number} opts.porosity 0 = solid membrane, ~0.3 = knitted shade cloth (blows through, less load)
*/
constructor({ anchors = [], gridN = 10, porosity = 0 } = {}) {
this.anchors = anchors;
this.N = gridN;
this.porosity = porosity;
this.corners = [];
/** Emits 'break' and 'repair' as {type, corner} — contracts.js SailRig. */
this.events = new Emitter();
this.tension = 1.0;
this.t = 0;
this.rigged = false;
this._acc = 0;
// scratch, reused every face to keep the hot loop allocation-free
this._probe = { x: 0, y: 0, z: 0 };
// Lane C's wind.sample(pos, t, out) takes an out-vector so we don't allocate
// one per face per substep — 162 faces at 60 Hz is ~9.7k throwaway Vector3s
// a second otherwise. A stub wind that ignores `out` still works: we read
// the RETURN value, not this.
this._windOut = new THREE.Vector3();
}
/**
* Rig the sail across 4 anchors.
* @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally
* @param {object[]} hwChoices hardware per anchor id, same order as anchorIds
* @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads)
*/
attach(anchorIds, hwChoices, tension = 1.0) {
if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`);
const picked = anchorIds.map((id) => {
const a = this.anchors.find((x) => x.id === id);
if (!a) throw new Error(`unknown anchor "${id}"`);
return a;
});
const hwById = new Map(anchorIds.map((id, i) => [id, hwChoices[i] || HARDWARE[0]]));
const ring = orderRing(picked);
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
this.corners = ring.map((a) => ({
anchorId: a.id,
anchor: a,
hw: hwById.get(a.id),
load: 0,
peakLoad: 0,
overload: 0,
broken: false,
trim: 1.0,
loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads
}));
this._build(ring);
this.rigged = true;
return this;
}
_build(ring) {
const N = this.N;
const nodeCount = N * N;
this.pos = new Float64Array(nodeCount * 3);
this.prev = new Float64Array(nodeCount * 3);
this.force = new Float64Array(nodeCount * 3);
this.invMass = new Float64Array(nodeCount);
// Bilinear patch across the 4 corners. Because the anchors sit at different
// heights, this initial surface is already a hypar — the sim just relaxes it.
const [c0, c1, c2, c3] = ring.map((a) => a.pos);
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
const fu = u / (N - 1), fv = v / (N - 1);
const i = (v * N + u) * 3;
for (let k = 0; k < 3; k++) {
const ax = ['x', 'y', 'z'][k];
const top = (1 - fu) * c0[ax] + fu * c1[ax];
const bot = (1 - fu) * c3[ax] + fu * c2[ax];
this.pos[i + k] = this.prev[i + k] = (1 - fv) * top + fv * bot;
}
}
}
const idx = (u, v) => v * N + u;
this.cornerIdx = [idx(0, 0), idx(N - 1, 0), idx(N - 1, N - 1), idx(0, N - 1)];
// springs: structural + shear carry load; bend only resists folding
this.springs = [];
const link = (a, b, kind) => {
const ax = a * 3, bx = b * 3;
const dx = this.pos[bx] - this.pos[ax];
const dy = this.pos[bx + 1] - this.pos[ax + 1];
const dz = this.pos[bx + 2] - this.pos[ax + 2];
this.springs.push({ a, b, restBase: Math.hypot(dx, dy, dz), rest: 0, kind });
};
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
if (u < N - 1) link(idx(u, v), idx(u + 1, v), 'struct');
if (v < N - 1) link(idx(u, v), idx(u, v + 1), 'struct');
if (u < N - 1 && v < N - 1) {
link(idx(u, v), idx(u + 1, v + 1), 'shear');
link(idx(u + 1, v), idx(u, v + 1), 'shear');
}
if (u < N - 2) link(idx(u, v), idx(u + 2, v), 'bend');
if (v < N - 2) link(idx(u, v), idx(u, v + 2), 'bend');
}
}
// XPBD Lagrange multipliers, one per spring, reset every substep
this.lambda = new Float64Array(this.springs.length);
// Springs meeting each corner, kept as {spring, index} so the load meter can
// look up each one's multiplier. Bend springs are included: the hardware
// physically carries every element that touches it, and leaving them out
// under-reports the reaction and breaks the statics balance.
this.cornerSprings = this.cornerIdx.map((ci) =>
this.springs
.map((s, si) => ({ s, si }))
.filter(({ s }) => s.a === ci || s.b === ci)
);
// triangles: wind acts per face, and coverage raycasts against these
this.tris = new Uint16Array((N - 1) * (N - 1) * 6);
let ti = 0;
for (let v = 0; v < N - 1; v++) {
for (let u = 0; u < N - 1; u++) {
const a = idx(u, v), b = idx(u + 1, v), c = idx(u + 1, v + 1), d = idx(u, v + 1);
this.tris[ti++] = a; this.tris[ti++] = b; this.tris[ti++] = c;
this.tris[ti++] = a; this.tris[ti++] = c; this.tris[ti++] = d;
}
}
// grid-space proximity of every node to each corner, for per-corner trim
this._cornerWeight = [];
for (let k = 0; k < 4; k++) {
const cu = [0, N - 1, N - 1, 0][k], cv = [0, 0, N - 1, N - 1][k];
const w = new Float64Array(nodeCount);
for (let v = 0; v < N; v++) {
for (let u = 0; u < N; u++) {
const dist = Math.hypot(u - cu, v - cv) / (N - 1);
w[idx(u, v)] = Math.max(0, 1 - dist);
}
}
this._cornerWeight.push(w);
}
this.area = this._surfaceArea();
const mass = (FABRIC_DENSITY * this.area) / nodeCount;
this.nodeMass = mass;
this.invMass.fill(1 / mass);
this._applyRestLengths();
this._repin(0);
}
/** Rest lengths shrink as the tension dial rises, modulated per corner by trim. */
_applyRestLengths() {
for (const s of this.springs) {
let wsum = 0, tsum = 0;
for (let k = 0; k < 4; k++) {
const w = this._cornerWeight[k][s.a] + this._cornerWeight[k][s.b];
wsum += w;
tsum += w * this.corners[k].trim;
}
const trim = wsum > 1e-9 ? tsum / wsum : 1;
s.rest = s.restBase * (1 - PRE_STRAIN * (this.tension * trim - 1));
}
}
/** Pinned corners are infinite-mass so springs stretch honestly against them. */
_repin(t) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
const ci = this.cornerIdx[k];
if (c.broken) {
this.invMass[ci] = 1 / this.nodeMass; // freed node — flogging falls out of this
continue;
}
this.invMass[ci] = 0;
const p = this._anchorPos(c.anchor, t);
this.pos[ci * 3] = p.x; this.pos[ci * 3 + 1] = p.y; this.pos[ci * 3 + 2] = p.z;
this.prev[ci * 3] = p.x; this.prev[ci * 3 + 1] = p.y; this.prev[ci * 3 + 2] = p.z;
}
}
/**
* Where a corner is pinned right now. `sway(t)` is the ABSOLUTE world
* position, not an offset from `pos` (contracts.js Anchor; Lane A called this
* out in THREADS). House and post anchors return a constant; tree anchors
* wander, and that wander is dynamic load — the reason a tree is the scary
* anchor. The returned vector is shared and reused between calls, so read it
* immediately and never store it.
*/
_anchorPos(a, t) {
return a.sway ? a.sway(t) : a.pos;
}
_surfaceArea() {
let total = 0;
for (let i = 0; i < this.tris.length; i += 3) {
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
const e1x = this.pos[b] - this.pos[a], e1y = this.pos[b + 1] - this.pos[a + 1], e1z = this.pos[b + 2] - this.pos[a + 2];
const e2x = this.pos[c] - this.pos[a], e2y = this.pos[c + 1] - this.pos[a + 1], e2z = this.pos[c + 2] - this.pos[a + 2];
const nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
total += Math.hypot(nx, ny, nz) * 0.5;
}
return total;
}
/**
* Advance the sim. Accumulates real time and burns it in fixed SIM_DT chunks,
* so a variable-rate render loop and a fast-forwarded selftest produce
* identical traces. Never reads a clock.
*
* @param {number} dt seconds elapsed since last call
* @param {object} wind { sample(pos, t) -> {x,y,z} }
* @param {number} t world time, seconds
* @param {object} [debris] Lane C's debris module, or anything with `.pieces`.
* Optional — the cloth runs fine without a storm's
* worth of crates in it.
*/
step(dt, wind, t, debris = null) {
if (!this.rigged) return;
const pieces = debris ? (debris.pieces ?? debris) : null;
this._acc += dt;
let n = 0;
while (this._acc >= SIM_DT && n < MAX_SUBSTEPS) {
this._substep(SIM_DT, wind, this.t, pieces);
this._acc -= SIM_DT;
this.t += SIM_DT;
n++;
}
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
}
_substep(dt, wind, t, pieces) {
this._accumulateWind(wind, t, dt);
if (pieces && pieces.length) this._applyDebris(pieces, dt);
this._integrate(dt);
this.lambda.fill(0); // XPBD multipliers are per-substep
for (let i = 0; i < RELAX_ITERS; i++) this._relax(dt * dt);
this._pinCorners(t);
this._measureLoads(dt);
this._checkFailure(dt);
}
/** Wind force per FACE — the hypar mechanic lives here. */
_accumulateWind(wind, t, dt) {
const pos = this.pos, prev = this.prev, F = this.force;
F.fill(0);
const coeff = PRESSURE_COEFF * (1 - this.porosity);
const tanCoeff = TANGENT_COEFF * (1 - this.porosity);
const invDt = 1 / dt;
const probe = this._probe;
for (let i = 0; i < this.tris.length; i += 3) {
const ia = this.tris[i] * 3, ib = this.tris[i + 1] * 3, ic = this.tris[i + 2] * 3;
const e1x = pos[ib] - pos[ia], e1y = pos[ib + 1] - pos[ia + 1], e1z = pos[ib + 2] - pos[ia + 2];
const e2x = pos[ic] - pos[ia], e2y = pos[ic + 1] - pos[ia + 1], e2z = pos[ic + 2] - pos[ia + 2];
// |cross| is twice the area and its direction is the face normal
let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
const len = Math.hypot(nx, ny, nz);
if (len < 1e-9) continue;
const area = len * 0.5;
nx /= len; ny /= len; nz /= len;
probe.x = (pos[ia] + pos[ib] + pos[ic]) / 3;
probe.y = (pos[ia + 1] + pos[ib + 1] + pos[ic + 1]) / 3;
probe.z = (pos[ia + 2] + pos[ib + 2] + pos[ic + 2]) / 3;
const w = wind.sample(probe, t, this._windOut);
// Relative wind, not absolute: as the cloth accelerates downwind the load
// bleeds off by itself. This is what stops flogging from exploding.
const vx = (pos[ia] - prev[ia] + pos[ib] - prev[ib] + pos[ic] - prev[ic]) / 3 * invDt;
const vy = (pos[ia + 1] - prev[ia + 1] + pos[ib + 1] - prev[ib + 1] + pos[ic + 1] - prev[ic + 1]) / 3 * invDt;
const vz = (pos[ia + 2] - prev[ia + 2] + pos[ib + 2] - prev[ib + 2] + pos[ic + 2] - prev[ic + 2]) / 3 * invDt;
const rx = w.x - vx, ry = w.y - vy, rz = w.z - vz;
const d = clamp(rx * nx + ry * ny + rz * nz, -MAX_NORMAL_SPEED, MAX_NORMAL_SPEED);
// d*|d| rather than d^2: keeps the v^2 magnitude but points the force the
// way the wind is actually blowing. A sail is double-sided.
const p = coeff * area * d * Math.abs(d);
const tx = (rx - nx * d) * tanCoeff * area;
const ty = (ry - ny * d) * tanCoeff * area;
const tz = (rz - nz * d) * tanCoeff * area;
const fx = (nx * p + tx) / 3, fy = (ny * p + ty) / 3, fz = (nz * p + tz) / 3;
F[ia] += fx; F[ia + 1] += fy; F[ia + 2] += fz;
F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz;
F[ic] += fx; F[ic + 1] += fy; F[ic + 2] += fz;
}
}
/**
* Sphere-vs-cloth impulses for Lane C's debris (SPRINT2 decision 5, option b).
*
* The exchange is symmetric: every newton-second the cloth takes out of a
* crate, the crate loses. That's the point of the decision — one integrator
* does the momentum bookkeeping, so a crate punching through a sail slows
* down by exactly as much as it speeds the cloth up. Asserted in
* sail.selftest.js.
*
* Pinned corners are the deliberate exception: they have invMass 0, so a
* crate that hits one bounces off and the momentum goes into the house. That
* is correct — the anchor is bolted to a wall — and it's why the momentum
* assert uses an interior hit.
*
* @param {Array} pieces debris.pieces — {x,y,z,vx,vy,vz,r,mass}
*/
_applyDebris(pieces, dt) {
const pos = this.pos, prev = this.prev, im = this.invMass;
for (const p of pieces) {
if (p.alive === false || !Number.isFinite(p.mass) || p.mass <= 0) continue;
// Swept: main.js steps the sail BEFORE the debris, so these positions are
// a frame stale, and a 0.3 m crate at 25 m/s covers 0.42 m in a frame —
// enough to pass clean between cloth nodes. Growing the contact radius by
// the piece's travel catches both the lag and the tunnelling.
const speed = Math.hypot(p.vx, p.vy, p.vz);
const solid = p.r + DEBRIS_SKIN;
const reach = solid + speed * dt;
const reachSq = reach * reach;
const wPiece = 1 / p.mass;
let jx = 0, jy = 0, jz = 0, hits = 0;
for (let n = 0; n < im.length; n++) {
const i = n * 3;
const dx = pos[i] - p.x, dy = pos[i + 1] - p.y, dz = pos[i + 2] - p.z;
const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > reachSq || dsq < 1e-12) continue;
const d = Math.sqrt(dsq);
const nx = dx / d, ny = dy / d, nz = dz / d; // piece centre -> node
// node velocity, read out of verlet
const vnx = (pos[i] - prev[i]) / dt;
const vny = (pos[i + 1] - prev[i + 1]) / dt;
const vnz = (pos[i + 2] - prev[i + 2]) / dt;
const vrel = (vnx - p.vx) * nx + (vny - p.vy) * ny + (vnz - p.vz) * nz;
if (vrel > 0) continue; // already separating — don't glue them together
const wNode = im[n];
const denom = wNode + wPiece;
if (denom < 1e-12) continue;
const j = (-(1 + DEBRIS_RESTITUTION) * vrel) / denom;
hits++;
// node takes +j along the contact normal; verlet stores velocity as a
// position difference, so the impulse goes in by moving `prev`
prev[i] -= nx * j * wNode * dt;
prev[i + 1] -= ny * j * wNode * dt;
prev[i + 2] -= nz * j * wNode * dt;
// ...and the piece takes exactly -j. This is the conservation.
jx -= nx * j; jy -= ny * j; jz -= nz * j;
// Depenetrate free nodes by moving pos AND prev together, so pushing
// the cloth off the crate doesn't secretly inject velocity.
if (wNode > 0 && d < solid) {
const push = solid - d;
pos[i] += nx * push; prev[i] += nx * push;
pos[i + 1] += ny * push; prev[i + 1] += ny * push;
pos[i + 2] += nz * push; prev[i + 2] += nz * push;
}
}
if (hits) {
p.vx += jx * wPiece; p.vy += jy * wPiece; p.vz += jz * wPiece;
this.events.emit('debrisHit', {
type: 'debrisHit', piece: p, nodes: hits,
impulse: Math.hypot(jx, jy, jz), t: this.t,
});
}
}
}
_integrate(dt) {
const pos = this.pos, prev = this.prev, F = this.force, im = this.invMass;
const dt2 = dt * dt;
for (let n = 0; n < im.length; n++) {
if (im[n] === 0) continue; // pinned
const i = n * 3;
for (let k = 0; k < 3; k++) {
const a = F[i + k] * im[n] + (k === 1 ? GRAVITY : 0);
const x = pos[i + k];
const nx = x + (x - prev[i + k]) * VEL_DAMP + a * dt2;
prev[i + k] = x;
pos[i + k] = nx;
}
}
}
/**
* XPBD constraint solve. The plain-PBD version of this is simpler, but its
* position corrections carry no force information — the leftover stretch
* after a fixed iteration count is solver error, not fabric strain, so
* reading load off it measures the solver. XPBD's Lagrange multiplier lambda
* is the real constraint impulse, so lambda/dt^2 is a genuine newton value
* and the corner reactions balance the applied wind by construction.
*/
_relax(dt2) {
const pos = this.pos, im = this.invMass, lam = this.lambda;
for (let si = 0; si < this.springs.length; si++) {
const s = this.springs[si];
const wa = im[s.a], wb = im[s.b];
const w = wa + wb;
if (w === 0) continue; // both ends pinned
const ia = s.a * 3, ib = s.b * 3;
const dx = pos[ib] - pos[ia], dy = pos[ib + 1] - pos[ia + 1], dz = pos[ib + 2] - pos[ia + 2];
const d = Math.hypot(dx, dy, dz);
if (d < 1e-9) continue;
const C = d - s.rest;
const compliance = s.kind === 'bend' ? COMP_BEND : C > 0 ? COMP_STRETCH : COMP_COMPRESS;
const at = compliance / dt2;
const dLambda = (-C - at * lam[si]) / (w + at);
lam[si] += dLambda;
// grad C is -n for node a and +n for node b, with n = (b - a)/d
const nx = dx / d, ny = dy / d, nz = dz / d;
pos[ia] -= nx * dLambda * wa; pos[ia + 1] -= ny * dLambda * wa; pos[ia + 2] -= nz * dLambda * wa;
pos[ib] += nx * dLambda * wb; pos[ib + 1] += ny * dLambda * wb; pos[ib + 2] += nz * dLambda * wb;
}
}
_pinCorners(t) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
const ci = this.cornerIdx[k] * 3;
const p = this._anchorPos(c.anchor, t);
this.pos[ci] = p.x; this.pos[ci + 1] = p.y; this.pos[ci + 2] = p.z;
}
}
/**
* Corner load = magnitude of the VECTOR sum of the tensions in the springs
* meeting that corner, each read off its XPBD multiplier as |lambda|/dt^2.
*
* Reading tension as FABRIC_K * leftover-stretch instead looks equivalent and
* is not: after a fixed 5 iterations the leftover stretch is solver error, so
* that number measures the solver rather than the fabric and comes out ~50x
* hot. The multiplier is the actual constraint impulse, which is why the
* statics assert balances.
*
* The vector sum (rather than a scalar total) is what
* makes DESIGN.md's anchor-angle mechanic fall out for free: edges pulling in
* nearly the same direction add up, edges pulling apart partly cancel — so a
* pinched corner really does multiply its own load.
*/
_measureLoads(dt) {
const pos = this.pos, lam = this.lambda;
const invDt2 = 1 / (dt * dt);
const alpha = 1 - Math.exp(-dt / LOAD_TAU);
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) { c.load = 0; c.loadVec.x = c.loadVec.y = c.loadVec.z = 0; continue; }
const ci = this.cornerIdx[k], cix = ci * 3;
let sx = 0, sy = 0, sz = 0;
for (const { s, si } of this.cornerSprings[k]) {
if (lam[si] >= 0) continue; // slack or compressed fabric pulls on nothing
const tension = -lam[si] * invDt2; // the multiplier IS the impulse; /dt^2 makes it newtons
const o = (s.a === ci ? s.b : s.a) * 3;
const dx = pos[o] - pos[cix], dy = pos[o + 1] - pos[cix + 1], dz = pos[o + 2] - pos[cix + 2];
const d = Math.hypot(dx, dy, dz);
if (d < 1e-9) continue;
sx += (dx / d) * tension; sy += (dy / d) * tension; sz += (dz / d) * tension;
}
c.loadVec.x += (sx - c.loadVec.x) * alpha;
c.loadVec.y += (sy - c.loadVec.y) * alpha;
c.loadVec.z += (sz - c.loadVec.z) * alpha;
const raw = Math.hypot(sx, sy, sz);
c.load += (raw - c.load) * alpha;
if (c.load > c.peakLoad) c.peakLoad = c.load;
}
}
/** Ported from the prototype: 0.4 s sustained over the rating and it lets go. */
_checkFailure(dt) {
for (let k = 0; k < 4; k++) {
const c = this.corners[k];
if (c.broken) continue;
if (c.load > c.hw.rating) c.overload += dt;
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
if (c.overload > OVERLOAD_SECS) {
c.broken = true;
c.overload = 0;
c.load = 0;
// Hand the node its mass back. Everything good about a failure comes
// from this one line: the freed corner stops being pinned, so it flies
// on the wind and the flogging is emergent rather than animated.
// Without it a "blown" corner stays welded in mid-air.
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
}
}
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
}
// --- Lane D's seam (SPRINT2 decision 4) --------------------------------
// D landed first and duck-typed these against the rig, so B conforms to D's
// spelling rather than the other way round. Thin aliases on purpose: the
// behaviour lives in repairCorner/trimCorner, these just match the call sites
// in interact.js and are what contracts.js promises.
/**
* Re-rig corner `i` with the spare the player was carrying. The spare is the
* "$15 spare shackle" the prep phase sells, so it re-rigs at shackle grade —
* which can be an UPGRADE on a corner that blew a carabiner, and a downgrade
* on one that blew a rated shackle. That's the prototype's behaviour and it's
* a real decision about which corner you run back to.
* @param {number} i
*/
repair(i) { this.repairCorner(i, SPARE_HW); }
/**
* Per-corner turnbuckle. @param {number} i @param {number} delta ±, clamped 0.851.15.
*/
trim(i, delta) { this.trimCorner(i, delta); }
/**
* Live world position of corner `i`, as a FRESH vector — a blown corner's node
* is flying, so Lane D's prompt has to chase it rather than sit on the anchor.
* Fresh (not shared scratch) because interact.js holds the result across the
* frame and two corners are read back to back.
* @param {number} i
* @returns {THREE.Vector3|null}
*/
cornerPos(i) {
if (!this.rigged || !this.corners[i]) return null;
const n = this.cornerIdx[i] * 3;
return new THREE.Vector3(this.pos[n], this.pos[n + 1], this.pos[n + 2]);
}
/** Re-rig a blown corner with fresh hardware. Lane D's hold-E repair calls this. */
repairCorner(index, hw = SPARE_HW) {
const c = this.corners[index];
if (!c || !c.broken) return false;
c.broken = false;
c.hw = hw;
c.load = 0;
c.overload = 0;
this._repin(this.t);
this.events.emit('repair', { type: 'repair', corner: c, anchorId: c.anchorId, hw: hw.name, t: this.t });
return true;
}
/** Turnbuckle trim at ONE corner (Lane D, 1.2 s hold). Tightens/eases locally. */
trimCorner(index, delta) {
const c = this.corners[index];
if (!c) return false;
c.trim = clamp(c.trim + delta, TRIM_MIN, TRIM_MAX);
this._dirtyRest = true;
return true;
}
setTension(tension) {
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
if (this.rigged) this._applyRestLengths();
}
/**
* Ground-projected shade over a rect: the fraction of sample points on the
* rect that the sail blocks from the sun. This IS the shade mechanic, so it
* raycasts toward the real sun rather than projecting straight down — which
* is what lets DESIGN.md's moving and seasonal sun change the answer.
*
* @param {object} rect world.gardenBed shape: CENTRE (x,z), size (w,d), metres
* @param {object} sunDir world.sunDir — unit vector from the ground TOWARD
* the sun. A hit means shaded. Defaults to overhead.
* @param {function} heightAt world.heightAt — rays start at the real ground.
* Defaults to a flat y=0, which is only right for tests.
*/
coverageOver(rect, sunDir = { x: 0, y: 1, z: 0 }, heightAt = null) {
if (!this.rigged) return 0;
const len = Math.hypot(sunDir.x, sunDir.y, sunDir.z) || 1;
const dx = sunDir.x / len, dy = sunDir.y / len, dz = sunDir.z / len;
if (dy <= 0.01) return 0; // sun at or below the horizon casts no useful shade
const COLS = 6, ROWS = 4; // prototype sampled 6x4 over the garden
let hit = 0;
for (let i = 0; i < COLS; i++) {
for (let j = 0; j < ROWS; j++) {
// rect is centre-and-size, so samples straddle (rect.x, rect.z)
const ox = rect.x + ((i + 0.5) / COLS - 0.5) * rect.w;
const oz = rect.z + ((j + 0.5) / ROWS - 0.5) * rect.d;
const oy = heightAt ? heightAt(ox, oz) : 0;
if (this._rayHitsSail(ox, oy, oz, dx, dy, dz)) hit++;
}
}
return hit / (COLS * ROWS);
}
/** Moller-Trumbore against every face; 162 tris, cheap enough to not bother accelerating. */
_rayHitsSail(ox, oy, oz, dx, dy, dz) {
const pos = this.pos;
for (let i = 0; i < this.tris.length; i += 3) {
const a = this.tris[i] * 3, b = this.tris[i + 1] * 3, c = this.tris[i + 2] * 3;
const e1x = pos[b] - pos[a], e1y = pos[b + 1] - pos[a + 1], e1z = pos[b + 2] - pos[a + 2];
const e2x = pos[c] - pos[a], e2y = pos[c + 1] - pos[a + 1], e2z = pos[c + 2] - pos[a + 2];
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
const det = e1x * px + e1y * py + e1z * pz;
if (Math.abs(det) < 1e-9) continue; // ray parallel to the face
const inv = 1 / det;
const tx = ox - pos[a], ty = oy - pos[a + 1], tz = oz - pos[a + 2];
const u = (tx * px + ty * py + tz * pz) * inv;
if (u < 0 || u > 1) continue;
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
const v = (dx * qx + dy * qy + dz * qz) * inv;
if (v < 0 || u + v > 1) continue;
const hitT = (e2x * qx + e2y * qy + e2z * qz) * inv;
if (hitT > 1e-6) return true;
}
return false;
}
/** Sum of the aerodynamic + weight force on the whole sail, N. Used by the statics assert. */
netAppliedForce(wind, t) {
this._accumulateWind(wind, t, SIM_DT);
let fx = 0, fy = 0, fz = 0;
for (let n = 0; n < this.invMass.length; n++) {
fx += this.force[n * 3];
fy += this.force[n * 3 + 1] + GRAVITY * this.nodeMass;
fz += this.force[n * 3 + 2];
}
return { x: fx, y: fy, z: fz };
}
maxLoad() {
let m = 0;
for (const c of this.corners) if (c.load > m) m = c.load;
return m;
}
}
/**
* three.js view over a rig. Imported lazily so the sim core above stays
* headless-runnable; call this only from the browser, after Lane A's vendor/
* exists. Returns a THREE.Group to add to the scene, with update() per frame.
*/
export async function createSailView(rig, { color = 0xd8c48a } = {}) {
const THREE = await import('../vendor/three.module.js');
const geo = new THREE.BufferGeometry();
const verts = new Float32Array(rig.pos.length);
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3));
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(rig.tris), 1));
const mat = new THREE.MeshStandardMaterial({
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true; // the shadow IS the product
mesh.receiveShadow = true;
mesh.frustumCulled = false; // it flogs well outside its initial bounds
const group = new THREE.Group();
group.add(mesh);
group.update = () => {
verts.set(rig.pos);
geo.attributes.position.needsUpdate = true;
geo.computeVertexNormals();
geo.computeBoundingSphere();
};
group.update();
return group;
}