Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions docs/domain/settlemaker-water-context.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Settlemaker measured village water
# Settlemaker measured settlement water

FMG implements the sender side of Settlemaker's `water-context-v1` contract
(`settlemaker/docs/water-context-v1.md`, revision `8ebc23a`). The renderer must
Expand All @@ -8,7 +8,7 @@ implement that contract before this mode is enabled.

`VITE_SETTLEMAKER_WATER_CONTEXT_URL` is a build-time opt-in. Leave it unset for
normal production builds until a deployed Settlemaker release explicitly supports
water-context v1 for villages. An old decoder ignores the new field and can still
water-context v1 for both villages and cities. An old decoder ignores the new field and can still
invent a nearby shore from the legacy bearing.

To exercise a supporting local renderer:
Expand All @@ -20,13 +20,28 @@ VITE_SETTLEMAKER_WATER_CONTEXT_URL=http://127.0.0.1:5173/fmg npm run dev
Production endpoints must use HTTPS and the `/fmg` path, without a query or
fragment. Pin the supporting renderer release in the deployment and configure
its endpoint when building FMG. This option routes both embedded and external
village previews to the same URL. Population 1–1000 uses the new contract;
cities continue using the existing production URL and payload.
settlement previews to the same URL. Every positive population uses the measured
contract when enabled, including cities. Leaving the endpoint unset retains the
existing production integration.

Villages initially survey 3 km; cities initially survey 4 km. These are starting
coverage envelopes, not guarantees of sufficient land for every settlement.
Set `VITE_SETTLEMAKER_WATER_SURVEY_RADIUS_M=6000` to request a larger envelope for
a deployment, or pass `{surveyRadiusM: 6000}` as the second argument to
`createSettlemakerPreview` for an individual request. The explicit request takes
precedence. Radius values must be finite and at least 3000 metres.

The renderer checks its actual planning extent and reports `water-coverage-exceeded`
with `requiredSurveyRadiusM` when it needs more geography. Use that value for a
new request; FMG does not automatically receive or retry cross-origin renderer
errors. Export, compression and URL-budget failures remain visible in the burg
editor and on external-open attempts. Increasing coverage can exceed the URL
budget; it never silently replaces measured geometry with a bearing.

## Sender behavior

- Survey a 3 km radius around the actual burg coordinates, independent of port
status. Export the enclosing 6 km square as simple filled water pieces, with
- Survey the selected radius around the actual burg coordinates, independent of port
status. Export its enclosing square as simple filled water pieces, with
islands retained as dry land. Always include `coastlineGeometry`, including
an explicit empty array for a dry survey. No simple-coast approximation is used.
- Use `Coastline.getFeaturePath`, shared with drawing and saving. Loading adopts
Expand All @@ -41,9 +56,42 @@ cities continue using the existing production URL and payload.
If real shores lie beyond coverage, search the canonical full boundary.
- Unsupported custom units produce `unknown-units`, with no metric geometry.
FMG currently has no custom metres-per-unit setting.
- Local river widths are unavailable from the burg context. Omit river surveys
and identify context rivers through `omittedRivers`; never substitute the
kilometre-valued river mouth width for the width beside the burg.
- Apply FMG's Catmull-Rom alpha 0.1 interpolation to the complete edited/meandered
centre anchors before clipping, with `courseSource: "fmg-catmull-rom-0.1"`
and `meander: false`. Capture full-precision cubic controls, flatten local
curves adaptively to 0.2 m, then quantize exported river coordinates to 0.01 m
(at most 0.0071 m extra error). Conservative cubic control hulls include bends
entering coverage even when their anchor chords remain outside. No new random
bends are introduced. This shares FMG's smoothing family, but does not claim
equality with the midpoint of separately offset and rounded painted banks.
- Evaluate the existing physical width model at every original course point
using cumulative discharge and the full-course point index. Convert those
kilometre-valued model widths with `* 1000`; map-unit conversion applies only
to coordinates. Interpolate widths linearly in each cubic segment's parameter
at adaptive stations, retaining the original anchor widths. `widthsM` carries
this profile and `widthM` is its nearest-burg fallback. Neither uses
`River.width`, which only describes the mouth.
- River widths are estimates, identified by `widthSource: "fmg-local-width-model"`.
Settlemaker reports `water-river-width-modelled` and approximate water status.
The geometry tolerance covers coast/lake geometry and interpolated centreline, not estimated river banks or agreement with the painted river
boundary or its visual midline.
The displayed river outline has illustrative map-unit offsets and is not
relabelled as physical survey geometry. Missing or zero local model widths
retain `omittedRivers` warnings instead of inventing a width.
- Survey all river courses for intersections, including crossings whose cell
centres fall outside the context window. Crop courses beyond the selected
survey square plus four maximum half-widths and one metre, matching the
consumer's capped bank miters at sharp bends, so artificial
endpoints cannot become local banks. Interpolate widths at crop points and
keep disconnected reentries as separate runs with the original river ID.
Detailed river courses share the existing URL budget and fail explicitly
when too large; no course is silently removed or simplified.
- For ports, send `harbourWaterKind` from the adjacent haven feature, or `river`
for an inland river port, with `harbourRiverId` from the burg cell's river ID.
This association prevents selecting an unrelated nearby river. A downstream
ocean ID does not turn a river port
into an ocean harbour. This identifies the local water type, not bathymetry
or a military role; FMG's `Naval` burg type only means it is a port.
- Keep the compressed `i` envelope. If the encoded payload exceeds 8192 bytes,
compression fails, or geometry cannot be surveyed safely, show the failure
in the burg editor and on an external-open attempt. Do not discard measured
Expand All @@ -64,7 +112,9 @@ supporting release in both embedded and external-open flows.
Focused tests cover distance/units, finite lakes and islands, peninsula geometry,
curve inclusion and precision, narrow channels, union shore measurement, crop
closures, unknown units, missing local river widths, saved geometry invalidation,
URL-budget failures, and population 1000/1001 rollout boundaries. The 11,115 m
URL-budget failures, population 1000/1001, Tetlapan-sized and large city payloads,
larger survey radii, local harbour-water classification, river source/mouth width
differences, width-unit invariance, edited courses and coastal river payloads. The 11,115 m
Tarrimas-Ha case is synthetic geography reproducing the measured distance; it
does not replay the original Borteland save.

Expand Down
62 changes: 61 additions & 1 deletion src/services/previews/burg-water-survey.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Burg } from "@/generators/burgs-generator";
import { Coastline } from "@/generators/coastline-generator";
import "@/generators/river-generator";
import { surveyBurgWater } from "./burg-water-survey";

afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("unknown physical units", () => {
it("reports custom units without reading geometry or asserting empty water", () => {
Expand All @@ -13,3 +18,58 @@ describe("unknown physical units", () => {
});
});
});

describe("city survey coverage", () => {
it("exports nearby river geometry alongside ocean polygons instead of marking every river omitted", () => {
vi.stubGlobal("options", {
map: { graph: { width: 100, height: 100 }, units: { distance: { unit: "km", scale: 1 } } }
});
vi.stubGlobal("pack", {
cells: { i: [0], p: [[60, 50]], f: [2] },
features: [undefined, { i: 1, type: "island" }, { i: 2, type: "ocean" }],
rivers: [{ i: 7, cells: [0, 1, 2], widthFactor: 1, sourceWidth: 0.15, width: 99 }]
});
vi.spyOn(Coastline, "boundaryRevision", "get").mockReturnValue(1);
vi.spyOn(Coastline, "getFeaturePath").mockReturnValue("M0,0H50.2V100H0Z");
vi.spyOn(Rivers, "addMeandering").mockReturnValue([
[45, 50.1, 100],
[50, 50.1, 200],
[55, 50.1, 300]
]);
const result = surveyBurgWater({ i: 7, x: 50, y: 50 } as Burg, [7, 99], 4000);
expect(result.waterContext).toMatchObject({
status: "measured",
bodies: [{ kind: "ocean", distanceM: expect.closeTo(200) }],
omittedRivers: [{ riverId: "99", reason: "local-width-unavailable" }]
});
if (!("rivers" in result)) throw new Error("Expected exported river");
expect(result.rivers).toHaveLength(1);
expect(result.rivers![0]).toMatchObject({ id: "7", meander: false, widthSource: "fmg-local-width-model" });
expect(result.rivers![0].centreline[1].y).toBeCloseTo(100);
expect(result.rivers![0].widthM).toBeLessThan(99000);
});

it.each([
["km", 1],
["mi", 1.609344],
["nmi", 1.852]
] as const)("uses actual burg coordinates and a larger survey in %s", (unit, kmPerUnit) => {
vi.stubGlobal("options", {
map: { graph: { width: 100, height: 100 }, units: { distance: { unit, scale: 1 / kmPerUnit } } }
});
vi.stubGlobal("pack", {
cells: { i: [0], p: [[60, 50]], f: [2] },
features: [undefined, { i: 1, type: "island" }, { i: 2, type: "ocean" }]
});
vi.spyOn(Coastline, "boundaryRevision", "get").mockReturnValue(1);
vi.spyOn(Coastline, "getFeaturePath").mockReturnValue("M0,0H50.04V100H0Z");
const result = surveyBurgWater({ i: 7, x: 50, y: 50 } as Burg, [], 6000);
expect(result.waterContext).toMatchObject({
status: "measured",
surveyRadiusM: 6000,
bodies: [{ kind: "ocean", distanceM: expect.closeTo(40, 5), bearingDeg: 90 }]
});
if (result.waterContext.status !== "measured") throw new Error("Expected measured water");
expect("coastlineGeometry" in result && result.coastlineGeometry[0].some(p => p.x === 6000)).toBe(true);
});
});
15 changes: 10 additions & 5 deletions src/services/previews/burg-water-survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { quadtree } from "d3";
import type { Burg } from "@/generators/burgs-generator";
import { Coastline } from "@/generators/coastline-generator";
import { getKmInDistanceUnit } from "@/utils/unitUtils";
import { surveyBurgRivers } from "./river-water-survey";
import type { WaterSurvey } from "./water-context-types";
import { WaterSurveyIndex } from "./water-survey";
import { WATER_SURVEY_RADIUS_M, WaterSurveyIndex } from "./water-survey";

let cached:
| {
Expand All @@ -14,7 +15,7 @@ let cached:
}
| undefined;

export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = []): WaterSurvey {
export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = [], radiusM?: number): WaterSurvey {
const kmPerUnit = getKmInDistanceUnit();
if (!kmPerUnit)
return {
Expand Down Expand Up @@ -52,14 +53,18 @@ export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = []): Wat
};
}
const oceanIndex = cached.oceans;
return cached.index.survey({
const metresPerMapUnit = options.map.units.distance.scale * kmPerUnit * 1000;
const riverSurvey = surveyBurgRivers(burg, metresPerMapUnit, radiusM ?? WATER_SURVEY_RADIUS_M, omittedRiverIds);
const water = cached.index.survey({
burg,
metresPerMapUnit: options.map.units.distance.scale * kmPerUnit * 1000,
radiusM,
metresPerMapUnit,
oceanAt: point => {
const ocean = oceanIndex.find(point.x, point.y);
if (!ocean) throw new Error("The map has no ocean feature for this measured water area");
return ocean[2];
},
omittedRiverIds
omittedRiverIds: riverSurvey.omittedRiverIds
});
return { ...water, ...(riverSurvey.rivers.length ? { rivers: riverSurvey.rivers } : {}) };
}
95 changes: 95 additions & 0 deletions src/services/previews/river-curve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// @vitest-environment node

import { curveCatmullRom, path } from "d3";
import { describe, expect, it } from "vitest";
import { closestPoint, parseShorePath } from "@/utils/shoreline-geometry";
import { smoothRiverCourse } from "./river-curve";
import { projectRiverSurvey } from "./river-water-survey";

const anchors = [
{ x: -5000, y: 0, width: 20 },
{ x: -1000, y: 800, width: 30 },
{ x: 0, y: 0, width: 40 },
{ x: 1000, y: 800, width: 50 },
{ x: 5000, y: 0, width: 60 }
];
const distance = (p: { x: number; y: number }, points: { x: number; y: number }[]) =>
Math.min(
...points.slice(1).map((b, i) => {
const q = closestPoint(p, points[i], b);
return Math.hypot(p.x - q.x, p.y - q.y);
})
);

describe("measured smooth river course", () => {
it("passes through original anchors and joins the banks with smooth tangents", () => {
const result = smoothRiverCourse(anchors, 6000);
for (const a of anchors) expect(result).toContainEqual(a);
expect(result.length).toBeGreaterThan(20);
for (const a of anchors.slice(1, -1)) {
const i = result.findIndex(p => p.x === a.x && p.y === a.y),
prev = result[i - 1],
next = result[i + 1];
const v = [a.x - prev.x, a.y - prev.y],
w = [next.x - a.x, next.y - a.y];
expect((v[0] * w[0] + v[1] * w[1]) / Math.hypot(...v) / Math.hypot(...w)).toBeGreaterThan(0.99);
}
expect(result.every(p => p.width >= 20 && p.width <= 60)).toBe(true);
});

it("converges to the full-precision FMG Catmull-Rom curve within the metric budget", () => {
const result = smoothRiverCourse(anchors, 6000, 0.2);
const finer = smoothRiverCourse(anchors, 6000, 0.02);
expect(finer.length).toBeGreaterThan(result.length);
const context = path();
const curve = curveCatmullRom.alpha(0.1)(context);
curve.lineStart();
for (const p of anchors) curve.point(p.x, p.y);
curve.lineEnd();
const curves = parseShorePath(context.toString(), 1).slice(0, -1);
let maxError = 0;
for (const c of curves)
for (let i = 0; i <= 400; i++) {
const t = i / 400,
u = 1 - t,
p = c.points;
const q = {
x: p[0].x * u ** 3 + 3 * p[1].x * u * u * t + 3 * p[2].x * u * t * t + p[3].x * t ** 3,
y: p[0].y * u ** 3 + 3 * p[1].y * u * u * t + 3 * p[2].y * u * t * t + p[3].y * t ** 3
};
maxError = Math.max(maxError, distance(q, result));
expect(distance(q, finer)).toBeLessThanOrEqual(0.021);
}
expect(maxError).toBeLessThanOrEqual(0.201);
});

it("retains an outside-anchor curve that bends into the survey", () => {
const points = [
{ x: 3300, y: -3000, width: 20 },
{ x: 3020, y: -1000, width: 30 },
{ x: 3020, y: 1000, width: 40 },
{ x: 3300, y: 3000, width: 50 }
];
const result = smoothRiverCourse(points, 3000);
expect(result.some(p => p.x < 3000)).toBe(true);
const survey = projectRiverSurvey(
1,
result.map(p => [p.x, p.y, 0]),
result.map(p => p.width),
{ x: 0, y: 0 },
1,
3000
);
expect(survey.rivers).toHaveLength(1);
expect(survey.rivers[0].centreline.some(p => p.x < 3000)).toBe(true);
});

it("keeps full-course tangents and widths when the survey crop changes", () => {
const small = smoothRiverCourse(anchors, 1000),
large = smoothRiverCourse(anchors, 6000);
const local = small.filter(p => Math.abs(p.x) < 1000 && Math.abs(p.y) < 1000);
expect(local.length).toBeGreaterThan(10);
for (const p of local) expect(large).toContainEqual(p);
expect(anchors[2]).toEqual({ x: 0, y: 0, width: 40 });
});
});
49 changes: 49 additions & 0 deletions src/services/previews/river-curve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { curveCatmullRom, path } from "d3";
import { flattenCurve } from "@/utils/shoreline-geometry";
import type { WaterPoint } from "./water-context-types";

export interface RiverCurvePoint extends WaterPoint {
width: number;
}

export const RIVER_CURVE_ERROR_M = 0.2;

/** FMG's drawing interpolation applied to its centre anchors, without rounded cartographic bank offsets. */
export function smoothRiverCourse(
points: RiverCurvePoint[],
radiusM: number,
toleranceM = RIVER_CURVE_ERROR_M
): RiverCurvePoint[] {
if (points.length < 2) return points;
const output: RiverCurvePoint[] = [points[0]];
const padding = points.reduce((max, p) => (Number.isFinite(p.width) ? Math.max(max, p.width) : max), 0) * 2 + 1;
let at: WaterPoint = points[0],
segment = 0;
const context = path();
const append = (controls: WaterPoint[]) => {
const start = points[segment],
end = points[++segment];
// Complete-course neighbours define every tangent; distant curves need no tessellation.
const outside = ["x", "y"].some(axis => {
const values = controls.map(p => p[axis as "x" | "y"]);
return Math.min(...values) > radiusM + padding || Math.max(...values) < -radiusM - padding;
});
if (outside) output.push(end);
else
flattenCurve(controls, toleranceM, (point, parameter) => {
if (parameter === 0) return;
output.push({ ...point, width: start.width + (end.width - start.width) * parameter });
});
at = controls.at(-1)!;
};
context.moveTo = (x, y) => {
at = { x, y };
};
context.lineTo = (x, y) => append([at, { x, y }]);
context.bezierCurveTo = (x1, y1, x2, y2, x, y) => append([at, { x: x1, y: y1 }, { x: x2, y: y2 }, { x, y }]);
const curve = curveCatmullRom.alpha(0.1)(context);
curve.lineStart();
for (const p of points) curve.point(p.x, p.y);
curve.lineEnd();
return output;
}
Loading
Loading