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,333 @@
1
+ /**
2
+ * Evidence for an imported reference (ADR 0067): what each registered sheet supplied, the class
3
+ * shares of the interpreted picture and how far the geography model's land agrees with it.
4
+ * Disagreement above the threshold is an error diagnostic, which blocks the write but leaves the
5
+ * preview and its evidence available.
6
+ */
7
+ import sharp from 'sharp';
8
+ import { canonicalJson } from '../core/canonical.js';
9
+ import { PACKAGE_VERSION } from '../version.js';
10
+ import { rasterizeGeographyKind } from './relief/land.js';
11
+ import { REFERENCE_TERRAIN } from './reference-world.js';
12
+ /** Conservative defaults: block only on a gross mismatch, warn on any visible one. */
13
+ export const DEFAULT_IMPORT_AGREEMENT = {
14
+ minLandIou: 0.85,
15
+ warnLandIou: 0.95,
16
+ };
17
+ const CELL = 128;
18
+ const WORST_CELLS = 12;
19
+ const FEATURE_LIMIT = 64;
20
+ /** A point this many pixels from the drawing's coast still counts as on either side. */
21
+ const COAST_TOLERANCE_PX = 3;
22
+ const TERRAIN_NAMES = Object.fromEntries(Object.entries(REFERENCE_TERRAIN).map(([name, index]) => [index, name]));
23
+ function surfaceAt(surface, x, y) {
24
+ const px = Math.floor(x * surface.width);
25
+ const py = Math.floor(y * surface.height);
26
+ if (px < 0 || py < 0 || px >= surface.width || py >= surface.height)
27
+ return undefined;
28
+ return surface.kind[py * surface.width + px];
29
+ }
30
+ /** True when some pixel within the coast tolerance of the point has the wanted surface. */
31
+ function nearSurface(surface, x, y, wanted) {
32
+ const cx = Math.floor(x * surface.width);
33
+ const cy = Math.floor(y * surface.height);
34
+ for (let dy = -COAST_TOLERANCE_PX; dy <= COAST_TOLERANCE_PX; dy += 1)
35
+ for (let dx = -COAST_TOLERANCE_PX; dx <= COAST_TOLERANCE_PX; dx += 1) {
36
+ const px = cx + dx;
37
+ const py = cy + dy;
38
+ if (px < 0 || py < 0 || px >= surface.width || py >= surface.height)
39
+ continue;
40
+ if (wanted(surface.kind[py * surface.width + px]))
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ function measureAgreement(surface, geography, seed, thresholds) {
46
+ const { width, height } = surface;
47
+ const model = rasterizeGeographyKind(geography, { width, height, seed });
48
+ const columns = Math.ceil(width / CELL);
49
+ const rows = Math.ceil(height / CELL);
50
+ const drawingOnlyCells = new Float64Array(columns * rows);
51
+ const modelOnlyCells = new Float64Array(columns * rows);
52
+ let drawingLand = 0;
53
+ let modelLand = 0;
54
+ let both = 0;
55
+ for (let y = 0; y < height; y += 1)
56
+ for (let x = 0; x < width; x += 1) {
57
+ const pixel = y * width + x;
58
+ const drawn = surface.kind[pixel] === 1;
59
+ const modelled = model[pixel] === 1;
60
+ if (drawn)
61
+ drawingLand += 1;
62
+ if (modelled)
63
+ modelLand += 1;
64
+ if (drawn && modelled)
65
+ both += 1;
66
+ else if (drawn || modelled) {
67
+ const cell = Math.floor(y / CELL) * columns + Math.floor(x / CELL);
68
+ if (drawn)
69
+ drawingOnlyCells[cell] = drawingOnlyCells[cell] + 1;
70
+ else
71
+ modelOnlyCells[cell] = modelOnlyCells[cell] + 1;
72
+ }
73
+ }
74
+ const union = drawingLand + modelLand - both;
75
+ const worstCells = Array.from({ length: columns * rows }, (_, cell) => cell)
76
+ .map((cell) => ({
77
+ x: (cell % columns) * CELL,
78
+ y: Math.floor(cell / columns) * CELL,
79
+ drawingOnly: drawingOnlyCells[cell],
80
+ modelOnly: modelOnlyCells[cell],
81
+ }))
82
+ .filter((cell) => cell.drawingOnly + cell.modelOnly > 0)
83
+ .sort((left, right) => right.drawingOnly + right.modelOnly - (left.drawingOnly + left.modelOnly) ||
84
+ left.y - right.y ||
85
+ left.x - right.x)
86
+ .slice(0, WORST_CELLS);
87
+ const features = [];
88
+ const isLand = (kind) => kind === 1;
89
+ const isWater = (kind) => kind !== 1;
90
+ for (const site of geography.sites) {
91
+ if (site.onWater || site.lakeId !== undefined)
92
+ continue;
93
+ if (!nearSurface(surface, site.at.x, site.at.y, isLand))
94
+ features.push({ id: site.id, kind: 'site', issue: 'in the drawing’s water', ...site.at });
95
+ }
96
+ for (const volcano of geography.volcanoes)
97
+ if (!nearSurface(surface, volcano.at.x, volcano.at.y, isLand))
98
+ features.push({
99
+ id: volcano.id,
100
+ kind: 'volcano',
101
+ issue: 'in the drawing’s water',
102
+ ...volcano.at,
103
+ });
104
+ for (const river of geography.rivers) {
105
+ const { source, mouth } = river;
106
+ if (source.type === 'point' && surfaceAt(surface, source.x, source.y) !== 1)
107
+ features.push({
108
+ id: river.id,
109
+ kind: 'river',
110
+ issue: 'source in the drawing’s water',
111
+ x: source.x,
112
+ y: source.y,
113
+ });
114
+ if (mouth.type === 'point' && !nearSurface(surface, mouth.x, mouth.y, isWater))
115
+ features.push({
116
+ id: river.id,
117
+ kind: 'river',
118
+ issue: 'mouth on the drawing’s land',
119
+ x: mouth.x,
120
+ y: mouth.y,
121
+ });
122
+ }
123
+ features.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
124
+ return {
125
+ landIou: union === 0 ? 1 : Number((both / union).toFixed(4)),
126
+ drawingLandPx: drawingLand,
127
+ modelLandPx: modelLand,
128
+ bothLandPx: both,
129
+ drawingOnlyLandPx: drawingLand - both,
130
+ modelOnlyLandPx: modelLand - both,
131
+ thresholds,
132
+ worstCells,
133
+ features: features.slice(0, FEATURE_LIMIT),
134
+ featureCount: features.length,
135
+ };
136
+ }
137
+ export function buildImportReport(input) {
138
+ const { surface, operationId } = input;
139
+ const terrainPx = {};
140
+ let sea = 0;
141
+ let lake = 0;
142
+ let land = 0;
143
+ for (let pixel = 0; pixel < surface.kind.length; pixel += 1) {
144
+ const kind = surface.kind[pixel];
145
+ if (kind === 0)
146
+ sea += 1;
147
+ else if (kind === 2)
148
+ lake += 1;
149
+ else {
150
+ land += 1;
151
+ const name = TERRAIN_NAMES[surface.terrain[pixel]] ?? 'other';
152
+ terrainPx[name] = (terrainPx[name] ?? 0) + 1;
153
+ }
154
+ }
155
+ const thresholds = {
156
+ minLandIou: input.thresholds?.minLandIou ?? DEFAULT_IMPORT_AGREEMENT.minLandIou,
157
+ warnLandIou: input.thresholds?.warnLandIou ?? DEFAULT_IMPORT_AGREEMENT.warnLandIou,
158
+ };
159
+ const report = {
160
+ operationId,
161
+ width: surface.width,
162
+ height: surface.height,
163
+ ...(input.registration === undefined
164
+ ? {}
165
+ : {
166
+ registration: {
167
+ sheets: input.registration.coverage,
168
+ uncoveredPx: input.registration.uncovered,
169
+ },
170
+ }),
171
+ classes: { seaPx: sea, lakePx: lake, landPx: land, terrainPx },
172
+ ...(input.geography === undefined
173
+ ? {}
174
+ : { agreement: measureAgreement(surface, input.geography, input.seed, thresholds) }),
175
+ };
176
+ const diagnostics = [];
177
+ for (const sheet of input.registration?.coverage ?? [])
178
+ if (sheet.pixels === 0)
179
+ diagnostics.push({
180
+ code: 'MAP_REFERENCE_SHEET_HIDDEN',
181
+ severity: 'warning',
182
+ category: 'map',
183
+ operationId,
184
+ message: `Sheet ${sheet.id} supplies no world pixel: higher-order sheets cover it or it lies outside the frame`,
185
+ details: { sheetId: sheet.id, hiddenPx: sheet.hidden },
186
+ });
187
+ const agreement = report.agreement;
188
+ if (agreement !== undefined) {
189
+ const severity = agreement.landIou < thresholds.minLandIou
190
+ ? 'error'
191
+ : agreement.landIou < thresholds.warnLandIou
192
+ ? 'warning'
193
+ : 'info';
194
+ diagnostics.push({
195
+ code: severity === 'error' ? 'MAP_IMPORT_SOURCES_DISAGREE' : 'MAP_IMPORT_SOURCES_AGREEMENT',
196
+ severity,
197
+ category: 'map',
198
+ operationId,
199
+ message: `The geography model's land and the reference's land overlap with intersection over union ${agreement.landIou} (block below ${thresholds.minLandIou}, warn below ${thresholds.warnLandIou}); ${agreement.drawingOnlyLandPx} pixels are land only in the reference and ${agreement.modelOnlyLandPx} only in the model. map-import-report.json lists the worst areas and map-import-alignment.png draws both coasts.`,
200
+ details: {
201
+ landIou: agreement.landIou,
202
+ drawingOnlyLandPx: agreement.drawingOnlyLandPx,
203
+ modelOnlyLandPx: agreement.modelOnlyLandPx,
204
+ worstCells: agreement.worstCells.slice(0, 3),
205
+ },
206
+ });
207
+ if (agreement.featureCount > 0)
208
+ diagnostics.push({
209
+ code: 'MAP_IMPORT_FEATURE_SURFACE',
210
+ severity: 'warning',
211
+ category: 'map',
212
+ operationId,
213
+ message: `${agreement.featureCount} model features lie on the wrong surface of the reference (a site or volcano in its water, a river source in water or mouth on land); see map-import-report.json`,
214
+ details: { features: agreement.features.slice(0, 8) },
215
+ });
216
+ }
217
+ return { report, diagnostics };
218
+ }
219
+ const CLASS_COLOURS = {
220
+ 0: [150, 200, 90],
221
+ 1: [40, 120, 50],
222
+ 2: [220, 205, 120],
223
+ 3: [140, 120, 110],
224
+ 4: [235, 240, 245],
225
+ 5: [150, 40, 30],
226
+ 6: [150, 120, 70],
227
+ 7: [80, 130, 110],
228
+ 8: [30, 90, 40],
229
+ };
230
+ /**
231
+ * Offline review drawings of the import, at most `maxSide` pixels per side: the classes, and the
232
+ * registered picture with the reference coast in red and the model coast in yellow. They are
233
+ * drawings of the tool's interpretation, never game images.
234
+ */
235
+ export async function renderImportImages(input) {
236
+ const { surface } = input;
237
+ const { width, height } = surface;
238
+ const scale = Math.min(1, input.maxSide / Math.max(width, height));
239
+ const outWidth = Math.max(1, Math.round(width * scale));
240
+ const outHeight = Math.max(1, Math.round(height * scale));
241
+ const classes = Buffer.alloc(outWidth * outHeight * 3);
242
+ const alignment = Buffer.alloc(outWidth * outHeight * 3);
243
+ const registered = input.registered === undefined
244
+ ? undefined
245
+ : await sharp(input.registered)
246
+ .resize(outWidth, outHeight, { fit: 'fill' })
247
+ .removeAlpha()
248
+ .raw()
249
+ .toBuffer();
250
+ const model = input.geography === undefined
251
+ ? undefined
252
+ : rasterizeGeographyKind(input.geography, { width, height, seed: input.seed });
253
+ const landAt = (kind, x, y) => kind[Math.min(height - 1, y) * width + Math.min(width - 1, x)] === 1;
254
+ for (let oy = 0; oy < outHeight; oy += 1)
255
+ for (let ox = 0; ox < outWidth; ox += 1) {
256
+ const x = Math.min(width - 1, Math.floor(ox / scale));
257
+ const y = Math.min(height - 1, Math.floor(oy / scale));
258
+ const pixel = y * width + x;
259
+ const target = (oy * outWidth + ox) * 3;
260
+ const kind = surface.kind[pixel];
261
+ const colour = kind === 0
262
+ ? [30, 70, 160]
263
+ : kind === 2
264
+ ? [70, 170, 230]
265
+ : CLASS_COLOURS[surface.terrain[pixel]];
266
+ classes.set(colour, target);
267
+ const base = registered?.subarray(target, target + 3) ?? Buffer.from(colour);
268
+ alignment[target] = Math.round(base[0] * 0.6);
269
+ alignment[target + 1] = Math.round(base[1] * 0.6);
270
+ alignment[target + 2] = Math.round(base[2] * 0.6);
271
+ const step = Math.max(1, Math.ceil(1 / scale));
272
+ const coast = (raster) => landAt(raster, x, y) !== landAt(raster, x + step, y) ||
273
+ landAt(raster, x, y) !== landAt(raster, x, y + step);
274
+ if (model !== undefined && coast(model))
275
+ alignment.set([255, 220, 0], target);
276
+ if (coast(surface.kind))
277
+ alignment.set([255, 40, 40], target);
278
+ }
279
+ const encode = (data) => sharp(data, { raw: { width: outWidth, height: outHeight, channels: 3 } })
280
+ .png({ compressionLevel: 9 })
281
+ .toBuffer();
282
+ return { classes: await encode(classes), alignment: await encode(alignment) };
283
+ }
284
+ const IMPORT_IMAGE_MAX_SIDE = 2_048;
285
+ export async function buildImportArtifacts(prepared, ordinal) {
286
+ const suffix = ordinal === 0 ? '' : `-${ordinal + 1}`;
287
+ const images = await renderImportImages({
288
+ surface: prepared.surface,
289
+ registered: prepared.registered,
290
+ ...(prepared.geography === undefined ? {} : { geography: prepared.geography }),
291
+ seed: prepared.seed,
292
+ maxSide: IMPORT_IMAGE_MAX_SIDE,
293
+ });
294
+ const provenance = {
295
+ kind: 'map-import',
296
+ toolVersion: PACKAGE_VERSION,
297
+ schemaVersion: 'map-import.v1',
298
+ sourceHashes: prepared.sourceHashes,
299
+ renderProfile: { offline: true, maxSide: IMPORT_IMAGE_MAX_SIDE },
300
+ metadata: {
301
+ operationId: prepared.report.operationId,
302
+ binding: 'offline drawing of the interpreted reference and the geography model; not a game screenshot',
303
+ },
304
+ };
305
+ const writes = [
306
+ {
307
+ name: `map-import-report${suffix}.json`,
308
+ mimeType: 'application/json',
309
+ content: `${canonicalJson(prepared.report)}\n`,
310
+ provenance,
311
+ },
312
+ {
313
+ name: `map-import-classes${suffix}.png`,
314
+ mimeType: 'image/png',
315
+ content: images.classes,
316
+ provenance,
317
+ },
318
+ {
319
+ name: `map-import-alignment${suffix}.png`,
320
+ mimeType: 'image/png',
321
+ content: images.alignment,
322
+ provenance,
323
+ },
324
+ ];
325
+ if (prepared.registered !== undefined)
326
+ writes.push({
327
+ name: `map-import-registration${suffix}.png`,
328
+ mimeType: 'image/png',
329
+ content: prepared.registered,
330
+ provenance,
331
+ });
332
+ return writes;
333
+ }
@@ -1,19 +1,116 @@
1
- import { readReferenceImageSource } from '../core/bounded-source.js';
1
+ import sharp from 'sharp';
2
+ import { readReferenceImageSource, } from '../core/bounded-source.js';
3
+ import { readWorkspaceJsonSource } from '../core/workspace-json.js';
4
+ import { REFERENCE_SHEETS_SOURCE_MAX_BYTES, referenceSheetsDocumentSchema, } from '../schemas/reference-sheets.js';
2
5
  import { extractReferenceLabels } from './reference-labels.js';
6
+ import { registerSheets, sheetAffine, sheetWorkingSize, warpSheets, } from './reference-sheets.js';
3
7
  import { extractReferenceWorld } from './reference-world.js';
4
8
  import { ServiceError } from '../core/result.js';
9
+ const PREVIEW_MAX_SIDE = 3_072;
10
+ async function loadSheetsDocument(resolver, workspaceId, reference, principal, signal) {
11
+ if (reference.sheets !== undefined)
12
+ return { document: reference.sheets };
13
+ if (reference.sheetsPath === undefined)
14
+ return undefined;
15
+ const read = await readWorkspaceJsonSource(resolver, workspaceId, reference.sheetsPath, REFERENCE_SHEETS_SOURCE_MAX_BYTES, {
16
+ pathInvalid: 'MAP_REFERENCE_SHEETS_PATH_INVALID',
17
+ tooLarge: 'MAP_REFERENCE_SHEETS_TOO_LARGE',
18
+ jsonInvalid: 'MAP_REFERENCE_SHEETS_JSON_INVALID',
19
+ label: 'Sheet document',
20
+ }, principal, signal);
21
+ const parsed = referenceSheetsDocumentSchema.safeParse(read.value);
22
+ if (!parsed.success)
23
+ throw new ServiceError('MAP_REFERENCE_SHEETS_INVALID', `Sheet document does not match reference-sheets version 1: ${parsed.error.issues
24
+ .slice(0, 3)
25
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
26
+ .join('; ')}`, {
27
+ issues: parsed.error.issues.slice(0, 20).map((issue) => ({
28
+ path: issue.path.join('.'),
29
+ message: issue.message,
30
+ })),
31
+ });
32
+ return { document: parsed.data, evidence: read.evidence };
33
+ }
5
34
  /** Analyze only bounded, authorized sources; the transaction retains the original source hashes. */
6
35
  export async function prepareReferenceWorld(resolver, workspaceId, operation, principal, signal) {
7
36
  const reference = operation.reference;
8
37
  if (reference === undefined)
9
38
  throw new ServiceError('MAP_REFERENCE_REQUIRED', 'Reference preparation requires imagePath');
39
+ const sheetsSource = await loadSheetsDocument(resolver, workspaceId, reference, principal, signal);
40
+ const sheeted = sheetsSource !== undefined;
41
+ const frame = reference.fit === 'frame' || sheeted;
42
+ const exact = reference.paletteMatch === 'exact';
43
+ if (sheeted &&
44
+ (reference.crop !== undefined ||
45
+ reference.politicalImagePath !== undefined ||
46
+ reference.terrainImagePath !== undefined ||
47
+ reference.heightImagePath !== undefined))
48
+ throw new ServiceError('MAP_REFERENCE_SHEETS_UNSUPPORTED', 'A registered sheet picture cannot be combined with crop or aligned political, terrain or height pictures yet');
49
+ if (frame && (operation.width === undefined || operation.height === undefined))
50
+ throw new ServiceError('MAP_REFERENCE_FRAME_SIZE_REQUIRED', 'A reference fitted to the frame needs explicit width and height');
51
+ // A frame-fitted picture keeps the frame's resolution instead of the default 3,072-pixel
52
+ // working copy; an exact palette is never resampled between colours.
53
+ const frameSize = { width: operation.width ?? 0, height: operation.height ?? 0 };
54
+ // Each sheet keeps at least one working pixel per world pixel; the maps alone decide the size.
55
+ const working = sheeted
56
+ ? {
57
+ size: () => sheetWorkingSize(sheetsSource.document.sheets.map((sheet) => sheetAffine(sheet)), frameSize),
58
+ kernel: exact ? 'nearest' : 'area',
59
+ }
60
+ : frame
61
+ ? {
62
+ size: (originalWidth, originalHeight) => {
63
+ const cropWidth = reference.crop?.width ?? originalWidth;
64
+ const cropHeight = reference.crop?.height ?? originalHeight;
65
+ return {
66
+ width: Math.ceil((originalWidth * operation.width) / cropWidth),
67
+ height: Math.ceil((originalHeight * operation.height) / cropHeight),
68
+ };
69
+ },
70
+ kernel: exact ? 'nearest' : 'area',
71
+ }
72
+ : undefined;
10
73
  const images = [];
11
74
  const load = async (relativePath) => {
12
- const image = await readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal);
75
+ const image = await readReferenceImageSource(resolver, workspaceId, relativePath, principal, signal, working);
13
76
  images.push(image);
14
77
  return image;
15
78
  };
16
79
  const main = await load(reference.imagePath);
80
+ let warped;
81
+ let picture;
82
+ if (sheetsSource !== undefined) {
83
+ const sheets = registerSheets(sheetsSource.document, frameSize, {
84
+ width: main.originalWidth,
85
+ height: main.originalHeight,
86
+ sha256: main.source.sha256,
87
+ });
88
+ const raw = await sharp(main.bytes)
89
+ .flatten({ background: '#245b82' })
90
+ .removeAlpha()
91
+ .toColourspace('srgb')
92
+ .raw()
93
+ .toBuffer({ resolveWithObject: true });
94
+ signal?.throwIfAborted();
95
+ warped = warpSheets({ data: raw.data, width: raw.info.width, height: raw.info.height }, sheets, frameSize, {
96
+ fill: sheetsSource.document.fill,
97
+ sampling: exact ? 'nearest' : 'bilinear',
98
+ ...(signal === undefined ? {} : { signal }),
99
+ });
100
+ picture = { data: warped.rgb, width: warped.width, height: warped.height };
101
+ }
102
+ if (frame && !sheeted) {
103
+ const sourceAspect = (reference.crop?.width ?? main.originalWidth) /
104
+ (reference.crop?.height ?? main.originalHeight);
105
+ const frameAspect = operation.width / operation.height;
106
+ if (Math.abs(sourceAspect - frameAspect) > frameAspect * 0.01)
107
+ throw new ServiceError('MAP_REFERENCE_FRAME_ASPECT', `The reference (${reference.crop?.width ?? main.originalWidth} x ${reference.crop?.height ?? main.originalHeight}) does not have the frame's shape (${operation.width} x ${operation.height}); fitting it would distort the map. Crop or resize the picture, or use the default fit.`, {
108
+ sourceWidth: reference.crop?.width ?? main.originalWidth,
109
+ sourceHeight: reference.crop?.height ?? main.originalHeight,
110
+ width: operation.width,
111
+ height: operation.height,
112
+ });
113
+ }
17
114
  if (reference.crop !== undefined &&
18
115
  (reference.crop.x + reference.crop.width > main.originalWidth ||
19
116
  reference.crop.y + reference.crop.height > main.originalHeight))
@@ -31,7 +128,24 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
31
128
  width: Math.max(1, Math.round((reference.crop.width * main.workingWidth) / main.originalWidth)),
32
129
  height: Math.max(1, Math.round((reference.crop.height * main.workingHeight) / main.originalHeight)),
33
130
  };
34
- const text = await extractReferenceLabels(main.bytes);
131
+ // The registered picture, reduced, is what previews show and what text recognition reads.
132
+ const previewBytes = picture === undefined
133
+ ? undefined
134
+ : await sharp(picture.data, {
135
+ raw: { width: picture.width, height: picture.height, channels: 3 },
136
+ })
137
+ .resize({
138
+ width: PREVIEW_MAX_SIDE,
139
+ height: PREVIEW_MAX_SIDE,
140
+ fit: 'inside',
141
+ withoutEnlargement: true,
142
+ })
143
+ .png()
144
+ .toBuffer();
145
+ // Text recognition masks labels out of heuristic classification; an exact palette has none.
146
+ const text = exact
147
+ ? { labels: [], warnings: [] }
148
+ : await extractReferenceLabels(previewBytes ?? main.bytes);
35
149
  signal?.throwIfAborted();
36
150
  const textMasks = text.labels
37
151
  .slice(0, Math.max(0, 256 - (reference.ignoreMasks?.length ?? 0)))
@@ -45,7 +159,10 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
45
159
  { x: left, y: bottom },
46
160
  ];
47
161
  });
48
- const surface = await extractReferenceWorld(main.bytes, {
162
+ const uncovered = warped === undefined
163
+ ? undefined
164
+ : Uint8Array.from(warped.owner, (owner) => (owner === 255 ? 1 : 0));
165
+ const surface = await extractReferenceWorld(picture ?? main.bytes, {
49
166
  width: operation.width ?? 5_632,
50
167
  height: operation.height ?? 2_048,
51
168
  seed: operation.seed ?? 1,
@@ -57,6 +174,9 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
57
174
  ? {}
58
175
  : { riverAnnotations: reference.riverAnnotations }),
59
176
  ...(reference.palette === undefined ? {} : { palette: reference.palette }),
177
+ ...(frame ? { fit: 'stretch' } : {}),
178
+ ...(exact ? { paletteMatch: 'exact' } : {}),
179
+ ...(uncovered === undefined ? {} : { uncovered }),
60
180
  ...(reference.coastDetail === undefined ? {} : { coastDetail: reference.coastDetail }),
61
181
  ...(political === undefined ? {} : { politicalImageBytes: political.bytes }),
62
182
  ...(terrain === undefined ? {} : { terrainImageBytes: terrain.bytes }),
@@ -104,7 +224,8 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
104
224
  Math.min(surface.width, surface.height) * 0.025);
105
225
  // An explicit review is authoritative, including an empty list. OCR still helps mask ink,
106
226
  // but must not reintroduce discarded or uncertain names outside the supplied label set.
107
- const automaticText = reference.labels === undefined ? text.labels : [];
227
+ // Names are never read from a registered drawing: its labels are the owner's to review.
228
+ const automaticText = reference.labels === undefined && !sheeted ? text.labels : [];
108
229
  const labels = automaticText
109
230
  .filter(({ text: label }) => !/\b(?:map|world|sea|ocean|bay|strait|channel|flow|belt|signature|north|south|east|west|compass)\b/iu.test(label))
110
231
  .slice(0, 200)
@@ -243,5 +364,17 @@ export async function prepareReferenceWorld(resolver, workspaceId, operation, pr
243
364
  diagnostics: [...new Set(warnings)],
244
365
  },
245
366
  images,
367
+ documents: sheetsSource?.evidence === undefined ? [] : [sheetsSource.evidence],
368
+ ...(previewBytes === undefined ? {} : { previewBytes }),
369
+ ...(warped === undefined
370
+ ? {}
371
+ : {
372
+ registration: {
373
+ coverage: warped.coverage,
374
+ uncovered: warped.uncovered,
375
+ width: warped.width,
376
+ height: warped.height,
377
+ },
378
+ }),
246
379
  };
247
380
  }