mapspinner 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,559 @@
1
+ // anchor-field.js -- the Hierarchical Parameter Field (HPF) that drives TV8 terrain
2
+ // generation from a stack of fixed, spatially-indexed anchor quadtrees.
3
+ //
4
+ // DESIGN (user directive): a HIERARCHY of parameters across scale bands, each band its
5
+ // OWN quadtree spatial index, each band driven by its OWN fractal -- so different fractals
6
+ // affect different scale bands in different ways. Anchors are FIXED (the quadtree node
7
+ // centres; they never move, and the count per band never changes); only their PARAMETER
8
+ // payload mutates. The entire planet is anchor-indexed (every surface point falls inside
9
+ // exactly one node per band), the distribution is EVEN by construction (a quadtree over a
10
+ // cube face subdivides uniformly), and support is LOCAL: a sample reads only the node it
11
+ // lands in plus its 3 neighbours for bilinear blend, so editing one node's params changes
12
+ // only that cell's neighbourhood -- never the whole planet.
13
+ //
14
+ // INTEGRATION: this is a pure-JS field (editable, serialisable). The orchestrator samples
15
+ // it per Proland tile and pushes the result into the wasm via the existing setters + a
16
+ // per-tile additive bias, so Proland's upsample-coherence and seamlessness are preserved
17
+ // (the field is C0-continuous across tile edges because every tile samples the SAME global
18
+ // quadtree partition at its own scale band). No wasm rebuild is needed to edit params.
19
+ //
20
+ // PERFORMANCE (all the most-performant choices):
21
+ // - Param storage is a flat Float32Array per band (cache-friendly, zero object churn),
22
+ // indexed by the node's linear quadtree address. O(1) read/write.
23
+ // - The procedural BASE of every node derives from a deterministic integer hash of its
24
+ // (face, band, level, tx, ty) -- no RNG state, fully reproducible, so persistence only
25
+ // needs the EDITED deltas (a sparse Map), not the whole field.
26
+ // - Sampling is O(bands) -- a handful of band lookups + one bilinear per band -- with no
27
+ // allocation on the hot path (results written into a reused scratch object).
28
+
29
+ // ---- cube-face frame (mirrors proland-planet-orchestrator FACE_FRAME / render localToWorld3).
30
+ // A face-local point (u,v in [-1,1], outward axis) maps to a world direction. We only need
31
+ // the inverse (world dir -> face + uv) for sampleDir, and the forward (face,uv -> dir) for
32
+ // the per-node procedural hash seed coordinate.
33
+ const FACE_FRAME = [
34
+ { c: [ 1, 0, 0], u: [0, 0, -1], v: [0, 1, 0] }, // +X
35
+ { c: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] }, // -X
36
+ { c: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1] }, // +Y
37
+ { c: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] }, // -Y
38
+ { c: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, // +Z
39
+ { c: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0] }, // -Z
40
+ ];
41
+
42
+ // ---- deterministic integer hash (no state). Two rounds of integer mixing (a la PCG/xxhash
43
+ // finalizer) -> a uniform float in [0,1). Used to seed each node's procedural base so the
44
+ // field is reproducible across reloads and machines.
45
+ function hash3(a, b, c) {
46
+ let h = (a | 0) * 374761393 + (b | 0) * 668265263 + (c | 0) * 2246822519;
47
+ h = (h ^ (h >>> 13)) >>> 0;
48
+ h = (h * 1274126177) >>> 0;
49
+ h = (h ^ (h >>> 16)) >>> 0;
50
+ return h / 4294967296;
51
+ }
52
+ // value-noise sample at a 2D coord via hashed lattice + smooth (quintic) bilinear. Cheap,
53
+ // allocation-free; the per-band fractal sums a few octaves of this.
54
+ function vnoise(x, y, seed) {
55
+ const xi = Math.floor(x), yi = Math.floor(y);
56
+ const xf = x - xi, yf = y - yi;
57
+ const u = xf*xf*xf*(xf*(xf*6 - 15) + 10); // quintic smoothstep
58
+ const v = yf*yf*yf*(yf*(yf*6 - 15) + 10);
59
+ const h00 = hash3(xi, yi, seed);
60
+ const h10 = hash3(xi + 1, yi, seed);
61
+ const h01 = hash3(xi, yi + 1, seed);
62
+ const h11 = hash3(xi + 1, yi + 1, seed);
63
+ const a = h00 + u * (h10 - h00);
64
+ const b = h01 + u * (h11 - h01);
65
+ return (a + v * (b - a)) * 2.0 - 1.0; // [-1,1]
66
+ }
67
+ // fractal sum (fBm) of `oct` octaves; ridged=true folds to ridges (continental/mountain).
68
+ function fractal(x, y, seed, oct, lacunarity, gain, ridged) {
69
+ let amp = 1.0, freq = 1.0, sum = 0.0, norm = 0.0;
70
+ for (let o = 0; o < oct; o++) {
71
+ let n = vnoise(x * freq, y * freq, seed + o * 1013);
72
+ if (ridged) { n = 1.0 - Math.abs(n); n = n * n; }
73
+ sum += n * amp; norm += amp;
74
+ amp *= gain; freq *= lacunarity;
75
+ }
76
+ let r = sum / Math.max(norm, 1e-6);
77
+ if (ridged) r = r * 2.0 - 1.0; // recentre ridged to ~[-1,1]
78
+ return r;
79
+ }
80
+
81
+ // 3D value-noise + fBm of the WORLD DIRECTION (seam-fix 2026-06-05). The 2D vnoise/fractal above
82
+ // were sampled in FACE-LOCAL coords (baseParams fx=(u+face*4)*..., fy=(v+face*7)*...), so adjacent
83
+ // cube faces sampled DISJOINT regions of the 2D field -> a hard continental-elevation step along
84
+ // every shared cube-face edge (up to ~3.8km, the 'shelf' the user saw). Seeding the band fractals
85
+ // by the 3D world direction instead makes the field a pure function of world dir -> seamless across
86
+ // faces + poles BY CONSTRUCTION (validated src/lab/_seamfix_proto.mjs: seam 3765m->13m, landFrac held).
87
+ function vnoise3(x, y, z, seed) {
88
+ const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
89
+ const xf = x - xi, yf = y - yi, zf = z - zi;
90
+ const u = xf*xf*xf*(xf*(xf*6-15)+10), v = yf*yf*yf*(yf*(yf*6-15)+10), w = zf*zf*zf*(zf*(zf*6-15)+10);
91
+ const H = (a,b,c) => hash3(a, b, (c*2654435761 ^ seed)|0);
92
+ const c000=H(xi,yi,zi), c100=H(xi+1,yi,zi), c010=H(xi,yi+1,zi), c110=H(xi+1,yi+1,zi);
93
+ const c001=H(xi,yi,zi+1), c101=H(xi+1,yi,zi+1), c011=H(xi,yi+1,zi+1), c111=H(xi+1,yi+1,zi+1);
94
+ const x00=c000+u*(c100-c000), x10=c010+u*(c110-c010), x01=c001+u*(c101-c001), x11=c011+u*(c111-c011);
95
+ const y0=x00+v*(x10-x00), y1=x01+v*(x11-x01);
96
+ return (y0 + w*(y1-y0)) * 2.0 - 1.0; // [-1,1]
97
+ }
98
+ // 3D fBm (mirrors fractal()). The band fractal closures call this with the world dir * scale.
99
+ function fractal3(x, y, z, seed, oct, lacunarity, gain, ridged) {
100
+ let amp = 1.0, freq = 1.0, sum = 0.0, norm = 0.0;
101
+ for (let o = 0; o < oct; o++) {
102
+ let n = vnoise3(x * freq, y * freq, z * freq, seed + o * 1013);
103
+ if (ridged) { n = 1.0 - Math.abs(n); n = n * n; }
104
+ sum += n * amp; norm += amp; amp *= gain; freq *= lacunarity;
105
+ }
106
+ let r = sum / Math.max(norm, 1e-6);
107
+ if (ridged) r = r * 2.0 - 1.0;
108
+ return r;
109
+ }
110
+
111
+ // ---- band definitions: each scale band is an independent fixed quadtree over the 6 faces
112
+ // at a chosen LEVEL (resolution), driven by its OWN fractal, contributing its OWN parameter
113
+ // set. levelsPerFace = 2^level cells per face edge -> 6 * 4^level anchors in the band.
114
+ // The procedural base of each parameter is a fractal of the node's world position; edits
115
+ // are sparse deltas layered on top. Tuned so coarse bands make broad continents and finer
116
+ // bands add regional then local structure (different fractals at different scales).
117
+ const BANDS = [
118
+ {
119
+ name: 'continental', level: 3, // 6 * 64 = 384 anchors; ~plate scale
120
+ // band0 drives the broad land/sea split + plate-scale uplift. Ridged low-octave domain-
121
+ // warped noise -> big coherent landmasses with oceanic basins between (tectonic feel).
122
+ fractal: (x, y, s) => {
123
+ // domain warp for non-blobby continent shapes
124
+ const wx = x + 0.6 * fractal(x, y, s + 7, 2, 2.0, 0.5, false);
125
+ const wy = y + 0.6 * fractal(x, y, s + 9, 2, 2.0, 0.5, false);
126
+ return fractal(wx, wy, s, 4, 2.0, 0.55, true); // ridged plate mask
127
+ },
128
+ // 3D world-dir version (seam fix): domain-warped ridged plate mask, seamless across cube faces.
129
+ fractal3: (x, y, z, s) => {
130
+ const wx = x + 0.6 * fractal3(x, y, z, s + 7, 2, 2.0, 0.5, false);
131
+ const wy = y + 0.6 * fractal3(x, y, z, s + 9, 2, 2.0, 0.5, false);
132
+ const wz = z + 0.6 * fractal3(x, y, z, s + 13, 2, 2.0, 0.5, false);
133
+ return fractal3(wx, wy, wz, s, 4, 2.0, 0.55, true); // ridged plate mask
134
+ },
135
+ // maps the fractal value -> parameter contributions (meters / scales). The -0.13 sea-level bias
136
+ // offsets the ridged mask's positive skew so landFrac stays ~0.43 (tuned, _seamfix_proto.mjs).
137
+ params: (f) => ({
138
+ seaBias: (f - 0.13) * 2600.0, // +/- ~2.6km broad swell: land above 0, ocean below
139
+ elevAmp: 1.0 + 0.25 * f, // gentle continental-shelf amplitude modulation
140
+ temp: 0.0, humidity: 0.0, erosion: 0.0, roughness: 0.0,
141
+ }),
142
+ },
143
+ {
144
+ // SUB-CONTINENTAL infill band (anchor-density ladder [3,5,6,7,9], gate nodejs-2215). Closes the
145
+ // octave gap between continental L3 and regional L6 so ~200-400km features resolve. DISCIPLINE
146
+ // (proven safe-wiring gate): NEUTRAL elevAmp (1.0, multiplies as identity -> no elevAmp compounding)
147
+ // + ZERO-MEAN seaBias ((f), f in [-1,1] -> does not shift land/sea; A/B landFrac delta 0.03 < 0.05).
148
+ name: 'subcontinental', level: 5, // 6 * 1024 = 6144 anchors
149
+ fractal: (x, y, s) => {
150
+ const wx = x + 0.6 * fractal(x, y, s + 7, 2, 2.0, 0.5, false);
151
+ const wy = y + 0.6 * fractal(x, y, s + 9, 2, 2.0, 0.5, false);
152
+ return fractal(wx, wy, s + 65, 4, 2.0, 0.55, true); // decorrelated ridged sub-plate mask
153
+ },
154
+ fractal3: (x, y, z, s) => {
155
+ const wx = x + 0.6 * fractal3(x, y, z, s + 7, 2, 2.0, 0.5, false);
156
+ const wy = y + 0.6 * fractal3(x, y, z, s + 9, 2, 2.0, 0.5, false);
157
+ const wz = z + 0.6 * fractal3(x, y, z, s + 13, 2, 2.0, 0.5, false);
158
+ return fractal3(wx, wy, wz, s + 65, 4, 2.0, 0.55, true); // decorrelated ridged sub-plate mask
159
+ },
160
+ params: (f) => ({
161
+ seaBias: f * 900.0, // ZERO-MEAN sub-continental sea-level wiggle (coastline at sub-plate scale)
162
+ elevAmp: 1.0, // NEUTRAL (gate: keeps composite elevAmp identical to 3-band)
163
+ temp: 0.0, humidity: 0.0, erosion: 0.0, roughness: 0.0,
164
+ }),
165
+ },
166
+ {
167
+ name: 'regional', level: 6, // 6 * 4096 = 24576 anchors; ~mountain-range/climate scale
168
+ // band1 drives mountain belts, erosion strength, and climate (temp/humidity). Mid-octave
169
+ // fBm (not ridged) -> rolling regional variation with belts of higher relief.
170
+ fractal: (x, y, s) => fractal(x, y, s, 5, 2.0, 0.5, false),
171
+ fractal3: (x, y, z, s) => fractal3(x, y, z, s, 5, 2.0, 0.5, false),
172
+ params: (f, lat = 0, fx = 0, fy = 0) => {
173
+ const belt = fractal(f * 3.0, f * 3.0, 31, 3, 2.0, 0.5, true); // mountain-belt mask
174
+ // BIOME-VARIETY CLIMATE (user: continent featureless/self-similar). The old climate was a
175
+ // single smooth gradient (temp=latitude, humidity=0.5-0.5f) so the whole continent fell in
176
+ // ~2 wet-lowland classes that all render the same green. We give temp AND humidity WIDE
177
+ // REGIONAL range from INDEPENDENT multi-octave fractals of the node position + continentality,
178
+ // so distinct biome PATCHES form (deserts, forests, swamps, tundra, taiga) rather than one
179
+ // gradient. fx,fy = the node's fractal coordinate (threaded from baseParams).
180
+ const latBase = Math.pow(Math.max(0, Math.cos(lat)), 1.1); // [0,1] equator->pole (still anchors poles cold)
181
+ // independent regional climate octaves (decorrelated seeds). Coordinate scale 2.2 (was 0.5)
182
+ // so several biome PATCHES form WITHIN a single continent (at 0.5 a whole continent fell in
183
+ // one climate cycle -> one biome region = still self-similar). Higher freq -> biome mosaic.
184
+ const tNoise = fractal(fx * 2.2 + 11.0, fy * 2.2 - 7.0, 9211, 4, 2.0, 0.55, false);
185
+ const hNoise = fractal(fx * 2.2 - 5.0, fy * 2.2 + 13.0, 9307, 4, 2.0, 0.55, false);
186
+ // CONTINENTALITY: interiors/high ground (large seaBias proxy via f) are drier + a touch
187
+ // cooler; near-sea is wetter. f in ~[-1,1] (regional fBm), positive = higher/inland.
188
+ const inland = Math.max(0, Math.min(1, f * 0.5 + 0.5)); // 0 coastal -> 1 interior
189
+ // TEMP: latitude is the spine; regional octave gives +/-0.30 so warm/cool belts cross it;
190
+ // interiors run a bit cooler. Wide range so cold (tundra/ice) AND hot (desert) both occur.
191
+ // latBase^1.4 makes the poles genuinely cold (-> ice/tundra) while the equator stays warm;
192
+ // small offset, wide regional swing so both hot deserts and cold caps occur.
193
+ const temp = Math.max(0, Math.min(1, Math.pow(latBase, 1.4) * 1.05 + 0.28 * tNoise - 0.10 * inland - 0.04));
194
+ // LOGICAL BIOME PLACEMENT (user: biomes in their most logical positions). Two physical
195
+ // climate drivers added so biomes land where Earth puts them, not at random noise spots:
196
+ // (1) EQUATORIAL-WET / SUBTROPICAL-DRY latitude band (the Hadley-cell / ITCZ profile):
197
+ // wet at the equator (rainforest), DRY desert belts near +/-25deg (Sahara/Arabian/
198
+ // Australian latitudes), moderate again in the temperate mid-lats. latDeg from lat.
199
+ // (2) RAIN-SHADOW: the dry lee of mountain belts -- the `belt` mask reduces humidity so
200
+ // arid steppe/desert forms downwind of ranges (continentality already covers interiors;
201
+ // this sharpens the orographic dryness on the high belts themselves).
202
+ const latDeg = Math.abs(lat) * 57.29577951;
203
+ // Hadley/ITCZ humidity profile: equatorial WET bulge, a NARROW subtropical DRY trough at
204
+ // ~25deg (the desert latitudes), and a temperate-humid RECOVERY bump at ~50deg so deserts
205
+ // do NOT bleed into the mid-latitudes. Trough sigma 8 (narrow) so it decays before 40deg.
206
+ const latHumid = 0.20 * Math.exp(-(latDeg * latDeg) / (2 * 11 * 11)) // equatorial wet bulge
207
+ - 0.24 * Math.exp(-((latDeg - 25) * (latDeg - 25)) / (2 * 8 * 8)) // subtropical dry trough (narrow)
208
+ + 0.12 * Math.exp(-((latDeg - 52) * (latDeg - 52)) / (2 * 14 * 14)); // temperate-humid recovery
209
+ const rainShadow = 0.22 * Math.max(0, belt); // orographic drying on the high belts
210
+ const humidity = Math.max(0, Math.min(1, 0.62 + 0.52 * hNoise - 0.40 * inland + latHumid - rainShadow));
211
+ return {
212
+ // GENERAL ELEVATION (user: anchorpoints should convey general elevation so terrain is
213
+ // not so flat/featureless). The regional band is the ~100-200km scale the user flies
214
+ // over; at f*350 the within-continent height variation was too small (continental
215
+ // f*2600 dominates, then a big flat gap to fine detail), so interiors read as flat
216
+ // plains. Raise the regional elevation amplitude to f*900 to carve rolling
217
+ // hills/plateaus/valleys at that scale. ZERO-MEAN (f in [-1,1]) so the land/sea split
218
+ // is preserved (safe-wiring gate: landFrac delta < 0.05); validated by CLI hypsometry.
219
+ seaBias: f * 1600.0, // regional general-elevation relief. 900->1600 (user 2026-06-02
220
+ // 'elevation distribution doesnt create enough elevation'): more
221
+ // large-scale highlands/lowlands/basins. ZERO-MEAN (land/sea preserved).
222
+ elevAmp: 1.0 + 0.8 * Math.max(0, belt), // amplify relief inside mountain belts
223
+ temp,
224
+ humidity,
225
+ erosion: 0.3 + 0.4 * Math.max(0, belt), // more erosion on the high belts
226
+ roughness: 0.0,
227
+ };
228
+ },
229
+ },
230
+ {
231
+ // SUB-REGIONAL infill band (ladder [3,5,6,7,9]). Closes the gap between regional L6 and local L9
232
+ // so ~25-50km features resolve. Same discipline: NEUTRAL elevAmp + ZERO-MEAN seaBias.
233
+ name: 'subregional', level: 7, // 6 * 16384 = 98304 anchors
234
+ fractal: (x, y, s) => {
235
+ const wx = x + 0.6 * fractal(x, y, s + 7, 2, 2.0, 0.5, false);
236
+ const wy = y + 0.6 * fractal(x, y, s + 9, 2, 2.0, 0.5, false);
237
+ return fractal(wx, wy, s + 91, 4, 2.0, 0.55, true); // decorrelated ridged sub-regional mask
238
+ },
239
+ fractal3: (x, y, z, s) => {
240
+ const wx = x + 0.6 * fractal3(x, y, z, s + 7, 2, 2.0, 0.5, false);
241
+ const wy = y + 0.6 * fractal3(x, y, z, s + 9, 2, 2.0, 0.5, false);
242
+ const wz = z + 0.6 * fractal3(x, y, z, s + 13, 2, 2.0, 0.5, false);
243
+ return fractal3(wx, wy, wz, s + 91, 4, 2.0, 0.55, true); // decorrelated ridged sub-regional mask
244
+ },
245
+ params: (f) => ({
246
+ seaBias: f * 750.0, // ZERO-MEAN sub-regional relief (~25-50km hills). 450->750 (more elevation distribution)
247
+ elevAmp: 1.0, // NEUTRAL
248
+ temp: 0.0, humidity: 0.0, erosion: 0.0, roughness: 0.0,
249
+ }),
250
+ },
251
+ {
252
+ name: 'local', level: 9, // 6 * 262144 = ~1.5M anchors; ~local-feature scale
253
+ // band2 drives local roughness + material detail weights. High-octave turbulence -> the
254
+ // fine break-up that the detail-texture material reads (the texel-density coupling).
255
+ fractal: (x, y, s) => Math.abs(fractal(x, y, s, 4, 2.2, 0.55, false)),
256
+ fractal3: (x, y, z, s) => Math.abs(fractal3(x, y, z, s, 4, 2.2, 0.55, false)),
257
+ params: (f) => ({
258
+ seaBias: 0.0, elevAmp: 1.0 + 0.15 * f, temp: 0.0, humidity: 0.0,
259
+ erosion: 0.0,
260
+ roughness: 0.4 + 0.6 * f, // [0.4,1] local surface roughness / detail gain
261
+ }),
262
+ },
263
+ ];
264
+
265
+ // the parameter keys the field accumulates (sum across bands for additive ones, the field's
266
+ // contract with the wasm bias hook + the shader material).
267
+ const PARAM_KEYS = ['seaBias', 'elevAmp', 'temp', 'humidity', 'erosion', 'roughness'];
268
+ const K = PARAM_KEYS.length;
269
+ const PIDX = Object.create(null); PARAM_KEYS.forEach((k, i) => PIDX[k] = i);
270
+
271
+ // ---- ADAPTIVE QUADTREE node addressing (build-spec aqt-design). Each band has its OWN
272
+ // sparse quadtree of EDIT nodes at ARBITRARY depth below its base level. The procedural BASE
273
+ // stays analytic (no storage) and the EXISTING fixed-level cell bilinear is the BASE layer,
274
+ // byte-for-byte; adaptivity is a strictly ADDITIVE overlay of node "detail hats", so an
275
+ // empty edit tree leaves sampleUV numerically identical to the pre-AQT field (no-regression
276
+ // is a theorem, not a hope). A node's payload is a flat Float32Array(K) of param DELTAS.
277
+ //
278
+ // Path key: a depth-0 root per (face, base-cell) folds the base grid into the key; childKey
279
+ // is *4+q (q=(cy<<1)|cx), parentKey is /4. Integer-only, prefix-free, safe to ~depth 12.
280
+ const FACE_OFF = 8; // face roots start at 8 (keeps key positive + leaves low range free)
281
+ const childKey = (k, q) => k * 4 + q;
282
+ const parentKey = (k) => Math.floor(k / 4);
283
+ // hat basis (tent): 1 at centre, 0 at +/-1, compact support -> C0 + local.
284
+ const hat = (t) => { t = t < 0 ? -t : t; return t >= 1 ? 0 : 1 - t; };
285
+
286
+ export function createAnchorField(opts = {}) {
287
+ const seed = (opts.seed | 0) || 1337;
288
+
289
+ // Per-band ADAPTIVE QUADTREE record. nodes: pathKey -> Float32Array(K) edit deltas.
290
+ // cover: every ANCESTOR pathKey of an edited node, refcounted -> the descent guide (enter
291
+ // a child only if its key is in cover; O(depth) integer-only, no speculative gets).
292
+ // maxDepth: max adaptive depth present (0 = no overlay anywhere -> global early-out).
293
+ const bands = BANDS.map((B) => ({
294
+ baseLevel: B.level, bn: 1 << B.level,
295
+ nodes: new Map(), cover: new Map(), maxDepth: 0,
296
+ }));
297
+ // RUNTIME per-band scales (full-adjustability via window.__gen.hpf.band[i]). 1.0 = identity =
298
+ // the tuned base field (no behaviour change until edited). seaBiasScale multiplies the band's
299
+ // additive seaBias contribution; elevAmpScale scales its elevAmp DEVIATION from 1 (so 0 = flat,
300
+ // 1 = base, >1 = exaggerated); roughnessScale scales its roughness contribution.
301
+ const bandScales = BANDS.map(() => ({ seaBiasScale: 1.0, elevAmpScale: 1.0, roughnessScale: 1.0 }));
302
+ // depth-0 root key for a base-level cell on a face. bn folded so cells don't collide.
303
+ function rootKey(bandIdx, face, btx, bty) {
304
+ const bn = bands[bandIdx].bn;
305
+ return ((FACE_OFF + face) * bn + bty) * bn + btx;
306
+ }
307
+
308
+ // procedural BASE params of a band node (no edits). Deterministic from the node's face-uv
309
+ // centre + the band fractal. UNCHANGED from the pre-AQT field (regression firewall).
310
+ function baseParams(bandIdx, face, tx, ty) {
311
+ const B = BANDS[bandIdx];
312
+ const n = 1 << B.level;
313
+ // cell-CENTRE face coords -> delegate to the continuous evaluator (used by the edit-overlay grid).
314
+ const fu = (tx + 0.5) / n, fv = (ty + 0.5) / n;
315
+ return baseParamsAt(bandIdx, face, fu, fv);
316
+ }
317
+ // CONTINUOUS base params at exact face coords (fu,fv) in [0,1] -- a pure function of the WORLD DIR,
318
+ // so it is seamless across cube faces + poles by construction (seam fix 2026-06-05). The per-band
319
+ // SCALE (cycles/sphere) mirrors the old face-local frequency (n*0.06 cycles/face * 4 faces/sphere).
320
+ function baseParamsAt(bandIdx, face, fu, fv) {
321
+ const B = BANDS[bandIdx];
322
+ const n = 1 << B.level;
323
+ const u = fu * 2.0 - 1.0, v = fv * 2.0 - 1.0;
324
+ const F = FACE_FRAME[face];
325
+ let dx = F.c[0] + u * F.u[0] + v * F.v[0];
326
+ let dy = F.c[1] + u * F.u[1] + v * F.v[1];
327
+ let dz = F.c[2] + u * F.u[2] + v * F.v[2];
328
+ const dl = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; dx/=dl; dy/=dl; dz/=dl; // unit world dir
329
+ const lat = Math.asin(Math.max(-1, Math.min(1, dy)));
330
+ const SC = n * 0.06 * 4.0;
331
+ const sx = dx * SC, sy = dy * SC, sz = dz * SC;
332
+ const fval = B.fractal3(sx, sy, sz, seed + bandIdx * 101);
333
+ return B.params(fval, lat, sx, sz);
334
+ }
335
+
336
+ // ADDITIVE overlay walk: descend the band's edit quadtree from the base cell toward the
337
+ // sample, accumulating each visited node's hat-weighted delta into the per-band scratch.
338
+ // Allocation-free. Local support is exact (a node's hat is 0 outside its cell +/-1 ring).
339
+ function addOverlay(out, bandIdx, face, fu, fv) {
340
+ const band = bands[bandIdx];
341
+ if (band.nodes.size === 0) return; // EARLY-OUT: no edits in this band. (Guard
342
+ // on nodes.size, NOT maxDepth===0 -- a DEPTH-0 base-cell edit leaves maxDepth at 0 yet must
343
+ // still apply; the old maxDepth guard silently dropped every depth-0 terraform edit. The
344
+ // loop below runs depth 0..maxDepth, so maxDepth 0 correctly does one iteration at the root.)
345
+ const bn = band.bn;
346
+ let btx = (fu * bn) | 0, bty = (fv * bn) | 0;
347
+ if (btx < 0) btx = 0; else if (btx >= bn) btx = bn - 1;
348
+ if (bty < 0) bty = 0; else if (bty >= bn) bty = bn - 1;
349
+ let key = rootKey(bandIdx, face, btx, bty);
350
+ let lu = fu * bn - btx, lv = fv * bn - bty; // local coords in [0,1] within base cell
351
+ for (let depth = 0; depth <= band.maxDepth; depth++) {
352
+ const node = band.nodes.get(key);
353
+ if (node) {
354
+ const w = hat((lu - 0.5) * 2.0) * hat((lv - 0.5) * 2.0);
355
+ if (w !== 0) for (let i = 0; i < K; i++) out[i] += node[i] * w;
356
+ }
357
+ const cx = lu >= 0.5 ? 1 : 0, cy = lv >= 0.5 ? 1 : 0;
358
+ const ck = childKey(key, (cy << 1) | cx);
359
+ // descend if the child is an ANCESTOR of some edit (in cover) OR is itself an edited
360
+ // leaf (in nodes). cover holds only ancestors, so the deepest edited node is reached
361
+ // only by also checking nodes -- else the descent breaks one level above the leaf.
362
+ if (!band.cover.has(ck) && !band.nodes.has(ck)) break;
363
+ key = ck; lu = lu * 2 - cx; lv = lv * 2 - cy;
364
+ }
365
+ }
366
+
367
+ // reused flat scratch for the hot sample path (no allocation per sample).
368
+ const _scratch = {}; for (const k of PARAM_KEYS) _scratch[k] = 0;
369
+ const _band = new Float32Array(K), _acc = new Float32Array(K);
370
+
371
+ // LOCAL-SUPPORT bilinear sample of one band's BASE at face uv, then the additive overlay.
372
+ // The base body is the pre-AQT fixed-level cell bilinear, byte-for-byte (regression firewall).
373
+ function sampleBand(out, bandIdx, face, fu, fv) {
374
+ // SEAM FIX (2026-06-05): the base used a per-face GRID bilinear (4 baseParams cells at clamped
375
+ // integer indices). Even after baseParams became a pure world-dir function, the grid CELL CENTRES
376
+ // do not align across a shared cube-face edge, so the clamped edge-cell bilinear reintroduced a
377
+ // ~2km step (the residual shelf). Since baseParams is now continuous in world dir, evaluate it
378
+ // DIRECTLY at the sample's exact (face,fu,fv) world dir -- no grid quantization, no edge step =
379
+ // fully seamless. (The additive edit OVERLAY still uses the band quadtree below; edits are local
380
+ // deltas and seam-irrelevant.) baseParamsAt takes continuous face coords (fu,fv) in [0,1].
381
+ const p = baseParamsAt(bandIdx, face, fu, fv);
382
+ for (let i = 0; i < K; i++) out[i] += p[PARAM_KEYS[i]];
383
+ addOverlay(out, bandIdx, face, fu, fv); // additive edit detail (0 if no edits)
384
+ }
385
+
386
+ // PUBLIC: sample the full hierarchical field at face uv in [0,1]^2. Sums every band, each
387
+ // at its own scale. Compose: additive seaBias/temp/humidity/erosion, MULTIPLICATIVE elevAmp,
388
+ // MAX roughness (finest-band-wins) -- identical semantics to the pre-AQT field. Alloc-free
389
+ // (flat Float32Array scratch, integer-indexed).
390
+ const _iAmp = PIDX.elevAmp, _iRgh = PIDX.roughness, _iSea = PIDX.seaBias;
391
+ // maxBandLevel (optional): skip bands FINER than this level. The baked HPF texture is HPF_RES/face;
392
+ // a band at level L resolves 2^L cells/face, so bands with level > log2(HPF_RES) are SUB-TEXEL in the
393
+ // bake (their detail aliases to noise) and sampling them per-texel is wasted cost. Passing
394
+ // maxBandLevel = log2(HPF_RES) skips them in the bake -> big speedup (the finest ~1.5M-anchor local
395
+ // band dominates cost) with negligible change to the baked channels (local band touches only elevAmp
396
+ // by <=1.1x, CLI-measured median 1.03). Default (undefined) samples ALL bands (full-fidelity path).
397
+ function sampleUV(face, fu, fv, maxBandLevel) {
398
+ for (let i = 0; i < K; i++) _acc[i] = 0;
399
+ let amp = 1.0, rough = 0.0;
400
+ for (let b = 0; b < bands.length; b++) {
401
+ if (maxBandLevel !== undefined && BANDS[b].level > maxBandLevel) continue;
402
+ for (let i = 0; i < K; i++) _band[i] = 0; _band[_iAmp] = 1.0;
403
+ sampleBand(_band, b, face, fu, fv);
404
+ // apply per-band runtime scales (full-adjustability; 1.0 = identity).
405
+ const bs = bandScales[b];
406
+ _band[_iSea] *= bs.seaBiasScale;
407
+ _band[_iAmp] = 1.0 + (_band[_iAmp] - 1.0) * bs.elevAmpScale; // scale deviation from 1
408
+ _band[_iRgh] *= bs.roughnessScale;
409
+ for (let i = 0; i < K; i++) if (i !== _iAmp && i !== _iRgh) _acc[i] += _band[i];
410
+ amp *= _band[_iAmp];
411
+ rough = Math.max(rough, _band[_iRgh]);
412
+ }
413
+ _acc[_iAmp] = amp; _acc[_iRgh] = rough;
414
+ for (let i = 0; i < K; i++) _scratch[PARAM_KEYS[i]] = _acc[i];
415
+ return _scratch;
416
+ }
417
+
418
+ // PER-TILE sample for the orchestrator: a Proland tile (face, level, tx, ty) covers a uv
419
+ // square on its face; sample the field at the tile CENTRE (the bias is per-tile, and the
420
+ // field is C0 across tiles because every tile samples the same global band quadtrees).
421
+ function sampleTile(face, level, tx, ty) {
422
+ const n = 1 << level;
423
+ const fu = (tx + 0.5) / n, fv = (ty + 0.5) / n;
424
+ return sampleUV(face, fu, fv);
425
+ }
426
+
427
+ // world-direction (unit vector) -> face + uv, for sampleDir / biomeAt(worldDir).
428
+ function dirToFaceUV(d) {
429
+ const ax = Math.abs(d[0]), ay = Math.abs(d[1]), az = Math.abs(d[2]);
430
+ let face, sc, fu, fv;
431
+ if (ax >= ay && ax >= az) { face = d[0] > 0 ? 0 : 1; sc = 1 / ax; }
432
+ else if (ay >= az) { face = d[1] > 0 ? 2 : 3; sc = 1 / ay; }
433
+ else { face = d[2] > 0 ? 4 : 5; sc = 1 / az; }
434
+ const F = FACE_FRAME[face];
435
+ // project d onto the face's u,v axes (face plane is the unit-cube face), -> [-1,1] -> [0,1]
436
+ const u = (d[0]*F.u[0] + d[1]*F.u[1] + d[2]*F.u[2]) * sc;
437
+ const v = (d[0]*F.v[0] + d[1]*F.v[1] + d[2]*F.v[2]) * sc;
438
+ fu = u * 0.5 + 0.5; fv = v * 0.5 + 0.5;
439
+ return { face, fu, fv };
440
+ }
441
+ function sampleDir(d) { const { face, fu, fv } = dirToFaceUV(d); return sampleUV(face, fu, fv); }
442
+
443
+ // ---- EDIT API (terraform), ADAPTIVE DEPTH. Edits a node's param DELTAS at ANY depth below
444
+ // the band base level. depth=0 -> the base cell (btx,bty); depth>0 -> the sub-cell (sx,sy)
445
+ // in [0,2^depth) within that base cell. Auto-creates the node + registers its ancestor
446
+ // chain in `cover` (refcounted) so the descent walk reaches it. Local support holds at
447
+ // every depth (additive hat). Returns the node's path key.
448
+ function editNode(bandIdx, face, btx, bty, deltas, depth = 0, sx = 0, sy = 0) {
449
+ const band = bands[bandIdx];
450
+ let key = rootKey(bandIdx, face, btx, bty);
451
+ for (let dd = 0; dd < depth; dd++) {
452
+ const sh = depth - 1 - dd, cx = (sx >> sh) & 1, cy = (sy >> sh) & 1;
453
+ key = childKey(key, (cy << 1) | cx);
454
+ }
455
+ let v = band.nodes.get(key);
456
+ const fresh = !v;
457
+ if (!v) { v = new Float32Array(K); band.nodes.set(key, v); }
458
+ for (const k in deltas) { const i = PIDX[k]; if (i !== undefined) v[i] += deltas[k]; }
459
+ if (fresh) {
460
+ const root = rootKey(bandIdx, face, btx, bty);
461
+ let pk = key;
462
+ while (pk !== root) { pk = parentKey(pk); band.cover.set(pk, (band.cover.get(pk) || 0) + 1); }
463
+ if (depth > band.maxDepth) band.maxDepth = depth;
464
+ }
465
+ return key;
466
+ }
467
+ // edit the node CONTAINING a world direction at a chosen depth (click-to-terraform).
468
+ function editAtDir(bandIdx, d, deltas, depth = 0) {
469
+ const { face, fu, fv } = dirToFaceUV(d);
470
+ const bn = bands[bandIdx].bn;
471
+ let btx = Math.min(bn - 1, Math.max(0, Math.floor(fu * bn)));
472
+ let bty = Math.min(bn - 1, Math.max(0, Math.floor(fv * bn)));
473
+ // sub-cell index within the base cell at `depth`.
474
+ const sub = 1 << depth;
475
+ const lu = fu * bn - btx, lv = fv * bn - bty;
476
+ const sx = Math.min(sub - 1, Math.max(0, Math.floor(lu * sub)));
477
+ const sy = Math.min(sub - 1, Math.max(0, Math.floor(lv * sub)));
478
+ return editNode(bandIdx, face, btx, bty, deltas, depth, sx, sy);
479
+ }
480
+
481
+ // rebuild a band's cover + maxDepth from a flat [key, deltaArray] node list (used by load).
482
+ // depth of a node is derived by walking parents until the key falls in a root range.
483
+ function rebuildBand(bandIdx, entries) {
484
+ const band = bands[bandIdx];
485
+ band.nodes = new Map(); band.cover = new Map(); band.maxDepth = 0;
486
+ const bn = band.bn, rootLo = FACE_OFF * bn * bn, rootHi = (FACE_OFF + 6) * bn * bn;
487
+ const isRoot = (k) => k >= rootLo && k < rootHi;
488
+ for (const [k, arr] of entries) {
489
+ band.nodes.set(k, arr instanceof Float32Array ? arr : Float32Array.from(arr));
490
+ // walk ancestors, counting depth, registering cover.
491
+ let pk = k, depth = 0;
492
+ while (!isRoot(pk)) { pk = parentKey(pk); band.cover.set(pk, (band.cover.get(pk) || 0) + 1); depth++; }
493
+ if (depth > band.maxDepth) band.maxDepth = depth;
494
+ }
495
+ }
496
+
497
+ // ---- PERSISTENCE (v2): serialise ONLY the sparse edit nodes (procedural base is
498
+ // deterministic from seed). Compact, reload-safe, offline-authorable. v1 shim maps old
499
+ // fixed-level addr edits to depth-0 nodes (terraform saves survive).
500
+ function serialize() {
501
+ return JSON.stringify({
502
+ seed, version: 2,
503
+ bands: bands.map((b) => ({ baseLevel: b.baseLevel,
504
+ nodes: Array.from(b.nodes, ([k, arr]) => [k, Array.from(arr)]) })),
505
+ });
506
+ }
507
+ function load(json) {
508
+ const o = typeof json === 'string' ? JSON.parse(json) : json;
509
+ if (!o) return false;
510
+ if (o.version === 2) {
511
+ for (let b = 0; b < bands.length && b < o.bands.length; b++) rebuildBand(b, o.bands[b].nodes);
512
+ return true;
513
+ }
514
+ if (o.version === 1) { // SHIM: old fixed-level addr -> depth-0 node
515
+ for (let b = 0; b < bands.length && b < (o.edits || []).length; b++) {
516
+ const bn = bands[b].bn;
517
+ for (const [addr, delta] of o.edits[b]) {
518
+ const face = (addr / (bn * bn)) | 0, rem = addr % (bn * bn);
519
+ const bty = (rem / bn) | 0, btx = rem % bn;
520
+ editNode(b, face, btx, bty, delta, 0);
521
+ }
522
+ }
523
+ return true;
524
+ }
525
+ return false;
526
+ }
527
+
528
+ // effective params of a base-level node = base + (depth-0 edit overlay at cell centre).
529
+ // Kept for introspection (__diag); the hot path uses sampleUV.
530
+ function nodeParams(bandIdx, face, tx, ty) {
531
+ const p = baseParams(bandIdx, face, tx, ty);
532
+ const node = bands[bandIdx].nodes.get(rootKey(bandIdx, face, tx, ty));
533
+ if (node) for (let i = 0; i < K; i++) p[PARAM_KEYS[i]] += node[i];
534
+ return p;
535
+ }
536
+
537
+ // ---- introspection for __diag.hpf
538
+ function bandsInfo() {
539
+ return BANDS.map((B, i) => ({
540
+ band: i, name: B.name, level: B.level,
541
+ anchorsPerFace: (1 << B.level) * (1 << B.level),
542
+ totalAnchors: 6 * (1 << B.level) * (1 << B.level),
543
+ editedNodes: bands[i].nodes.size, maxDepth: bands[i].maxDepth,
544
+ }));
545
+ }
546
+
547
+ return {
548
+ BANDS, PARAM_KEYS,
549
+ sampleUV, sampleTile, sampleDir,
550
+ editNode, editAtDir,
551
+ nodeParams, baseParams, rootKey, dirToFaceUV,
552
+ serialize, load, bandsInfo,
553
+ // RUNTIME per-band scale setter (full-adjustability; consumed in sampleUV). i = band index.
554
+ setBandScales(i, s){ if(i>=0 && i<bandScales.length && s){ const b=bandScales[i];
555
+ if(s.seaBiasScale!=null)b.seaBiasScale=+s.seaBiasScale; if(s.elevAmpScale!=null)b.elevAmpScale=+s.elevAmpScale; if(s.roughnessScale!=null)b.roughnessScale=+s.roughnessScale; } return bandScales[i]; },
556
+ getBandScales(i){ return bandScales[i]; },
557
+ get totalEdits() { return bands.reduce((s, b) => s + b.nodes.size, 0); },
558
+ };
559
+ }