hoi4-agent-tools 4.3.0 → 4.5.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 +33 -1
- package/dist/hoi4_agent_tools/core/bounded-source.js +31 -9
- package/dist/hoi4_agent_tools/core/configuration.js +11 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +26 -6
- package/dist/hoi4_agent_tools/core/operation-context.js +17 -1
- package/dist/hoi4_agent_tools/core/source/encoding.js +56 -2
- package/dist/hoi4_agent_tools/core/source/parser.js +16 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +7 -2
- package/dist/hoi4_agent_tools/core/transaction-limits.js +18 -4
- package/dist/hoi4_agent_tools/core/transactions.js +23 -4
- package/dist/hoi4_agent_tools/core/workspace-json.js +54 -0
- package/dist/hoi4_agent_tools/core/workspace.js +42 -6
- package/dist/hoi4_agent_tools/focus/drift.js +34 -0
- package/dist/hoi4_agent_tools/focus/service.js +9 -3
- package/dist/hoi4_agent_tools/focus/tool-operations.js +2 -0
- package/dist/hoi4_agent_tools/map/appearance-validation.js +1110 -0
- package/dist/hoi4_agent_tools/map/bmp.js +9 -1
- package/dist/hoi4_agent_tools/map/border-quality-input.js +209 -0
- package/dist/hoi4_agent_tools/map/border-quality.js +1180 -0
- package/dist/hoi4_agent_tools/map/colormap.js +128 -0
- package/dist/hoi4_agent_tools/map/geography/geometry.js +284 -0
- package/dist/hoi4_agent_tools/map/geography/index.js +6 -0
- package/dist/hoi4_agent_tools/map/geography/model.js +196 -0
- package/dist/hoi4_agent_tools/map/geography/preparation.js +120 -0
- package/dist/hoi4_agent_tools/map/geography/preview.js +647 -0
- package/dist/hoi4_agent_tools/map/geography/raster.js +253 -0
- package/dist/hoi4_agent_tools/map/geography/resolve.js +1467 -0
- package/dist/hoi4_agent_tools/map/model.js +17 -3
- package/dist/hoi4_agent_tools/map/operations.js +284 -14
- package/dist/hoi4_agent_tools/map/partition-fields.js +398 -0
- package/dist/hoi4_agent_tools/map/partition-geography.js +113 -0
- package/dist/hoi4_agent_tools/map/partition-graph.js +165 -0
- package/dist/hoi4_agent_tools/map/partition-groups.js +164 -0
- package/dist/hoi4_agent_tools/map/partition-provinces.js +727 -0
- package/dist/hoi4_agent_tools/map/partition-tuning.js +101 -0
- package/dist/hoi4_agent_tools/map/partition-watershed.js +252 -0
- package/dist/hoi4_agent_tools/map/partition.js +163 -0
- package/dist/hoi4_agent_tools/map/reference-import.js +333 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +142 -6
- package/dist/hoi4_agent_tools/map/reference-sheets.js +231 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +104 -13
- package/dist/hoi4_agent_tools/map/relief/checks.js +281 -0
- package/dist/hoi4_agent_tools/map/relief/fields.js +15 -0
- package/dist/hoi4_agent_tools/map/relief/full-resolution.js +133 -0
- package/dist/hoi4_agent_tools/map/relief/grid.js +190 -0
- package/dist/hoi4_agent_tools/map/relief/hydrology.js +1315 -0
- package/dist/hoi4_agent_tools/map/relief/index.js +27 -0
- package/dist/hoi4_agent_tools/map/relief/land.js +110 -0
- package/dist/hoi4_agent_tools/map/relief/macro-relief.js +535 -0
- package/dist/hoi4_agent_tools/map/relief/math.js +93 -0
- package/dist/hoi4_agent_tools/map/relief/river-raster.js +680 -0
- package/dist/hoi4_agent_tools/map/relief/terrain-synthesis.js +304 -0
- package/dist/hoi4_agent_tools/map/render.js +36 -0
- package/dist/hoi4_agent_tools/map/service.js +206 -16
- package/dist/hoi4_agent_tools/map/tool-operations.js +249 -3
- package/dist/hoi4_agent_tools/map/validation.js +9 -0
- package/dist/hoi4_agent_tools/map/water.js +227 -0
- package/dist/hoi4_agent_tools/map/world-context.js +47 -1
- package/dist/hoi4_agent_tools/map/world-edge.js +335 -0
- package/dist/hoi4_agent_tools/map/world-noise.js +34 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +22 -10
- package/dist/hoi4_agent_tools/map/world-partition.js +119 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +131 -56
- package/dist/hoi4_agent_tools/map/world-realms.js +152 -0
- package/dist/hoi4_agent_tools/map/world.js +562 -163
- package/dist/hoi4_agent_tools/mcp/tools/map.js +69 -1
- package/dist/hoi4_agent_tools/reference/operations.js +3 -2
- package/dist/hoi4_agent_tools/schemas/geography.js +325 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +20 -2
- package/dist/hoi4_agent_tools/schemas/map.js +280 -15
- package/dist/hoi4_agent_tools/schemas/reference-sheets.js +92 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/configuration.md +17 -14
- package/docs/focus.md +6 -0
- package/docs/map.md +292 -15
- package/docs/tools.md +4 -4
- package/docs/troubleshooting.md +11 -11
- package/npm-shrinkwrap.json +109 -91
- package/package.json +3 -3
- package/schemas/configuration.schema.json +65 -58
- 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/geography-model.schema.json +1148 -0
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +5 -11
- 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 +2325 -292
- 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 +25 -7
- 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/reference-sheets.schema.json +267 -0
- 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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration of a picture made of drawn sheets onto the world frame (ADR 0067, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Every sheet becomes one affine map from normalised picture coordinates to normalised world
|
|
5
|
+
* coordinates. Each world pixel is mapped back by the inverse of the highest-order sheet whose box
|
|
6
|
+
* (and clip) holds it and sampled from the working picture, so the result is one frame-aligned
|
|
7
|
+
* picture from which every map layer is derived. Only `+ - * /` are used, so results are the same
|
|
8
|
+
* on every platform.
|
|
9
|
+
*/
|
|
10
|
+
import { ServiceError } from '../core/result.js';
|
|
11
|
+
const NONE = 255;
|
|
12
|
+
const UNCOVERED_COLOUR = [0x24, 0x5b, 0x82];
|
|
13
|
+
export function sheetAffine(sheet) {
|
|
14
|
+
if (sheet.affine !== undefined)
|
|
15
|
+
return sheet.affine;
|
|
16
|
+
const [anchorX, anchorY] = sheet.anchor;
|
|
17
|
+
const [targetX, targetY] = sheet.target;
|
|
18
|
+
const [scaleX, scaleY] = sheet.scale;
|
|
19
|
+
return [scaleX, 0, targetX - scaleX * anchorX, 0, scaleY, targetY - scaleY * anchorY];
|
|
20
|
+
}
|
|
21
|
+
export function invertAffine(affine) {
|
|
22
|
+
const [a, b, c, d, e, f] = affine;
|
|
23
|
+
const determinant = a * e - b * d;
|
|
24
|
+
if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-12)
|
|
25
|
+
return undefined;
|
|
26
|
+
const ia = e / determinant;
|
|
27
|
+
const ib = -b / determinant;
|
|
28
|
+
const id = -d / determinant;
|
|
29
|
+
const ie = a / determinant;
|
|
30
|
+
return [ia, ib, -(ia * c + ib * f), id, ie, -(id * c + ie * f)];
|
|
31
|
+
}
|
|
32
|
+
export function applyAffine(affine, x, y) {
|
|
33
|
+
return [affine[0] * x + affine[1] * y + affine[2], affine[3] * x + affine[4] * y + affine[5]];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Check a document against the picture and the frame and build each sheet's maps. Throws for a
|
|
37
|
+
* picture the registrations were not made for, a degenerate map or a sheet entirely outside the
|
|
38
|
+
* frame.
|
|
39
|
+
*/
|
|
40
|
+
export function registerSheets(document, frame, picture) {
|
|
41
|
+
if (document.source !== undefined &&
|
|
42
|
+
(document.source.width !== picture.width || document.source.height !== picture.height))
|
|
43
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_SOURCE_MISMATCH', `The sheet registrations were made for a ${document.source.width} x ${document.source.height} picture, but the reference is ${picture.width} x ${picture.height}`, {
|
|
44
|
+
expectedWidth: document.source.width,
|
|
45
|
+
expectedHeight: document.source.height,
|
|
46
|
+
width: picture.width,
|
|
47
|
+
height: picture.height,
|
|
48
|
+
});
|
|
49
|
+
if (document.source?.sha256 !== undefined && document.source.sha256 !== picture.sha256)
|
|
50
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_SOURCE_MISMATCH', 'The sheet registrations were made for a picture with another SHA-256', { expectedSha256: document.source.sha256, sha256: picture.sha256 });
|
|
51
|
+
return document.sheets.map((sheet, index) => {
|
|
52
|
+
const forward = sheetAffine(sheet);
|
|
53
|
+
const inverse = invertAffine(forward);
|
|
54
|
+
if (inverse === undefined)
|
|
55
|
+
throw new ServiceError('MAP_REFERENCE_SHEET_DEGENERATE', `Sheet ${sheet.id} maps its box onto a line or a point`, { sheetId: sheet.id });
|
|
56
|
+
const [left, top, right, bottom] = sheet.box;
|
|
57
|
+
const corners = [
|
|
58
|
+
applyAffine(forward, left, top),
|
|
59
|
+
applyAffine(forward, right, top),
|
|
60
|
+
applyAffine(forward, left, bottom),
|
|
61
|
+
applyAffine(forward, right, bottom),
|
|
62
|
+
];
|
|
63
|
+
const minX = Math.min(...corners.map(([x]) => x));
|
|
64
|
+
const maxX = Math.max(...corners.map(([x]) => x));
|
|
65
|
+
const minY = Math.min(...corners.map(([, y]) => y));
|
|
66
|
+
const maxY = Math.max(...corners.map(([, y]) => y));
|
|
67
|
+
if (maxX <= 0 || minX >= 1 || maxY <= 0 || minY >= 1)
|
|
68
|
+
throw new ServiceError('MAP_REFERENCE_SHEET_OUTSIDE', `Sheet ${sheet.id} lies entirely outside the world frame`, { sheetId: sheet.id, bounds: { left: minX, top: minY, right: maxX, bottom: maxY } });
|
|
69
|
+
const clip = sheet.clip === undefined
|
|
70
|
+
? undefined
|
|
71
|
+
: Float64Array.from(sheet.clip.flatMap(([x, y]) => [x, y]));
|
|
72
|
+
return {
|
|
73
|
+
id: sheet.id,
|
|
74
|
+
order: sheet.order,
|
|
75
|
+
index,
|
|
76
|
+
forward,
|
|
77
|
+
inverse,
|
|
78
|
+
box: sheet.box,
|
|
79
|
+
...(clip === undefined ? {} : { clip }),
|
|
80
|
+
pixels: {
|
|
81
|
+
minX: Math.max(0, Math.floor(minX * frame.width)),
|
|
82
|
+
minY: Math.max(0, Math.floor(minY * frame.height)),
|
|
83
|
+
maxX: Math.min(frame.width - 1, Math.ceil(maxX * frame.width) - 1),
|
|
84
|
+
maxY: Math.min(frame.height - 1, Math.ceil(maxY * frame.height) - 1),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Working picture size at which every sheet still has at least one working pixel per world
|
|
91
|
+
* pixel: the longest world distance one picture unit travels along each picture axis.
|
|
92
|
+
*/
|
|
93
|
+
export function sheetWorkingSize(forwards, frame) {
|
|
94
|
+
let width = 1;
|
|
95
|
+
let height = 1;
|
|
96
|
+
for (const forward of forwards) {
|
|
97
|
+
const [a, b, , d, e] = forward;
|
|
98
|
+
width = Math.max(width, Math.sqrt((a * frame.width) ** 2 + (d * frame.height) ** 2));
|
|
99
|
+
height = Math.max(height, Math.sqrt((b * frame.width) ** 2 + (e * frame.height) ** 2));
|
|
100
|
+
}
|
|
101
|
+
return { width: Math.ceil(width), height: Math.ceil(height) };
|
|
102
|
+
}
|
|
103
|
+
function insideSheet(sheet, x, y) {
|
|
104
|
+
const [left, top, right, bottom] = sheet.box;
|
|
105
|
+
if (x < left || x >= right || y < top || y >= bottom)
|
|
106
|
+
return false;
|
|
107
|
+
const clip = sheet.clip;
|
|
108
|
+
if (clip === undefined)
|
|
109
|
+
return true;
|
|
110
|
+
let inside = false;
|
|
111
|
+
for (let index = 0, previous = clip.length - 2; index < clip.length; previous = index, index += 2) {
|
|
112
|
+
const ax = clip[index];
|
|
113
|
+
const ay = clip[index + 1];
|
|
114
|
+
const bx = clip[previous];
|
|
115
|
+
const by = clip[previous + 1];
|
|
116
|
+
if (ay > y !== by > y && x < ((bx - ax) * (y - ay)) / (by - ay) + ax)
|
|
117
|
+
inside = !inside;
|
|
118
|
+
}
|
|
119
|
+
return inside;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Warp the working picture (raw RGB) into the frame. `nearest` sampling keeps the colours of an
|
|
123
|
+
* indexed picture; `bilinear` blends neighbours for drawings. Throws when two sheets of equal
|
|
124
|
+
* order both cover a world pixel, and when `fill` is `error` and a world pixel has no sheet.
|
|
125
|
+
*/
|
|
126
|
+
export function warpSheets(picture, sheets, frame, options) {
|
|
127
|
+
const { width, height } = frame;
|
|
128
|
+
if (sheets.length > NONE)
|
|
129
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_LIMIT', 'Too many sheets');
|
|
130
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
131
|
+
const owner = new Uint8Array(width * height).fill(NONE);
|
|
132
|
+
const ordered = [...sheets].sort((left, right) => right.order - left.order || left.index - right.index);
|
|
133
|
+
const taken = new Float64Array(sheets.length);
|
|
134
|
+
const hidden = new Float64Array(sheets.length);
|
|
135
|
+
let ties = 0;
|
|
136
|
+
let tieSample;
|
|
137
|
+
const pictureWidth = picture.width;
|
|
138
|
+
const pictureHeight = picture.height;
|
|
139
|
+
const data = picture.data;
|
|
140
|
+
for (let y = 0; y < height; y += 1) {
|
|
141
|
+
if ((y & 63) === 0)
|
|
142
|
+
options.signal?.throwIfAborted();
|
|
143
|
+
const worldY = (y + 0.5) / height;
|
|
144
|
+
for (const sheet of ordered) {
|
|
145
|
+
const { minX, maxX, minY, maxY } = sheet.pixels;
|
|
146
|
+
if (y < minY || y > maxY)
|
|
147
|
+
continue;
|
|
148
|
+
const [ia, ib, ic, id, ie, iff] = sheet.inverse;
|
|
149
|
+
for (let x = minX; x <= maxX; x += 1) {
|
|
150
|
+
const worldX = (x + 0.5) / width;
|
|
151
|
+
const pictureX = ia * worldX + ib * worldY + ic;
|
|
152
|
+
const pictureY = id * worldX + ie * worldY + iff;
|
|
153
|
+
if (!insideSheet(sheet, pictureX, pictureY))
|
|
154
|
+
continue;
|
|
155
|
+
const pixel = y * width + x;
|
|
156
|
+
const current = owner[pixel];
|
|
157
|
+
if (current !== NONE) {
|
|
158
|
+
hidden[sheet.index] = hidden[sheet.index] + 1;
|
|
159
|
+
if (sheets[current].order === sheet.order) {
|
|
160
|
+
ties += 1;
|
|
161
|
+
tieSample ??= { x, y, sheets: [sheets[current].id, sheet.id] };
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
owner[pixel] = sheet.index;
|
|
166
|
+
taken[sheet.index] = taken[sheet.index] + 1;
|
|
167
|
+
const sourceX = pictureX * pictureWidth - 0.5;
|
|
168
|
+
const sourceY = pictureY * pictureHeight - 0.5;
|
|
169
|
+
const target = pixel * 3;
|
|
170
|
+
if (options.sampling === 'nearest') {
|
|
171
|
+
const column = Math.min(pictureWidth - 1, Math.max(0, Math.round(sourceX)));
|
|
172
|
+
const row = Math.min(pictureHeight - 1, Math.max(0, Math.round(sourceY)));
|
|
173
|
+
const source = (row * pictureWidth + column) * 3;
|
|
174
|
+
rgb[target] = data[source];
|
|
175
|
+
rgb[target + 1] = data[source + 1];
|
|
176
|
+
rgb[target + 2] = data[source + 2];
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const left = Math.min(pictureWidth - 1, Math.max(0, Math.floor(sourceX)));
|
|
180
|
+
const top = Math.min(pictureHeight - 1, Math.max(0, Math.floor(sourceY)));
|
|
181
|
+
const right = Math.min(pictureWidth - 1, left + 1);
|
|
182
|
+
const bottom = Math.min(pictureHeight - 1, top + 1);
|
|
183
|
+
const fx = Math.min(1, Math.max(0, sourceX - left));
|
|
184
|
+
const fy = Math.min(1, Math.max(0, sourceY - top));
|
|
185
|
+
const p00 = (top * pictureWidth + left) * 3;
|
|
186
|
+
const p10 = (top * pictureWidth + right) * 3;
|
|
187
|
+
const p01 = (bottom * pictureWidth + left) * 3;
|
|
188
|
+
const p11 = (bottom * pictureWidth + right) * 3;
|
|
189
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
190
|
+
const upper = data[p00 + channel] + (data[p10 + channel] - data[p00 + channel]) * fx;
|
|
191
|
+
const lower = data[p01 + channel] + (data[p11 + channel] - data[p01 + channel]) * fx;
|
|
192
|
+
rgb[target + channel] = Math.round(upper + (lower - upper) * fy);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (ties > 0)
|
|
199
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_OVERLAP_ORDER', `${ties} world pixels are covered by two sheets of the same order (first at ${tieSample.x}, ${tieSample.y}: ${tieSample.sheets.join(' and ')}); give one of them a higher order or clip it`, { pixels: ties, sample: tieSample });
|
|
200
|
+
let uncovered = 0;
|
|
201
|
+
let uncoveredSample;
|
|
202
|
+
for (let pixel = 0; pixel < owner.length; pixel += 1)
|
|
203
|
+
if (owner[pixel] === NONE) {
|
|
204
|
+
uncovered += 1;
|
|
205
|
+
uncoveredSample ??= { x: pixel % width, y: Math.floor(pixel / width) };
|
|
206
|
+
// The open-sea colour every heuristic mode reads as water, so no ink appears at sheet edges.
|
|
207
|
+
rgb.set(UNCOVERED_COLOUR, pixel * 3);
|
|
208
|
+
}
|
|
209
|
+
if (uncovered > 0 && options.fill === 'error')
|
|
210
|
+
throw new ServiceError('MAP_REFERENCE_SHEETS_UNCOVERED', `${uncovered} world pixels lie outside every sheet (first at ${uncoveredSample.x}, ${uncoveredSample.y}); add a sheet or set fill to "sea"`, { pixels: uncovered, sample: uncoveredSample });
|
|
211
|
+
const coverage = sheets.map((sheet) => {
|
|
212
|
+
const [left, top, right, bottom] = sheet.box;
|
|
213
|
+
const corners = [
|
|
214
|
+
applyAffine(sheet.forward, left, top),
|
|
215
|
+
applyAffine(sheet.forward, right, bottom),
|
|
216
|
+
];
|
|
217
|
+
return {
|
|
218
|
+
id: sheet.id,
|
|
219
|
+
order: sheet.order,
|
|
220
|
+
pixels: taken[sheet.index],
|
|
221
|
+
hidden: hidden[sheet.index],
|
|
222
|
+
bounds: {
|
|
223
|
+
left: Math.min(corners[0][0], corners[1][0]),
|
|
224
|
+
top: Math.min(corners[0][1], corners[1][1]),
|
|
225
|
+
right: Math.max(corners[0][0], corners[1][0]),
|
|
226
|
+
bottom: Math.max(corners[0][1], corners[1][1]),
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
return { width, height, rgb, owner, coverage, uncovered };
|
|
231
|
+
}
|
|
@@ -75,12 +75,22 @@ export const referenceWorldOptionsSchema = z
|
|
|
75
75
|
.optional(),
|
|
76
76
|
coastDetail: z.number().min(0).max(1).optional(),
|
|
77
77
|
fit: z.enum(['contain', 'stretch']).optional(),
|
|
78
|
+
paletteMatch: z.enum(['nearest', 'exact']).optional(),
|
|
79
|
+
uncovered: bytesSchema.optional(),
|
|
78
80
|
politicalImageBytes: bytesSchema.optional(),
|
|
79
81
|
terrainImageBytes: bytesSchema.optional(),
|
|
80
82
|
heightImageBytes: bytesSchema.optional(),
|
|
81
83
|
})
|
|
82
84
|
.strict();
|
|
83
85
|
async function decode(bytes) {
|
|
86
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
87
|
+
if (bytes.width < 1 ||
|
|
88
|
+
bytes.height < 1 ||
|
|
89
|
+
bytes.width * bytes.height > REFERENCE_IMAGE_PIXEL_LIMIT ||
|
|
90
|
+
bytes.data.length !== bytes.width * bytes.height * 3)
|
|
91
|
+
throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Reference image exceeds the bounded decode limit');
|
|
92
|
+
return bytes;
|
|
93
|
+
}
|
|
84
94
|
if (bytes.byteLength === 0 || bytes.byteLength > REFERENCE_IMAGE_BYTE_LIMIT)
|
|
85
95
|
throw new ServiceError('MAP_REFERENCE_IMAGE_BLOCKED', 'Reference image bytes exceed the bounded decode limit');
|
|
86
96
|
try {
|
|
@@ -173,6 +183,54 @@ function classify(color, mode, palette) {
|
|
|
173
183
|
return semanticCode('hills');
|
|
174
184
|
return 3;
|
|
175
185
|
}
|
|
186
|
+
const UNKNOWN_COLOR_LIMIT = 16;
|
|
187
|
+
const UNKNOWN_COLOR_SAMPLES = 5;
|
|
188
|
+
/** Classify by exact palette colour; report every other colour with counts and positions. */
|
|
189
|
+
function exactCodes(data, width, height, palette, toSource, uncovered) {
|
|
190
|
+
const known = new Map();
|
|
191
|
+
for (const entry of palette) {
|
|
192
|
+
const key = (entry.color.r << 16) | (entry.color.g << 8) | entry.color.b;
|
|
193
|
+
const code = semanticCode(entry.kind);
|
|
194
|
+
if (known.has(key) && known.get(key) !== code)
|
|
195
|
+
throw new ServiceError('MAP_REFERENCE_PALETTE_CONFLICT', 'One palette colour is given two different kinds', { color: [entry.color.r, entry.color.g, entry.color.b] });
|
|
196
|
+
known.set(key, code);
|
|
197
|
+
}
|
|
198
|
+
const codes = new Uint8Array(width * height);
|
|
199
|
+
const unknown = new Map();
|
|
200
|
+
let unknownPixels = 0;
|
|
201
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1) {
|
|
202
|
+
// Pixels outside every registered sheet are open sea whatever colour stands there.
|
|
203
|
+
if (uncovered?.[pixel])
|
|
204
|
+
continue;
|
|
205
|
+
const key = (data[pixel * 3] << 16) | (data[pixel * 3 + 1] << 8) | data[pixel * 3 + 2];
|
|
206
|
+
const code = known.get(key);
|
|
207
|
+
if (code !== undefined) {
|
|
208
|
+
codes[pixel] = code;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
unknownPixels += 1;
|
|
212
|
+
let entry = unknown.get(key);
|
|
213
|
+
if (entry === undefined) {
|
|
214
|
+
entry = { count: 0, samples: [] };
|
|
215
|
+
unknown.set(key, entry);
|
|
216
|
+
}
|
|
217
|
+
entry.count += 1;
|
|
218
|
+
if (entry.samples.length < UNKNOWN_COLOR_SAMPLES)
|
|
219
|
+
entry.samples.push(toSource(pixel % width, Math.floor(pixel / width)));
|
|
220
|
+
}
|
|
221
|
+
if (unknownPixels > 0) {
|
|
222
|
+
const colors = [...unknown.entries()]
|
|
223
|
+
.sort(([leftKey, left], [rightKey, right]) => right.count - left.count || leftKey - rightKey)
|
|
224
|
+
.slice(0, UNKNOWN_COLOR_LIMIT)
|
|
225
|
+
.map(([key, { count, samples }]) => ({
|
|
226
|
+
color: [key >> 16, (key >> 8) & 255, key & 255],
|
|
227
|
+
count,
|
|
228
|
+
samples,
|
|
229
|
+
}));
|
|
230
|
+
throw new ServiceError('MAP_REFERENCE_PALETTE_UNKNOWN_COLOR', `${unknownPixels} reference pixels in ${unknown.size} colours match no palette entry; add the colours to the palette or repaint them. Samples are source pixel coordinates.`, { unknownPixels, unknownColors: unknown.size, colors });
|
|
231
|
+
}
|
|
232
|
+
return codes;
|
|
233
|
+
}
|
|
176
234
|
function fillUnknown(codes, width, height) {
|
|
177
235
|
if (!codes.includes(255))
|
|
178
236
|
return;
|
|
@@ -329,6 +387,9 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
329
387
|
const height = options.height ?? 2048;
|
|
330
388
|
if (width * height > REFERENCE_WORLD_PIXEL_LIMIT)
|
|
331
389
|
throw new ServiceError('MAP_REFERENCE_PIXELS_BLOCKED', 'Reference output exceeds the pixel budget');
|
|
390
|
+
const exact = options.paletteMatch === 'exact';
|
|
391
|
+
if (exact && (options.palette?.length ?? 0) === 0)
|
|
392
|
+
throw new ServiceError('MAP_REFERENCE_PALETTE_REQUIRED', 'Exact palette matching needs a palette that lists every colour of the picture');
|
|
332
393
|
const scale = options.fit === 'stretch' ? 1 : Math.min(width / crop.width, height / crop.height);
|
|
333
394
|
const bounds = options.fit === 'stretch'
|
|
334
395
|
? { x: 0, y: 0, width, height }
|
|
@@ -345,14 +406,27 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
345
406
|
.resize(bounds.width, bounds.height, { kernel: sharp.kernel.nearest })
|
|
346
407
|
.raw()
|
|
347
408
|
.toBuffer();
|
|
348
|
-
const mode =
|
|
409
|
+
const mode = exact
|
|
410
|
+
? 'political'
|
|
411
|
+
: options.mode === undefined || options.mode === 'auto'
|
|
412
|
+
? modeFor(resized)
|
|
413
|
+
: options.mode;
|
|
349
414
|
const diagnostics = [
|
|
350
|
-
|
|
415
|
+
exact
|
|
416
|
+
? 'Reference interpretation matches every pixel to the exact palette; no visual heuristics are applied.'
|
|
417
|
+
: `Reference interpretation uses ${mode} visual heuristics; illustrated labels and ambiguous biomes may need semantic overrides.`,
|
|
351
418
|
];
|
|
352
419
|
const codes = new Uint8Array(width * height);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
:
|
|
420
|
+
// Unknown-colour samples name the source pixel, so the picture can be repainted there.
|
|
421
|
+
const toSource = (x, y) => ({
|
|
422
|
+
x: Math.min(source.width - 1, crop.x + Math.floor(((x + 0.5) * crop.width) / bounds.width)),
|
|
423
|
+
y: Math.min(source.height - 1, crop.y + Math.floor(((y + 0.5) * crop.height) / bounds.height)),
|
|
424
|
+
});
|
|
425
|
+
const localCodes = exact
|
|
426
|
+
? exactCodes(resized, bounds.width, bounds.height, options.palette ?? [], toSource, options.uncovered?.length === bounds.width * bounds.height ? options.uncovered : undefined)
|
|
427
|
+
: mode === 'sketch' || mode === 'grayscale'
|
|
428
|
+
? grayscaleCodes(resized, bounds.width, bounds.height, mode)
|
|
429
|
+
: undefined;
|
|
356
430
|
for (let y = 0; y < bounds.height; y += 1)
|
|
357
431
|
for (let x = 0; x < bounds.width; x += 1) {
|
|
358
432
|
const local = y * bounds.width + x;
|
|
@@ -360,9 +434,9 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
360
434
|
codes[target] =
|
|
361
435
|
localCodes?.[local] ?? classify(pixelColor(resized, local), mode, options.palette ?? []);
|
|
362
436
|
}
|
|
363
|
-
if (mode === 'illustrated' || mode === 'satellite')
|
|
437
|
+
if (!exact && (mode === 'illustrated' || mode === 'satellite'))
|
|
364
438
|
cleanWaterTexture(codes, resized, width, bounds, options.coastDetail ?? 1, diagnostics);
|
|
365
|
-
if (mode !== 'sketch' && mode !== 'grayscale')
|
|
439
|
+
if (!exact && mode !== 'sketch' && mode !== 'grayscale')
|
|
366
440
|
maskOceanText(codes, resized, width, bounds, diagnostics);
|
|
367
441
|
const transform = (point) => ({
|
|
368
442
|
x: bounds.x + ((point.x * source.width - crop.x) / crop.width) * bounds.width,
|
|
@@ -376,8 +450,22 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
376
450
|
throw new ServiceError('MAP_REFERENCE_MASK_WORK_BLOCKED', 'Reference polygon controls exceed the bounded scanline work budget');
|
|
377
451
|
for (const mask of options.ignoreMasks ?? [])
|
|
378
452
|
polygon(codes, width, height, mask.map(transform), 255);
|
|
453
|
+
const uncovered = options.uncovered;
|
|
454
|
+
if (uncovered !== undefined && uncovered.length !== codes.length)
|
|
455
|
+
throw new ServiceError('MAP_REFERENCE_OPTIONS_INVALID', 'The uncovered mask must have the frame size');
|
|
456
|
+
// Open sea outside every sheet is decided, not inferred: no mask fill, cleanup or smoothing moves it.
|
|
457
|
+
if (uncovered !== undefined)
|
|
458
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1)
|
|
459
|
+
if (uncovered[pixel])
|
|
460
|
+
codes[pixel] = 0;
|
|
379
461
|
fillUnknown(codes, width, height);
|
|
380
|
-
const protectedFeatures = (options.annotations?.length ?? 0) > 0
|
|
462
|
+
const protectedFeatures = (options.annotations?.length ?? 0) > 0 || uncovered !== undefined
|
|
463
|
+
? new Uint8Array(codes.length)
|
|
464
|
+
: undefined;
|
|
465
|
+
if (protectedFeatures !== undefined && uncovered !== undefined)
|
|
466
|
+
for (let pixel = 0; pixel < codes.length; pixel += 1)
|
|
467
|
+
if (uncovered[pixel])
|
|
468
|
+
protectedFeatures[pixel] = 1;
|
|
381
469
|
for (const annotation of options.annotations ?? []) {
|
|
382
470
|
polygon(codes, width, height, annotation.polygon.map(transform), semanticCode(annotation.kind));
|
|
383
471
|
if (protectedFeatures)
|
|
@@ -394,7 +482,7 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
394
482
|
throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Annotated rivers exceed the bounded point budget');
|
|
395
483
|
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
484
|
traceCoastalRivers(kind, width, height, rivers, diagnostics);
|
|
397
|
-
normalizeWater(kind, width, height, rivers, diagnostics);
|
|
485
|
+
normalizeWater(kind, width, height, rivers, diagnostics, !exact);
|
|
398
486
|
cleanSmallComponents(kind, width, height, protectedFeatures, diagnostics);
|
|
399
487
|
repairCorners(kind, width, height, diagnostics);
|
|
400
488
|
// Corner repairs can detach an inlet from the ocean. Recheck sea fragments as well as
|
|
@@ -404,7 +492,7 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
404
492
|
break;
|
|
405
493
|
repairCorners(kind, width, height, diagnostics);
|
|
406
494
|
}
|
|
407
|
-
if (mode === 'illustrated')
|
|
495
|
+
if (!exact && mode === 'illustrated')
|
|
408
496
|
smoothIllustratedTerrain(terrain, kind, width, height, protectedFeatures, diagnostics);
|
|
409
497
|
const elevation = new Float32Array(codes.length);
|
|
410
498
|
const moisture = new Float32Array(codes.length);
|
|
@@ -428,7 +516,8 @@ export async function extractReferenceWorld(imageBytes, input) {
|
|
|
428
516
|
inferredCountries: [],
|
|
429
517
|
diagnostics,
|
|
430
518
|
};
|
|
431
|
-
|
|
519
|
+
if (!exact)
|
|
520
|
+
inferCountries(result, resized, mode);
|
|
432
521
|
await applyLayers(result, input, crop);
|
|
433
522
|
normalizeElevation(result, options.seed, options.heightImageBytes !== undefined);
|
|
434
523
|
return result;
|
|
@@ -915,7 +1004,7 @@ function connectedPath(pixels, width, height) {
|
|
|
915
1004
|
throw new ServiceError('MAP_REFERENCE_RIVER_COMPLEXITY', 'Visible river path exceeds the bounded trace length; use a smaller reference output');
|
|
916
1005
|
return path;
|
|
917
1006
|
}
|
|
918
|
-
function normalizeWater(kind, width, height, rivers, diagnostics) {
|
|
1007
|
+
function normalizeWater(kind, width, height, rivers, diagnostics, thinWaterToRivers = true) {
|
|
919
1008
|
const visited = new Uint8Array(kind.length);
|
|
920
1009
|
const queue = new Int32Array(kind.length);
|
|
921
1010
|
let lakeCount = 0;
|
|
@@ -953,7 +1042,9 @@ function normalizeWater(kind, width, height, rivers, diagnostics) {
|
|
|
953
1042
|
const spanX = maxX - minX + 1;
|
|
954
1043
|
const spanY = maxY - minY + 1;
|
|
955
1044
|
const thin = tail / Math.max(spanX, spanY) <= Math.max(2, Math.min(width, height) / 350);
|
|
956
|
-
if (
|
|
1045
|
+
if (thinWaterToRivers &&
|
|
1046
|
+
thin &&
|
|
1047
|
+
Math.max(spanX, spanY) >= Math.max(5, Math.min(width, height) / 100)) {
|
|
957
1048
|
const points = connectedPath(queue.subarray(0, tail), width, height);
|
|
958
1049
|
for (let index = 0; index < tail; index += 1)
|
|
959
1050
|
kind[queue[index]] = 1;
|