hoi4-agent-tools 4.3.0 → 4.4.0

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
  3. package/dist/hoi4_agent_tools/core/configuration.js +11 -0
  4. package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
  5. package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
  6. package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
  7. package/dist/hoi4_agent_tools/core/transactions.js +23 -4
  8. package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
  9. package/dist/hoi4_agent_tools/core/workspace.js +42 -6
  10. package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
  11. package/dist/hoi4_agent_tools/map/bmp.js +9 -1
  12. package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
  13. package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
  14. package/dist/hoi4_agent_tools/map/colormap.js +128 -0
  15. package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
  16. package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
  17. package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
  18. package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
  19. package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
  20. package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
  21. package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
  22. package/dist/hoi4_agent_tools/map/model.js +15 -2
  23. package/dist/hoi4_agent_tools/map/operations.js +111 -8
  24. package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
  25. package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
  26. package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
  27. package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
  28. package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
  29. package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
  30. package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
  31. package/dist/hoi4_agent_tools/map/partition.js +163 -0
  32. package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
  33. package/dist/hoi4_agent_tools/map/reference-preparation.js +138 -5
  34. package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
  35. package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
  36. package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
  37. package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
  38. package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
  39. package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
  40. package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
  41. package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
  42. package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
  43. package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
  44. package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
  45. package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
  46. package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
  47. package/dist/hoi4_agent_tools/map/render.js +36 -0
  48. package/dist/hoi4_agent_tools/map/service.js +191 -16
  49. package/dist/hoi4_agent_tools/map/tool-operations.js +208 -2
  50. package/dist/hoi4_agent_tools/map/validation.js +9 -0
  51. package/dist/hoi4_agent_tools/map/water.js +227 -0
  52. package/dist/hoi4_agent_tools/map/world-context.js +24 -1
  53. package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
  54. package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
  55. package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
  56. package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
  57. package/dist/hoi4_agent_tools/map/world.js +462 -124
  58. package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
  59. package/dist/hoi4_agent_tools/reference/operations.js +3 -2
  60. package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
  61. package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
  62. package/dist/hoi4_agent_tools/schemas/map.js +209 -2
  63. package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
  64. package/dist/hoi4_agent_tools/version.js +1 -1
  65. package/docs/configuration.md +17 -14
  66. package/docs/map.md +258 -15
  67. package/docs/tools.md +4 -4
  68. package/docs/troubleshooting.md +11 -11
  69. package/npm-shrinkwrap.json +109 -91
  70. package/package.json +3 -3
  71. package/schemas/configuration.schema.json +65 -58
  72. package/schemas/continuous-focus-palette.schema.json +1 -1
  73. package/schemas/custom-weighted-pool.schema.json +1 -1
  74. package/schemas/decision-inspect-input.schema.json +1 -1
  75. package/schemas/error-log-input.schema.json +1 -1
  76. package/schemas/event-feature-manifest.schema.json +1 -1
  77. package/schemas/focus-plan.schema.json +1 -1
  78. package/schemas/focus-planning-sidecar.schema.json +1 -1
  79. package/schemas/geography-model.schema.json +1148 -0
  80. package/schemas/gui-animation-source.schema.json +1 -1
  81. package/schemas/gui-helper.schema.json +5 -11
  82. package/schemas/gui-scenario.schema.json +1 -1
  83. package/schemas/helper-expansion-request.schema.json +1 -1
  84. package/schemas/helper-expansion-summary.schema.json +1 -1
  85. package/schemas/impact-inspect-input.schema.json +1 -1
  86. package/schemas/map-operation.schema.json +2156 -259
  87. package/schemas/mechanic-test-input.schema.json +1 -1
  88. package/schemas/mod-index-input.schema.json +1 -1
  89. package/schemas/operation-result.schema.json +1 -1
  90. package/schemas/package-check-input.schema.json +1 -1
  91. package/schemas/package-manifest.schema.json +1 -1
  92. package/schemas/probability-analysis-result.schema.json +25 -7
  93. package/schemas/probability-compare-input.schema.json +1 -1
  94. package/schemas/probability-evaluate-input.schema.json +1 -1
  95. package/schemas/probability-inspect-input.schema.json +1 -1
  96. package/schemas/probability-render-input.schema.json +1 -1
  97. package/schemas/probability-scenario-set.schema.json +1 -1
  98. package/schemas/probability-sequence-input.schema.json +1 -1
  99. package/schemas/probability-simulate-input.schema.json +1 -1
  100. package/schemas/probability-sweep-input.schema.json +1 -1
  101. package/schemas/reference-context-input.schema.json +1 -1
  102. package/schemas/reference-read-input.schema.json +1 -1
  103. package/schemas/reference-search-input.schema.json +1 -1
  104. package/schemas/reference-sheets.schema.json +267 -0
  105. package/schemas/scenario-suite.schema.json +1 -1
  106. package/schemas/scenario-test-input.schema.json +1 -1
  107. package/schemas/script-validate-input.schema.json +1 -1
  108. package/schemas/shared-scenario.schema.json +1 -1
  109. package/schemas/source-lookup-input.schema.json +1 -1
  110. package/server.json +2 -2
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Self-checks of the synthesised terrain (ADR 0056). They read only the finished rasters and the
3
+ * resolved model, so they would also catch a regression in the generator. Findings are notes with
4
+ * `MAP_RELIEF_*`, `MAP_RANGE_*`, `MAP_PASS_*`, `MAP_VOLCANO_*` and `MAP_RIVER_*` codes.
5
+ */
6
+ import { sampleAlong } from '../geography/index.js';
7
+ import { LAND } from './grid.js';
8
+ import { CREST_WANDER_SHARE } from './macro-relief.js';
9
+ /** Highest quantised height within `radius` pixels of a point; -1 when the point is not land. */
10
+ function maxAround(raster, cx, cy, radius) {
11
+ const { width, height, kind, heights } = raster;
12
+ const px = Math.min(width - 1, Math.max(0, Math.floor(cx)));
13
+ const py = Math.min(height - 1, Math.max(0, Math.floor(cy)));
14
+ if (kind[py * width + px] !== LAND)
15
+ return -1;
16
+ let best = 0;
17
+ const r = Math.ceil(radius);
18
+ for (let dy = -r; dy <= r; dy += 1) {
19
+ const y = py + dy;
20
+ if (y < 0 || y >= height)
21
+ continue;
22
+ for (let dx = -r; dx <= r; dx += 1) {
23
+ const x = px + dx;
24
+ if (x < 0 || x >= width || dx * dx + dy * dy > radius * radius)
25
+ continue;
26
+ const index = y * width + x;
27
+ if (kind[index] !== LAND)
28
+ continue;
29
+ if (heights[index] > best)
30
+ best = heights[index];
31
+ }
32
+ }
33
+ return best;
34
+ }
35
+ /** True where a pass saddle lies within `margin` of arc fraction `t`. */
36
+ function inPassWindow(table, t, margin) {
37
+ return table.passes.some((pass) => Math.abs(t - pass.at) * table.length <= pass.halfLength * margin);
38
+ }
39
+ /**
40
+ * Crest, pass and volcano checks against the quantised heights. `MAP_PASS_NOT_LOWER` is an error:
41
+ * a gate that is not a gap would block a route the model declares.
42
+ */
43
+ export function checkRangesAndFeatures(geography, tables, raster, peakOf, volcanoPeak) {
44
+ const notes = [];
45
+ const frame = { width: raster.width, height: raster.height };
46
+ for (const range of geography.ranges) {
47
+ const table = tables.find((candidate) => candidate.id === range.id);
48
+ if (table === undefined)
49
+ continue;
50
+ const samples = Math.max(8, Math.min(64, Math.floor(table.count / 8)));
51
+ let measured = 0;
52
+ let low = 0;
53
+ let worst = 1;
54
+ let worstAt = 0;
55
+ for (let k = 0; k < samples; k += 1) {
56
+ const t = 0.04 + (0.92 * k) / (samples - 1);
57
+ if (inPassWindow(table, t, 1.5))
58
+ continue;
59
+ const sample = sampleAlong(range.path, geography.frame, t);
60
+ const radius = Math.max(3, CREST_WANDER_SHARE * 1.2 * sample.halfWidth * raster.width);
61
+ const found = maxAround(raster, sample.x * raster.width, sample.y * raster.height, radius);
62
+ if (found < 0)
63
+ continue;
64
+ const target = peakOf(sample.peak);
65
+ measured += 1;
66
+ const ratio = found / target;
67
+ if (ratio < 0.9)
68
+ low += 1;
69
+ if (ratio < worst) {
70
+ worst = ratio;
71
+ worstAt = t;
72
+ }
73
+ }
74
+ if (measured >= 4 && low / measured > 0.2)
75
+ notes.push({
76
+ code: 'MAP_RANGE_CREST_LOW',
77
+ severity: 'warning',
78
+ message: `Range ${range.id} reaches less than 90% of its requested crest height along ${low} of ${measured} sampled points (worst ${Math.round(worst * 100)}% at ${Math.round(worstAt * 100)}% of its length); a plateau, lake or the coast may overlap it`,
79
+ details: {
80
+ rangeId: range.id,
81
+ sampled: measured,
82
+ low,
83
+ worstRatio: Math.round(worst * 100) / 100,
84
+ },
85
+ });
86
+ }
87
+ for (const pass of geography.passes) {
88
+ const range = geography.get('range', pass.rangeId);
89
+ const table = tables.find((candidate) => candidate.id === pass.rangeId);
90
+ if (range === undefined || table === undefined || pass.depth < 0.15)
91
+ continue;
92
+ const entry = table.passes.find((candidate) => candidate.id === pass.id);
93
+ if (entry === undefined)
94
+ continue;
95
+ const reach = (entry.halfLength * 1.5) / table.length;
96
+ const at = pass.at;
97
+ const probe = (t) => {
98
+ const sample = sampleAlong(range.path, frame, t);
99
+ const radius = Math.max(3, CREST_WANDER_SHARE * 1.2 * sample.halfWidth * raster.width);
100
+ return maxAround(raster, sample.x * raster.width, sample.y * raster.height, radius);
101
+ };
102
+ if (at - reach < 0 || at + reach > 1)
103
+ continue;
104
+ const center = probe(at);
105
+ const before = probe(at - reach);
106
+ const after = probe(at + reach);
107
+ if (center < 0 || before < 0 || after < 0)
108
+ continue;
109
+ const kmid = Math.min(table.count - 1, Math.round(at * (table.count - 1)));
110
+ const expected = Math.max(0, table.crest[kmid] - table.base[kmid]) * pass.depth;
111
+ if (center >= Math.min(before, after) - 0.25 * expected)
112
+ notes.push({
113
+ code: 'MAP_PASS_NOT_LOWER',
114
+ severity: 'error',
115
+ message: `Pass ${pass.id} through range ${pass.rangeId} is not lower than the crest either side of it (${center} against ${before} and ${after})`,
116
+ details: {
117
+ passId: pass.id,
118
+ rangeId: pass.rangeId,
119
+ passHeight: center,
120
+ flanks: [before, after],
121
+ },
122
+ });
123
+ }
124
+ for (const volcano of geography.volcanoes) {
125
+ const cx = volcano.at.x * raster.width;
126
+ const cy = volcano.at.y * raster.height;
127
+ const centre = raster.heights[Math.floor(cy) * raster.width + Math.floor(cx)];
128
+ const crater = volcano.crater * raster.width;
129
+ let ring = 0;
130
+ const r = Math.ceil(crater * 1.6);
131
+ for (let dy = -r; dy <= r; dy += 1)
132
+ for (let dx = -r; dx <= r; dx += 1) {
133
+ const d = Math.sqrt(dx * dx + dy * dy);
134
+ if (d < crater * 0.8 || d > crater * 1.6)
135
+ continue;
136
+ const x = Math.floor(cx) + dx;
137
+ const y = Math.floor(cy) + dy;
138
+ if (x < 0 || y < 0 || x >= raster.width || y >= raster.height)
139
+ continue;
140
+ const value = raster.heights[y * raster.width + x];
141
+ if (value > ring)
142
+ ring = value;
143
+ }
144
+ void volcanoPeak;
145
+ if (raster.kind[Math.floor(cy) * raster.width + Math.floor(cx)] === LAND && ring - centre < 3)
146
+ notes.push({
147
+ code: 'MAP_VOLCANO_NO_CRATER',
148
+ severity: 'warning',
149
+ message: `Volcano ${volcano.id} has no crater at its centre (${centre} against a rim of ${ring}); widen its crater or its radius`,
150
+ details: { volcanoId: volcano.id, centre, rim: ring },
151
+ });
152
+ }
153
+ return notes;
154
+ }
155
+ /** Percentiles and the mean central step of the land heights. */
156
+ export function reliefStatistics(raster) {
157
+ const { width, height, kind, heights } = raster;
158
+ const histogram = new Float64Array(256);
159
+ let landPixels = 0;
160
+ let maximum = 0;
161
+ let stepSum = 0;
162
+ let stepCount = 0;
163
+ for (let y = 0; y < height; y += 1)
164
+ for (let x = 0; x < width; x += 1) {
165
+ const index = y * width + x;
166
+ if (kind[index] !== LAND)
167
+ continue;
168
+ landPixels += 1;
169
+ const value = heights[index];
170
+ histogram[value] = histogram[value] + 1;
171
+ if (value > maximum)
172
+ maximum = value;
173
+ // Central height step of the half-resolution map: four full-resolution pixels apart.
174
+ if ((x & 1) === 0 && (y & 1) === 0 && x >= 2 && x < width - 2 && y >= 2 && y < height - 2) {
175
+ const gx = heights[index + 2] - heights[index - 2];
176
+ const gy = heights[index + 2 * width] - heights[index - 2 * width];
177
+ stepSum += (Math.abs(gx) + Math.abs(gy)) / 2;
178
+ stepCount += 1;
179
+ }
180
+ }
181
+ const percentile = (share) => {
182
+ const target = landPixels * share;
183
+ let seen = 0;
184
+ for (let value = 0; value < 256; value += 1) {
185
+ seen += histogram[value];
186
+ if (seen >= target)
187
+ return value;
188
+ }
189
+ return 255;
190
+ };
191
+ return {
192
+ landPixels,
193
+ median: landPixels === 0 ? 0 : percentile(0.5),
194
+ p99: landPixels === 0 ? 0 : percentile(0.99),
195
+ maximum,
196
+ meanStep: stepCount === 0 ? 0 : stepSum / stepCount,
197
+ };
198
+ }
199
+ /**
200
+ * Share of river pixels whose neighbourhood of 20 pixels either way along the river lies within
201
+ * `tolerance` pixels of one straight line: the measure of rivers that run as if drawn with a ruler.
202
+ * The base game measures 1.1% (staircase diagonals count as straight, as they look it). Only pixels
203
+ * with exactly two river neighbours are walked, so confluences and mouths are never counted.
204
+ */
205
+ export function straightRiverShare(bitmap, width, height, tolerance = 1.5, half = 20) {
206
+ const isRiver = (x, y) => bitmap[y * width + x] <= 11;
207
+ const neighbours = (x, y, out) => {
208
+ let count = 0;
209
+ if (x + 1 < width && isRiver(x + 1, y))
210
+ out[count++] = y * width + x + 1;
211
+ if (x > 0 && isRiver(x - 1, y))
212
+ out[count++] = y * width + x - 1;
213
+ if (y + 1 < height && isRiver(x, y + 1))
214
+ out[count++] = (y + 1) * width + x;
215
+ if (y > 0 && isRiver(x, y - 1))
216
+ out[count++] = (y - 1) * width + x;
217
+ return count;
218
+ };
219
+ let river = 0;
220
+ let straight = 0;
221
+ const around = [0, 0, 0, 0];
222
+ const ahead = [0, 0, 0, 0];
223
+ const points = new Int32Array(2 * half + 1);
224
+ for (let y = 0; y < height; y += 1)
225
+ for (let x = 0; x < width; x += 1) {
226
+ if (!isRiver(x, y))
227
+ continue;
228
+ river += 1;
229
+ if (neighbours(x, y, around) !== 2)
230
+ continue;
231
+ points[half] = y * width + x;
232
+ let complete = true;
233
+ for (let side = 0; side < 2 && complete; side += 1) {
234
+ let previous = y * width + x;
235
+ let current = around[side];
236
+ for (let step = 1; step <= half; step += 1) {
237
+ points[side === 0 ? half - step : half + step] = current;
238
+ if (step === half)
239
+ break;
240
+ const cx = current % width;
241
+ const cy = (current - cx) / width;
242
+ const found = neighbours(cx, cy, ahead);
243
+ let next = -1;
244
+ let others = 0;
245
+ for (let k = 0; k < found; k += 1)
246
+ if (ahead[k] !== previous) {
247
+ next = ahead[k];
248
+ others += 1;
249
+ }
250
+ if (others !== 1) {
251
+ complete = false;
252
+ break;
253
+ }
254
+ previous = current;
255
+ current = next;
256
+ }
257
+ }
258
+ if (!complete)
259
+ continue;
260
+ const a = points[0];
261
+ const b = points[2 * half];
262
+ const ax = a % width;
263
+ const ay = (a - ax) / width;
264
+ const dx = (b % width) - ax;
265
+ const dy = Math.floor(b / width) - ay;
266
+ const length = Math.sqrt(dx * dx + dy * dy);
267
+ if (length < half * 1.2)
268
+ continue;
269
+ let line = true;
270
+ for (let k = 1; k < 2 * half && line; k += 1) {
271
+ const p = points[k];
272
+ const px = p % width;
273
+ const py = (p - px) / width;
274
+ if (Math.abs((px - ax) * dy - (py - ay) * dx) / length > tolerance)
275
+ line = false;
276
+ }
277
+ if (line)
278
+ straight += 1;
279
+ }
280
+ return { river, straight };
281
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The typed interface between terrain synthesis and the stages that read its fields: the
3
+ * feature-aware partition (ADR 0058), the border-naturalness metric (ADR 0059), climate and biome and the
4
+ * composite renders. Every field is a plain typed array with its grid size, so a consumer needs
5
+ * no knowledge of how it was made (ADR 0056).
6
+ */
7
+ /** What makes a pixel a barrier; the flags combine in `BarrierField.sources`. */
8
+ export const BARRIER_RIVER = 1;
9
+ export const BARRIER_CREST = 2;
10
+ export const BARRIER_STEEP = 4;
11
+ export const BARRIER_LAKE_SHORE = 8;
12
+ /** A range declared `barrier: 'impassable'`, outside its pass gaps. */
13
+ export const BARRIER_WALL = 16;
14
+ /** A range declared `barrier: 'fort-line'`, outside its pass gaps. */
15
+ export const BARRIER_FORT_LINE = 32;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Full-resolution height field (ADR 0056): the macro relief upsampled bilinearly with the macro
3
+ * kind as a mask, plus fractal and ridged detail weighted by the mountain footprint, plus the coast
4
+ * ramp. Heights stay floating point until the very end, so shading never terraces.
5
+ */
6
+ import { rowStrips } from '../geography/index.js';
7
+ import { LAND } from './grid.js';
8
+ import { LAND_FLOOR } from './macro-relief.js';
9
+ import { clamp, fractal2, lerp, ridged2, smoothstep } from './math.js';
10
+ /** Length of the coast ramp, in pixels: land stands at the floor at the shore and free of it from here. */
11
+ export const COAST_RAMP_PIXELS = 6;
12
+ /**
13
+ * Four-neighbour distance of land pixels from the nearest water pixel: 1 for land next to water,
14
+ * up to `limit`; 0 for water and for land farther than `limit`.
15
+ */
16
+ export function landEdgeDistance(kind, width, height, limit) {
17
+ const total = width * height;
18
+ const distance = new Uint8Array(total);
19
+ const queue = new Int32Array(total);
20
+ let head = 0;
21
+ let tail = 0;
22
+ for (let pixel = 0; pixel < total; pixel += 1) {
23
+ if (kind[pixel] !== LAND)
24
+ continue;
25
+ const x = pixel % width;
26
+ const touchesWater = (x > 0 && kind[pixel - 1] !== LAND) ||
27
+ (x < width - 1 && kind[pixel + 1] !== LAND) ||
28
+ (pixel >= width && kind[pixel - width] !== LAND) ||
29
+ (pixel < total - width && kind[pixel + width] !== LAND);
30
+ if (touchesWater) {
31
+ distance[pixel] = 1;
32
+ queue[tail++] = pixel;
33
+ }
34
+ }
35
+ while (head < tail) {
36
+ const pixel = queue[head++];
37
+ const next = distance[pixel] + 1;
38
+ if (next > limit)
39
+ continue;
40
+ const x = pixel % width;
41
+ const visit = (other) => {
42
+ if (kind[other] === LAND && distance[other] === 0) {
43
+ distance[other] = next;
44
+ queue[tail++] = other;
45
+ }
46
+ };
47
+ if (x > 0)
48
+ visit(pixel - 1);
49
+ if (x < width - 1)
50
+ visit(pixel + 1);
51
+ if (pixel >= width)
52
+ visit(pixel - width);
53
+ if (pixel < total - width)
54
+ visit(pixel + width);
55
+ }
56
+ return distance;
57
+ }
58
+ /**
59
+ * Float heights of land at full resolution, in map values. Water pixels hold 0: their heights are
60
+ * a function of the distance to land and are written when the field is quantised.
61
+ */
62
+ export function buildFullResolutionHeights(input) {
63
+ const { macro, kind, width, height, seed } = input;
64
+ const mw = macro.width;
65
+ const mh = macro.height;
66
+ const out = new Float32Array(width * height);
67
+ const edge = landEdgeDistance(kind, width, height, COAST_RAMP_PIXELS);
68
+ const wavelength = Math.max(12, 0.0046 * width);
69
+ const broad = Math.max(24, 0.0125 * width);
70
+ const detailAmplitude = 0.4 + 1.2 * input.detail;
71
+ const heights = macro.heights;
72
+ const macroKind = macro.kind;
73
+ for (const strip of rowStrips({ width, height }, 64)) {
74
+ input.signal?.throwIfAborted();
75
+ for (let y = strip.y0; y < strip.y1; y += 1) {
76
+ const v = (y - 0.5) / 2;
77
+ const vy0 = clamp(Math.floor(v), 0, mh - 1);
78
+ const vy1 = vy0 + 1 < mh ? vy0 + 1 : vy0;
79
+ const ty = clamp(v - vy0, 0, 1);
80
+ for (let x = 0; x < width; x += 1) {
81
+ const pixel = y * width + x;
82
+ if (kind[pixel] !== LAND)
83
+ continue;
84
+ const u = (x - 0.5) / 2;
85
+ const ux0 = clamp(Math.floor(u), 0, mw - 1);
86
+ const ux1 = ux0 + 1 < mw ? ux0 + 1 : ux0;
87
+ const tx = clamp(u - ux0, 0, 1);
88
+ // Masked bilinear: only land cells contribute, so the coast does not sag to zero.
89
+ const i00 = vy0 * mw + ux0;
90
+ const i10 = vy0 * mw + ux1;
91
+ const i01 = vy1 * mw + ux0;
92
+ const i11 = vy1 * mw + ux1;
93
+ const w00 = macroKind[i00] === LAND ? (1 - tx) * (1 - ty) : 0;
94
+ const w10 = macroKind[i10] === LAND ? tx * (1 - ty) : 0;
95
+ const w01 = macroKind[i01] === LAND ? (1 - tx) * ty : 0;
96
+ const w11 = macroKind[i11] === LAND ? tx * ty : 0;
97
+ const total = w00 + w10 + w01 + w11;
98
+ let level;
99
+ let footprint;
100
+ if (total > 1e-6) {
101
+ level =
102
+ (heights[i00] * w00 +
103
+ heights[i10] * w10 +
104
+ heights[i01] * w01 +
105
+ heights[i11] * w11) /
106
+ total;
107
+ footprint =
108
+ (macro.footprint[i00] * w00 +
109
+ macro.footprint[i10] * w10 +
110
+ macro.footprint[i01] * w01 +
111
+ macro.footprint[i11] * w11) /
112
+ total;
113
+ }
114
+ else {
115
+ level = LAND_FLOOR + 3;
116
+ footprint = 0;
117
+ }
118
+ // Detail: ridged crests and gullies in the mountains, quiet roughness on the plains.
119
+ const warpX = (fractal2(seed + 211, x, y, broad, 2) - 0.5) * 2 * wavelength * 0.9;
120
+ const warpY = (fractal2(seed + 223, x, y, broad, 2) - 0.5) * 2 * wavelength * 0.9;
121
+ const ridge = ridged2(seed + 101, x + warpX, y + warpY, wavelength, 3);
122
+ const rolling = fractal2(seed + 131, x, y, broad, 3) - 0.5;
123
+ const amplitude = detailAmplitude * (0.5 + 5.5 * footprint);
124
+ let h = level + (ridge - 0.45) * 2 * amplitude + rolling * 2 * detailAmplitude * 0.6;
125
+ const near = edge[pixel];
126
+ if (near > 0 && near <= COAST_RAMP_PIXELS)
127
+ h = lerp(LAND_FLOOR, Math.max(h, LAND_FLOOR), smoothstep((near - 1) / (COAST_RAMP_PIXELS - 1)));
128
+ out[pixel] = h < LAND_FLOOR ? LAND_FLOOR : h;
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Raster helpers for relief and hydrology synthesis: bilinear sampling, chamfer distances,
3
+ * component labelling and a packed binary heap. All results are deterministic.
4
+ */
5
+ import { SQRT2 } from './math.js';
6
+ /** Cell kinds used throughout the synthesis: the same codes as the world generator. */
7
+ export const SEA = 0;
8
+ export const LAND = 1;
9
+ export const LAKE = 2;
10
+ /** Bilinear sample at continuous cell coordinates; cell centres lie on integers. Clamped at the border. */
11
+ export function bilinear(field, width, height, u, v) {
12
+ const fu = u < 0 ? 0 : u > width - 1 ? width - 1 : u;
13
+ const fv = v < 0 ? 0 : v > height - 1 ? height - 1 : v;
14
+ const x0 = Math.floor(fu);
15
+ const y0 = Math.floor(fv);
16
+ const x1 = x0 + 1 < width ? x0 + 1 : x0;
17
+ const y1 = y0 + 1 < height ? y0 + 1 : y0;
18
+ const tx = fu - x0;
19
+ const ty = fv - y0;
20
+ const a = field[y0 * width + x0];
21
+ const b = field[y0 * width + x1];
22
+ const c = field[y1 * width + x0];
23
+ const d = field[y1 * width + x1];
24
+ return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
25
+ }
26
+ /**
27
+ * Euclidean-like distance in cells from every cell to the nearest cell where `source` is non-zero,
28
+ * by a two-pass chamfer with weights 1 and sqrt(2). Cells farther than `limit` hold `limit`.
29
+ */
30
+ export function chamferDistance(source, width, height, limit = 65_535) {
31
+ const distance = new Float32Array(width * height);
32
+ for (let index = 0; index < distance.length; index += 1)
33
+ distance[index] = source[index] !== 0 ? 0 : limit;
34
+ for (let y = 0; y < height; y += 1)
35
+ for (let x = 0; x < width; x += 1) {
36
+ const index = y * width + x;
37
+ let best = distance[index];
38
+ if (best === 0)
39
+ continue;
40
+ if (x > 0)
41
+ best = Math.min(best, distance[index - 1] + 1);
42
+ if (y > 0) {
43
+ best = Math.min(best, distance[index - width] + 1);
44
+ if (x > 0)
45
+ best = Math.min(best, distance[index - width - 1] + SQRT2);
46
+ if (x < width - 1)
47
+ best = Math.min(best, distance[index - width + 1] + SQRT2);
48
+ }
49
+ distance[index] = best;
50
+ }
51
+ for (let y = height - 1; y >= 0; y -= 1)
52
+ for (let x = width - 1; x >= 0; x -= 1) {
53
+ const index = y * width + x;
54
+ let best = distance[index];
55
+ if (best === 0)
56
+ continue;
57
+ if (x < width - 1)
58
+ best = Math.min(best, distance[index + 1] + 1);
59
+ if (y < height - 1) {
60
+ best = Math.min(best, distance[index + width] + 1);
61
+ if (x < width - 1)
62
+ best = Math.min(best, distance[index + width + 1] + SQRT2);
63
+ if (x > 0)
64
+ best = Math.min(best, distance[index + width - 1] + SQRT2);
65
+ }
66
+ distance[index] = best > limit ? limit : best;
67
+ }
68
+ return distance;
69
+ }
70
+ /** Offsets of the eight neighbours: east, south-east, south, south-west, west, north-west, north, north-east. */
71
+ export const NEIGHBOUR_DX = [1, 1, 0, -1, -1, -1, 0, 1];
72
+ export const NEIGHBOUR_DY = [0, 1, 1, 1, 0, -1, -1, -1];
73
+ export const NEIGHBOUR_LENGTH = [1, SQRT2, 1, SQRT2, 1, SQRT2, 1, SQRT2];
74
+ /**
75
+ * Four-connected components of cells for which `inside` is true. Returns the per-cell component
76
+ * index (-1 outside any component), the component sizes and one seed cell of each.
77
+ */
78
+ export function labelComponents(width, height, inside) {
79
+ const total = width * height;
80
+ const label = new Int32Array(total).fill(-1);
81
+ const sizes = [];
82
+ const seeds = [];
83
+ const queue = new Int32Array(total);
84
+ for (let start = 0; start < total; start += 1) {
85
+ if (label[start] !== -1 || !inside(start))
86
+ continue;
87
+ const id = sizes.length;
88
+ let head = 0;
89
+ let tail = 0;
90
+ queue[tail++] = start;
91
+ label[start] = id;
92
+ while (head < tail) {
93
+ const cell = queue[head++];
94
+ const x = cell % width;
95
+ if (x > 0 && label[cell - 1] === -1 && inside(cell - 1)) {
96
+ label[cell - 1] = id;
97
+ queue[tail++] = cell - 1;
98
+ }
99
+ if (x < width - 1 && label[cell + 1] === -1 && inside(cell + 1)) {
100
+ label[cell + 1] = id;
101
+ queue[tail++] = cell + 1;
102
+ }
103
+ if (cell >= width && label[cell - width] === -1 && inside(cell - width)) {
104
+ label[cell - width] = id;
105
+ queue[tail++] = cell - width;
106
+ }
107
+ if (cell < total - width && label[cell + width] === -1 && inside(cell + width)) {
108
+ label[cell + width] = id;
109
+ queue[tail++] = cell + width;
110
+ }
111
+ }
112
+ sizes.push(tail);
113
+ seeds.push(start);
114
+ }
115
+ return { label, sizes, seeds };
116
+ }
117
+ /**
118
+ * A packed binary min-heap of (key, id) pairs. Ties on the key are broken by the smaller id, so the
119
+ * pop order never depends on insertion history.
120
+ */
121
+ export class MinHeap {
122
+ #keys;
123
+ #ids;
124
+ #length = 0;
125
+ /** The key of the entry returned by the last `pop`. */
126
+ popKey = 0;
127
+ constructor(capacity) {
128
+ this.#keys = new Float32Array(capacity);
129
+ this.#ids = new Int32Array(capacity);
130
+ }
131
+ get length() {
132
+ return this.#length;
133
+ }
134
+ clear() {
135
+ this.#length = 0;
136
+ }
137
+ push(key, id) {
138
+ if (this.#length === this.#keys.length) {
139
+ // Lazy-deletion searches push a cell more than once: grow instead of dropping entries.
140
+ const keys = new Float32Array(this.#keys.length * 2);
141
+ const ids = new Int32Array(this.#ids.length * 2);
142
+ keys.set(this.#keys);
143
+ ids.set(this.#ids);
144
+ this.#keys = keys;
145
+ this.#ids = ids;
146
+ }
147
+ let index = this.#length++;
148
+ while (index > 0) {
149
+ const parent = (index - 1) >>> 1;
150
+ const parentKey = this.#keys[parent];
151
+ if (!(key < parentKey || (key === parentKey && id < this.#ids[parent])))
152
+ break;
153
+ this.#keys[index] = parentKey;
154
+ this.#ids[index] = this.#ids[parent];
155
+ index = parent;
156
+ }
157
+ this.#keys[index] = key;
158
+ this.#ids[index] = id;
159
+ }
160
+ /** Remove and return the id of the smallest entry; `popKey` holds its key. */
161
+ pop() {
162
+ const topId = this.#ids[0];
163
+ this.popKey = this.#keys[0];
164
+ this.#length -= 1;
165
+ const length = this.#length;
166
+ if (length === 0)
167
+ return topId;
168
+ const key = this.#keys[length];
169
+ const id = this.#ids[length];
170
+ let index = 0;
171
+ while (index * 2 + 1 < length) {
172
+ let child = index * 2 + 1;
173
+ if (child + 1 < length) {
174
+ const right = this.#keys[child + 1];
175
+ const left = this.#keys[child];
176
+ if (right < left || (right === left && this.#ids[child + 1] < this.#ids[child]))
177
+ child += 1;
178
+ }
179
+ const childKey = this.#keys[child];
180
+ if (!(childKey < key || (childKey === key && this.#ids[child] < id)))
181
+ break;
182
+ this.#keys[index] = childKey;
183
+ this.#ids[index] = this.#ids[child];
184
+ index = child;
185
+ }
186
+ this.#keys[index] = key;
187
+ this.#ids[index] = id;
188
+ return topId;
189
+ }
190
+ }