D's S16 poison poke, filed for this lane: _checkFailure's corner loop is the heal's only trigger and it skips broken corners, so once all four were divergence-broken a SECOND poison had no unbroken corner left to read NaN through — the corpse stayed NaN forever and rendered as nothing. A lost sail is still cloth in the yard; it must LOOK lost, not vanish. The sentinel runs ONLY on a fully-broken rig (the corner wire covers every other case): sum pos+prev — any NaN/±Inf makes the sum non-finite (Inf−Inf is NaN), finite cloth cannot overflow it. Branch-free O(n) adds, no mutation, so a finite lost sail is untouched byte-for-byte. Test replays D's gait: cascade 4/4 → 5 s finite with ZERO heals (the negative control — the sentinel must never mistake 'lost' for 'sick') → second poison heals in one substep, finite a full second on. Node suite 47/47.
1301 lines
59 KiB
JavaScript
1301 lines
59 KiB
JavaScript
/**
|
||
* 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';
|
||
import { RAIN_TIME_COMPRESSION } from './weather.core.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
|
||
|
||
// ---------- ponding (SPRINT4 decision 10 / SPRINT5) ----------
|
||
// DESIGN.md §"Rain → ponding": "Flat sails collect water; water is heavy; the
|
||
// belly collects more (positive feedback) until sudden dump, tear, or corner
|
||
// failure." Lane C owns how hard it rains (rainMmPerHour + RAIN_TIME_COMPRESSION);
|
||
// this owns how much of it a sail holds.
|
||
//
|
||
// The model is FLOW, not a drain coefficient. Water leaves a sail because it has
|
||
// somewhere to GO, not because the fabric is tilted: each node pushes water down
|
||
// its steepest neighbour, rim nodes pour it over the edge. The neighbourhood is
|
||
// 8-way ON PURPOSE — on a hypar the water runs along the saddle's DIAGONAL ridge
|
||
// to the two low corners, and a 4-way graph can't follow that, so it traps water
|
||
// in the shallow gravity belly the cloth sags into between grid lines and a hypar
|
||
// pools as much as a flat sail (measured: it did, until diagonals). So a flat
|
||
// sail's belly is a basin water flows INTO and can't climb out of, while a hypar
|
||
// drains to its low corners and off — which is why ponding cannot pincer §7.
|
||
//
|
||
// (My reverted Sprint-3 prototype drained by slope magnitude instead and pooled
|
||
// 1 kg — it measured the coefficient I'd invented, not the sail.)
|
||
const POND_FLOW = 6.0; // 1/s per unit of downhill gradient — how fast water finds the low spot
|
||
const POND_SPILL = 3.0; // 1/s — a rim node pouring over the edge, when the edge is downhill
|
||
// Depth cap per node. Water funnels into ~10 belly nodes, not evenly, so this is
|
||
// the depth AT THE DEEPEST POINT (a torn-sail extreme), not the average — set
|
||
// too low and the whole sail saturates shallow and never reaches a kill load.
|
||
const POND_MAX_KG_M2 = 900; // ~90 cm at the single deepest node
|
||
const BROOM_DRAIN = 2.5; // 1/s at the poke — a ~1.5 s hold clears the belly
|
||
|
||
// ---------- 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 ----------
|
||
// Genuine solver-blowup threshold. NOT a "loads shouldn't get this high" cap:
|
||
// investigating Lane D's tn-1.04 report showed a 155 m² flat sail holding
|
||
// 2100 kg of ponded water really does put ~21 kN on a corner, and it stays
|
||
// finite and tracks the water — that's correct physics for an absurd rig, not
|
||
// divergence. Real divergence is 100 kN+ and NaN. So this sits well above any
|
||
// real load; POND_BELLY_MAX below is what stops a sail bellying to 5 m first.
|
||
const DIVERGENCE_N = 80000; // 80 kN — past here the solver has actually blown up
|
||
const POND_BELLY_MAX = 4.0; // metres of sag before the sail tears and dumps (DESIGN.md "sudden dump… tear")
|
||
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);
|
||
|
||
/**
|
||
* A new sail is a new clock. [SPRINT13 — D's pool nit, and it wasn't a nit]
|
||
*
|
||
* `this.t` is the sim's OWN fixed-step clock: step() burns ragged frame dt
|
||
* into SIM_DT chunks and samples the wind at this.t, which is the whole
|
||
* reason a 144 Hz browser and a fast-forwarded selftest produce byte-equal
|
||
* traces. But it was only ever zeroed in the constructor, and main.js builds
|
||
* ONE SailRig at boot and re-attach()es it every night — while step() runs
|
||
* every frame in EVERY phase (storm, aftermath, the forecast card) the
|
||
* moment `rigged` is true.
|
||
*
|
||
* So night 2 opened its storm with the clock wherever night 1's aftermath
|
||
* left it, and sampled the storm curve from there. Measured on the wild
|
||
* night, p1..p4 with rated shackles:
|
||
*
|
||
* clock reset (as authored) p4 3.65 kN p2 2.78 p3 1.62 p1 0.98
|
||
* carried t=110 (night 2) p4 2.60 kN p2 1.92 p3 0.81 p1 0.54
|
||
*
|
||
* Every night after the first flew a storm 30-50% weaker than the one C
|
||
* authored — past the end of its own baseCurve, so it read the tail breeze
|
||
* flat, with no build and no peak. Worse, the offset was however long the
|
||
* player spent reading the invoice, so it wasn't even the same wrong storm
|
||
* twice: a determinism break in a repo whose whole sim is built on not
|
||
* having one.
|
||
*
|
||
* Zeroed here rather than in main.js because attach() is the one door every
|
||
* caller comes through (boot, the picking adapter, balance.test, the audit),
|
||
* and commit → attach → game.advance() → storm all land in a single keypress,
|
||
* so t=0 at attach IS t=0 at the first storm frame.
|
||
*/
|
||
this.t = 0;
|
||
this._acc = 0;
|
||
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);
|
||
|
||
// ---- ponding state ----
|
||
this.water = new Float64Array(nodeCount); // kg on each node
|
||
this._nodeFlat = new Float64Array(nodeCount); // area-weighted |ny|, refilled by the wind pass
|
||
this._nodeArea = new Float64Array(nodeCount);
|
||
this._wFlow = new Float64Array(nodeCount); // per-step transfer buffer
|
||
// 8-neighbour graph, -1 padded (see the ponding note for why diagonals).
|
||
this._nbr = new Int32Array(nodeCount * 8).fill(-1);
|
||
this._isRim = new Uint8Array(nodeCount);
|
||
for (let v = 0; v < N; v++) {
|
||
for (let u = 0; u < N; u++) {
|
||
const n = idx(u, v);
|
||
let k = 0;
|
||
for (const [du, dv] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
||
const uu = u + du, vv = v + dv;
|
||
this._nbr[n * 8 + k++] = (uu >= 0 && uu < N && vv >= 0 && vv < N) ? idx(uu, vv) : -1;
|
||
}
|
||
this._isRim[n] = (u === 0 || u === N - 1 || v === 0 || v === N - 1) ? 1 : 0;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
// after the wind pass — it fills the per-node flatness/area ponding reads
|
||
this._applyPonding(wind.rainMmPerHour ? wind.rainMmPerHour(t) : 0, 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);
|
||
if (this.watchDivergence) this._checkDivergence();
|
||
}
|
||
|
||
/**
|
||
* Optional tripwire for Lane D's tn-1.04 report. That spike does NOT reproduce
|
||
* on current main — with swaying tree anchors, 3xrated+1carabiner, loads climb
|
||
* smoothly 7.2->8.7 kN across the whole tension range, no discontinuity at
|
||
* 1.04 (decision 11's downdraft bump and the §7 re-point changed the load
|
||
* regime under it). So rather than clamp real physics to fix a bug I can't
|
||
* demonstrate — the displacement clamp I tried moved the thesis 39->34% — this
|
||
* just WATCHES. Set `rig.watchDivergence = true` and it throws with the corner,
|
||
* load and time the instant a corner exceeds a physically-impossible load, so
|
||
* if it ever comes back it comes back with a repro instead of a mystery.
|
||
*/
|
||
_checkDivergence() {
|
||
for (let k = 0; k < 4; k++) {
|
||
const c = this.corners[k];
|
||
if (c.load > DIVERGENCE_N || !Number.isFinite(c.load)) {
|
||
throw new Error(
|
||
`sail divergence: corner ${c.anchorId} at ${(c.load / 1000).toFixed(1)} kN, ` +
|
||
`t=${this.t.toFixed(2)}s, tension=${this.tension.toFixed(3)}. This is the tn-1.04 cliff ` +
|
||
`(THREADS [D] 2026-07-17) recurring — capture this rig and ping Lane B.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
|
||
// |ny| is how horizontal the face is (1 flat, 0 on edge), which is also
|
||
// exactly the fraction of its area rain sees from straight up — so the
|
||
// ponding catch area is free here rather than a second geometry pass.
|
||
const share = area / 3, flat = Math.abs(ny) * share;
|
||
const na = this.tris[i], nb = this.tris[i + 1], nc = this.tris[i + 2];
|
||
this._nodeFlat[na] += flat; this._nodeFlat[nb] += flat; this._nodeFlat[nc] += flat;
|
||
this._nodeArea[na] += share; this._nodeArea[nb] += share; this._nodeArea[nc] += share;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rain lands, runs downhill, and pools where it can't get out. Called after
|
||
* the wind pass (which fills _nodeFlat / _nodeArea).
|
||
*
|
||
* ⚠ FABRIC-BLIND, on purpose and on the record (SPRINT16, C's finding):
|
||
* nothing below reads `this.porosity`, so an open weave ponds exactly like a
|
||
* membrane. Ruled a known simplification — see setFabric's comment for the
|
||
* ruling and the re-measure bill a real through-weave leak would incur.
|
||
* @param {number} mmPerHour wind.rainMmPerHour(t) — REAL-world rate, Lane C's data
|
||
*/
|
||
_applyPonding(mmPerHour, dt) {
|
||
const pos = this.pos, F = this.force, w = this.water, flow = this._wFlow;
|
||
const N2 = w.length;
|
||
// 1 mm over 1 m² is 1 kg. Lane C owns the rate and the 40× compression
|
||
// constant; multiplying them here is the whole of "how much a sail holds".
|
||
const kgPerM2PerSec = (mmPerHour * RAIN_TIME_COMPRESSION) / 3600;
|
||
|
||
for (let n = 0; n < N2; n++) {
|
||
const a = this._nodeArea[n];
|
||
if (a > 1e-9 && kgPerM2PerSec > 0) {
|
||
w[n] += kgPerM2PerSec * this._nodeFlat[n] * dt; // rain lands on the horizontal projection
|
||
}
|
||
flow[n] = 0;
|
||
}
|
||
|
||
// steepest-descent transfer: water leaves down the biggest GRADIENT, not the
|
||
// biggest drop — a diagonal is √2 farther, so the same drop across it is a
|
||
// gentler slope than straight down. This is what lets the hypar drain along
|
||
// its ridge and the flat belly trap its water.
|
||
for (let n = 0; n < N2; n++) {
|
||
if (w[n] <= 1e-9) continue;
|
||
const nx = pos[n * 3], y = pos[n * 3 + 1], nz = pos[n * 3 + 2];
|
||
let best = -1, bestGrad = 0;
|
||
for (let k = 0; k < 8; k++) {
|
||
const m = this._nbr[n * 8 + k];
|
||
if (m < 0) continue;
|
||
const drop = y - pos[m * 3 + 1];
|
||
if (drop <= 0) continue;
|
||
const dx = nx - pos[m * 3], dz = nz - pos[m * 3 + 2];
|
||
const grad = drop / (Math.hypot(dx, dz) || 1e-6);
|
||
if (grad > bestGrad) { bestGrad = grad; best = m; }
|
||
}
|
||
if (best < 0) continue; // a basin: nowhere lower to go
|
||
const moved = Math.min(w[n], w[n] * POND_FLOW * bestGrad * dt);
|
||
flow[n] -= moved; flow[best] += moved;
|
||
}
|
||
// The per-node ceiling is enforced AFTER the flow step, not on the rain-add:
|
||
// the old rain-add clamp only ran while it was raining, so once the rain
|
||
// stopped, downhill consolidation could quietly stack a basin node past
|
||
// POND_MAX_KG_M2 with nothing left to trim it (the belly-tear guard below
|
||
// only bounds the gross runaway). Water over the cap sheets off the cloth —
|
||
// it leaves the sail, same as the rim spill; it is not conserved.
|
||
for (let n = 0; n < N2; n++) {
|
||
w[n] += flow[n];
|
||
const a = this._nodeArea[n];
|
||
if (a > 1e-9 && w[n] > POND_MAX_KG_M2 * a) w[n] = POND_MAX_KG_M2 * a;
|
||
}
|
||
|
||
// A rim node spills over the edge ONLY when the edge is downhill — i.e. it
|
||
// has no lower interior neighbour to send water to. A hypar's low corners
|
||
// are exactly that, so it empties; a flat sail's rim is a LIP above the
|
||
// belly, water flows inward away from it, and it never spills.
|
||
for (let n = 0; n < N2; n++) {
|
||
if (!this._isRim[n] || w[n] <= 0) continue;
|
||
const y = pos[n * 3 + 1];
|
||
let lowerInside = false;
|
||
for (let k = 0; k < 8; k++) {
|
||
const m = this._nbr[n * 8 + k];
|
||
if (m >= 0 && pos[m * 3 + 1] < y - 1e-4) { lowerInside = true; break; }
|
||
}
|
||
if (!lowerInside) w[n] = Math.max(0, w[n] - w[n] * POND_SPILL * dt);
|
||
}
|
||
|
||
// the weight. Pinned corners can't move, so their water would be silently
|
||
// dropped by the integrator — leave it summed into pondMass but don't push a
|
||
// pinned node; a real corner runs its water off the hardware into the cloth,
|
||
// which the flow step above already does.
|
||
let lowestNode = 1e9, lowestCorner = 1e9;
|
||
for (let n = 0; n < N2; n++) {
|
||
if (w[n] > 0 && this.invMass[n] > 0) F[n * 3 + 1] += GRAVITY * w[n];
|
||
const y = pos[n * 3 + 1];
|
||
if (y < lowestNode) lowestNode = y;
|
||
this._nodeFlat[n] = 0;
|
||
this._nodeArea[n] = 0;
|
||
}
|
||
for (let k = 0; k < 4; k++) {
|
||
const cy = pos[this.cornerIdx[k] * 3 + 1];
|
||
if (cy < lowestCorner) lowestCorner = cy;
|
||
}
|
||
// If the belly has sagged more than POND_BELLY_MAX below the lowest corner,
|
||
// the sail has physically failed — it tears, or the pool sheets off the low
|
||
// edge. Either way the water goes. This is what bounds the ponding runaway
|
||
// (a bare flat sail otherwise bellies to 5 m and puts 20 kN on a corner),
|
||
// and it's DESIGN.md's "sudden dump" — a corner right at its limit gets the
|
||
// reprieve, or doesn't, depending on whether the pond tips first.
|
||
if (lowestCorner - lowestNode > POND_BELLY_MAX && this.pondMass() > 1) {
|
||
this.dumpPond('belly tore');
|
||
}
|
||
}
|
||
|
||
/** Total water on the sail, kg. Lane A's "SAIL PONDING — get the broom" number. */
|
||
pondMass() {
|
||
if (!this.water) return 0; // the HUD may read this before the sail is rigged
|
||
let m = 0;
|
||
for (let n = 0; n < this.water.length; n++) m += this.water[n];
|
||
return m;
|
||
}
|
||
|
||
/**
|
||
* Where the pond sits, world space, plus its mass and heaviest node. Null if
|
||
* there's nothing worth pointing at. Lane D walks to this; Lane E draws it.
|
||
* @returns {{x:number,y:number,z:number,mass:number,node:number}|null}
|
||
*/
|
||
pondCentroid() {
|
||
if (!this.water) return null;
|
||
let m = 0, x = 0, y = 0, z = 0, node = -1, hw = 0;
|
||
for (let n = 0; n < this.water.length; n++) {
|
||
const q = this.water[n];
|
||
if (q <= 0) continue;
|
||
m += q; x += this.pos[n * 3] * q; y += this.pos[n * 3 + 1] * q; z += this.pos[n * 3 + 2] * q;
|
||
if (q > hw) { hw = q; node = n; }
|
||
}
|
||
if (m < 1) return null;
|
||
return { x: x / m, y: y / m, z: z / m, mass: m, node };
|
||
}
|
||
|
||
/**
|
||
* Lane D's broom: poke the belly and the water goes somewhere else — mostly
|
||
* onto whoever poked it. Call every frame of the ~1.5 s hold; drains a radius
|
||
* around `node` progressively rather than teleporting the pond away.
|
||
* @param {number} node grid node index — aim at pondCentroid().node
|
||
* @returns {number} kg dumped THIS call. Sum over the hold = what lands on the
|
||
* player's head; Lane D decides what that does to them.
|
||
*/
|
||
drainPondAt(node, dt, radius = 2) {
|
||
if (!this.rigged || node == null || node < 0 || node >= this.water.length) return 0;
|
||
const N = this.N, cu = node % N, cv = (node / N) | 0;
|
||
let dumped = 0;
|
||
for (let v = Math.max(0, cv - radius); v <= Math.min(N - 1, cv + radius); v++) {
|
||
for (let u = Math.max(0, cu - radius); u <= Math.min(N - 1, cu + radius); u++) {
|
||
const n = v * N + u;
|
||
if (this.water[n] <= 0) continue;
|
||
const fall = 1 - Math.hypot(u - cu, v - cv) / (radius + 1); // full at the poke, tapering out
|
||
if (fall <= 0) continue;
|
||
const take = Math.min(this.water[n], this.water[n] * BROOM_DRAIN * fall * dt);
|
||
this.water[n] -= take;
|
||
dumped += take;
|
||
}
|
||
}
|
||
if (dumped > 0) this.events.emit('pondDump', { type: 'pondDump', kg: dumped, node, t: this.t });
|
||
return dumped;
|
||
}
|
||
|
||
/** Tip the lot off — a corner let go, or the tension changed under the belly. */
|
||
dumpPond(reason = 'dump') {
|
||
const kg = this.pondMass();
|
||
if (kg <= 0) return 0;
|
||
this.water.fill(0);
|
||
this.events.emit('pondDump', { type: 'pondDump', kg, reason, t: this.t });
|
||
return kg;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zero the peak-load watermarks to the CURRENT load. `peakLoad` is otherwise
|
||
* peak-since-attach, which silently folds the attach transient and any settle
|
||
* into whatever you meant to measure — call this where measurement starts.
|
||
*
|
||
* Written for tools/site_audit, which was reporting a 12 s settle's transient
|
||
* as a storm peak until it called this. `load` itself is untouched.
|
||
*/
|
||
resetPeaks() {
|
||
for (const c of this.corners) c.peakLoad = c.load;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* RMS speed of the cloth's nodes, m/s — read straight out of verlet, since
|
||
* position IS the state here and velocity is just (pos - prev)/dt.
|
||
*
|
||
* This is what "is the sail settled" actually asks. Corner LOAD cannot answer
|
||
* it: measured on the dressed yard, a load-trend guard reads 17% on a rig with
|
||
* NO settle and 96% on a properly settled one, because what it really sees is
|
||
* the rig loading up when the wind changes — the better-settled the cloth, the
|
||
* sharper that ramp. Motion inverts none of that. Under unchanged conditions:
|
||
*
|
||
* 0 s settle 0.19 m/s 12 s settle 0.02 m/s 30 s 0.007
|
||
*
|
||
* an order of magnitude, and in the direction the word "settled" means. Sample
|
||
* it over a window, not per-step: the cloth breathes and never reaches zero.
|
||
*/
|
||
nodeSpeed() {
|
||
const p = this.pos, q = this.prev;
|
||
if (!p || !q) return 0;
|
||
let sum = 0, n = 0;
|
||
for (let i = 0; i < p.length; i += 3) {
|
||
const dx = p[i] - q[i], dy = p[i + 1] - q[i + 1], dz = p[i + 2] - q[i + 2];
|
||
sum += dx * dx + dy * dy + dz * dz;
|
||
n++;
|
||
}
|
||
return n ? Math.sqrt(sum / n) / SIM_DT : 0;
|
||
}
|
||
|
||
/**
|
||
* Ported from the prototype: 0.4 s sustained over the rating and it lets go.
|
||
*
|
||
* SPRINT12 — the threshold is the ANCHOR's, not just the hardware's:
|
||
* `hw.rating * anchor.ratingHint`. A's ruling (THREADS sprint 11, "THE
|
||
* RATINGS ARE REAL. WIRE THEM."): DESIGN's fascia/carport lie is an ANCHOR
|
||
* failing, and before this line every anchor was exactly as strong as the
|
||
* carabiner you hung off it — E's baked 0.22/0.30/0.35 were read by nothing,
|
||
* and the lever that decided whether the trap fired was tension, not steel.
|
||
* Read LIVE from c.anchor each check (not captured at attach): dress() adopts
|
||
* GLB hints by MUTATING the anchor objects in place, and a prep-time rig must
|
||
* see the dressed number, not whatever was there when attach() ran.
|
||
* The `?? 1` is belt-and-braces — world.js guarantees a finite hint on every
|
||
* anchor (DEFAULT_RATING_HINT, a.test pins it), because `load > rating *
|
||
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
|
||
*/
|
||
_checkFailure(dt) {
|
||
let diverged = false;
|
||
for (let k = 0; k < 4; k++) {
|
||
const c = this.corners[k];
|
||
if (c.broken) continue;
|
||
// A non-finite load can never trip the rating check below — NaN compares
|
||
// false to everything — so a diverged corner would otherwise hold FOREVER
|
||
// while NaN spreads node-to-node through the spring network. Divergence
|
||
// is a failure, not weather: break the corner now, on the production
|
||
// path, not just under the test-only watchDivergence tripwire. The event
|
||
// carries reason:'divergence' so a log can tell it from an honest blow.
|
||
if (!Number.isFinite(c.load)) {
|
||
c.broken = true;
|
||
c.overload = 0;
|
||
c.load = 0;
|
||
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
|
||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||
this.dumpPond('corner blew');
|
||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
|
||
diverged = true;
|
||
continue;
|
||
}
|
||
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) 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;
|
||
// the belly loses its shape the instant a corner goes, so any pond goes
|
||
// with it — DESIGN.md's "sudden dump", onto whatever is below.
|
||
this.dumpPond('corner blew');
|
||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||
}
|
||
}
|
||
// SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable
|
||
// (the fresh-eyes review's fix); this makes it survivable to LOOK at. The
|
||
// freed corner otherwise keeps integrating from the NaN the solver left in
|
||
// the spring network, and the cloth a player sees after the bang is a
|
||
// shredded z-fighting mess, not a cloth. Heal ONLY on a divergence break —
|
||
// an honest overload break has finite state and must not be touched.
|
||
//
|
||
// SPRINT17 gate 0.2 — THE SECOND WIRE (D's poison poke, S16 filing). The
|
||
// loop above is the heal's only trigger and it SKIPS broken corners, so
|
||
// once all four are gone there is no corner load left to read NaN through:
|
||
// a fully-lost sail that diverged again stayed NaN forever and rendered as
|
||
// nothing. A lost sail is still cloth in the yard — it must LOOK lost, not
|
||
// vanish. The sentinel runs ONLY on a fully-broken rig (the corner wire
|
||
// covers every other case): sum the state — any NaN/±Inf makes the sum
|
||
// non-finite (Inf−Inf is NaN), and finite cloth cannot overflow it
|
||
// (positions are metres, prev within a step of pos). Branch-free O(n)
|
||
// adds, no mutation, so a finite lost sail is untouched byte-for-byte —
|
||
// the negative control in sail.selftest.js pins that.
|
||
if (!diverged
|
||
&& this.corners[0].broken && this.corners[1].broken
|
||
&& this.corners[2].broken && this.corners[3].broken) {
|
||
const pos = this.pos, prev = this.prev;
|
||
let s = 0;
|
||
for (let i = 0; i < pos.length; i++) s += pos[i] + prev[i];
|
||
if (!Number.isFinite(s)) diverged = true;
|
||
}
|
||
if (diverged) this._healNonFinite();
|
||
if (this._dirtyRest) { this._applyRestLengths(); this._dirtyRest = false; }
|
||
}
|
||
|
||
/**
|
||
* Reset every non-finite node from its nearest finite neighbour (grid rings,
|
||
* straight neighbours before diagonals — _nbr's own order), or from the
|
||
* corners' anchors when the whole cloth is poisoned. `prev` is matched to
|
||
* `pos` on every healed node so verlet reads zero velocity out of the heal
|
||
* instead of inventing a launch. Non-finite WATER is zeroed too: a NaN pond
|
||
* re-poisons the healed positions on the very next substep through the
|
||
* weight term (F += GRAVITY * w), so healing positions alone heals nothing.
|
||
*
|
||
* Deterministic (fixed iteration order, no randomness, no clock), and pure
|
||
* repair: a rig with fully finite state is untouched byte-for-byte — the
|
||
* negative control in sail.selftest.js asserts a clean storm never emits
|
||
* 'heal'. Emits 'heal' {nodes, t} so a log can count what a bang cost.
|
||
*
|
||
* @returns {number} nodes healed
|
||
*/
|
||
_healNonFinite() {
|
||
const pos = this.pos, prev = this.prev, w = this.water;
|
||
const n = this.invMass.length;
|
||
// A NaN pond is poison whether or not its node's position survived. On
|
||
// the divergence path today this is a SECOND wire — the break's
|
||
// dumpPond('corner blew') already fill(0)s the whole pond — but the heal's
|
||
// own contract must not depend on the dump's side effect: if divergence
|
||
// ever stops dumping (a "the cloth healed, keep the water" retune), the
|
||
// NaN pond would corrupt pondMass/centroid/broom forever. Unit-asserted
|
||
// directly in sail.selftest.js for exactly that reason.
|
||
for (let i = 0; i < n; i++) if (!Number.isFinite(w[i])) w[i] = 0;
|
||
|
||
const sick = new Uint8Array(n); // 1 = position needs a donor
|
||
let sickCount = 0, anyFinite = false, healed = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const x = i * 3;
|
||
if (Number.isFinite(pos[x]) && Number.isFinite(pos[x + 1]) && Number.isFinite(pos[x + 2])) {
|
||
anyFinite = true;
|
||
// finite position, poisoned velocity: match prev, keep the position
|
||
if (!Number.isFinite(prev[x]) || !Number.isFinite(prev[x + 1]) || !Number.isFinite(prev[x + 2])) {
|
||
prev[x] = pos[x]; prev[x + 1] = pos[x + 1]; prev[x + 2] = pos[x + 2];
|
||
healed++;
|
||
}
|
||
} else { sick[i] = 1; sickCount++; }
|
||
}
|
||
if (sickCount) {
|
||
// Whole cloth gone: re-seed the corners from their anchors — the one
|
||
// world position a rig always knows — then flood from there.
|
||
if (!anyFinite) {
|
||
for (let k = 0; k < 4; k++) {
|
||
const ci = this.cornerIdx[k], x = ci * 3;
|
||
const p = this._anchorPos(this.corners[k].anchor, this.t);
|
||
pos[x] = prev[x] = p.x; pos[x + 1] = prev[x + 1] = p.y; pos[x + 2] = prev[x + 2] = p.z;
|
||
if (sick[ci]) { sick[ci] = 0; sickCount--; healed++; }
|
||
}
|
||
}
|
||
// Ring-by-ring flood: each pass, a sick node bordering the finite region
|
||
// copies that neighbour and joins it, so positions march inward from the
|
||
// nearest healthy cloth. Terminates: the grid is connected, so every
|
||
// pass with sick nodes left heals at least one.
|
||
let changed = true;
|
||
while (sickCount && changed) {
|
||
changed = false;
|
||
for (let i = 0; i < n; i++) {
|
||
if (!sick[i]) continue;
|
||
for (let k = 0; k < 8; k++) {
|
||
const m = this._nbr[i * 8 + k];
|
||
if (m < 0 || sick[m]) continue;
|
||
const x = i * 3, mx = m * 3;
|
||
pos[x] = prev[x] = pos[mx];
|
||
pos[x + 1] = prev[x + 1] = pos[mx + 1];
|
||
pos[x + 2] = prev[x + 2] = pos[mx + 2];
|
||
sick[i] = 0; sickCount--; healed++; changed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (healed) this.events.emit('heal', { type: 'heal', nodes: healed, t: this.t });
|
||
return healed;
|
||
}
|
||
|
||
// --- 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.85–1.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;
|
||
}
|
||
|
||
/**
|
||
* What the sail is made of. Porosity scales the wind pressure term — and, as
|
||
* of SPRINT16, that is ALL it scales. The previous version of this comment
|
||
* claimed an open weave "sheds the water it can't hold — so a porous cloth
|
||
* also ponds far less", and C's gate-3 measurement caught the cheque the sim
|
||
* doesn't cash: _applyPonding never reads porosity, so cloth and membrane
|
||
* pond IDENTICALLY (the $75 soaker ring's q2 gap, 3.24 vs 3.32 kN, is wind
|
||
* term only). RULED a known simplification rather than silently fixed
|
||
* ([B] THREADS 2026-07-20): every pinned cloth number in the repo — the
|
||
* backyard separation block, the §7 gates, C's storm_06 pins, the S15
|
||
* zero-delta lattice — was measured under fabric-blind ponding, and a pond-
|
||
* physics change mid-TEETH would move all of them against the sprint's own
|
||
* one-variable law. The fix (porosity-scaled intake + through-weave leak)
|
||
* is real, wants its own gate with a full gauntlet re-measure, and C's
|
||
* soaker is its ready-made test case; rigging.js's membrane-pricing comment
|
||
* is the same conversation. Set BEFORE attach(); RiggingSession.commit()
|
||
* does that.
|
||
* @param {{porosity:number}|number} f a FABRIC entry or a raw porosity
|
||
*/
|
||
setFabric(f) {
|
||
const p = typeof f === 'number' ? f : (f && f.porosity);
|
||
if (Number.isFinite(p)) this.porosity = clamp(p, 0, 0.9);
|
||
return this;
|
||
}
|
||
|
||
setTension(tension) {
|
||
const was = this.tension;
|
||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||
if (!this.rigged) return;
|
||
this._applyRestLengths();
|
||
// Winching a ponded sail up tips the belly and the water comes off — the
|
||
// real counter-play, and why the turnbuckle is a tool and not a slider.
|
||
if (this.tension > was + 0.02) this.dumpPond('tensioned up');
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
|
||
// UVs: the grid IS the UV space, so (i, j) maps straight to (u, v). Without
|
||
// this three defaults every vertex to (0,0), the map samples one texel, and
|
||
// the membrane reads as flat colour — which looks like the texture failing
|
||
// rather than like a bug. (Lane E's recipe, THREADS.)
|
||
const N = rig.N;
|
||
const uv = new Float32Array(N * N * 2);
|
||
for (let j = 0, k = 0; j < N; j++) {
|
||
for (let i = 0; i < N; i++, k += 2) { uv[k] = i / (N - 1); uv[k + 1] = j / (N - 1); }
|
||
}
|
||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
color, side: THREE.DoubleSide, roughness: 0.92, metalness: 0.0,
|
||
});
|
||
|
||
// Resolved against this module rather than the server root: the same reason
|
||
// weather.js builds STORM_DIR this way, and it's what the integrator's
|
||
// /world/ -> relative pass was fixing. A missing texture must not take the
|
||
// sail down — the cloth is the game, the weave is a finish.
|
||
try {
|
||
const tex = await new THREE.TextureLoader().loadAsync(
|
||
new URL('../models/textures/sail_weave.png', import.meta.url).href,
|
||
);
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail (E's density)
|
||
tex.colorSpace = THREE.SRGBColorSpace; // r175 spelling — `encoding` is gone
|
||
tex.anisotropy = 4; // it's viewed at a raking angle from underneath
|
||
mat.map = tex; // keep mat.color: the weave multiplies it
|
||
} catch (err) {
|
||
console.warn('[sail] weave texture missing, falling back to flat colour:', err.message);
|
||
}
|
||
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;
|
||
}
|