Land decisions 4 and 5: Lane D's rig seam, debris impulses
Decision 4 — conform to Lane D's call sites rather than the reverse (D landed first and duck-typed them): repair(i), trim(i, delta) and cornerPos(i). repair takes no hardware argument because prep sells exactly one kind of spare, so it re-rigs at shackle grade — an upgrade on a blown carabiner, a downgrade on a blown rated shackle, which is the prototype's behaviour and a real choice about which corner you run to. cornerPos returns a fresh vector at the live node, so a blown corner's prompt chases the flogging corner instead of sitting on a dead anchor (measured: 13 m off). All three are contract entries now, not PROPOSED comments, so the merge tripwire enforces the seam. Decision 5 — sail.step() takes an optional debris and applies sphere-vs- cloth impulses. The exchange is symmetric: every newton-second the cloth takes out of a crate, the crate loses. Asserted, and it conserves to 0.000% on an interior hit. Pinned corners are the deliberate exception — invMass 0 means a crate off a corner dumps its momentum into the house, which is correct, the anchor is bolted to a wall. Note this leaves debris.js's applyToSail dead: it guards on `sail.nodes`, which never existed on the rig — the cloth stores Float64Arrays. So the debris-vs-sail impulse has been silently doing nothing in the assembled game. Decision 5 puts it on this side; flagged for Lane C in THREADS. The contact radius is swept by the piece's travel because main.js steps the sail before the debris (so piece positions are a frame stale) and a 0.3 m crate at 25 m/s covers 0.42 m per frame — enough to pass clean between cloth nodes. Also: coverageOver() rays now start at heightAt(x,z) rather than y=0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
de86aa1662
commit
7e8ffa307c
@ -177,9 +177,20 @@ export class Emitter {
|
||||
* angle around their centroid. tension scales spring rest lengths, 0.6–1.4
|
||||
* (low = loose and floggy, high = drum tight and shock-loaded).
|
||||
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
|
||||
* @property {(rect: {x:number,z:number,w:number,d:number}) => number} coverageOver
|
||||
* Ground-projected shade over a rect, 0..1.
|
||||
* @property {(rect: {x:number,z:number,w:number,d:number}, sunDir?: THREE.Vector3, heightAt?: (x:number,z:number)=>number) => number} coverageOver
|
||||
* Ground-projected shade over a rect, 0..1. Pass world.sunDir and
|
||||
* world.heightAt so the rays start at the real ground and point at the real
|
||||
* sun; the defaults (overhead sun, flat y=0) are only for tests.
|
||||
* @property {Emitter} events Emits 'break' and 'repair' as {type, corner}.
|
||||
* @property {(i: number) => void} repair
|
||||
* Re-rig corner i with the carried spare (shackle grade — the only kind prep
|
||||
* sells). No-op if the corner isn't broken. Lane D's 2.5 s hold-E.
|
||||
* @property {(i: number, delta: number) => void} trim
|
||||
* Per-corner turnbuckle; delta is ±, clamped to 0.85–1.15. Lane D's 1.2 s hold.
|
||||
* @property {(i: number) => (THREE.Vector3|null)} cornerPos
|
||||
* LIVE world position of corner i, as a fresh vector safe to keep. A blown
|
||||
* corner's node is flying, so an interaction prompt anchored to this chases
|
||||
* the flogging corner instead of sitting on the dead anchor. null if unrigged.
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -253,7 +264,7 @@ export class Emitter {
|
||||
export const CONTRACT = {
|
||||
wind: { sample: 'function', gustTelegraph: 'function' },
|
||||
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' },
|
||||
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object' },
|
||||
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object', repair: 'function', trim: 'function', cornerPos: 'function' },
|
||||
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
|
||||
interact: { register: 'function' },
|
||||
camera: { object: 'object', yaw: 'number', update: 'function' },
|
||||
|
||||
@ -18,10 +18,17 @@
|
||||
* 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
|
||||
@ -49,6 +56,10 @@ 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
|
||||
@ -306,16 +317,20 @@ export class SailRig {
|
||||
* 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 {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) {
|
||||
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);
|
||||
this._substep(SIM_DT, wind, this.t, pieces);
|
||||
this._acc -= SIM_DT;
|
||||
this.t += SIM_DT;
|
||||
n++;
|
||||
@ -323,8 +338,9 @@ export class SailRig {
|
||||
if (n === MAX_SUBSTEPS) this._acc = 0; // dropped frames: don't try to catch up
|
||||
}
|
||||
|
||||
_substep(dt, wind, t) {
|
||||
_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);
|
||||
@ -382,6 +398,88 @@ export class SailRig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@ -503,8 +601,43 @@ export class SailRig {
|
||||
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.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 = HARDWARE[1]) {
|
||||
repairCorner(index, hw = SPARE_HW) {
|
||||
const c = this.corners[index];
|
||||
if (!c || !c.broken) return false;
|
||||
c.broken = false;
|
||||
@ -539,8 +672,10 @@ export class SailRig {
|
||||
* @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 }) {
|
||||
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;
|
||||
@ -553,7 +688,8 @@ export class SailRig {
|
||||
// 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;
|
||||
if (this._rayHitsSail(ox, 0, oz, dx, dy, dz)) hit++;
|
||||
const oy = heightAt ? heightAt(ox, oz) : 0;
|
||||
if (this._rayHitsSail(ox, oy, oz, dx, dy, dz)) hit++;
|
||||
}
|
||||
}
|
||||
return hit / (COLS * ROWS);
|
||||
|
||||
@ -332,6 +332,146 @@ test('break and repair emit on the events Emitter', () => {
|
||||
return `repaired corner back to ${kN(r.corners[0].load)}, ${seen.length} event(s) emitted`;
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 4: the seam Lane D already calls ---------------------
|
||||
|
||||
test('decision 4: repair(i) re-rigs a blown corner with the spare', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[0] });
|
||||
runStorm(r, w, 4);
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
|
||||
// exactly Lane D's interact.js call: no hardware argument, return ignored
|
||||
r.repair(0);
|
||||
assert(!r.corners[0].broken, 'repair(0) should have re-rigged the corner');
|
||||
assert(r.corners[0].hw === HARDWARE[1], `spare should re-rig at shackle grade, got ${r.corners[0].hw.name}`);
|
||||
assert(r.invMass[r.cornerIdx[0]] === 0, 'repaired corner should be pinned again');
|
||||
runStorm(r, w, 3);
|
||||
assert(r.corners[0].load > 100, `repaired corner only pulling ${kN(r.corners[0].load)}`);
|
||||
return `repair(0) -> ${r.corners[0].hw.name}, back to ${kN(r.corners[0].load)}`;
|
||||
});
|
||||
|
||||
test('decision 4: repair(i) on an intact corner is a no-op', () => {
|
||||
const r = rig(HEIGHTS_HYPAR, { hw: HARDWARE[2] });
|
||||
runStorm(r, constantWind({ x: 0, y: 0, z: 12 }), 2);
|
||||
const hw = r.corners[1].hw;
|
||||
r.repair(1); // D gates on corner.broken, but the rig must not trust that
|
||||
assert(r.corners[1].hw === hw, 'repairing an intact corner downgraded its hardware');
|
||||
return 'intact corner untouched';
|
||||
});
|
||||
|
||||
test('decision 4: trim(i, delta) tightens one corner only', () => {
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
r.trim(0, +0.1);
|
||||
assert(Math.abs(r.corners[0].trim - 1.1) < 1e-9, `corner 0 trim ${r.corners[0].trim}`);
|
||||
assert(r.corners[1].trim === 1.0, 'trim leaked onto a neighbour');
|
||||
for (let i = 0; i < 40; i++) r.trim(0, +0.1); // Lane D can hold the key down
|
||||
assert(r.corners[0].trim <= 1.15 + 1e-9, `trim ran past its clamp: ${r.corners[0].trim}`);
|
||||
return `trim clamps at ${r.corners[0].trim.toFixed(2)}, neighbours unmoved`;
|
||||
});
|
||||
|
||||
test('decision 4: cornerPos(i) is live, fresh, and chases a flogging corner', () => {
|
||||
const w = makeStubWind({ seed: 11, stormLen: 90 });
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[0], tension: 1.3 });
|
||||
const anchor = r.corners[0].anchor.pos;
|
||||
const p0 = r.cornerPos(0);
|
||||
assert(Math.hypot(p0.x - anchor.x, p0.y - anchor.y, p0.z - anchor.z) < 1e-6,
|
||||
'an intact corner should report its anchor position');
|
||||
assert(r.cornerPos(0) !== r.cornerPos(0), 'cornerPos must return a FRESH vector, not shared scratch');
|
||||
|
||||
// blow it, then confirm the prompt would follow the flying corner
|
||||
r.corners[0].broken = true;
|
||||
r._repin(r.t);
|
||||
runStorm(r, w, 6);
|
||||
const p1 = r.cornerPos(0);
|
||||
const drift = Math.hypot(p1.x - anchor.x, p1.y - anchor.y, p1.z - anchor.z);
|
||||
assert(drift > 0.3, `blown corner's prompt only moved ${drift.toFixed(2)} m off the anchor`);
|
||||
assert(new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT) }).cornerPos(0) === null,
|
||||
'cornerPos on an unrigged rig should be null, not a throw');
|
||||
return `prompt tracks the blown corner ${drift.toFixed(2)} m off its anchor`;
|
||||
});
|
||||
|
||||
// --- SPRINT2 decision 5: debris -------------------------------------------
|
||||
|
||||
const crate = (over) => ({ x: 0, y: 3.25, z: 0, vx: 0, vy: 0, vz: 14, r: 0.3, mass: 9, alive: true, ...over });
|
||||
|
||||
test('decision 5: a crate hitting the sail conserves momentum', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4); // settle, so the cloth isn't ringing
|
||||
// aimed at the belly, not a corner: a pinned corner would (correctly) dump
|
||||
// momentum into the house and there'd be nothing to conserve
|
||||
const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
|
||||
const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.25, z: r.pos[mid * 3 + 2], vy: 6, vz: 0 });
|
||||
|
||||
const clothP = () => {
|
||||
let x = 0, y = 0, z = 0;
|
||||
for (let n = 0; n < r.invMass.length; n++) {
|
||||
if (r.invMass[n] === 0) continue; // pinned: its momentum belongs to the house
|
||||
const i = n * 3;
|
||||
x += (r.pos[i] - r.prev[i]) / SIM_DT * r.nodeMass;
|
||||
y += (r.pos[i + 1] - r.prev[i + 1]) / SIM_DT * r.nodeMass;
|
||||
z += (r.pos[i + 2] - r.prev[i + 2]) / SIM_DT * r.nodeMass;
|
||||
}
|
||||
return { x, y, z };
|
||||
};
|
||||
const total = () => {
|
||||
const c = clothP();
|
||||
return { x: c.x + p.vx * p.mass, y: c.y + p.vy * p.mass, z: c.z + p.vz * p.mass };
|
||||
};
|
||||
|
||||
const before = total();
|
||||
r._applyDebris([p], SIM_DT);
|
||||
const after = total();
|
||||
|
||||
const drift = Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z);
|
||||
const scale = Math.hypot(before.x, before.y, before.z);
|
||||
assert(scale > 1, 'test crate carries no momentum to conserve');
|
||||
assert(drift / scale < 0.01, `momentum drifted ${drift.toFixed(3)} of ${scale.toFixed(1)} kg·m/s (${(drift / scale * 100).toFixed(1)}%)`);
|
||||
assert(p.vy < 6, `the crate should have LOST speed to the cloth, still at ${p.vy.toFixed(2)} m/s`);
|
||||
return `crate ${scale.toFixed(0)} kg·m/s, exchange conserves to ${(drift / scale * 100).toFixed(3)}%`;
|
||||
});
|
||||
|
||||
test('decision 5: a crate through the sail shoves the cloth and emits', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 4);
|
||||
const hits = [];
|
||||
r.events.on('debrisHit', (e) => hits.push(e));
|
||||
|
||||
const mid = r.N * Math.floor(r.N / 2) + Math.floor(r.N / 2);
|
||||
const before = r.pos[mid * 3 + 1];
|
||||
const p = crate({ x: r.pos[mid * 3], y: r.pos[mid * 3 + 1] - 0.6, z: r.pos[mid * 3 + 2], vy: 12, vz: 0 });
|
||||
const v0 = p.vy;
|
||||
|
||||
// Peak, not final: the crate crosses the cloth in about three frames and the
|
||||
// membrane springs back well inside the run, so sampling the end measures the
|
||||
// recovery rather than the punch.
|
||||
const wind = makeStubWind({ calm: true });
|
||||
let peak = before;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
r.step(SIM_DT, wind, i * SIM_DT, { pieces: [p] });
|
||||
p.y += p.vy * SIM_DT; p.z += p.vz * SIM_DT;
|
||||
peak = Math.max(peak, r.pos[mid * 3 + 1]);
|
||||
}
|
||||
assert(hits.length > 0, 'crate passed through the cloth without a single contact');
|
||||
assert(peak > before + 0.05, `belly only lifted ${(peak - before).toFixed(3)} m — the crate went straight through`);
|
||||
assert(p.vy < v0, `crate left at ${p.vy.toFixed(2)} m/s, never paid for the punch (entered at ${v0})`);
|
||||
return `${hits.length} contacts, belly punched ${(peak - before).toFixed(2)} m, crate ${v0} -> ${p.vy.toFixed(1)} m/s`;
|
||||
});
|
||||
|
||||
test('decision 5: no debris and empty debris are both fine', () => {
|
||||
const w = makeStubWind({ seed: 2, stormLen: 20 });
|
||||
const a = rig(HEIGHTS_HYPAR), b = rig(HEIGHTS_HYPAR);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
a.step(SIM_DT, w, i * SIM_DT); // Lane A's 3-arg call still works
|
||||
b.step(SIM_DT, makeStubWind({ seed: 2, stormLen: 20 }), i * SIM_DT, { pieces: [] });
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
assert(Math.abs(a.corners[k].load - b.corners[k].load) < 1e-9,
|
||||
'an empty debris list changed the sim');
|
||||
}
|
||||
return 'empty and absent debris both no-op';
|
||||
});
|
||||
|
||||
test('runs against the shared contracts.js stub wind', () => {
|
||||
// Proves the rig eats the sanctioned Wind implementation, not just my local
|
||||
// stub — so nothing surprises us when Lane C's weather.js drops in.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user