The pin (co-owned with B, probe/second moved and argued in THREADS): site_02_corner_block / storm_02_wildnight / the authored throat centre (-6,0) / t=60.0 / exact ===. B proposed backyard_01, the garden bed and t=30; all three are places the funnel contributes ~nothing (backyard_01's venturi is [], the bed measures 0.0000 m/s of funnel even on site_02, and t=30 is worth 0.4%), so that pin could not have failed. A vacuity guard now asserts the funnel is worth >25% AT the pin, so it cannot rot back into decoration. Also fixed, found by looking: editor.wind rebuilt its wind in commit() rather than on 'change', so any EXTERNAL mutation + markDirty() re-rendered the panel against the previous site's wind. The readout kept insisting a restored gap was 24% aligned. Refresh moved to the listener, so the overlay is a function of the site as it is, whoever changed it. 368/0/0 (362 baseline + 6). All six mutation-checked in one run: dropping setVenturi from windForSite reds the pin (31.60 vs 47.38 m/s), the guard and the sweep; normalizeAxis as identity reds the fold; drifting the shelterAtten mirror 0.5 -> 0.62 reds the volume assert.
788 lines
32 KiB
JavaScript
788 lines
32 KiB
JavaScript
/**
|
||
* SHADES / HARD YARDS — WIND AUTHORING for the yard editor. Lane C owns this
|
||
* file (SPRINT14 gate 2.2). It builds INTO Lane A's editor page through the
|
||
* seams A published in `editor.html` / `editor.js`; it does not own a pixel of
|
||
* either, and it must never grow a private harness on that page.
|
||
*
|
||
* WHAT IT ADDS
|
||
* · venturi authoring — drag the throat, drag either end of the axis, gain /
|
||
* radius / sharp on sliders, with the gizmo redrawn live
|
||
* · tree-shelter volumes — what a tree actually protects, at the storm second
|
||
* you are looking at
|
||
* · a wind-field overlay — `speedAt` sampled across the yard at a chosen
|
||
* storm + time, so you SEE where the funnel screams before D has to feel it
|
||
*
|
||
* ── THE FIVE THINGS THIS FILE IS CAREFUL ABOUT ─────────────────────────────
|
||
*
|
||
* 1. **`windForSite()` IS THE ONLY WIND THIS FILE BUILDS.** One sprint old and
|
||
* already load-bearing: three harnesses (B's site_audit, my own
|
||
* garden_bench, probe4) independently measured a yard with the funnel
|
||
* switched OFF, because `def.wind.venturi` off a STORM def looks right and
|
||
* never fires. The venturi lives in the SITE. There is exactly one call to
|
||
* one builder in here (`buildWind`), it takes the site and the live anchors,
|
||
* and no other function in this file is allowed to make a wind. If you are
|
||
* reading this because you want a wind for something new — call `buildWind`.
|
||
*
|
||
* 2. **The gizmos are drawn from what the WIND flies, not from what the JSON
|
||
* says.** `wind.core.venturi` and `wind.core.shelters` are read back after
|
||
* the build, so the arrow you see is the funnel the sim has, resolved
|
||
* defaults and all. Drawing from `site.wind.venturi` instead would render a
|
||
* funnel that is merely authored — and a gizmo that agrees with the JSON
|
||
* while disagreeing with the sim is worse than no gizmo, because it is
|
||
* evidence. This is also why a mis-wired `setVenturi` shows up here as a
|
||
* funnel that vanishes, rather than as a number nobody looks at.
|
||
*
|
||
* 3. **An axis is a LINE, not a heading, and the UI teaches that.** The venturi
|
||
* aligns on `Math.abs(dot(wind, axis))` — a gap funnels either way through
|
||
* it — so the axis is only defined mod π. A and I proved this from opposite
|
||
* sides in Sprint 11 and it cost two exchanges, so: the axis draws as one
|
||
* line through the throat with an identical handle at BOTH ends, grabbing
|
||
* either end rotates the same line, and the value normalises into [0, π).
|
||
* The readout says `120° ≡ 300°` for as long as anyone needs to believe it.
|
||
* There is no arrowhead anywhere on the axis, on purpose.
|
||
*
|
||
* 4. **Neither the venturi nor the shelter maths has a vertical term.** Both
|
||
* are functions of (x, z) only — a tree's shadow and a gap's funnel apply at
|
||
* every height. The shelter VOLUME is therefore a reading aid drawn to a
|
||
* nominal canopy height, and the panel says so. Drawing a finite volume
|
||
* without saying that would be the gizmo quietly inventing physics.
|
||
*
|
||
* 5. **Staleness is A's, by construction.** Every object I make is parented to
|
||
* `EDITOR.overlay`, which the editor CLEARS on every rebuild — A's "a stale
|
||
* arrow over a moved venturi is the wind cousin of the phantom sail, and one
|
||
* lane clearing beats two lanes remembering". `overlay.clear()` detaches but
|
||
* does not free GPU buffers, so I keep my own disposal list and empty it at
|
||
* the top of every redraw; that is my half of the deal, not a second
|
||
* lifetime model.
|
||
*
|
||
* DETERMINISM: everything sampled here is a pure function of (x, z, t) and the
|
||
* storm seed — `speedAt`/`dirAt` have no internal clock and no RNG at call
|
||
* time, so the same storm at the same second draws the same field on any
|
||
* machine. Nothing in this file feeds a score; B's SCORE IT and the game read
|
||
* the same `windForSite`, and gate 2.3 pins them equal at a probe point.
|
||
*
|
||
* LOADING: this module self-registers off `globalThis.EDITOR` when imported.
|
||
* `editor.html` needs one line after `createEditor()` resolves — asked for in
|
||
* THREADS rather than added here, because that page is A's.
|
||
*/
|
||
|
||
import * as THREE from '../vendor/three.module.js';
|
||
import { smoothstep } from './weather.core.js';
|
||
import { loadStorm, windForSite } from './weather.js';
|
||
|
||
/** Keep in step with data/storms/ — same list the game's STORMS carries. */
|
||
export const STORM_KEYS = [
|
||
'storm_01_gentle',
|
||
'storm_02_wildnight',
|
||
'storm_02b_icenight',
|
||
'storm_03_southerly',
|
||
'storm_03b_earlybuster',
|
||
];
|
||
|
||
/** The southerly is the storm that makes a corner block scream — a sensible
|
||
* place for an author to land, because a funnel authored under the gentle
|
||
* storm looks like it does nothing. */
|
||
const DEFAULT_STORM = 'storm_03_southerly';
|
||
|
||
/** Nominal canopy height for the shelter volume. A READING AID — see (4). */
|
||
const CANOPY_Y = 3.2;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pure helpers — exported so c.test.js can assert them without a DOM
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fold an angle into [0, π). THE mod-π rule, in one place.
|
||
*
|
||
* A venturi axis is a line through a gap. `axis` and `axis + π` are the same
|
||
* gap read from opposite ends — weather.core aligns on `|dot|` precisely so
|
||
* that a gap funnels either way through it. Normalising here is what makes
|
||
* "drag either end of the line" produce one stable number instead of two that
|
||
* flip-flop by π depending on which handle the author happened to grab.
|
||
*/
|
||
export function normalizeAxis(rad) {
|
||
const a = rad % Math.PI;
|
||
return a < 0 ? a + Math.PI : a;
|
||
}
|
||
|
||
/** Degrees, folded the same way — for the readout. */
|
||
export function axisDeg(rad) {
|
||
return normalizeAxis(rad) * 180 / Math.PI;
|
||
}
|
||
|
||
/**
|
||
* The fraction of wind speed a shelter REMOVES at a point, in the shelter's own
|
||
* (along, perp) frame. Mirrors `shelterFactor` in weather.core.js, which
|
||
* returns `1 - this`, and shares its `smoothstep` rather than easing by eye.
|
||
*
|
||
* This is a mirror and mirrors drift, so c.test.js measures it against the real
|
||
* field through `speedAt` and fails if the two ever disagree. Documentation
|
||
* cannot fail; an assert can.
|
||
*
|
||
* @param {{radius:number,length:number,strength:number}} s
|
||
* @param {number} along metres DOWNWIND of the tree (>0 is in the shadow)
|
||
* @param {number} perp metres to either side of the shadow's centreline
|
||
*/
|
||
export function shelterAtten(s, along, perp) {
|
||
if (along <= 0 || along >= s.length) return 0;
|
||
const ap = Math.abs(perp);
|
||
if (ap >= s.radius) return 0;
|
||
const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
|
||
const fPerp = 1 - smoothstep(0, s.radius, ap);
|
||
return s.strength * fAlong * fPerp;
|
||
}
|
||
|
||
/**
|
||
* Sample the wind across a yard on a grid. Pure: same wind + same args = same
|
||
* array, so the overlay is reproducible and the numbers are assertable.
|
||
*
|
||
* Returns absolute speed AND the amplification ratio against the storm's
|
||
* uniform speed at that second, because those answer different authoring
|
||
* questions: "is this corner survivable" is absolute, "is my funnel doing
|
||
* anything" is a ratio. The overlay colours by ratio and lengths by speed.
|
||
*
|
||
* @returns {{samples:Array<{x:number,z:number,speed:number,ratio:number}>,
|
||
* dir:number, uniform:number, max:{speed:number,x:number,z:number},
|
||
* min:{speed:number,x:number,z:number}}}
|
||
*/
|
||
export function sampleWindField(wind, { width, depth, step = 1.5, t = 0 }) {
|
||
const samples = [];
|
||
const uniform = wind.core.uniformSpeed(t);
|
||
const dir = wind.core.dirAt(t);
|
||
let max = { speed: -Infinity, x: 0, z: 0 };
|
||
let min = { speed: Infinity, x: 0, z: 0 };
|
||
const nx = Math.max(2, Math.round(width / step));
|
||
const nz = Math.max(2, Math.round(depth / step));
|
||
for (let i = 0; i < nx; i++) {
|
||
for (let j = 0; j < nz; j++) {
|
||
const x = -width / 2 + (i + 0.5) * (width / nx);
|
||
const z = -depth / 2 + (j + 0.5) * (depth / nz);
|
||
const speed = wind.core.speedAt(x, z, t);
|
||
const ratio = uniform > 1e-6 ? speed / uniform : 1;
|
||
samples.push({ x, z, speed, ratio });
|
||
if (speed > max.speed) max = { speed, x, z };
|
||
if (speed < min.speed) min = { speed, x, z };
|
||
}
|
||
}
|
||
return { samples, dir, uniform, max, min };
|
||
}
|
||
|
||
/** Amplification → colour. Cool/dim = sheltered, white = untouched, hot = funnelled. */
|
||
function ratioColor(ratio, out) {
|
||
// 0.55 …… 1.0 …… 1.6 mapped blue → pale → red. The neutral band is
|
||
// deliberately pale rather than green: an author should read "nothing
|
||
// happening here" as absence, and see only the two things a site DOES.
|
||
const k = Math.max(0, Math.min(1, (ratio - 0.55) / (1.6 - 0.55)));
|
||
const mid = 0.43; // where ratio == 1 lands on k
|
||
if (k < mid) {
|
||
const u = k / mid;
|
||
return out.setRGB(0.20 + 0.65 * u, 0.45 + 0.45 * u, 0.85 + 0.10 * u);
|
||
}
|
||
const u = (k - mid) / (1 - mid);
|
||
return out.setRGB(0.85 + 0.15 * u, 0.90 - 0.72 * u, 0.95 - 0.80 * u);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function mountWindPanel(EDITOR) {
|
||
const { root, body } = EDITOR.mountPanel({ id: 'wind', title: 'WIND', order: 50 });
|
||
|
||
// --- state -------------------------------------------------------------
|
||
const storms = {};
|
||
let stormKey = DEFAULT_STORM;
|
||
let time = 40;
|
||
let showField = true;
|
||
let showShelters = true;
|
||
let gridStep = 1.5;
|
||
let wind = null;
|
||
let selVenturi = 0;
|
||
|
||
/** Everything I ever allocate on the GPU, so redraw can free it. See (5). */
|
||
const mine = [];
|
||
|
||
// --- the ONE wind builder ----------------------------------------------
|
||
/**
|
||
* The site's wind for the picked storm. THE only wind this file makes — see
|
||
* (1). Anchors come from the LIVE world so tree shelters are the ones the
|
||
* game would fly, not ones re-derived from the JSON by hand.
|
||
*/
|
||
function buildWind() {
|
||
const def = storms[stormKey];
|
||
if (!def) return null;
|
||
return windForSite(def, EDITOR.site, EDITOR.world?.anchors ?? []);
|
||
}
|
||
|
||
function refreshWind() {
|
||
wind = buildWind();
|
||
if (wind && time > wind.duration) time = wind.duration;
|
||
}
|
||
|
||
// --- gizmo drawing ------------------------------------------------------
|
||
|
||
function freeMine() {
|
||
for (const o of mine) {
|
||
o.geometry?.dispose?.();
|
||
if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
|
||
else o.material?.dispose?.();
|
||
}
|
||
mine.length = 0;
|
||
}
|
||
|
||
function add(obj) {
|
||
mine.push(obj);
|
||
EDITOR.overlay.add(obj);
|
||
return obj;
|
||
}
|
||
|
||
const groundY = (x, z) => EDITOR.HEIGHT_AT(x, z);
|
||
|
||
function lineSegs(points, color, opacity = 1, width = 1) {
|
||
const g = new THREE.BufferGeometry().setFromPoints(points);
|
||
const m = new THREE.LineBasicMaterial({
|
||
color, transparent: opacity < 1, opacity, depthTest: true, linewidth: width,
|
||
});
|
||
return add(new THREE.LineSegments(g, m));
|
||
}
|
||
|
||
/** A flat ring on the ground — throat reach, throat core. */
|
||
function ringAt(x, z, r, color, opacity) {
|
||
const pts = [];
|
||
const N = 64;
|
||
for (let i = 0; i < N; i++) {
|
||
const a0 = (i / N) * Math.PI * 2;
|
||
const a1 = ((i + 1) / N) * Math.PI * 2;
|
||
const p0x = x + Math.cos(a0) * r, p0z = z + Math.sin(a0) * r;
|
||
const p1x = x + Math.cos(a1) * r, p1z = z + Math.sin(a1) * r;
|
||
pts.push(new THREE.Vector3(p0x, groundY(p0x, p0z) + 0.07, p0z));
|
||
pts.push(new THREE.Vector3(p1x, groundY(p1x, p1z) + 0.07, p1z));
|
||
}
|
||
return lineSegs(pts, color, opacity);
|
||
}
|
||
|
||
/**
|
||
* The venturi gizmo. The axis is ONE LINE through the throat, drawn to the
|
||
* same length in both directions with an identical square handle at each end
|
||
* — see (3). No arrowhead: an arrow would say "the wind goes this way", and
|
||
* the wind goes whichever way the STORM sends it; this line is the gap's
|
||
* geometry, which is the site's business and does not move when the wind does.
|
||
*/
|
||
function drawVenturi(v, i, isSel) {
|
||
const { x, z } = v;
|
||
const axis = Math.atan2(v.axisZ, v.axisX);
|
||
const L = v.radius * 1.45;
|
||
const ax = Math.cos(axis), az = Math.sin(axis);
|
||
const hot = isSel ? 0xffd166 : 0xff9f43;
|
||
|
||
// reach + core rings (radial falloff is full inside 0.4r, gone at r)
|
||
ringAt(x, z, v.radius, hot, isSel ? 0.85 : 0.45);
|
||
ringAt(x, z, v.radius * 0.4, hot, isSel ? 0.55 : 0.28);
|
||
|
||
// the axis line, both ways from the throat
|
||
const pts = [];
|
||
const SEG = 24;
|
||
for (let s = 0; s < SEG; s++) {
|
||
const u0 = -1 + (2 * s) / SEG, u1 = -1 + (2 * (s + 1)) / SEG;
|
||
const x0 = x + ax * L * u0, z0 = z + az * L * u0;
|
||
const x1 = x + ax * L * u1, z1 = z + az * L * u1;
|
||
pts.push(new THREE.Vector3(x0, groundY(x0, z0) + 0.1, z0));
|
||
pts.push(new THREE.Vector3(x1, groundY(x1, z1) + 0.1, z1));
|
||
}
|
||
lineSegs(pts, hot, 1);
|
||
|
||
// a mast at the throat so the funnel is findable in a 3D orbit
|
||
lineSegs([
|
||
new THREE.Vector3(x, groundY(x, z) + 0.05, z),
|
||
new THREE.Vector3(x, groundY(x, z) + 2.2, z),
|
||
], hot, 0.7);
|
||
|
||
// identical handles at BOTH ends — the mod-π teaching, in geometry
|
||
for (const sgn of [-1, 1]) {
|
||
const hx = x + ax * L * sgn, hz = z + az * L * sgn;
|
||
const g = new THREE.SphereGeometry(0.34, 12, 8);
|
||
const m = new THREE.MeshBasicMaterial({ color: hot, transparent: true, opacity: 0.95 });
|
||
const s = new THREE.Mesh(g, m);
|
||
s.position.set(hx, groundY(hx, hz) + 0.1, hz);
|
||
add(s);
|
||
}
|
||
// the throat handle, visually distinct (you drag it to move the gap)
|
||
{
|
||
const g = new THREE.SphereGeometry(0.42, 14, 10);
|
||
const m = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: isSel ? 0.95 : 0.5 });
|
||
const s = new THREE.Mesh(g, m);
|
||
s.position.set(x, groundY(x, z) + 0.1, z);
|
||
add(s);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A tree's shelter, as a translucent volume. Built in the shelter's own
|
||
* (along, perp) frame and rotated downwind of the CURRENT storm second — the
|
||
* shadow points where the wind is going, so scrubbing time swings it, which
|
||
* is the honest picture: a tree shelters a different patch of yard at 20 s
|
||
* than at 60 s.
|
||
*
|
||
* Alpha carries the strength (there is no hard edge in the maths, so there is
|
||
* no hard edge here). Three stacked layers at equal alpha, because the maths
|
||
* has NO vertical term — see (4). The height is a reading aid; the panel says
|
||
* so in words as well.
|
||
*/
|
||
function drawShelter(s, dir) {
|
||
const dx = Math.cos(dir), dz = Math.sin(dir);
|
||
const NA = 22, NP = 12;
|
||
const layers = [0.12, 1.25, CANOPY_Y];
|
||
for (const y of layers) {
|
||
const pos = [];
|
||
const col = [];
|
||
const idx = [];
|
||
for (let ia = 0; ia <= NA; ia++) {
|
||
const along = (ia / NA) * s.length;
|
||
for (let ip = 0; ip <= NP; ip++) {
|
||
const perp = -s.radius + (ip / NP) * (2 * s.radius);
|
||
const wx = s.x + dx * along - dz * perp;
|
||
const wz = s.z + dz * along + dx * perp;
|
||
pos.push(wx, groundY(wx, wz) + y, wz);
|
||
const a = shelterAtten(s, along, perp);
|
||
// colour is constant; the ALPHA is the physics. Vertex alpha needs a
|
||
// 4-component colour attribute and a material that reads it.
|
||
//
|
||
// DARK cool blue, so the volume DARKENS the grass. Two earlier cuts
|
||
// were light blue (0.42,0.72,1.0 at 0.75, then 0.24,0.48,0.95 at 0.38)
|
||
// and both read as a floodlight on the lawn no matter how far the
|
||
// alpha came down — light over grass is light. A wind SHADOW that
|
||
// glows is a gizmo arguing against its own name, so the fix was the
|
||
// colour, not the opacity. Verified by looking, twice.
|
||
col.push(0.05, 0.11, 0.28, a * 0.5);
|
||
}
|
||
}
|
||
for (let ia = 0; ia < NA; ia++) {
|
||
for (let ip = 0; ip < NP; ip++) {
|
||
const a = ia * (NP + 1) + ip;
|
||
const b = a + (NP + 1);
|
||
idx.push(a, b, a + 1, b, b + 1, a + 1);
|
||
}
|
||
}
|
||
const g = new THREE.BufferGeometry();
|
||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||
g.setAttribute('color', new THREE.Float32BufferAttribute(col, 4));
|
||
g.setIndex(idx);
|
||
const m = new THREE.MeshBasicMaterial({
|
||
vertexColors: true, transparent: true, side: THREE.DoubleSide,
|
||
depthWrite: false, blending: THREE.NormalBlending,
|
||
});
|
||
const mesh = new THREE.Mesh(g, m);
|
||
// The volume is CONTEXT for the arrows, so it draws under them. Two of the
|
||
// three layers sit above arrow height (0.55 m), so without this the shadow
|
||
// paints over the very samples that prove it.
|
||
mesh.renderOrder = 1;
|
||
add(mesh);
|
||
}
|
||
// the trunk tick, so you can tell which tree owns which shadow
|
||
lineSegs([
|
||
new THREE.Vector3(s.x, groundY(s.x, s.z) + 0.05, s.z),
|
||
new THREE.Vector3(s.x, groundY(s.x, s.z) + CANOPY_Y, s.z),
|
||
], 0x6bb8ff, 0.6);
|
||
}
|
||
|
||
/**
|
||
* The wind field: one arrow per grid cell. All arrows are PARALLEL and that
|
||
* is not a simplification — `vecAt` takes the direction from `dirAt(t)`
|
||
* alone, so the yard's local effects change the SPEED of the wind and never
|
||
* its heading. Length reads absolute speed, colour reads amplification.
|
||
* One LineSegments for the lot: 250-ish arrows as 250 Object3Ds would cost
|
||
* more than the whole rest of this panel.
|
||
*/
|
||
function drawField(field) {
|
||
const dx = Math.cos(field.dir), dz = Math.sin(field.dir);
|
||
const pos = [];
|
||
const col = [];
|
||
const c = new THREE.Color();
|
||
const ref = Math.max(1e-3, field.max.speed);
|
||
const LEN = gridStep * 0.92;
|
||
for (const sm of field.samples) {
|
||
const len = LEN * Math.max(0.12, sm.speed / ref);
|
||
const hx = sm.x + dx * len * 0.5, hz = sm.z + dz * len * 0.5; // head
|
||
const tx = sm.x - dx * len * 0.5, tz = sm.z - dz * len * 0.5; // tail
|
||
ratioColor(sm.ratio, c);
|
||
const push = (x0, z0, x1, z1) => {
|
||
pos.push(x0, groundY(x0, z0) + 0.55, z0, x1, groundY(x1, z1) + 0.55, z1);
|
||
col.push(c.r, c.g, c.b, c.r, c.g, c.b);
|
||
};
|
||
push(tx, tz, hx, hz);
|
||
// barbs — a heading needs a head; this one is the WIND, which really does
|
||
// have a direction (unlike the axis above).
|
||
const bl = len * 0.34;
|
||
const ca = Math.cos(2.5), sa = Math.sin(2.5);
|
||
push(hx, hz, hx + (dx * ca - dz * sa) * bl, hz + (dz * ca + dx * sa) * bl);
|
||
push(hx, hz, hx + (dx * ca + dz * sa) * bl, hz + (dz * ca - dx * sa) * bl);
|
||
}
|
||
const g = new THREE.BufferGeometry();
|
||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||
g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
|
||
const m = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.95 });
|
||
const seg = new THREE.LineSegments(g, m);
|
||
seg.renderOrder = 2; // over the shelter volumes — see drawShelter
|
||
add(seg);
|
||
}
|
||
|
||
/** Rebuild every gizmo from the current site + storm + second. */
|
||
function redraw() {
|
||
freeMine();
|
||
if (!wind) return null;
|
||
const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
|
||
const field = sampleWindField(wind, {
|
||
width: yard.width, depth: yard.depth, step: gridStep, t: time,
|
||
});
|
||
if (showField) drawField(field);
|
||
if (showShelters) for (const s of wind.core.shelters) drawShelter(s, field.dir);
|
||
wind.core.venturi.forEach((v, i) => drawVenturi(v, i, i === selVenturi));
|
||
return field;
|
||
}
|
||
|
||
// --- the venturi tool ---------------------------------------------------
|
||
/**
|
||
* Registered through `EDITOR.registerTool` rather than listening on the
|
||
* canvas, so my drag and A's select/drag never compete for the same click —
|
||
* A's seam, used as offered.
|
||
*/
|
||
const HIT = 0.95; // metres; handles are ~0.4 m spheres
|
||
|
||
let drag = null;
|
||
|
||
function venturiList() {
|
||
EDITOR.site.wind ??= {};
|
||
EDITOR.site.wind.venturi ??= [];
|
||
return EDITOR.site.wind.venturi;
|
||
}
|
||
|
||
/** Which handle (if any) is under the cursor. Pure XZ distance — the gizmo is
|
||
* ground-plane geometry, so a ground-plane test is the honest pick. */
|
||
function hitHandle(gx, gz) {
|
||
const list = venturiList();
|
||
for (let i = 0; i < list.length; i++) {
|
||
const v = list[i];
|
||
const axis = normalizeAxis(v.axis ?? 0);
|
||
const r = v.radius ?? 4;
|
||
const L = r * 1.45;
|
||
if (Math.hypot(gx - v.x, gz - v.z) < HIT) return { i, mode: 'throat' };
|
||
for (const sgn of [-1, 1]) {
|
||
const hx = v.x + Math.cos(axis) * L * sgn;
|
||
const hz = v.z + Math.sin(axis) * L * sgn;
|
||
if (Math.hypot(gx - hx, gz - hz) < HIT) return { i, mode: 'axis' };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
EDITOR.registerTool({
|
||
id: 'venturi',
|
||
label: 'VENTURI',
|
||
cursor: 'crosshair',
|
||
onPointerDown(ev, ground) {
|
||
if (!ground || ev.button !== 0 || ev.shiftKey) return false;
|
||
const hit = hitHandle(ground.x, ground.z);
|
||
if (hit) {
|
||
drag = hit;
|
||
selVenturi = hit.i;
|
||
render();
|
||
return true;
|
||
}
|
||
// empty ground under the venturi tool = author a new gap here
|
||
const list = venturiList();
|
||
list.push({ x: round2(ground.x), z: round2(ground.z), axis: 0, gain: 1.4, radius: 4, sharp: 3 });
|
||
selVenturi = list.length - 1;
|
||
drag = { i: selVenturi, mode: 'throat' };
|
||
commit();
|
||
return true;
|
||
},
|
||
onPointerMove(ev, ground) {
|
||
if (!drag || !ground) return false;
|
||
const v = venturiList()[drag.i];
|
||
if (!v) { drag = null; return false; }
|
||
if (drag.mode === 'throat') {
|
||
v.x = round2(ground.x);
|
||
v.z = round2(ground.z);
|
||
} else {
|
||
// Grab either end: both ends are the same line, so we fold into [0, π)
|
||
// and the gizmo mirrors. THIS is the mod-π lesson as a gesture.
|
||
v.axis = round3(normalizeAxis(Math.atan2(ground.z - v.z, ground.x - v.x)));
|
||
}
|
||
commit();
|
||
return true;
|
||
},
|
||
onPointerUp() {
|
||
if (!drag) return false;
|
||
drag = null;
|
||
commit();
|
||
return true;
|
||
},
|
||
});
|
||
|
||
const round2 = (n) => Math.round(n * 100) / 100;
|
||
const round3 = (n) => Math.round(n * 1000) / 1000;
|
||
|
||
/**
|
||
* Site changed: tell the editor (it revalidates — `validateSiteWind` is
|
||
* already inside A's `validateSite`, so a bad gain lands in A's panel and I
|
||
* do NOT write a second wind validator), then rebuild my wind and gizmos.
|
||
*
|
||
* `markDirty()` deliberately, not `rebuild()`: a venturi edit changes no
|
||
* geometry and no anchor, so re-dressing the world would be a 40-90 ms hitch
|
||
* per slider tick for nothing.
|
||
*/
|
||
function commit() {
|
||
// Just say the site changed. The 'change' listener rebuilds the wind and
|
||
// redraws — see the note there for why the refresh lives at the LISTENER
|
||
// and not here.
|
||
EDITOR.markDirty();
|
||
}
|
||
|
||
// --- DOM ----------------------------------------------------------------
|
||
|
||
const el = (tag, cls, txt) => {
|
||
const n = document.createElement(tag);
|
||
if (cls) n.className = cls;
|
||
if (txt != null) n.textContent = txt;
|
||
return n;
|
||
};
|
||
|
||
function row(labelText, ...controls) {
|
||
const r = el('div', 'ed-row');
|
||
r.append(el('span', 'ed-label', labelText));
|
||
r.append(...controls);
|
||
return r;
|
||
}
|
||
|
||
/** A labelled slider that reads its own value. `.ed-num` per A's class kit —
|
||
* a range is a numeric control and the contract says don't invent. */
|
||
function slider(min, max, stepv, value, fmt, onInput) {
|
||
const wrap = el('div', 'ed-row');
|
||
const i = document.createElement('input');
|
||
i.type = 'range';
|
||
i.className = 'ed-num';
|
||
i.min = min; i.max = max; i.step = stepv; i.value = value;
|
||
i.style.flex = '1 1 auto';
|
||
i.style.width = 'auto';
|
||
const out = el('span', null, fmt(value));
|
||
out.style.flex = '0 0 78px';
|
||
out.style.textAlign = 'right';
|
||
i.addEventListener('input', () => {
|
||
const v = parseFloat(i.value);
|
||
out.textContent = fmt(v);
|
||
onInput(v);
|
||
});
|
||
wrap.append(i, out);
|
||
return wrap;
|
||
}
|
||
|
||
function render() {
|
||
body.textContent = '';
|
||
const field = redraw();
|
||
|
||
// --- storm + second ---
|
||
const sel = el('select', 'ed-sel');
|
||
for (const k of STORM_KEYS) {
|
||
const o = el('option', null, k.replace(/^storm_/, ''));
|
||
o.value = k;
|
||
if (k === stormKey) o.selected = true;
|
||
sel.append(o);
|
||
}
|
||
sel.addEventListener('change', async () => {
|
||
stormKey = sel.value;
|
||
await ensureStorm(stormKey);
|
||
refreshWind();
|
||
render();
|
||
});
|
||
body.append(row('storm', sel));
|
||
|
||
const dur = wind?.duration ?? 90;
|
||
body.append(row('second', el('span', null, `${time.toFixed(1)} s / ${dur.toFixed(0)} s`)));
|
||
body.append(slider(0, dur, 0.5, Math.min(time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
|
||
time = v;
|
||
render();
|
||
}));
|
||
|
||
if (field) {
|
||
const card = el('div', 'ed-card');
|
||
const head = el('div', 'ed-card-head');
|
||
head.append(el('span', null, 'FIELD'), el('span', 'ed-kv', `${axisDeg(field.dir).toFixed(0)}° axis of flow`));
|
||
card.append(head);
|
||
const kv = (k, v, cls) => {
|
||
const r = el('div', 'ed-card-row');
|
||
r.append(el('span', 'ed-kv', k), el('span', cls, v));
|
||
card.append(r);
|
||
};
|
||
kv('uniform', `${field.uniform.toFixed(2)} m/s`);
|
||
kv('fastest', `${field.max.speed.toFixed(2)} m/s`,
|
||
field.max.speed > field.uniform * 1.15 ? 'ed-warn' : null);
|
||
kv(' at', `${field.max.x.toFixed(1)}, ${field.max.z.toFixed(1)}`, 'ed-kv');
|
||
kv('calmest', `${field.min.speed.toFixed(2)} m/s`);
|
||
kv(' at', `${field.min.x.toFixed(1)}, ${field.min.z.toFixed(1)}`, 'ed-kv');
|
||
const amp = field.uniform > 1e-6 ? field.max.speed / field.uniform : 1;
|
||
kv('peak gain', `×${amp.toFixed(2)}`, amp > 1.15 ? 'ed-warn' : 'ed-kv');
|
||
body.append(card);
|
||
}
|
||
|
||
// --- overlays ---
|
||
const bField = el('button', `ed-btn${showField ? ' on' : ''}`, 'FIELD');
|
||
bField.addEventListener('click', () => { showField = !showField; render(); });
|
||
const bShel = el('button', `ed-btn${showShelters ? ' on' : ''}`, 'SHELTERS');
|
||
bShel.addEventListener('click', () => { showShelters = !showShelters; render(); });
|
||
body.append(row('show', bField, bShel));
|
||
|
||
const gsel = el('select', 'ed-sel');
|
||
for (const g of [1, 1.5, 2, 3]) {
|
||
const o = el('option', null, `${g} m`);
|
||
o.value = g;
|
||
if (g === gridStep) o.selected = true;
|
||
gsel.append(o);
|
||
}
|
||
gsel.addEventListener('change', () => { gridStep = parseFloat(gsel.value); render(); });
|
||
body.append(row('grid', gsel));
|
||
|
||
body.append(el('p', 'ed-note',
|
||
'Colour is amplification against the storm’s uniform speed — blue is sheltered, '
|
||
+ 'red is funnelled, pale is untouched. Length is absolute speed. Every arrow is '
|
||
+ 'parallel because a yard changes the wind’s SPEED, never its heading.'));
|
||
|
||
// --- venturi ---
|
||
const list = venturiList();
|
||
const bAdd = el('button', 'ed-btn primary', 'PLACE VENTURI');
|
||
const armed = EDITOR.tool === 'venturi';
|
||
if (armed) bAdd.classList.add('on');
|
||
bAdd.textContent = armed ? 'PLACING — click the gap' : 'PLACE VENTURI';
|
||
bAdd.addEventListener('click', () => {
|
||
EDITOR.setTool(armed ? null : 'venturi');
|
||
render();
|
||
});
|
||
body.append(row('venturi', bAdd));
|
||
|
||
if (!list.length) {
|
||
body.append(el('p', 'ed-note',
|
||
'No funnel on this site. A venturi is a GAP between buildings that speeds the '
|
||
+ 'wind up when the storm swings to run along it — the corner block is calm until '
|
||
+ 'the southerly arrives, then it screams.'));
|
||
}
|
||
|
||
list.forEach((v, i) => {
|
||
const card = el('div', 'ed-card');
|
||
const head = el('div', 'ed-card-head');
|
||
const name = el('span', null, `gap ${i + 1}`);
|
||
name.style.cursor = 'pointer';
|
||
name.addEventListener('click', () => { selVenturi = i; render(); });
|
||
if (i === selVenturi) name.classList.add('ed-ok');
|
||
const del = el('button', 'ed-btn danger', '×');
|
||
del.addEventListener('click', () => {
|
||
list.splice(i, 1);
|
||
selVenturi = Math.max(0, selVenturi - 1);
|
||
commit();
|
||
});
|
||
head.append(name, del);
|
||
card.append(head);
|
||
|
||
const at = el('div', 'ed-card-row');
|
||
at.append(el('span', 'ed-kv', 'throat'), el('span', null, `${v.x.toFixed(1)}, ${v.z.toFixed(1)}`));
|
||
card.append(at);
|
||
|
||
// THE AXIS ROW — the mod-π teaching in words as well as geometry.
|
||
const a = normalizeAxis(v.axis ?? 0);
|
||
const aRow = el('div', 'ed-card-row');
|
||
aRow.append(el('span', 'ed-kv', 'axis'),
|
||
el('span', null, `${(a * 180 / Math.PI).toFixed(0)}° ≡ ${((a * 180 / Math.PI) + 180).toFixed(0)}°`));
|
||
card.append(aRow);
|
||
|
||
card.append(slider(0, 180, 1, a * 180 / Math.PI, (x) => `${x.toFixed(0)}°`, (x) => {
|
||
v.axis = round3(normalizeAxis(x * Math.PI / 180));
|
||
commit();
|
||
}));
|
||
card.append(slider(1, 2, 0.05, v.gain ?? 1.4, (x) => `gain ×${x.toFixed(2)}`, (x) => {
|
||
v.gain = round2(x);
|
||
commit();
|
||
}));
|
||
card.append(slider(1, 12, 0.5, v.radius ?? 4, (x) => `r ${x.toFixed(1)} m`, (x) => {
|
||
v.radius = round2(x);
|
||
commit();
|
||
}));
|
||
card.append(slider(1, 8, 1, v.sharp ?? 3, (x) => `sharp ${x.toFixed(0)}`, (x) => {
|
||
v.sharp = x;
|
||
commit();
|
||
}));
|
||
|
||
// What this gap is doing RIGHT NOW, at the second on the scrubber — the
|
||
// number an author actually wants: not "is it authored" but "is it firing".
|
||
if (wind) {
|
||
const core = wind.core.venturi[i];
|
||
if (core) {
|
||
const d = wind.core.dirAt(time);
|
||
const align = Math.abs(Math.cos(d) * core.axisX + Math.sin(d) * core.axisZ);
|
||
const fired = Math.pow(align, core.sharp);
|
||
const at0 = wind.core.speedAt(core.x, core.z, time);
|
||
const r = el('div', 'ed-card-row');
|
||
r.append(el('span', 'ed-kv', 'now'),
|
||
el('span', fired > 0.5 ? 'ed-warn' : 'ed-kv',
|
||
`${at0.toFixed(1)} m/s · align ${(fired * 100).toFixed(0)}%`));
|
||
card.append(r);
|
||
}
|
||
}
|
||
body.append(card);
|
||
});
|
||
|
||
body.append(el('p', 'ed-note',
|
||
'An axis is a LINE, not a heading: the gap funnels either way through it, so '
|
||
+ '120° and 300° are the same gap and the slider stops at 180°. Drag either end '
|
||
+ 'handle — both are the same line. The throat handle moves the gap.'));
|
||
|
||
body.append(el('p', 'ed-note',
|
||
'Neither funnels nor shelters have a height term — they apply at every height. '
|
||
+ 'The shelter volume is drawn to canopy height as a reading aid only.'));
|
||
}
|
||
|
||
// --- storms are fetched, so the panel boots async -----------------------
|
||
async function ensureStorm(k) {
|
||
if (!storms[k]) storms[k] = await loadStorm(k);
|
||
}
|
||
|
||
await ensureStorm(stormKey);
|
||
refreshWind();
|
||
render();
|
||
|
||
// A clears the overlay on every rebuild — so every rebuild, I redraw. My
|
||
// gizmos are a pure function of (site, storm, second), which is exactly why
|
||
// this is one line instead of a lifetime problem.
|
||
EDITOR.on('rebuild', () => { refreshWind(); render(); });
|
||
EDITOR.on('siteload', () => { selVenturi = 0; });
|
||
/**
|
||
* The wind is rebuilt HERE, on any 'change', rather than in my own commit().
|
||
*
|
||
* Caught by looking: with the refresh in commit(), my sliders were correct and
|
||
* everything else was a lie. Setting `site.wind.venturi[0].axis` from outside
|
||
* and calling `markDirty()` — which is A's documented way for ANY lane to
|
||
* change the site, and what an undo or a scripted edit does — re-rendered the
|
||
* panel against the wind built from the PREVIOUS site, so the funnel readout
|
||
* kept insisting the gap was 24% aligned after it had been put back to 100%.
|
||
* A stale wind behind a fresh panel is the exact failure mode A parented the
|
||
* overlay to `rebuild` to kill, reintroduced one layer up by me.
|
||
*
|
||
* Refreshing on the event instead of in the mutator means the overlay is a
|
||
* function of the site as it IS, no matter who changed it. render() never
|
||
* calls markDirty(), so this cannot loop.
|
||
*/
|
||
EDITOR.on('change', () => { refreshWind(); render(); });
|
||
|
||
return { root, body, redraw, buildWind, get wind() { return wind; } };
|
||
}
|
||
|
||
// Self-register. `editor.html` imports this after `createEditor()` resolves, so
|
||
// `globalThis.EDITOR` is up by the time this runs (asked for in THREADS — that
|
||
// page is A's file and I do not edit it).
|
||
if (globalThis.EDITOR) {
|
||
await mountWindPanel(globalThis.EDITOR);
|
||
} else if (globalThis.document?.getElementById('ed-canvas')) {
|
||
// On the editor page with no EDITOR = the import landed in the wrong place.
|
||
// Anywhere else (c.test.js importing the pure helpers) this is not a problem
|
||
// and must not print — a selftest that warns every run trains people to
|
||
// ignore warnings.
|
||
console.warn('[editor.wind] no globalThis.EDITOR — import me AFTER createEditor() resolves');
|
||
}
|