Wind authoring for the yard editor: venturi gizmo (axis as a LINE, mod pi), tree-shelter volumes, wind-field overlay
Gate 2.2. Mounts into Lane A's editor through the published seams only — panel at reserved order 50, every gizmo parented to EDITOR.overlay, venturi drag through registerTool, site.wind mutated then markDirty(). No second wind validator (validateSiteWind is already inside A's validateSite) and no private harness: windForSite() is the only wind this file builds. Gizmos are drawn from wind.core.venturi/shelters — what the wind actually flies — not from the site JSON, so a mis-wired setVenturi shows up as a funnel that vanishes rather than a number nobody reads.
This commit is contained in:
parent
05d6128be7
commit
4c1086c805
783
web/world/js/editor.wind.js
Normal file
783
web/world/js/editor.wind.js
Normal file
@ -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');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user