hoi4-agent-tools 4.0.4 → 4.1.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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/gui/layout.js +61 -25
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/gui.md +6 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +373 -70
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
/** Bounded, offline reference-image interpretation. These are visual heuristics, not OCR. */
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ServiceError } from '../core/result.js';
|
|
5
|
+
export const REFERENCE_WORLD_PIXEL_LIMIT = 13_238_272;
|
|
6
|
+
export const REFERENCE_IMAGE_PIXEL_LIMIT = 32_000_000;
|
|
7
|
+
export const REFERENCE_IMAGE_BYTE_LIMIT = 64 * 1024 * 1024;
|
|
8
|
+
export const REFERENCE_TERRAIN = {
|
|
9
|
+
plains: 0,
|
|
10
|
+
forest: 1,
|
|
11
|
+
desert: 2,
|
|
12
|
+
mountain: 3,
|
|
13
|
+
snow: 4,
|
|
14
|
+
volcano: 5,
|
|
15
|
+
hills: 6,
|
|
16
|
+
marsh: 7,
|
|
17
|
+
jungle: 8,
|
|
18
|
+
};
|
|
19
|
+
const TERRAIN_ELEVATION = [0.045, 0.055, 0.035, 0.44, 0.045, 0.35, 0.18, 0.025, 0.06];
|
|
20
|
+
const TERRAIN_MOISTURE = [0.45, 0.8, 0.06, 0.35, 0.25, 0.05, 0.4, 0.96, 0.92];
|
|
21
|
+
const pointSchema = z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) }).strict();
|
|
22
|
+
const polygonSchema = z.array(pointSchema).min(3).max(2048);
|
|
23
|
+
const semanticSchema = z.enum([
|
|
24
|
+
'sea',
|
|
25
|
+
'land',
|
|
26
|
+
'lake',
|
|
27
|
+
'plains',
|
|
28
|
+
'forest',
|
|
29
|
+
'desert',
|
|
30
|
+
'mountain',
|
|
31
|
+
'snow',
|
|
32
|
+
'volcano',
|
|
33
|
+
'hills',
|
|
34
|
+
'marsh',
|
|
35
|
+
'jungle',
|
|
36
|
+
]);
|
|
37
|
+
const bytesSchema = z.custom((value) => value instanceof Uint8Array);
|
|
38
|
+
export const referenceWorldOptionsSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
width: z.number().int().min(1).max(8192).optional(),
|
|
41
|
+
height: z.number().int().min(1).max(4096).optional(),
|
|
42
|
+
seed: z.number().int().min(0).max(0xffffffff),
|
|
43
|
+
mode: z
|
|
44
|
+
.enum(['auto', 'illustrated', 'satellite', 'sketch', 'political', 'grayscale'])
|
|
45
|
+
.optional(),
|
|
46
|
+
crop: z
|
|
47
|
+
.object({
|
|
48
|
+
x: z.number().int().min(0),
|
|
49
|
+
y: z.number().int().min(0),
|
|
50
|
+
width: z.number().int().positive(),
|
|
51
|
+
height: z.number().int().positive(),
|
|
52
|
+
})
|
|
53
|
+
.strict()
|
|
54
|
+
.optional(),
|
|
55
|
+
ignoreMasks: z.array(polygonSchema).max(256).optional(),
|
|
56
|
+
annotations: z
|
|
57
|
+
.array(z.object({ kind: semanticSchema, polygon: polygonSchema }).strict())
|
|
58
|
+
.max(512)
|
|
59
|
+
.optional(),
|
|
60
|
+
riverAnnotations: z.array(z.array(pointSchema).min(2).max(8192)).max(512).optional(),
|
|
61
|
+
palette: z
|
|
62
|
+
.array(z
|
|
63
|
+
.object({
|
|
64
|
+
kind: semanticSchema,
|
|
65
|
+
color: z
|
|
66
|
+
.object({
|
|
67
|
+
r: z.number().int().min(0).max(255),
|
|
68
|
+
g: z.number().int().min(0).max(255),
|
|
69
|
+
b: z.number().int().min(0).max(255),
|
|
70
|
+
})
|
|
71
|
+
.strict(),
|
|
72
|
+
})
|
|
73
|
+
.strict())
|
|
74
|
+
.max(128)
|
|
75
|
+
.optional(),
|
|
76
|
+
coastDetail: z.number().min(0).max(1).optional(),
|
|
77
|
+
fit: z.enum(['contain', 'stretch']).optional(),
|
|
78
|
+
politicalImageBytes: bytesSchema.optional(),
|
|
79
|
+
terrainImageBytes: bytesSchema.optional(),
|
|
80
|
+
heightImageBytes: bytesSchema.optional(),
|
|
81
|
+
})
|
|
82
|
+
.strict();
|
|
83
|
+
async function decode(bytes) {
|
|
84
|
+
if (bytes.byteLength === 0 || bytes.byteLength > REFERENCE_IMAGE_BYTE_LIMIT)
|
|
85
|
+
throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Reference image bytes exceed the bounded decode limit');
|
|
86
|
+
try {
|
|
87
|
+
const image = sharp(Buffer.from(bytes), {
|
|
88
|
+
limitInputPixels: REFERENCE_IMAGE_PIXEL_LIMIT,
|
|
89
|
+
animated: false,
|
|
90
|
+
failOn: 'error',
|
|
91
|
+
});
|
|
92
|
+
const metadata = await image.metadata();
|
|
93
|
+
if (!['png', 'jpeg', 'webp', 'tiff', 'bmp', 'gif'].includes(metadata.format) ||
|
|
94
|
+
(metadata.pages ?? 1) !== 1)
|
|
95
|
+
throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Use a single-frame local raster image');
|
|
96
|
+
const output = await image
|
|
97
|
+
.flatten({ background: '#245b82' })
|
|
98
|
+
.removeAlpha()
|
|
99
|
+
.toColourspace('srgb')
|
|
100
|
+
.raw()
|
|
101
|
+
.toBuffer({ resolveWithObject: true });
|
|
102
|
+
return { data: output.data, width: output.info.width, height: output.info.height };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof ServiceError)
|
|
106
|
+
throw error;
|
|
107
|
+
throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Reference image cannot be decoded within the raster limits');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function neighbors(pixel, width, height, visit) {
|
|
111
|
+
const x = pixel % width;
|
|
112
|
+
if (x > 0)
|
|
113
|
+
visit(pixel - 1);
|
|
114
|
+
if (x + 1 < width)
|
|
115
|
+
visit(pixel + 1);
|
|
116
|
+
if (pixel >= width)
|
|
117
|
+
visit(pixel - width);
|
|
118
|
+
if (pixel + width < width * height)
|
|
119
|
+
visit(pixel + width);
|
|
120
|
+
}
|
|
121
|
+
function semanticCode(kind) {
|
|
122
|
+
return kind === 'sea'
|
|
123
|
+
? 0
|
|
124
|
+
: kind === 'lake'
|
|
125
|
+
? 2
|
|
126
|
+
: kind === 'land'
|
|
127
|
+
? 3
|
|
128
|
+
: REFERENCE_TERRAIN[kind] + 3;
|
|
129
|
+
}
|
|
130
|
+
function pixelColor(data, pixel) {
|
|
131
|
+
return { r: data[pixel * 3], g: data[pixel * 3 + 1], b: data[pixel * 3 + 2] };
|
|
132
|
+
}
|
|
133
|
+
function distance(a, b) {
|
|
134
|
+
return (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2;
|
|
135
|
+
}
|
|
136
|
+
function classify(color, mode, palette) {
|
|
137
|
+
if (palette.length > 0) {
|
|
138
|
+
let best = palette[0];
|
|
139
|
+
let nearest = distance(color, best.color);
|
|
140
|
+
for (const entry of palette) {
|
|
141
|
+
const score = distance(color, entry.color);
|
|
142
|
+
if (score < nearest) {
|
|
143
|
+
nearest = score;
|
|
144
|
+
best = entry;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return semanticCode(best.kind);
|
|
148
|
+
}
|
|
149
|
+
const { r, g, b } = color;
|
|
150
|
+
const maximum = Math.max(r, g, b);
|
|
151
|
+
const minimum = Math.min(r, g, b);
|
|
152
|
+
if (mode !== 'political' && r > g + 25 && b > g + 25 && r - g > (b - g) * 0.42)
|
|
153
|
+
return semanticCode('forest');
|
|
154
|
+
if (b > r + 12 && b > g - 28 && b > 70)
|
|
155
|
+
return 0;
|
|
156
|
+
if (mode === 'political')
|
|
157
|
+
return 3;
|
|
158
|
+
// Pencil illustrations often use black dead lands, purple woods and red volcanic terrain.
|
|
159
|
+
if (maximum < 38 || (r > 120 && r > g * 1.8 && r > b * 1.4))
|
|
160
|
+
return semanticCode('volcano');
|
|
161
|
+
if (minimum > 211 && maximum - minimum < 30)
|
|
162
|
+
return semanticCode('snow');
|
|
163
|
+
if (g > r + 12 && g > b + 7) {
|
|
164
|
+
if (g < 105 && mode === 'satellite')
|
|
165
|
+
return semanticCode('jungle');
|
|
166
|
+
return semanticCode(g < 170 || r < 105 ? 'forest' : 'plains');
|
|
167
|
+
}
|
|
168
|
+
if (r > 158 && g > 120 && b < g * 0.83)
|
|
169
|
+
return semanticCode('desert');
|
|
170
|
+
if (maximum - minimum < 34 && maximum < 175)
|
|
171
|
+
return semanticCode('mountain');
|
|
172
|
+
if (r > b + 20 && g < 150)
|
|
173
|
+
return semanticCode('hills');
|
|
174
|
+
return 3;
|
|
175
|
+
}
|
|
176
|
+
function fillUnknown(codes, width, height) {
|
|
177
|
+
if (!codes.includes(255))
|
|
178
|
+
return;
|
|
179
|
+
const queue = new Int32Array(codes.length);
|
|
180
|
+
let head = 0;
|
|
181
|
+
let tail = 0;
|
|
182
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1)
|
|
183
|
+
if (codes[pixel] !== 255)
|
|
184
|
+
queue[tail++] = pixel;
|
|
185
|
+
if (tail === 0)
|
|
186
|
+
throw new ServiceError('MAP_REFERENCE_EMPTY', 'Reference masks exclude every usable image pixel');
|
|
187
|
+
while (head < tail) {
|
|
188
|
+
const pixel = queue[head++];
|
|
189
|
+
neighbors(pixel, width, height, (next) => {
|
|
190
|
+
if (codes[next] === 255) {
|
|
191
|
+
codes[next] = codes[pixel];
|
|
192
|
+
queue[tail++] = next;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Scanline rasterization bounds work to image rows and polygon edges. */
|
|
198
|
+
function polygon(codes, width, height, points, value) {
|
|
199
|
+
const minY = Math.max(0, Math.floor(Math.min(...points.map((point) => point.y))));
|
|
200
|
+
const maxY = Math.min(height - 1, Math.ceil(Math.max(...points.map((point) => point.y))));
|
|
201
|
+
for (let y = minY; y <= maxY; y += 1) {
|
|
202
|
+
const intersections = [];
|
|
203
|
+
for (let index = 0, previous = points.length - 1; index < points.length; previous = index++) {
|
|
204
|
+
const a = points[index];
|
|
205
|
+
const b = points[previous];
|
|
206
|
+
if (a.y > y + 0.5 !== b.y > y + 0.5)
|
|
207
|
+
intersections.push(a.x + ((y + 0.5 - a.y) * (b.x - a.x)) / (b.y - a.y));
|
|
208
|
+
}
|
|
209
|
+
intersections.sort((a, b) => a - b);
|
|
210
|
+
for (let index = 0; index + 1 < intersections.length; index += 2) {
|
|
211
|
+
const first = Math.max(0, Math.ceil(intersections[index] - 0.5));
|
|
212
|
+
const last = Math.min(width - 1, Math.ceil(intersections[index + 1] - 0.5) - 1);
|
|
213
|
+
if (first <= last)
|
|
214
|
+
codes.fill(value, y * width + first, y * width + last + 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function modeFor(data) {
|
|
219
|
+
let colorful = 0;
|
|
220
|
+
let ink = 0;
|
|
221
|
+
let white = 0;
|
|
222
|
+
let samples = 0;
|
|
223
|
+
const histogram = new Map();
|
|
224
|
+
const stride = Math.max(1, Math.floor(data.length / 3 / 100_000));
|
|
225
|
+
for (let pixel = 0; pixel < data.length / 3; pixel += stride) {
|
|
226
|
+
const { r, g, b } = pixelColor(data, pixel);
|
|
227
|
+
const maximum = Math.max(r, g, b);
|
|
228
|
+
const minimum = Math.min(r, g, b);
|
|
229
|
+
if (maximum - minimum > 35)
|
|
230
|
+
colorful += 1;
|
|
231
|
+
if (maximum < 80)
|
|
232
|
+
ink += 1;
|
|
233
|
+
if (minimum > 225)
|
|
234
|
+
white += 1;
|
|
235
|
+
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
|
|
236
|
+
histogram.set(key, (histogram.get(key) ?? 0) + 1);
|
|
237
|
+
samples += 1;
|
|
238
|
+
}
|
|
239
|
+
if (colorful / samples < 0.04)
|
|
240
|
+
return white / samples > 0.65 && ink / samples < 0.25 ? 'sketch' : 'grayscale';
|
|
241
|
+
const largest = [...histogram.values()]
|
|
242
|
+
.sort((a, b) => b - a)
|
|
243
|
+
.slice(0, 8)
|
|
244
|
+
.reduce((sum, value) => sum + value, 0);
|
|
245
|
+
// Flat maps are the only source from which political colors can be inferred by default.
|
|
246
|
+
return largest / samples > 0.83 && histogram.size > 2 ? 'political' : 'illustrated';
|
|
247
|
+
}
|
|
248
|
+
function grayscaleCodes(data, width, height, mode) {
|
|
249
|
+
const codes = new Uint8Array(width * height);
|
|
250
|
+
if (mode === 'sketch') {
|
|
251
|
+
const open = new Uint8Array(codes.length);
|
|
252
|
+
for (let pixel = 0; pixel < open.length; pixel += 1)
|
|
253
|
+
open[pixel] = data[pixel * 3] > 115 ? 1 : 0;
|
|
254
|
+
const queue = new Int32Array(codes.length);
|
|
255
|
+
let head = 0;
|
|
256
|
+
let tail = 0;
|
|
257
|
+
const add = (pixel) => {
|
|
258
|
+
if (open[pixel] === 1) {
|
|
259
|
+
open[pixel] = 2;
|
|
260
|
+
queue[tail++] = pixel;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
for (let x = 0; x < width; x += 1) {
|
|
264
|
+
add(x);
|
|
265
|
+
add((height - 1) * width + x);
|
|
266
|
+
}
|
|
267
|
+
for (let y = 0; y < height; y += 1) {
|
|
268
|
+
add(y * width);
|
|
269
|
+
add(y * width + width - 1);
|
|
270
|
+
}
|
|
271
|
+
while (head < tail)
|
|
272
|
+
neighbors(queue[head++], width, height, add);
|
|
273
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1)
|
|
274
|
+
codes[pixel] = open[pixel] === 2 ? 0 : 3;
|
|
275
|
+
return codes;
|
|
276
|
+
}
|
|
277
|
+
let border = 0;
|
|
278
|
+
let borderCount = 0;
|
|
279
|
+
let minimum = 255;
|
|
280
|
+
let maximum = 0;
|
|
281
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1) {
|
|
282
|
+
const value = data[pixel * 3];
|
|
283
|
+
minimum = Math.min(minimum, value);
|
|
284
|
+
maximum = Math.max(maximum, value);
|
|
285
|
+
const x = pixel % width;
|
|
286
|
+
const y = Math.floor(pixel / width);
|
|
287
|
+
if (x === 0 || y === 0 || x === width - 1 || y === height - 1) {
|
|
288
|
+
border += value;
|
|
289
|
+
borderCount += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const background = border / borderCount;
|
|
293
|
+
const threshold = Math.max(18, (maximum - minimum) * 0.18);
|
|
294
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1)
|
|
295
|
+
codes[pixel] = Math.abs(data[pixel * 3] - background) < threshold ? 0 : 3;
|
|
296
|
+
return codes;
|
|
297
|
+
}
|
|
298
|
+
function latticeNoise(seed, x, y) {
|
|
299
|
+
let hash = Math.imul(Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ seed, 0x45d9f3b);
|
|
300
|
+
hash = Math.imul(hash ^ (hash >>> 16), 0x45d9f3b);
|
|
301
|
+
return ((hash ^ (hash >>> 16)) >>> 0) / 0xffffffff;
|
|
302
|
+
}
|
|
303
|
+
function coherentNoise(seed, x, y, scale) {
|
|
304
|
+
const px = x / scale;
|
|
305
|
+
const py = y / scale;
|
|
306
|
+
const left = Math.floor(px);
|
|
307
|
+
const top = Math.floor(py);
|
|
308
|
+
const fx = px - left;
|
|
309
|
+
const fy = py - top;
|
|
310
|
+
const tx = fx * fx * (3 - 2 * fx);
|
|
311
|
+
const ty = fy * fy * (3 - 2 * fy);
|
|
312
|
+
const a = latticeNoise(seed, left, top);
|
|
313
|
+
const b = latticeNoise(seed, left + 1, top);
|
|
314
|
+
const c = latticeNoise(seed, left, top + 1);
|
|
315
|
+
const d = latticeNoise(seed, left + 1, top + 1);
|
|
316
|
+
return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
|
|
317
|
+
}
|
|
318
|
+
/** The returned mask remains a raster; coastlines never go through lossy polygon tracing. */
|
|
319
|
+
export async function extractReferenceWorld(imageBytes, input) {
|
|
320
|
+
const parsed = referenceWorldOptionsSchema.safeParse(input);
|
|
321
|
+
if (!parsed.success)
|
|
322
|
+
throw new ServiceError('MAP_REFERENCE_OPTIONS_INVALID', 'Reference image options are invalid');
|
|
323
|
+
const options = parsed.data;
|
|
324
|
+
const source = await decode(imageBytes);
|
|
325
|
+
const crop = options.crop ?? { x: 0, y: 0, width: source.width, height: source.height };
|
|
326
|
+
if (crop.x + crop.width > source.width || crop.y + crop.height > source.height)
|
|
327
|
+
throw new ServiceError('MAP_REFERENCE_CROP_INVALID', 'Reference crop lies outside the image');
|
|
328
|
+
const width = options.width ?? 5632;
|
|
329
|
+
const height = options.height ?? 2048;
|
|
330
|
+
if (width * height > REFERENCE_WORLD_PIXEL_LIMIT)
|
|
331
|
+
throw new ServiceError('MAP_REFERENCE_PIXELS_BLOCKED', 'Reference output exceeds the pixel budget');
|
|
332
|
+
const scale = options.fit === 'stretch' ? 1 : Math.min(width / crop.width, height / crop.height);
|
|
333
|
+
const bounds = options.fit === 'stretch'
|
|
334
|
+
? { x: 0, y: 0, width, height }
|
|
335
|
+
: {
|
|
336
|
+
x: Math.floor((width - Math.round(crop.width * scale)) / 2),
|
|
337
|
+
y: Math.floor((height - Math.round(crop.height * scale)) / 2),
|
|
338
|
+
width: Math.max(1, Math.round(crop.width * scale)),
|
|
339
|
+
height: Math.max(1, Math.round(crop.height * scale)),
|
|
340
|
+
};
|
|
341
|
+
const resized = await sharp(source.data, {
|
|
342
|
+
raw: { width: source.width, height: source.height, channels: 3 },
|
|
343
|
+
})
|
|
344
|
+
.extract({ left: crop.x, top: crop.y, width: crop.width, height: crop.height })
|
|
345
|
+
.resize(bounds.width, bounds.height, { kernel: sharp.kernel.nearest })
|
|
346
|
+
.raw()
|
|
347
|
+
.toBuffer();
|
|
348
|
+
const mode = options.mode === undefined || options.mode === 'auto' ? modeFor(resized) : options.mode;
|
|
349
|
+
const diagnostics = [
|
|
350
|
+
`Reference interpretation uses ${mode} visual heuristics; illustrated labels and ambiguous biomes may need semantic overrides.`,
|
|
351
|
+
];
|
|
352
|
+
const codes = new Uint8Array(width * height);
|
|
353
|
+
const localCodes = mode === 'sketch' || mode === 'grayscale'
|
|
354
|
+
? grayscaleCodes(resized, bounds.width, bounds.height, mode)
|
|
355
|
+
: undefined;
|
|
356
|
+
for (let y = 0; y < bounds.height; y += 1)
|
|
357
|
+
for (let x = 0; x < bounds.width; x += 1) {
|
|
358
|
+
const local = y * bounds.width + x;
|
|
359
|
+
const target = (y + bounds.y) * width + x + bounds.x;
|
|
360
|
+
codes[target] =
|
|
361
|
+
localCodes?.[local] ?? classify(pixelColor(resized, local), mode, options.palette ?? []);
|
|
362
|
+
}
|
|
363
|
+
if (mode === 'illustrated' || mode === 'satellite')
|
|
364
|
+
cleanWaterTexture(codes, resized, width, bounds, options.coastDetail ?? 1, diagnostics);
|
|
365
|
+
if (mode !== 'sketch' && mode !== 'grayscale')
|
|
366
|
+
maskOceanText(codes, resized, width, bounds, diagnostics);
|
|
367
|
+
const transform = (point) => ({
|
|
368
|
+
x: bounds.x + ((point.x * source.width - crop.x) / crop.width) * bounds.width,
|
|
369
|
+
y: bounds.y + ((point.y * source.height - crop.y) / crop.height) * bounds.height,
|
|
370
|
+
});
|
|
371
|
+
const polygonEdges = [
|
|
372
|
+
...(options.ignoreMasks ?? []),
|
|
373
|
+
...(options.annotations ?? []).map((annotation) => annotation.polygon),
|
|
374
|
+
].reduce((sum, points) => sum + points.length, 0);
|
|
375
|
+
if (polygonEdges * height > 50_000_000)
|
|
376
|
+
throw new ServiceError('MAP_REFERENCE_MASK_WORK_BLOCKED', 'Reference polygon controls exceed the bounded scanline work budget');
|
|
377
|
+
for (const mask of options.ignoreMasks ?? [])
|
|
378
|
+
polygon(codes, width, height, mask.map(transform), 255);
|
|
379
|
+
fillUnknown(codes, width, height);
|
|
380
|
+
const protectedFeatures = (options.annotations?.length ?? 0) > 0 ? new Uint8Array(codes.length) : undefined;
|
|
381
|
+
for (const annotation of options.annotations ?? []) {
|
|
382
|
+
polygon(codes, width, height, annotation.polygon.map(transform), semanticCode(annotation.kind));
|
|
383
|
+
if (protectedFeatures)
|
|
384
|
+
polygon(protectedFeatures, width, height, annotation.polygon.map(transform), 1);
|
|
385
|
+
}
|
|
386
|
+
const kind = new Uint8Array(codes.length);
|
|
387
|
+
const terrain = new Uint8Array(codes.length);
|
|
388
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1) {
|
|
389
|
+
kind[pixel] = codes[pixel] < 3 ? codes[pixel] : 1;
|
|
390
|
+
terrain[pixel] = codes[pixel] < 3 ? 0 : codes[pixel] - 3;
|
|
391
|
+
}
|
|
392
|
+
const riverPoints = (options.riverAnnotations ?? []).reduce((total, path) => total + path.length, 0);
|
|
393
|
+
if (riverPoints > 131072)
|
|
394
|
+
throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Annotated rivers exceed the bounded point budget');
|
|
395
|
+
const rivers = (options.riverAnnotations ?? []).flatMap((points) => clipRiver(points.map(transform).map((point) => ({ x: point.x - bounds.x, y: point.y - bounds.y })), bounds.width, bounds.height).map((path) => path.map((point) => ({ x: point.x + bounds.x, y: point.y + bounds.y }))));
|
|
396
|
+
traceCoastalRivers(kind, width, height, rivers, diagnostics);
|
|
397
|
+
normalizeWater(kind, width, height, rivers, diagnostics);
|
|
398
|
+
cleanSmallComponents(kind, width, height, protectedFeatures, diagnostics);
|
|
399
|
+
repairCorners(kind, width, height, diagnostics);
|
|
400
|
+
// Corner repairs can detach an inlet from the ocean. Recheck sea fragments as well as
|
|
401
|
+
// islands/lakes, otherwise a harmless subpixel correction becomes a one-pixel province.
|
|
402
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
403
|
+
if (!cleanSmallComponents(kind, width, height, protectedFeatures, diagnostics))
|
|
404
|
+
break;
|
|
405
|
+
repairCorners(kind, width, height, diagnostics);
|
|
406
|
+
}
|
|
407
|
+
if (mode === 'illustrated')
|
|
408
|
+
smoothIllustratedTerrain(terrain, kind, width, height, protectedFeatures, diagnostics);
|
|
409
|
+
const elevation = new Float32Array(codes.length);
|
|
410
|
+
const moisture = new Float32Array(codes.length);
|
|
411
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1) {
|
|
412
|
+
elevation[pixel] =
|
|
413
|
+
kind[pixel] === 1 ? TERRAIN_ELEVATION[terrain[pixel]] : kind[pixel] === 2 ? -0.02 : -0.15;
|
|
414
|
+
moisture[pixel] = kind[pixel] === 1 ? TERRAIN_MOISTURE[terrain[pixel]] : 1;
|
|
415
|
+
}
|
|
416
|
+
const result = {
|
|
417
|
+
width,
|
|
418
|
+
height,
|
|
419
|
+
sourceWidth: source.width,
|
|
420
|
+
sourceHeight: source.height,
|
|
421
|
+
referenceBounds: bounds,
|
|
422
|
+
kind,
|
|
423
|
+
terrain,
|
|
424
|
+
elevation,
|
|
425
|
+
moisture,
|
|
426
|
+
rivers,
|
|
427
|
+
country: new Uint16Array(kind.length),
|
|
428
|
+
inferredCountries: [],
|
|
429
|
+
diagnostics,
|
|
430
|
+
};
|
|
431
|
+
inferCountries(result, resized, mode);
|
|
432
|
+
await applyLayers(result, input, crop);
|
|
433
|
+
normalizeElevation(result, options.seed, options.heightImageBytes !== undefined);
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
/** Remove isolated pencil/paper biome noise without changing any land/water cell or reviewed mask. */
|
|
437
|
+
function smoothIllustratedTerrain(terrain, kind, width, height, protectedFeatures, diagnostics) {
|
|
438
|
+
const votes = new Uint8Array(Object.keys(REFERENCE_TERRAIN).length);
|
|
439
|
+
let changed = 0;
|
|
440
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
441
|
+
const before = terrain.slice();
|
|
442
|
+
for (let y = 1; y + 1 < height; y++)
|
|
443
|
+
for (let x = 1; x + 1 < width; x++) {
|
|
444
|
+
const p = y * width + x;
|
|
445
|
+
if (kind[p] !== 1 || protectedFeatures?.[p])
|
|
446
|
+
continue;
|
|
447
|
+
votes.fill(0);
|
|
448
|
+
for (let dy = -1; dy <= 1; dy++)
|
|
449
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
450
|
+
const q = p + dy * width + dx;
|
|
451
|
+
if (kind[q] === 1)
|
|
452
|
+
votes[before[q]]++;
|
|
453
|
+
}
|
|
454
|
+
for (let candidate = 0; candidate < votes.length; candidate++) {
|
|
455
|
+
if (votes[candidate] >= 6 && candidate !== before[p]) {
|
|
456
|
+
terrain[p] = candidate;
|
|
457
|
+
changed++;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (changed)
|
|
464
|
+
diagnostics.push(`Smoothed ${changed} isolated illustrated biome samples; coastlines and explicit semantic masks were preserved.`);
|
|
465
|
+
}
|
|
466
|
+
/** Discard unannotated speckles smaller than a playable province at the target resolution. */
|
|
467
|
+
function cleanSmallComponents(kind, width, height, protectedFeatures, diagnostics) {
|
|
468
|
+
const minimum = Math.max(1, Math.round(32 * (Math.min(width, height) / 2048) ** 2));
|
|
469
|
+
if (minimum <= 1)
|
|
470
|
+
return 0;
|
|
471
|
+
const visited = new Uint8Array(kind.length);
|
|
472
|
+
const queue = new Int32Array(kind.length);
|
|
473
|
+
let islands = 0;
|
|
474
|
+
let lakes = 0;
|
|
475
|
+
let changed = 0;
|
|
476
|
+
for (let start = 0; start < kind.length; start += 1) {
|
|
477
|
+
const value = kind[start];
|
|
478
|
+
if (visited[start])
|
|
479
|
+
continue;
|
|
480
|
+
let head = 0;
|
|
481
|
+
let tail = 0;
|
|
482
|
+
let protectedComponent = false;
|
|
483
|
+
queue[tail++] = start;
|
|
484
|
+
visited[start] = 1;
|
|
485
|
+
while (head < tail) {
|
|
486
|
+
const pixel = queue[head++];
|
|
487
|
+
if (protectedFeatures?.[pixel])
|
|
488
|
+
protectedComponent = true;
|
|
489
|
+
neighbors(pixel, width, height, (next) => {
|
|
490
|
+
if (kind[next] === value && !visited[next]) {
|
|
491
|
+
visited[next] = 1;
|
|
492
|
+
queue[tail++] = next;
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (tail >= minimum || protectedComponent)
|
|
497
|
+
continue;
|
|
498
|
+
const boundary = [0, 0, 0];
|
|
499
|
+
for (let index = 0; index < tail; index += 1)
|
|
500
|
+
neighbors(queue[index], width, height, (next) => {
|
|
501
|
+
if (kind[next] !== value)
|
|
502
|
+
boundary[kind[next]]++;
|
|
503
|
+
});
|
|
504
|
+
const replacement = boundary.indexOf(Math.max(...boundary));
|
|
505
|
+
for (let index = 0; index < tail; index += 1)
|
|
506
|
+
kind[queue[index]] = replacement;
|
|
507
|
+
changed += tail;
|
|
508
|
+
if (value === 1)
|
|
509
|
+
islands += 1;
|
|
510
|
+
else
|
|
511
|
+
lakes += 1;
|
|
512
|
+
}
|
|
513
|
+
if (changed)
|
|
514
|
+
diagnostics.push(`Removed ${islands} unannotated land speckles and filled ${lakes} enclosed water speckles below ${minimum} target pixels (${changed} pixels total); explicit semantic features are retained.`);
|
|
515
|
+
return changed;
|
|
516
|
+
}
|
|
517
|
+
/** Conservative lettering heuristic: aligned hollow dark components surrounded by ocean. */
|
|
518
|
+
function maskOceanText(codes, data, width, bounds, diagnostics) {
|
|
519
|
+
const visited = new Uint8Array(bounds.width * bounds.height);
|
|
520
|
+
const queue = new Int32Array(visited.length);
|
|
521
|
+
const candidates = [];
|
|
522
|
+
const dark = (pixel) => {
|
|
523
|
+
const r = data[pixel * 3], g = data[pixel * 3 + 1], b = data[pixel * 3 + 2];
|
|
524
|
+
const maximum = Math.max(r, g, b);
|
|
525
|
+
return maximum < 85 || (maximum < 150 && maximum - Math.min(r, g, b) < 35);
|
|
526
|
+
};
|
|
527
|
+
for (let start = 0; start < visited.length; start += 1) {
|
|
528
|
+
if (visited[start] || !dark(start))
|
|
529
|
+
continue;
|
|
530
|
+
let head = 0;
|
|
531
|
+
let tail = 0;
|
|
532
|
+
queue[tail++] = start;
|
|
533
|
+
visited[start] = 1;
|
|
534
|
+
let minX = bounds.width;
|
|
535
|
+
let minY = bounds.height;
|
|
536
|
+
let maxX = 0;
|
|
537
|
+
let maxY = 0;
|
|
538
|
+
while (head < tail) {
|
|
539
|
+
const pixel = queue[head++];
|
|
540
|
+
const x = pixel % bounds.width;
|
|
541
|
+
const y = Math.floor(pixel / bounds.width);
|
|
542
|
+
minX = Math.min(minX, x);
|
|
543
|
+
maxX = Math.max(maxX, x);
|
|
544
|
+
minY = Math.min(minY, y);
|
|
545
|
+
maxY = Math.max(maxY, y);
|
|
546
|
+
neighbors(pixel, bounds.width, bounds.height, (next) => {
|
|
547
|
+
if (!visited[next] && dark(next)) {
|
|
548
|
+
visited[next] = 1;
|
|
549
|
+
queue[tail++] = next;
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const height = maxY - minY + 1;
|
|
554
|
+
const boxArea = (maxX - minX + 1) * height;
|
|
555
|
+
if (tail < 5 ||
|
|
556
|
+
tail > Math.max(256, visited.length * 0.005) ||
|
|
557
|
+
tail / boxArea > 0.72 ||
|
|
558
|
+
height < 4)
|
|
559
|
+
continue;
|
|
560
|
+
const padding = Math.max(1, Math.round(height / 5));
|
|
561
|
+
let sea = 0;
|
|
562
|
+
let samples = 0;
|
|
563
|
+
for (let y = Math.max(0, minY - padding); y <= Math.min(bounds.height - 1, maxY + padding); y += Math.max(1, Math.floor(height / 8)))
|
|
564
|
+
for (const x of [minX - padding, maxX + padding]) {
|
|
565
|
+
if (x < 0 || x >= bounds.width)
|
|
566
|
+
continue;
|
|
567
|
+
samples += 1;
|
|
568
|
+
if (codes[(y + bounds.y) * width + x + bounds.x] === 0)
|
|
569
|
+
sea += 1;
|
|
570
|
+
}
|
|
571
|
+
if (samples > 0 && sea / samples >= 0.9)
|
|
572
|
+
candidates.push({ pixels: Int32Array.from(queue.subarray(0, tail)), minX, minY, maxX, maxY });
|
|
573
|
+
if (candidates.length > 1024)
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
let removed = 0;
|
|
577
|
+
for (const candidate of candidates) {
|
|
578
|
+
const height = candidate.maxY - candidate.minY + 1;
|
|
579
|
+
const center = (candidate.minY + candidate.maxY) / 2;
|
|
580
|
+
const aligned = candidates.filter((other) => {
|
|
581
|
+
const otherHeight = other.maxY - other.minY + 1;
|
|
582
|
+
const gap = Math.max(0, candidate.minX - other.maxX, other.minX - candidate.maxX);
|
|
583
|
+
return (Math.abs((other.minY + other.maxY) / 2 - center) <= height * 0.3 &&
|
|
584
|
+
otherHeight >= height * 0.6 &&
|
|
585
|
+
otherHeight <= height * 1.6 &&
|
|
586
|
+
gap <= height * 4);
|
|
587
|
+
});
|
|
588
|
+
if (aligned.length < 3)
|
|
589
|
+
continue;
|
|
590
|
+
for (const pixel of candidate.pixels)
|
|
591
|
+
codes[(Math.floor(pixel / bounds.width) + bounds.y) * width + (pixel % bounds.width) + bounds.x] = 255;
|
|
592
|
+
removed += 1;
|
|
593
|
+
}
|
|
594
|
+
if (removed)
|
|
595
|
+
diagnostics.push(`Masked ${removed} aligned dark lettering-like components surrounded by ocean; isolated dark islands are retained.`);
|
|
596
|
+
}
|
|
597
|
+
/** Smooth inferred relief at world scale, then join each shoreline at the land/sea thresholds. */
|
|
598
|
+
function normalizeElevation(result, seed, explicit) {
|
|
599
|
+
const { width, height, kind, terrain, elevation } = result;
|
|
600
|
+
const shortSide = Math.min(width, height);
|
|
601
|
+
if (!explicit) {
|
|
602
|
+
const scale = Math.max(16, shortSide / 6);
|
|
603
|
+
for (let y = 0; y < height; y += 1)
|
|
604
|
+
for (let x = 0; x < width; x += 1) {
|
|
605
|
+
const pixel = y * width + x;
|
|
606
|
+
if (kind[pixel] !== 1)
|
|
607
|
+
continue;
|
|
608
|
+
const noise = coherentNoise(seed, x, y, scale);
|
|
609
|
+
const base = TERRAIN_ELEVATION[terrain[pixel]];
|
|
610
|
+
elevation[pixel] = base * (0.82 + noise * 0.18) + noise * 0.009;
|
|
611
|
+
}
|
|
612
|
+
const temporary = new Float32Array(elevation.length);
|
|
613
|
+
const radius = Math.max(2, Math.round(shortSide / 180));
|
|
614
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
615
|
+
blurElevation(elevation, temporary, kind, width, height, radius, false);
|
|
616
|
+
blurElevation(temporary, elevation, kind, width, height, radius, true);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const distance = new Uint16Array(kind.length).fill(65535);
|
|
620
|
+
const queue = new Int32Array(kind.length);
|
|
621
|
+
let head = 0;
|
|
622
|
+
let tail = 0;
|
|
623
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1) {
|
|
624
|
+
const land = kind[pixel] === 1;
|
|
625
|
+
const x = pixel % width;
|
|
626
|
+
const coastal = (x > 0 && (kind[pixel - 1] === 1) !== land) ||
|
|
627
|
+
(x + 1 < width && (kind[pixel + 1] === 1) !== land) ||
|
|
628
|
+
(pixel >= width && (kind[pixel - width] === 1) !== land) ||
|
|
629
|
+
(pixel + width < kind.length && (kind[pixel + width] === 1) !== land);
|
|
630
|
+
if (coastal) {
|
|
631
|
+
distance[pixel] = 0;
|
|
632
|
+
queue[tail++] = pixel;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
while (head < tail) {
|
|
636
|
+
const pixel = queue[head++];
|
|
637
|
+
const land = kind[pixel] === 1;
|
|
638
|
+
neighbors(pixel, width, height, (next) => {
|
|
639
|
+
if (distance[next] === 65535 && (kind[next] === 1) === land) {
|
|
640
|
+
distance[next] = distance[pixel] + 1;
|
|
641
|
+
queue[tail++] = next;
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
const rampWidth = Math.max(4, Math.round(shortSide / 120));
|
|
646
|
+
let normalized = 0;
|
|
647
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1) {
|
|
648
|
+
const t = Math.min(1, distance[pixel] / rampWidth);
|
|
649
|
+
const factor = t * t * (3 - 2 * t);
|
|
650
|
+
if (kind[pixel] === 1) {
|
|
651
|
+
if (explicit && factor < 1 && elevation[pixel] !== 0)
|
|
652
|
+
normalized += 1;
|
|
653
|
+
elevation[pixel] = Math.max(0, Math.min(1, elevation[pixel])) * factor;
|
|
654
|
+
}
|
|
655
|
+
else if (kind[pixel] === 0)
|
|
656
|
+
elevation[pixel] = -0.15 * factor;
|
|
657
|
+
// Explicit lakes retain their fixed below-land surface rather than becoming relief noise.
|
|
658
|
+
else
|
|
659
|
+
elevation[pixel] = -0.02;
|
|
660
|
+
}
|
|
661
|
+
if (explicit && normalized > 0)
|
|
662
|
+
result.diagnostics.push(`Normalized ${normalized} explicit height-layer shoreline pixels through a ${rampWidth}-pixel coastal ramp; interior heights are preserved.`);
|
|
663
|
+
}
|
|
664
|
+
/** Sliding-window, land-only separable blur: water cannot darken adjacent terrestrial relief. */
|
|
665
|
+
function blurElevation(source, target, kind, width, height, radius, vertical) {
|
|
666
|
+
const lines = vertical ? width : height;
|
|
667
|
+
const length = vertical ? height : width;
|
|
668
|
+
const stride = vertical ? width : 1;
|
|
669
|
+
for (let line = 0; line < lines; line += 1) {
|
|
670
|
+
const start = vertical ? line : line * width;
|
|
671
|
+
let sum = 0;
|
|
672
|
+
let count = 0;
|
|
673
|
+
for (let coordinate = 0; coordinate <= Math.min(radius, length - 1); coordinate += 1) {
|
|
674
|
+
const pixel = start + coordinate * stride;
|
|
675
|
+
if (kind[pixel] === 1) {
|
|
676
|
+
sum += source[pixel];
|
|
677
|
+
count += 1;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
for (let coordinate = 0; coordinate < length; coordinate += 1) {
|
|
681
|
+
const pixel = start + coordinate * stride;
|
|
682
|
+
target[pixel] = kind[pixel] === 1 && count > 0 ? sum / count : source[pixel];
|
|
683
|
+
const remove = coordinate - radius;
|
|
684
|
+
if (remove >= 0) {
|
|
685
|
+
const previous = start + remove * stride;
|
|
686
|
+
if (kind[previous] === 1) {
|
|
687
|
+
sum -= source[previous];
|
|
688
|
+
count -= 1;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const add = coordinate + radius + 1;
|
|
692
|
+
if (add < length) {
|
|
693
|
+
const next = start + add * stride;
|
|
694
|
+
if (kind[next] === 1) {
|
|
695
|
+
sum += source[next];
|
|
696
|
+
count += 1;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/** Clip annotation segments so cropped-away coordinates never reach the world generator. */
|
|
703
|
+
function clipRiver(points, width, height) {
|
|
704
|
+
const paths = [];
|
|
705
|
+
let current = [];
|
|
706
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
707
|
+
const a = points[index - 1];
|
|
708
|
+
const b = points[index];
|
|
709
|
+
const dx = b.x - a.x;
|
|
710
|
+
const dy = b.y - a.y;
|
|
711
|
+
let minimum = 0;
|
|
712
|
+
let maximum = 1;
|
|
713
|
+
let visible = true;
|
|
714
|
+
for (const [p, q] of [
|
|
715
|
+
[-dx, a.x],
|
|
716
|
+
[dx, width - 1 - a.x],
|
|
717
|
+
[-dy, a.y],
|
|
718
|
+
[dy, height - 1 - a.y],
|
|
719
|
+
]) {
|
|
720
|
+
if (p === 0) {
|
|
721
|
+
if (q < 0)
|
|
722
|
+
visible = false;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const ratio = q / p;
|
|
726
|
+
if (p < 0)
|
|
727
|
+
minimum = Math.max(minimum, ratio);
|
|
728
|
+
else
|
|
729
|
+
maximum = Math.min(maximum, ratio);
|
|
730
|
+
}
|
|
731
|
+
if (!visible || minimum > maximum) {
|
|
732
|
+
if (current.length >= 2)
|
|
733
|
+
paths.push(current);
|
|
734
|
+
current = [];
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const start = { x: a.x + minimum * dx, y: a.y + minimum * dy };
|
|
738
|
+
const end = { x: a.x + maximum * dx, y: a.y + maximum * dy };
|
|
739
|
+
const previous = current.at(-1);
|
|
740
|
+
if (previous && (previous.x !== start.x || previous.y !== start.y)) {
|
|
741
|
+
if (current.length >= 2)
|
|
742
|
+
paths.push(current);
|
|
743
|
+
current = [];
|
|
744
|
+
}
|
|
745
|
+
if (current.length === 0)
|
|
746
|
+
current.push(start);
|
|
747
|
+
current.push(end);
|
|
748
|
+
}
|
|
749
|
+
if (current.length >= 2)
|
|
750
|
+
paths.push(current);
|
|
751
|
+
return paths;
|
|
752
|
+
}
|
|
753
|
+
/** Remove light paper hatching surrounded by blue water; broad white snow remains land. */
|
|
754
|
+
function cleanWaterTexture(codes, data, width, bounds, detail, diagnostics) {
|
|
755
|
+
let cleaned = 0;
|
|
756
|
+
const radius = Math.max(2, Math.min(24, Math.round((Math.min(bounds.width, bounds.height) / 240) * (2 - detail))));
|
|
757
|
+
const replacements = [];
|
|
758
|
+
for (let y = 0; y < bounds.height; y += 1)
|
|
759
|
+
for (let x = 0; x < bounds.width; x += 1) {
|
|
760
|
+
const pixel = (y + bounds.y) * width + x + bounds.x;
|
|
761
|
+
if (codes[pixel] === 0)
|
|
762
|
+
continue;
|
|
763
|
+
const color = pixelColor(data, y * bounds.width + x);
|
|
764
|
+
if (Math.min(color.r, color.g, color.b) < 160 ||
|
|
765
|
+
Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b) > 65)
|
|
766
|
+
continue;
|
|
767
|
+
let surrounding = 0;
|
|
768
|
+
let samples = 0;
|
|
769
|
+
for (const [dx, dy] of [
|
|
770
|
+
[-radius, 0],
|
|
771
|
+
[radius, 0],
|
|
772
|
+
[0, -radius],
|
|
773
|
+
[0, radius],
|
|
774
|
+
[-radius, -radius],
|
|
775
|
+
[radius, -radius],
|
|
776
|
+
[-radius, radius],
|
|
777
|
+
[radius, radius],
|
|
778
|
+
]) {
|
|
779
|
+
if (x + dx < 0 || x + dx >= bounds.width || y + dy < 0 || y + dy >= bounds.height)
|
|
780
|
+
continue;
|
|
781
|
+
samples += 1;
|
|
782
|
+
if (codes[pixel + dy * width + dx] === 0)
|
|
783
|
+
surrounding += 1;
|
|
784
|
+
}
|
|
785
|
+
if (samples >= 3 && surrounding >= Math.ceil(samples * 0.75))
|
|
786
|
+
replacements.push(pixel);
|
|
787
|
+
}
|
|
788
|
+
for (const pixel of replacements) {
|
|
789
|
+
codes[pixel] = 0;
|
|
790
|
+
cleaned += 1;
|
|
791
|
+
}
|
|
792
|
+
if (cleaned)
|
|
793
|
+
diagnostics.push(`Normalized ${cleaned} pale water-texture or paper-seam pixels surrounded by ocean color.`);
|
|
794
|
+
}
|
|
795
|
+
/** Water farther from any broad water interior than its own stroke width is a river candidate. */
|
|
796
|
+
function traceCoastalRivers(kind, width, height, rivers, diagnostics) {
|
|
797
|
+
const distanceToLand = new Uint16Array(kind.length).fill(65535);
|
|
798
|
+
const queue = new Int32Array(kind.length);
|
|
799
|
+
let head = 0;
|
|
800
|
+
let tail = 0;
|
|
801
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1)
|
|
802
|
+
if (kind[pixel] === 1) {
|
|
803
|
+
distanceToLand[pixel] = 0;
|
|
804
|
+
queue[tail++] = pixel;
|
|
805
|
+
}
|
|
806
|
+
if (tail === 0)
|
|
807
|
+
return;
|
|
808
|
+
while (head < tail) {
|
|
809
|
+
const pixel = queue[head++];
|
|
810
|
+
neighbors(pixel, width, height, (next) => {
|
|
811
|
+
if (distanceToLand[next] === 65535) {
|
|
812
|
+
distanceToLand[next] = distanceToLand[pixel] + 1;
|
|
813
|
+
queue[tail++] = next;
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
const stroke = Math.max(1, Math.min(6, Math.round(Math.min(width, height) / 450)));
|
|
818
|
+
const coreDistance = new Uint16Array(kind.length).fill(65535);
|
|
819
|
+
head = 0;
|
|
820
|
+
tail = 0;
|
|
821
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1)
|
|
822
|
+
if (kind[pixel] === 0 && distanceToLand[pixel] > stroke) {
|
|
823
|
+
coreDistance[pixel] = 0;
|
|
824
|
+
queue[tail++] = pixel;
|
|
825
|
+
}
|
|
826
|
+
while (head < tail) {
|
|
827
|
+
const pixel = queue[head++];
|
|
828
|
+
neighbors(pixel, width, height, (next) => {
|
|
829
|
+
if (kind[next] === 0 && coreDistance[next] === 65535) {
|
|
830
|
+
coreDistance[next] = coreDistance[pixel] + 1;
|
|
831
|
+
queue[tail++] = next;
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
const candidates = new Uint8Array(kind.length);
|
|
836
|
+
for (let pixel = 0; pixel < kind.length; pixel += 1)
|
|
837
|
+
if (kind[pixel] === 0 &&
|
|
838
|
+
coreDistance[pixel] !== 65535 &&
|
|
839
|
+
coreDistance[pixel] > stroke * 3 &&
|
|
840
|
+
distanceToLand[pixel] <= stroke)
|
|
841
|
+
candidates[pixel] = 1;
|
|
842
|
+
let traced = 0;
|
|
843
|
+
for (let start = 0; start < candidates.length; start += 1) {
|
|
844
|
+
if (candidates[start] !== 1)
|
|
845
|
+
continue;
|
|
846
|
+
head = 0;
|
|
847
|
+
tail = 0;
|
|
848
|
+
queue[tail++] = start;
|
|
849
|
+
candidates[start] = 2;
|
|
850
|
+
while (head < tail)
|
|
851
|
+
neighbors(queue[head++], width, height, (next) => {
|
|
852
|
+
if (candidates[next] === 1) {
|
|
853
|
+
candidates[next] = 2;
|
|
854
|
+
queue[tail++] = next;
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
if (tail < Math.max(12, stroke * 8))
|
|
858
|
+
continue;
|
|
859
|
+
// A passage with water at both ends is a strait. Only terminal thin channels are rivers.
|
|
860
|
+
let terminal = false;
|
|
861
|
+
for (let index = 0; index < tail; index += 1) {
|
|
862
|
+
let waterNeighbors = 0;
|
|
863
|
+
neighbors(queue[index], width, height, (next) => {
|
|
864
|
+
if (kind[next] === 0)
|
|
865
|
+
waterNeighbors += 1;
|
|
866
|
+
});
|
|
867
|
+
if (waterNeighbors <= 1) {
|
|
868
|
+
terminal = true;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (!terminal)
|
|
873
|
+
continue;
|
|
874
|
+
if (rivers.length < 512) {
|
|
875
|
+
const path = connectedPath(queue.subarray(0, tail), width, height);
|
|
876
|
+
if (path.length >= 2) {
|
|
877
|
+
rivers.push(path);
|
|
878
|
+
traced += 1;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
for (let index = 0; index < tail; index += 1)
|
|
882
|
+
kind[queue[index]] = 1;
|
|
883
|
+
}
|
|
884
|
+
if (traced)
|
|
885
|
+
diagnostics.push(`Traced ${traced} narrow visible river channels adjoining broad water.`);
|
|
886
|
+
}
|
|
887
|
+
/** Geodesic ordering follows bends rather than sorting river pixels by a coordinate. */
|
|
888
|
+
function connectedPath(pixels, width, height) {
|
|
889
|
+
if (pixels.length > 262144)
|
|
890
|
+
throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Visible river component exceeds the bounded trace budget');
|
|
891
|
+
const available = new Set(pixels);
|
|
892
|
+
const parents = new Map();
|
|
893
|
+
let farthest = pixels[0];
|
|
894
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
895
|
+
parents.clear();
|
|
896
|
+
parents.set(farthest, -1);
|
|
897
|
+
const queue = [farthest];
|
|
898
|
+
for (const pixel of queue) {
|
|
899
|
+
farthest = pixel;
|
|
900
|
+
neighbors(pixel, width, height, (next) => {
|
|
901
|
+
if (available.has(next) && !parents.has(next)) {
|
|
902
|
+
parents.set(next, pixel);
|
|
903
|
+
queue.push(next);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const path = [];
|
|
909
|
+
let pixel = farthest;
|
|
910
|
+
while (pixel !== -1) {
|
|
911
|
+
path.push({ x: pixel % width, y: Math.floor(pixel / width) });
|
|
912
|
+
pixel = parents.get(pixel) ?? -1;
|
|
913
|
+
}
|
|
914
|
+
if (path.length > 16384)
|
|
915
|
+
throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Visible river path exceeds the bounded trace length; use a smaller reference output');
|
|
916
|
+
return path;
|
|
917
|
+
}
|
|
918
|
+
function normalizeWater(kind, width, height, rivers, diagnostics) {
|
|
919
|
+
const visited = new Uint8Array(kind.length);
|
|
920
|
+
const queue = new Int32Array(kind.length);
|
|
921
|
+
let lakeCount = 0;
|
|
922
|
+
for (let start = 0; start < kind.length; start += 1) {
|
|
923
|
+
if (kind[start] !== 0 || visited[start])
|
|
924
|
+
continue;
|
|
925
|
+
let head = 0;
|
|
926
|
+
let tail = 0;
|
|
927
|
+
let border = false;
|
|
928
|
+
let minX = width;
|
|
929
|
+
let maxX = 0;
|
|
930
|
+
let minY = height;
|
|
931
|
+
let maxY = 0;
|
|
932
|
+
queue[tail++] = start;
|
|
933
|
+
visited[start] = 1;
|
|
934
|
+
while (head < tail) {
|
|
935
|
+
const pixel = queue[head++];
|
|
936
|
+
const x = pixel % width;
|
|
937
|
+
const y = Math.floor(pixel / width);
|
|
938
|
+
minX = Math.min(minX, x);
|
|
939
|
+
maxX = Math.max(maxX, x);
|
|
940
|
+
minY = Math.min(minY, y);
|
|
941
|
+
maxY = Math.max(maxY, y);
|
|
942
|
+
if (x === 0 || x === width - 1 || y === 0 || y === height - 1)
|
|
943
|
+
border = true;
|
|
944
|
+
neighbors(pixel, width, height, (next) => {
|
|
945
|
+
if (kind[next] === 0 && !visited[next]) {
|
|
946
|
+
visited[next] = 1;
|
|
947
|
+
queue[tail++] = next;
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
if (border)
|
|
952
|
+
continue;
|
|
953
|
+
const spanX = maxX - minX + 1;
|
|
954
|
+
const spanY = maxY - minY + 1;
|
|
955
|
+
const thin = tail / Math.max(spanX, spanY) <= Math.max(2, Math.min(width, height) / 350);
|
|
956
|
+
if (thin && Math.max(spanX, spanY) >= Math.max(5, Math.min(width, height) / 100)) {
|
|
957
|
+
const points = connectedPath(queue.subarray(0, tail), width, height);
|
|
958
|
+
for (let index = 0; index < tail; index += 1)
|
|
959
|
+
kind[queue[index]] = 1;
|
|
960
|
+
if (rivers.length < 512)
|
|
961
|
+
rivers.push(points);
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
for (let index = 0; index < tail; index += 1)
|
|
965
|
+
kind[queue[index]] = 2;
|
|
966
|
+
lakeCount += 1;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
diagnostics.push(`Detected ${lakeCount} enclosed water bodies and ${rivers.length} visible or annotated river paths.`);
|
|
970
|
+
}
|
|
971
|
+
function repairCorners(kind, width, height, diagnostics) {
|
|
972
|
+
let repaired = 0;
|
|
973
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
974
|
+
let changed = 0;
|
|
975
|
+
for (let y = 0; y + 1 < height; y += 1)
|
|
976
|
+
for (let x = 0; x < width; x += 1) {
|
|
977
|
+
const a = y * width + x;
|
|
978
|
+
const b = y * width + ((x + 1) % width);
|
|
979
|
+
const c = a + width;
|
|
980
|
+
const d = b + width;
|
|
981
|
+
if (kind[a] !== kind[b] &&
|
|
982
|
+
kind[a] !== kind[c] &&
|
|
983
|
+
kind[d] !== kind[b] &&
|
|
984
|
+
kind[d] !== kind[c]) {
|
|
985
|
+
kind[d] = kind[c];
|
|
986
|
+
changed += 1;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
repaired += changed;
|
|
990
|
+
if (changed === 0)
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
if (repaired)
|
|
994
|
+
diagnostics.push(`Repaired ${repaired} subpixel diagonal water/land crossings for connected province geometry.`);
|
|
995
|
+
}
|
|
996
|
+
function inferCountries(result, data, mode) {
|
|
997
|
+
const { referenceBounds: bounds, kind, width, country } = result;
|
|
998
|
+
const bins = new Map();
|
|
999
|
+
let land = 0;
|
|
1000
|
+
if (mode === 'political')
|
|
1001
|
+
for (let y = 0; y < bounds.height; y += 1)
|
|
1002
|
+
for (let x = 0; x < bounds.width; x += 1) {
|
|
1003
|
+
if (kind[(y + bounds.y) * width + x + bounds.x] !== 1)
|
|
1004
|
+
continue;
|
|
1005
|
+
const color = pixelColor(data, y * bounds.width + x);
|
|
1006
|
+
if (Math.max(color.r, color.g, color.b) < 65)
|
|
1007
|
+
continue;
|
|
1008
|
+
const key = ((color.r >> 4) << 8) | ((color.g >> 4) << 4) | (color.b >> 4);
|
|
1009
|
+
const entry = bins.get(key);
|
|
1010
|
+
if (entry)
|
|
1011
|
+
entry.count += 1;
|
|
1012
|
+
else
|
|
1013
|
+
bins.set(key, { color, count: 1 });
|
|
1014
|
+
land += 1;
|
|
1015
|
+
}
|
|
1016
|
+
const colors = [...bins.entries()]
|
|
1017
|
+
.sort((a, b) => b[1].count - a[1].count || a[0] - b[0])
|
|
1018
|
+
.filter((entry) => entry[1].count >= Math.max(4, land * 0.015))
|
|
1019
|
+
.map((entry) => entry[1].color);
|
|
1020
|
+
const distinct = [];
|
|
1021
|
+
for (const color of colors)
|
|
1022
|
+
if (distinct.every((known) => distance(color, known) > 40 ** 2) && distinct.length < 32)
|
|
1023
|
+
distinct.push(color);
|
|
1024
|
+
if (distinct.length < 2) {
|
|
1025
|
+
distinct.splice(0, distinct.length, { r: 90, g: 145, b: 95 });
|
|
1026
|
+
result.diagnostics.push('Political borders could not be inferred confidently; land uses one provisional country.');
|
|
1027
|
+
}
|
|
1028
|
+
else
|
|
1029
|
+
result.diagnostics.push(`Inferred ${distinct.length} provisional countries from flat political colors; names require labels or supplied countries.`);
|
|
1030
|
+
result.inferredCountries = distinct.map((color, index) => ({
|
|
1031
|
+
id: index + 1,
|
|
1032
|
+
name: `Reference country ${index + 1}`,
|
|
1033
|
+
color,
|
|
1034
|
+
}));
|
|
1035
|
+
for (let y = 0; y < result.height; y += 1)
|
|
1036
|
+
for (let x = 0; x < width; x += 1) {
|
|
1037
|
+
const pixel = y * width + x;
|
|
1038
|
+
if (kind[pixel] !== 1)
|
|
1039
|
+
continue;
|
|
1040
|
+
let best = 0;
|
|
1041
|
+
if (distinct.length > 1 &&
|
|
1042
|
+
x >= bounds.x &&
|
|
1043
|
+
y >= bounds.y &&
|
|
1044
|
+
x < bounds.x + bounds.width &&
|
|
1045
|
+
y < bounds.y + bounds.height) {
|
|
1046
|
+
const color = pixelColor(data, (y - bounds.y) * bounds.width + x - bounds.x);
|
|
1047
|
+
let nearest = Infinity;
|
|
1048
|
+
distinct.forEach((entry, index) => {
|
|
1049
|
+
const score = distance(color, entry);
|
|
1050
|
+
if (score < nearest) {
|
|
1051
|
+
nearest = score;
|
|
1052
|
+
best = index;
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
country[pixel] = best + 1;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
async function applyLayers(result, options, crop) {
|
|
1060
|
+
for (const name of ['terrainImageBytes', 'heightImageBytes', 'politicalImageBytes']) {
|
|
1061
|
+
const bytes = options[name];
|
|
1062
|
+
if (!bytes)
|
|
1063
|
+
continue;
|
|
1064
|
+
const source = await decode(bytes);
|
|
1065
|
+
if (source.width !== result.sourceWidth || source.height !== result.sourceHeight)
|
|
1066
|
+
throw new ServiceError('MAP_REFERENCE_LAYER_ALIGNMENT', 'Reference layers must have the same decoded dimensions');
|
|
1067
|
+
const bounds = result.referenceBounds;
|
|
1068
|
+
const data = await sharp(source.data, {
|
|
1069
|
+
raw: { width: source.width, height: source.height, channels: 3 },
|
|
1070
|
+
})
|
|
1071
|
+
.extract({ left: crop.x, top: crop.y, width: crop.width, height: crop.height })
|
|
1072
|
+
.resize(bounds.width, bounds.height, { kernel: sharp.kernel.nearest })
|
|
1073
|
+
.raw()
|
|
1074
|
+
.toBuffer();
|
|
1075
|
+
if (name === 'politicalImageBytes') {
|
|
1076
|
+
inferCountries(result, data, 'political');
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
for (let y = 0; y < bounds.height; y += 1)
|
|
1080
|
+
for (let x = 0; x < bounds.width; x += 1) {
|
|
1081
|
+
const pixel = (y + bounds.y) * result.width + x + bounds.x;
|
|
1082
|
+
if (result.kind[pixel] !== 1)
|
|
1083
|
+
continue;
|
|
1084
|
+
const color = pixelColor(data, y * bounds.width + x);
|
|
1085
|
+
if (name === 'heightImageBytes')
|
|
1086
|
+
result.elevation[pixel] = (color.r + color.g + color.b) / 765;
|
|
1087
|
+
else {
|
|
1088
|
+
const code = classify(color, 'illustrated', options.palette ?? []);
|
|
1089
|
+
if (code >= 3) {
|
|
1090
|
+
result.terrain[pixel] = code - 3;
|
|
1091
|
+
result.elevation[pixel] = TERRAIN_ELEVATION[code - 3];
|
|
1092
|
+
result.moisture[pixel] = TERRAIN_MOISTURE[code - 3];
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|