diff --git a/web/world/js/editor.wind.js b/web/world/js/editor.wind.js new file mode 100644 index 0000000..bed5b87 --- /dev/null +++ b/web/world/js/editor.wind.js @@ -0,0 +1,783 @@ +/** + * 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 { + console.warn('[editor.wind] no globalThis.EDITOR — import me AFTER createEditor() resolves'); +}