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,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibrated land colour map (ADR 0054, stage A). The engine overlays the colour map on the
|
|
3
|
+
* terrain atlas at strength 0.75 in gamma space, so a bright colour map leaves the pale atlas
|
|
4
|
+
* almost untouched while the base game's dark, varied one darkens and saturates it. The tables
|
|
5
|
+
* below are the base game's measured class means (mean value 74, 1,084 distinct 5-bit colours);
|
|
6
|
+
* variation comes from deterministic low-frequency noise and a hillshade term, so one terrain
|
|
7
|
+
* class is never one flat colour.
|
|
8
|
+
*/
|
|
9
|
+
import { fractal, lattice } from './world-noise.js';
|
|
10
|
+
export const COLOUR_CLASSES = [
|
|
11
|
+
'plains',
|
|
12
|
+
'farmland',
|
|
13
|
+
'forest',
|
|
14
|
+
'hills',
|
|
15
|
+
'desert',
|
|
16
|
+
'mountain',
|
|
17
|
+
'marsh',
|
|
18
|
+
'jungle',
|
|
19
|
+
'snow',
|
|
20
|
+
'urban',
|
|
21
|
+
'lakes',
|
|
22
|
+
'ocean',
|
|
23
|
+
];
|
|
24
|
+
/** Means of the base game's land colour map by graphical terrain (measured on 1.19.3.0). */
|
|
25
|
+
const VANILLA_TEMPERATE = {
|
|
26
|
+
plains: [68, 68, 41],
|
|
27
|
+
farmland: [76, 74, 42],
|
|
28
|
+
forest: [47, 55, 24],
|
|
29
|
+
hills: [64, 66, 36],
|
|
30
|
+
desert: [118, 98, 64],
|
|
31
|
+
mountain: [59, 60, 35],
|
|
32
|
+
marsh: [51, 55, 30],
|
|
33
|
+
jungle: [36, 51, 16],
|
|
34
|
+
snow: [55, 58, 35],
|
|
35
|
+
urban: [69, 73, 43],
|
|
36
|
+
lakes: [82, 101, 128],
|
|
37
|
+
ocean: [79, 102, 141],
|
|
38
|
+
};
|
|
39
|
+
/** Per-channel multipliers applied to the vanilla table for the other palette sets. */
|
|
40
|
+
const LOOK_ADJUSTMENTS = {
|
|
41
|
+
fertile: {
|
|
42
|
+
plains: [0.88, 1.1, 0.92],
|
|
43
|
+
farmland: [0.9, 1.1, 0.9],
|
|
44
|
+
hills: [0.92, 1.06, 0.95],
|
|
45
|
+
forest: [0.95, 1.08, 0.95],
|
|
46
|
+
desert: [0.9, 0.97, 0.96],
|
|
47
|
+
marsh: [0.95, 1.05, 0.95],
|
|
48
|
+
},
|
|
49
|
+
arid: {
|
|
50
|
+
plains: [1.18, 1.05, 0.95],
|
|
51
|
+
farmland: [1.12, 1.03, 0.95],
|
|
52
|
+
hills: [1.15, 1.02, 0.95],
|
|
53
|
+
forest: [1.15, 1.0, 0.95],
|
|
54
|
+
marsh: [1.1, 1.0, 0.95],
|
|
55
|
+
desert: [1.04, 1.0, 0.98],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
export function colourTable(look, overrides) {
|
|
59
|
+
const table = { ...VANILLA_TEMPERATE };
|
|
60
|
+
if (look === 'fertile' || look === 'arid') {
|
|
61
|
+
for (const [name, factor] of Object.entries(LOOK_ADJUSTMENTS[look])) {
|
|
62
|
+
const base = VANILLA_TEMPERATE[name];
|
|
63
|
+
table[name] = [
|
|
64
|
+
Math.min(255, base[0] * factor[0]),
|
|
65
|
+
Math.min(255, base[1] * factor[1]),
|
|
66
|
+
Math.min(255, base[2] * factor[2]),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (overrides !== undefined)
|
|
71
|
+
for (const [name, color] of Object.entries(overrides))
|
|
72
|
+
table[name] = color;
|
|
73
|
+
return table;
|
|
74
|
+
}
|
|
75
|
+
/** Number of standard deviations of the value noise that one unit of `fractal()` spans. */
|
|
76
|
+
const NOISE_SIGMA = 0.1;
|
|
77
|
+
/**
|
|
78
|
+
* The half-resolution colour map as BGRA bytes, rows top to bottom. Deterministic: the noise is
|
|
79
|
+
* integer-hash based and every operation is addition, subtraction, multiplication or division.
|
|
80
|
+
*/
|
|
81
|
+
export function calibratedColourMap(input) {
|
|
82
|
+
const { width, height, seed, classes, moisture, heights, lights } = input;
|
|
83
|
+
const halfWidth = width / 2;
|
|
84
|
+
const halfHeight = height / 2;
|
|
85
|
+
const table = colourTable(input.look, input.overrides);
|
|
86
|
+
const colours = COLOUR_CLASSES.map((name) => table[name]);
|
|
87
|
+
const output = new Uint8Array(halfWidth * halfHeight * 4);
|
|
88
|
+
const large = Math.max(40, width / 14);
|
|
89
|
+
const medium = Math.max(16, width / 70);
|
|
90
|
+
const heightAt = (x, y) => heights[Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))];
|
|
91
|
+
for (let y = 0; y < halfHeight; y += 1)
|
|
92
|
+
for (let x = 0; x < halfWidth; x += 1) {
|
|
93
|
+
const sx = x * 2;
|
|
94
|
+
const sy = y * 2;
|
|
95
|
+
const source = sy * width + sx;
|
|
96
|
+
const base = colours[classes[source]];
|
|
97
|
+
const water = classes[source] === 10 || classes[source] === 11;
|
|
98
|
+
const offset = (y * halfWidth + x) * 4;
|
|
99
|
+
let factor;
|
|
100
|
+
let wet = 0;
|
|
101
|
+
let hue = 0;
|
|
102
|
+
if (water) {
|
|
103
|
+
// The sea floor under the water layer is nearly uniform in the base game.
|
|
104
|
+
factor = 1 + (lattice(seed + 17, sx, sy) - 0.5) * 2 * 0.03;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const low = (fractal(seed + 4201, sx, sy, large, 4) - 0.5) / NOISE_SIGMA;
|
|
108
|
+
const mid = (fractal(seed + 6007, sx, sy, medium, 3) - 0.5) / NOISE_SIGMA;
|
|
109
|
+
hue = (fractal(seed + 8803, sx, sy, large * 0.5, 3) - 0.5) / NOISE_SIGMA;
|
|
110
|
+
const dx = heightAt(sx + 2, sy) - heightAt(sx - 2, sy);
|
|
111
|
+
const dy = heightAt(sx, sy + 2) - heightAt(sx, sy - 2);
|
|
112
|
+
const shade = Math.max(-0.22, Math.min(0.22, (0.7071 * (dx + dy)) / 14));
|
|
113
|
+
factor = 1 + 0.14 * low + 0.07 * mid + shade;
|
|
114
|
+
wet = (moisture[source] - 0.5) * 2;
|
|
115
|
+
}
|
|
116
|
+
factor = Math.min(1.75, Math.max(0.5, factor));
|
|
117
|
+
const jitter = (channel) => 1 + (lattice(seed + 29 + channel, sx, sy) - 0.5) * (water ? 0.06 : 0.16);
|
|
118
|
+
// Wet ground is greener, dry ground warmer; the hue drifts slowly across the land.
|
|
119
|
+
const red = base[0] * factor * jitter(0) * (1 - 0.06 * wet) * (1 + 0.06 * hue);
|
|
120
|
+
const green = base[1] * factor * jitter(1) * (1 + 0.03 * wet);
|
|
121
|
+
const blue = base[2] * factor * jitter(2) * (1 - 0.08 * hue);
|
|
122
|
+
output[offset] = Math.min(255, Math.max(0, Math.round(blue)));
|
|
123
|
+
output[offset + 1] = Math.min(255, Math.max(0, Math.round(green)));
|
|
124
|
+
output[offset + 2] = Math.min(255, Math.max(0, Math.round(red)));
|
|
125
|
+
output[offset + 3] = lights[source];
|
|
126
|
+
}
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planar geometry for the geography model, in normalised frame coordinates. Only `+ - * /` and
|
|
3
|
+
* `sqrt` are used so results are identical on every platform.
|
|
4
|
+
*/
|
|
5
|
+
/** Distance under which a point counts as lying on a boundary. */
|
|
6
|
+
export const BOUNDARY_EPSILON = 1e-9;
|
|
7
|
+
export function boundsOf(xs, ys) {
|
|
8
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
9
|
+
for (let index = 0; index < xs.length; index++) {
|
|
10
|
+
const x = xs[index], y = ys[index];
|
|
11
|
+
if (x < x0)
|
|
12
|
+
x0 = x;
|
|
13
|
+
if (x > x1)
|
|
14
|
+
x1 = x;
|
|
15
|
+
if (y < y0)
|
|
16
|
+
y0 = y;
|
|
17
|
+
if (y > y1)
|
|
18
|
+
y1 = y;
|
|
19
|
+
}
|
|
20
|
+
return { x0, y0, x1, y1 };
|
|
21
|
+
}
|
|
22
|
+
export function unionBounds(items) {
|
|
23
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
if (item.x0 < x0)
|
|
26
|
+
x0 = item.x0;
|
|
27
|
+
if (item.y0 < y0)
|
|
28
|
+
y0 = item.y0;
|
|
29
|
+
if (item.x1 > x1)
|
|
30
|
+
x1 = item.x1;
|
|
31
|
+
if (item.y1 > y1)
|
|
32
|
+
y1 = item.y1;
|
|
33
|
+
}
|
|
34
|
+
return { x0, y0, x1, y1 };
|
|
35
|
+
}
|
|
36
|
+
export function boundsContain(bounds, x, y) {
|
|
37
|
+
return (x >= bounds.x0 - BOUNDARY_EPSILON &&
|
|
38
|
+
x <= bounds.x1 + BOUNDARY_EPSILON &&
|
|
39
|
+
y >= bounds.y0 - BOUNDARY_EPSILON &&
|
|
40
|
+
y <= bounds.y1 + BOUNDARY_EPSILON);
|
|
41
|
+
}
|
|
42
|
+
export function boundsOverlap(a, b) {
|
|
43
|
+
return a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1;
|
|
44
|
+
}
|
|
45
|
+
/** Shoelace area in frame units; positive for a counter-clockwise ring with y pointing up. */
|
|
46
|
+
export function signedArea(xs, ys) {
|
|
47
|
+
let sum = 0;
|
|
48
|
+
const n = xs.length;
|
|
49
|
+
for (let index = 0, previous = n - 1; index < n; previous = index++)
|
|
50
|
+
sum += xs[previous] * ys[index] - xs[index] * ys[previous];
|
|
51
|
+
return sum / 2;
|
|
52
|
+
}
|
|
53
|
+
function orientation(ax, ay, bx, by, cx, cy) {
|
|
54
|
+
const value = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
|
55
|
+
return value > 0 ? 1 : value < 0 ? -1 : 0;
|
|
56
|
+
}
|
|
57
|
+
function onSegment(ax, ay, bx, by, px, py) {
|
|
58
|
+
return (px >= Math.min(ax, bx) &&
|
|
59
|
+
px <= Math.max(ax, bx) &&
|
|
60
|
+
py >= Math.min(ay, by) &&
|
|
61
|
+
py <= Math.max(ay, by));
|
|
62
|
+
}
|
|
63
|
+
/** Closed segments `ab` and `cd` share at least one point (touching and overlapping count). */
|
|
64
|
+
export function segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy) {
|
|
65
|
+
const o1 = orientation(ax, ay, bx, by, cx, cy);
|
|
66
|
+
const o2 = orientation(ax, ay, bx, by, dx, dy);
|
|
67
|
+
const o3 = orientation(cx, cy, dx, dy, ax, ay);
|
|
68
|
+
const o4 = orientation(cx, cy, dx, dy, bx, by);
|
|
69
|
+
if (o1 !== o2 && o3 !== o4)
|
|
70
|
+
return true;
|
|
71
|
+
if (o1 === 0 && onSegment(ax, ay, bx, by, cx, cy))
|
|
72
|
+
return true;
|
|
73
|
+
if (o2 === 0 && onSegment(ax, ay, bx, by, dx, dy))
|
|
74
|
+
return true;
|
|
75
|
+
if (o3 === 0 && onSegment(cx, cy, dx, dy, ax, ay))
|
|
76
|
+
return true;
|
|
77
|
+
if (o4 === 0 && onSegment(cx, cy, dx, dy, bx, by))
|
|
78
|
+
return true;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/** Distance from a point to the closed segment `ab`, and the position along it (0 to 1). */
|
|
82
|
+
export function pointSegment(px, py, ax, ay, bx, by) {
|
|
83
|
+
const dx = bx - ax, dy = by - ay;
|
|
84
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
85
|
+
let t = lengthSquared === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lengthSquared;
|
|
86
|
+
if (t < 0)
|
|
87
|
+
t = 0;
|
|
88
|
+
else if (t > 1)
|
|
89
|
+
t = 1;
|
|
90
|
+
const qx = ax + t * dx - px, qy = ay + t * dy - py;
|
|
91
|
+
return { distance: Math.sqrt(qx * qx + qy * qy), t };
|
|
92
|
+
}
|
|
93
|
+
/** Even-odd containment; a point within `BOUNDARY_EPSILON` of the ring counts as inside. */
|
|
94
|
+
export function ringContains(xs, ys, px, py) {
|
|
95
|
+
let inside = false;
|
|
96
|
+
const n = xs.length;
|
|
97
|
+
for (let index = 0, previous = n - 1; index < n; previous = index++) {
|
|
98
|
+
const ax = xs[previous], ay = ys[previous], bx = xs[index], by = ys[index];
|
|
99
|
+
if (pointSegment(px, py, ax, ay, bx, by).distance <= BOUNDARY_EPSILON)
|
|
100
|
+
return true;
|
|
101
|
+
if (ay > py !== by > py && px < ((bx - ax) * (py - ay)) / (by - ay) + ax)
|
|
102
|
+
inside = !inside;
|
|
103
|
+
}
|
|
104
|
+
return inside;
|
|
105
|
+
}
|
|
106
|
+
/** Uniform grid over segments, so intersection tests stay near-linear on long rings. */
|
|
107
|
+
export class SegmentGrid {
|
|
108
|
+
xs;
|
|
109
|
+
ys;
|
|
110
|
+
closed;
|
|
111
|
+
cells = new Map();
|
|
112
|
+
size;
|
|
113
|
+
minX;
|
|
114
|
+
minY;
|
|
115
|
+
cellWidth;
|
|
116
|
+
cellHeight;
|
|
117
|
+
constructor(xs, ys, closed, bounds) {
|
|
118
|
+
this.xs = xs;
|
|
119
|
+
this.ys = ys;
|
|
120
|
+
this.closed = closed;
|
|
121
|
+
const segments = closed ? xs.length : xs.length - 1;
|
|
122
|
+
this.size = Math.max(1, Math.min(64, Math.ceil(Math.sqrt(segments))));
|
|
123
|
+
this.minX = bounds.x0;
|
|
124
|
+
this.minY = bounds.y0;
|
|
125
|
+
this.cellWidth = Math.max((bounds.x1 - bounds.x0) / this.size, 1e-12);
|
|
126
|
+
this.cellHeight = Math.max((bounds.y1 - bounds.y0) / this.size, 1e-12);
|
|
127
|
+
for (let segment = 0; segment < segments; segment++) {
|
|
128
|
+
const [ax, ay, bx, by] = this.endpoints(segment);
|
|
129
|
+
const [cx0, cx1] = this.span(Math.min(ax, bx), Math.max(ax, bx), this.minX, this.cellWidth);
|
|
130
|
+
const [cy0, cy1] = this.span(Math.min(ay, by), Math.max(ay, by), this.minY, this.cellHeight);
|
|
131
|
+
for (let cy = cy0; cy <= cy1; cy++)
|
|
132
|
+
for (let cx = cx0; cx <= cx1; cx++) {
|
|
133
|
+
const key = cy * this.size + cx;
|
|
134
|
+
const list = this.cells.get(key);
|
|
135
|
+
if (list === undefined)
|
|
136
|
+
this.cells.set(key, [segment]);
|
|
137
|
+
else
|
|
138
|
+
list.push(segment);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
get segmentCount() {
|
|
143
|
+
return this.closed ? this.xs.length : this.xs.length - 1;
|
|
144
|
+
}
|
|
145
|
+
endpoints(segment) {
|
|
146
|
+
const next = (segment + 1) % this.xs.length;
|
|
147
|
+
return [this.xs[segment], this.ys[segment], this.xs[next], this.ys[next]];
|
|
148
|
+
}
|
|
149
|
+
span(low, high, origin, cell) {
|
|
150
|
+
const clamp = (value) => Math.max(0, Math.min(this.size - 1, value));
|
|
151
|
+
return [clamp(Math.floor((low - origin) / cell)), clamp(Math.floor((high - origin) / cell))];
|
|
152
|
+
}
|
|
153
|
+
/** Visit candidate segments that share a grid cell with the box; stop when `visit` returns true. */
|
|
154
|
+
candidates(x0, y0, x1, y1, visit) {
|
|
155
|
+
const [cx0, cx1] = this.span(x0, x1, this.minX, this.cellWidth);
|
|
156
|
+
const [cy0, cy1] = this.span(y0, y1, this.minY, this.cellHeight);
|
|
157
|
+
for (let cy = cy0; cy <= cy1; cy++)
|
|
158
|
+
for (let cx = cx0; cx <= cx1; cx++) {
|
|
159
|
+
const list = this.cells.get(cy * this.size + cx);
|
|
160
|
+
if (list === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
for (const segment of list)
|
|
163
|
+
if (visit(segment))
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* First pair of segments that cross, touch or overlap, ignoring neighbours that only share their
|
|
171
|
+
* common vertex. A spike (a segment that doubles back on its neighbour) counts as an overlap.
|
|
172
|
+
*/
|
|
173
|
+
export function findSelfIntersection(xs, ys, closed) {
|
|
174
|
+
const n = xs.length;
|
|
175
|
+
const segments = closed ? n : n - 1;
|
|
176
|
+
if (segments < 2)
|
|
177
|
+
return undefined;
|
|
178
|
+
const grid = new SegmentGrid(xs, ys, closed, boundsOf(xs, ys));
|
|
179
|
+
for (let i = 0; i < segments; i++) {
|
|
180
|
+
const [ax, ay, bx, by] = grid.endpoints(i);
|
|
181
|
+
let found;
|
|
182
|
+
grid.candidates(Math.min(ax, bx), Math.min(ay, by), Math.max(ax, bx), Math.max(ay, by), (j) => {
|
|
183
|
+
if (j <= i)
|
|
184
|
+
return false;
|
|
185
|
+
const adjacent = j === i + 1 || (closed && i === 0 && j === segments - 1);
|
|
186
|
+
const [cx, cy, dx, dy] = grid.endpoints(j);
|
|
187
|
+
if (adjacent) {
|
|
188
|
+
// Neighbours legitimately meet at one vertex; they overlap when they double back.
|
|
189
|
+
const shared = j === i + 1 ? [bx, by, ax, ay, dx, dy] : [ax, ay, bx, by, cx, cy];
|
|
190
|
+
const [sx, sy, ux, uy, vx, vy] = shared;
|
|
191
|
+
const cross = (ux - sx) * (vy - sy) - (uy - sy) * (vx - sx);
|
|
192
|
+
const dot = (ux - sx) * (vx - sx) + (uy - sy) * (vy - sy);
|
|
193
|
+
if (cross === 0 && dot > 0) {
|
|
194
|
+
found = [i, j];
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy)) {
|
|
200
|
+
found = [i, j];
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
});
|
|
205
|
+
if (found !== undefined)
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
/** True when any segment of ring `a` meets any segment of ring `b`. */
|
|
211
|
+
export function ringsIntersect(axs, ays, bxs, bys) {
|
|
212
|
+
const boundsA = boundsOf(axs, ays), boundsB = boundsOf(bxs, bys);
|
|
213
|
+
if (!boundsOverlap(boundsA, boundsB))
|
|
214
|
+
return false;
|
|
215
|
+
const grid = new SegmentGrid(axs, ays, true, boundsA);
|
|
216
|
+
const n = bxs.length;
|
|
217
|
+
for (let index = 0; index < n; index++) {
|
|
218
|
+
const next = (index + 1) % n;
|
|
219
|
+
const cx = bxs[index], cy = bys[index], dx = bxs[next], dy = bys[next];
|
|
220
|
+
const hit = grid.candidates(Math.min(cx, dx), Math.min(cy, dy), Math.max(cx, dx), Math.max(cy, dy), (segment) => {
|
|
221
|
+
const [ax, ay, bx, by] = grid.endpoints(segment);
|
|
222
|
+
return segmentsIntersect(ax, ay, bx, by, cx, cy, dx, dy);
|
|
223
|
+
});
|
|
224
|
+
if (hit)
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
/** Area centroid of a simple ring (the vertex mean for a zero-area ring). */
|
|
230
|
+
export function ringCentroid(xs, ys) {
|
|
231
|
+
const area = signedArea(xs, ys);
|
|
232
|
+
const n = xs.length;
|
|
233
|
+
if (Math.abs(area) < 1e-15) {
|
|
234
|
+
let sx = 0, sy = 0;
|
|
235
|
+
for (let index = 0; index < n; index++) {
|
|
236
|
+
sx += xs[index];
|
|
237
|
+
sy += ys[index];
|
|
238
|
+
}
|
|
239
|
+
return [sx / n, sy / n];
|
|
240
|
+
}
|
|
241
|
+
let cx = 0, cy = 0;
|
|
242
|
+
for (let index = 0, previous = n - 1; index < n; previous = index++) {
|
|
243
|
+
const cross = xs[previous] * ys[index] - xs[index] * ys[previous];
|
|
244
|
+
cx += (xs[previous] + xs[index]) * cross;
|
|
245
|
+
cy += (ys[previous] + ys[index]) * cross;
|
|
246
|
+
}
|
|
247
|
+
return [cx / (6 * area), cy / (6 * area)];
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The midpoint of the longest horizontal span of the shape through its centroid height, which
|
|
251
|
+
* lies inside the shape even when the shape is concave. Deterministic and cheap.
|
|
252
|
+
*/
|
|
253
|
+
export function interiorPoint(rings, centroidY, bounds) {
|
|
254
|
+
const candidates = [
|
|
255
|
+
centroidY,
|
|
256
|
+
...[0.5, 0.25, 0.75, 0.375, 0.625].map((f) => bounds.y0 + (bounds.y1 - bounds.y0) * f),
|
|
257
|
+
];
|
|
258
|
+
let best;
|
|
259
|
+
let bestLength = -1;
|
|
260
|
+
for (const y of candidates) {
|
|
261
|
+
const crossings = [];
|
|
262
|
+
for (const ring of rings) {
|
|
263
|
+
const n = ring.xs.length;
|
|
264
|
+
for (let index = 0, previous = n - 1; index < n; previous = index++) {
|
|
265
|
+
const ay = ring.ys[previous], by = ring.ys[index];
|
|
266
|
+
if (ay > y !== by > y) {
|
|
267
|
+
const ax = ring.xs[previous], bx = ring.xs[index];
|
|
268
|
+
crossings.push(ax + ((y - ay) * (bx - ax)) / (by - ay));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
crossings.sort((a, b) => a - b);
|
|
273
|
+
for (let index = 0; index + 1 < crossings.length; index += 2) {
|
|
274
|
+
const length = crossings[index + 1] - crossings[index];
|
|
275
|
+
if (length > bestLength) {
|
|
276
|
+
bestLength = length;
|
|
277
|
+
best = [(crossings[index] + crossings[index + 1]) / 2, y];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (best !== undefined && y === centroidY && bestLength > 0)
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
return best ?? [(bounds.x0 + bounds.x1) / 2, (bounds.y0 + bounds.y1) / 2];
|
|
284
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { boundsContain, pointSegment, ringContains } from './geometry.js';
|
|
2
|
+
/** Fixed order of kinds wherever features are listed together. */
|
|
3
|
+
export const GEOGRAPHY_FEATURE_KINDS = [
|
|
4
|
+
'sea',
|
|
5
|
+
'land',
|
|
6
|
+
'region',
|
|
7
|
+
'range',
|
|
8
|
+
'pass',
|
|
9
|
+
'volcano',
|
|
10
|
+
'plateau',
|
|
11
|
+
'river',
|
|
12
|
+
'lake',
|
|
13
|
+
'biome',
|
|
14
|
+
'strait',
|
|
15
|
+
'site',
|
|
16
|
+
'road',
|
|
17
|
+
'place',
|
|
18
|
+
];
|
|
19
|
+
function areaContains(area, x, y) {
|
|
20
|
+
if (!boundsContain(area.bounds, x, y))
|
|
21
|
+
return false;
|
|
22
|
+
let inside = false;
|
|
23
|
+
for (const ring of area.outers)
|
|
24
|
+
if (boundsContain(ring.bounds, x, y) && ringContains(ring.xs, ring.ys, x, y)) {
|
|
25
|
+
inside = true;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (!inside)
|
|
29
|
+
return false;
|
|
30
|
+
for (const hole of area.holes)
|
|
31
|
+
if (boundsContain(hole.bounds, x, y) && ringContainsStrictly(hole, x, y))
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
/** A hole boundary belongs to the shape: only points strictly inside the hole are excluded. */
|
|
36
|
+
function ringContainsStrictly(ring, x, y) {
|
|
37
|
+
let inside = false;
|
|
38
|
+
const n = ring.xs.length;
|
|
39
|
+
for (let index = 0, previous = n - 1; index < n; previous = index++) {
|
|
40
|
+
const ay = ring.ys[previous], by = ring.ys[index];
|
|
41
|
+
if (ay > y !== by > y) {
|
|
42
|
+
const ax = ring.xs[previous], bx = ring.xs[index];
|
|
43
|
+
if (x < ax + ((y - ay) * (bx - ax)) / (by - ay))
|
|
44
|
+
inside = !inside;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return inside;
|
|
48
|
+
}
|
|
49
|
+
/** Surface lookup over the water and land features, usable before a full model exists. */
|
|
50
|
+
export function findSurface(features, x, y) {
|
|
51
|
+
for (const lake of features.lakes)
|
|
52
|
+
if (areaContains(lake.area, x, y))
|
|
53
|
+
return { surface: 'lake', id: lake.id };
|
|
54
|
+
for (const land of features.lands)
|
|
55
|
+
if (areaContains(land.area, x, y))
|
|
56
|
+
return { surface: 'land', id: land.id };
|
|
57
|
+
for (const sea of features.seas)
|
|
58
|
+
if (areaContains(sea.area, x, y))
|
|
59
|
+
return { surface: 'sea', id: sea.id };
|
|
60
|
+
return { surface: 'open-water' };
|
|
61
|
+
}
|
|
62
|
+
export function resolvedAreaContains(area, x, y) {
|
|
63
|
+
return areaContains(area, x, y);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Distance from a point to the nearest ring edge of the area, inside or outside, in map-width
|
|
67
|
+
* units (`aspect` is height over width, so one unit is the whole map width on both axes).
|
|
68
|
+
*/
|
|
69
|
+
export function areaBoundaryDistance(area, x, y, aspect) {
|
|
70
|
+
let best = Infinity;
|
|
71
|
+
for (const ring of [...area.outers, ...area.holes]) {
|
|
72
|
+
const n = ring.xs.length;
|
|
73
|
+
for (let index = 0, previous = n - 1; index < n; previous = index++) {
|
|
74
|
+
const found = pointSegment(x, y * aspect, ring.xs[previous], ring.ys[previous] * aspect, ring.xs[index], ring.ys[index] * aspect).distance;
|
|
75
|
+
if (found < best)
|
|
76
|
+
best = found;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
81
|
+
/** The resolved model with lookup by id and by point. Construct it with `resolveGeography`. */
|
|
82
|
+
export class ResolvedGeography {
|
|
83
|
+
frame;
|
|
84
|
+
hash;
|
|
85
|
+
name;
|
|
86
|
+
description;
|
|
87
|
+
seas;
|
|
88
|
+
lands;
|
|
89
|
+
regions;
|
|
90
|
+
ranges;
|
|
91
|
+
passes;
|
|
92
|
+
volcanoes;
|
|
93
|
+
plateaus;
|
|
94
|
+
rivers;
|
|
95
|
+
lakes;
|
|
96
|
+
biomes;
|
|
97
|
+
straits;
|
|
98
|
+
sites;
|
|
99
|
+
roads;
|
|
100
|
+
places;
|
|
101
|
+
climate;
|
|
102
|
+
pointCount;
|
|
103
|
+
byId;
|
|
104
|
+
constructor(data) {
|
|
105
|
+
this.frame = data.frame;
|
|
106
|
+
this.hash = data.hash;
|
|
107
|
+
if (data.name !== undefined)
|
|
108
|
+
this.name = data.name;
|
|
109
|
+
if (data.description !== undefined)
|
|
110
|
+
this.description = data.description;
|
|
111
|
+
this.seas = data.seas;
|
|
112
|
+
this.lands = data.lands;
|
|
113
|
+
this.regions = data.regions;
|
|
114
|
+
this.ranges = data.ranges;
|
|
115
|
+
this.passes = data.passes;
|
|
116
|
+
this.volcanoes = data.volcanoes;
|
|
117
|
+
this.plateaus = data.plateaus;
|
|
118
|
+
this.rivers = data.rivers;
|
|
119
|
+
this.lakes = data.lakes;
|
|
120
|
+
this.biomes = data.biomes;
|
|
121
|
+
this.straits = data.straits;
|
|
122
|
+
this.sites = data.sites;
|
|
123
|
+
this.roads = data.roads;
|
|
124
|
+
this.places = data.places;
|
|
125
|
+
this.climate = data.climate;
|
|
126
|
+
this.pointCount = data.pointCount;
|
|
127
|
+
this.byId = new Map(this.features().map((feature) => [feature.id, feature]));
|
|
128
|
+
}
|
|
129
|
+
/** Every feature, grouped by `GEOGRAPHY_FEATURE_KINDS` and sorted by id inside a group. */
|
|
130
|
+
features() {
|
|
131
|
+
return [
|
|
132
|
+
...this.seas,
|
|
133
|
+
...this.lands,
|
|
134
|
+
...this.regions,
|
|
135
|
+
...this.ranges,
|
|
136
|
+
...this.passes,
|
|
137
|
+
...this.volcanoes,
|
|
138
|
+
...this.plateaus,
|
|
139
|
+
...this.rivers,
|
|
140
|
+
...this.lakes,
|
|
141
|
+
...[...this.biomes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
142
|
+
...this.straits,
|
|
143
|
+
...this.sites,
|
|
144
|
+
...this.roads,
|
|
145
|
+
...this.places,
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
get featureCount() {
|
|
149
|
+
return this.byId.size;
|
|
150
|
+
}
|
|
151
|
+
/** The feature with this id, of any kind. */
|
|
152
|
+
find(id) {
|
|
153
|
+
return this.byId.get(id);
|
|
154
|
+
}
|
|
155
|
+
/** The feature with this id when it has the given kind. */
|
|
156
|
+
get(kind, id) {
|
|
157
|
+
const feature = this.byId.get(id);
|
|
158
|
+
return feature?.kind === kind ? feature : undefined;
|
|
159
|
+
}
|
|
160
|
+
list(kind) {
|
|
161
|
+
const lists = {
|
|
162
|
+
sea: this.seas,
|
|
163
|
+
land: this.lands,
|
|
164
|
+
region: this.regions,
|
|
165
|
+
range: this.ranges,
|
|
166
|
+
pass: this.passes,
|
|
167
|
+
volcano: this.volcanoes,
|
|
168
|
+
plateau: this.plateaus,
|
|
169
|
+
river: this.rivers,
|
|
170
|
+
lake: this.lakes,
|
|
171
|
+
biome: this.biomes,
|
|
172
|
+
strait: this.straits,
|
|
173
|
+
site: this.sites,
|
|
174
|
+
road: this.roads,
|
|
175
|
+
place: this.places,
|
|
176
|
+
};
|
|
177
|
+
return lists[kind];
|
|
178
|
+
}
|
|
179
|
+
/** The first land (by id) containing the point, if any. */
|
|
180
|
+
landIdAt(x, y) {
|
|
181
|
+
for (const land of this.lands)
|
|
182
|
+
if (areaContains(land.area, x, y))
|
|
183
|
+
return land.id;
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* What the model says is at a point. Lakes carve land; land beats sea; a point in no feature is
|
|
188
|
+
* open water. Boundaries belong to the feature (a coastline point is land).
|
|
189
|
+
*/
|
|
190
|
+
surfaceAt(x, y) {
|
|
191
|
+
return findSurface(this, x, y);
|
|
192
|
+
}
|
|
193
|
+
isLandAt(x, y) {
|
|
194
|
+
return this.surfaceAt(x, y).surface === 'land';
|
|
195
|
+
}
|
|
196
|
+
}
|