SPRINT4 §Lane C 2/3/4. PONDING DATA (decision 10). rainAt() was a dimensionless 0..1 — fine for drop count and opacity, useless for water mass. Lane B would have had to invent the mm/hr scale, which is exactly the "default-off code tuned by a constant I invented" they rightly reverted. So the scale is storm data now: rain.peakMmPerHour (validated 0..300; a rain curve without one is a hard error, since ponding would silently use the default instead of what the author meant), plus wind.rainMmPerHour(t) and rainDepthMm(t0,t1). RAIN_TIME_COMPRESSION=40 is exported from weather.core so B applies it cloth-side rather than either of us hardcoding 40 twice: how hard it rains is mine, how much water a sail holds is theirs. Calibrated to B's own arithmetic, and the numbers land on it: storm_02 80 mm/hr severe -> 50.9 mm = 3.12 kN/corner (B predicted 3.1) storm_03 30 mm/hr moderate -> 8.3 mm = 0.51 kN/corner (teases a carabiner) storm_01 8 mm/hr shower -> 0.9 mm = 0.06 kN/corner (harmless, as designed) against a storm_02 wind load of 0.2-1.1 kN. A flat rig should drown in the wild night; a hypar pools nothing and won't notice. Asserted with B's arithmetic so the storms are provably fit for their water before their cloth lands. DECISION 7 helper for Lane A: sky.gardenExposure(bed, t) = rainAt x (1-shadow), the whole drain term in one call. Note it moves on its own — the rain shadow follows the wind, so the southerly change walks the dry patch off the bed and the drain climbs with no corner having failed. NIGHT PASS. sky.night is now the author's call (was: inferred from a darkness threshold), and darkening scene.background did nothing anyway — the cloud dome covers it at 0.85 opacity, so an overcast-grey texture was what you actually saw. The dome now tints AND crushes (a lerp alone lands #717273: it runs in linear space and the texture is baked near-white). Stops at 0.78 because the yard has no lights and a storm you can't see is a black screen. Lightning now lights the cloud it's inside (#3e3e3f -> #d4ddf2), and fires on the biggest gusts via sky.lightningGustPow, not just the three authored strikes — driven off the telegraph so the flash lands with the gust that earned it. Also: c.test's storm list was hardcoded to two storms while the node runner globs the directory, so storm_03 was untested in the browser half. Its own ponding assert caught it. Selftest 195/0/0 (was 184). Verified live: night reads as night, flash lights the cloud, exposure responds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
4.1 KiB
JavaScript
109 lines
4.1 KiB
JavaScript
'use strict';
|
|
// SHADES — Lane C — weather: the wind field everyone samples.
|
|
//
|
|
// Implements the contracts.js wind surface (PLAN3D §4):
|
|
// wind.sample(pos, t) -> Vector3 m/s, includes gusts & local effects
|
|
// wind.gustTelegraph(t) -> {eta, dir, power} | null
|
|
//
|
|
// All the maths lives in weather.core.js (pure, no imports). This file is just
|
|
// the THREE adapter + storm loading, so the sim stays node-testable and the
|
|
// determinism rule can't be broken by accident.
|
|
|
|
import * as THREE from '../vendor/three.module.js';
|
|
import { createWindField, validateStorm, GUST, RAIN_TIME_COMPRESSION } from './weather.core.js';
|
|
|
|
export { GUST, validateStorm, RAIN_TIME_COMPRESSION };
|
|
|
|
// Resolved against this module, not the server root: server.py serves the repo
|
|
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
|
// import.meta.url is right under both, and under whatever Lane A does next.
|
|
const STORM_DIR = new URL('../data/storms', import.meta.url).href;
|
|
|
|
/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
|
|
export async function loadStorm(name, dir = STORM_DIR) {
|
|
const url = `${dir}/${name}.json`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
|
|
const def = await res.json();
|
|
const { ok, errors } = validateStorm(def, name);
|
|
if (!ok) throw new Error(`weather: ${url} is invalid:\n ${errors.join('\n ')}`);
|
|
return def;
|
|
}
|
|
|
|
/**
|
|
* @param {object} def parsed storm JSON
|
|
* @param {object} [opts] {seed} — same seed + same def = same storm, every run
|
|
* @returns the `wind` object from contracts.js
|
|
*/
|
|
export function createWind(def, opts = {}) {
|
|
const field = createWindField(def, opts);
|
|
const scratch = { x: 0, y: 0, z: 0 };
|
|
|
|
const wind = {
|
|
/**
|
|
* Wind velocity at a world position, m/s.
|
|
* @param {THREE.Vector3} pos
|
|
* @param {number} t storm time, seconds
|
|
* @param {THREE.Vector3} [out] pass one to avoid allocating — sail.js
|
|
* samples per-face per-frame, so this matters
|
|
*/
|
|
sample(pos, t, out) {
|
|
const v = out || new THREE.Vector3();
|
|
field.vecAt(pos.x, pos.z, t, scratch);
|
|
return v.set(scratch.x, scratch.y, scratch.z);
|
|
},
|
|
|
|
/** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
|
|
speedAt(pos, t) {
|
|
return field.speedAt(pos.x, pos.z, t);
|
|
},
|
|
|
|
/** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
|
|
gustTelegraph(t) {
|
|
return field.telegraph(t);
|
|
},
|
|
|
|
/**
|
|
* Register wind shadows (trees, house). Lane A: call after the yard is built.
|
|
* Until then there are simply no shadows — nothing breaks.
|
|
* @param {Array<{x,z,radius,strength,length}>} list
|
|
*/
|
|
setShelters(list) { field.setShelters(list); return wind; },
|
|
|
|
/** Convenience: take shadows straight off world.anchors' tree entries. */
|
|
setSheltersFromTrees(trees, o = {}) {
|
|
return wind.setShelters(trees.map((tr) => ({
|
|
x: tr.pos ? tr.pos.x : tr.x,
|
|
z: tr.pos ? tr.pos.z : tr.z,
|
|
radius: o.radius ?? tr.radius ?? 3,
|
|
strength: o.strength ?? 0.45,
|
|
length: o.length ?? 14,
|
|
})));
|
|
},
|
|
|
|
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
|
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
|
|
|
/** 0..1 rain intensity — drives drop count and opacity. */
|
|
rainAt(t) { return field.rainAt(t); },
|
|
|
|
/** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
|
|
rainMmPerHour(t) { return field.rainMmPerHour(t); },
|
|
|
|
/** Real-world mm of water delivered over (t0,t1]. Multiply by
|
|
* RAIN_TIME_COMPRESSION cloth-side — see weather.core. */
|
|
rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },
|
|
|
|
/** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
|
|
dirAt(t) { return field.dirAt(t); },
|
|
|
|
get duration() { return field.duration; },
|
|
get gusts() { return field.gusts; },
|
|
get def() { return field.def; },
|
|
get seed() { return field.seed; },
|
|
core: field,
|
|
};
|
|
|
|
return wind;
|
|
}
|