From ec5c099e7079dec4a5af999f9b45e3dc673f7ed4 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 17 Oct 2026 23:47:49 +0100 Subject: [PATCH 1/2] Enable measured settlement water previews by default --- docs/domain/settlemaker-water-context.md | 40 +++--- .../previews/settlemaker-burg.test.ts | 51 +++++-- src/services/previews/settlemaker-burg.ts | 16 ++- .../previews/water-coordinate-pack.test.ts | 51 +++++++ .../previews/water-coordinate-pack.ts | 97 +++++++++++++ .../previews/water-polygon-pack.test.ts | 120 ++++++++++++++++ src/services/previews/water-polygon-pack.ts | 132 ++++++++++++++++++ src/services/previews/water-survey.test.ts | 53 ++++++- src/services/previews/water-survey.ts | 88 ++++++++++-- src/utils/shoreline-geometry.ts | 6 +- 10 files changed, 598 insertions(+), 56 deletions(-) create mode 100644 src/services/previews/water-coordinate-pack.test.ts create mode 100644 src/services/previews/water-coordinate-pack.ts create mode 100644 src/services/previews/water-polygon-pack.test.ts create mode 100644 src/services/previews/water-polygon-pack.ts diff --git a/docs/domain/settlemaker-water-context.md b/docs/domain/settlemaker-water-context.md index 95870352fd..8a4c877b25 100644 --- a/docs/domain/settlemaker-water-context.md +++ b/docs/domain/settlemaker-water-context.md @@ -1,15 +1,15 @@ # 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 -implement that contract before this mode is enabled. +(`settlemaker/docs/water-context-v1.md`). The production renderer supports this +contract for both villages and cities. ## Rollout -`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 both villages and cities. An old decoder ignores the new field and can still -invent a nearby shore from the legacy bearing. +Normal builds export measured water to `https://settlemaker.com/fmg` by default. +`VITE_SETTLEMAKER_WATER_CONTEXT_URL` is an optional build-time endpoint override, +not an enable switch. An unset or empty override uses the production endpoint. +This prevents river ports from being exported as ocean-bearing-only previews. To exercise a supporting local renderer: @@ -18,14 +18,16 @@ 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 -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. +fragment. Any custom endpoint must support water-context v1 for both villages +and cities. Both embedded and external settlement previews use the same URL, +and every positive population uses the measured contract. Previously saved +bearing-only URLs cannot recover river geometry; reopen the burg preview from +the source map to create a new URL. -Villages initially survey 3 km; cities initially survey 4 km. These are starting -coverage envelopes, not guarantees of sufficient land for every settlement. +Villages initially survey 3 km. Cities start at 4 km and scale with population +to cover their cultivated envelope and navigation margin (8 km for Erdemeli's +173,976 residents, 13 km for 500,000). These are starting coverage estimates, +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 @@ -49,8 +51,12 @@ budget; it never silently replaces measured geometry with a bearing. coastline edits and coastline settings invalidate the geometry cache/index. - Convert map coordinates once using the selected distance unit. Flatten curves adaptively within 0.2 m; report a conservative combined `geometryErrorM` of - 0.5 m. Do not round or simplify exported coordinates. Reject ambiguous narrow - or crossing curved shores instead of silently changing local topology. + 0.5 m. Pack adjacent fill pieces without filling islands or closing channels; + remove collinear arithmetic noise and shorten coordinate tails to eight decimal + metres only when edge contacts, crossings and rings remain unchanged. Reserve + the rounding error within the geometry budget and retain full precision when + those checks fail. Reject ambiguous narrow or crossing curved shores instead + of silently changing local topology. - Measure exposed real shores from the same flattened boundary that supplies the polygons. Shared piece edges and survey-square closures are not banks. If real shores lie beyond coverage, search the canonical full boundary. @@ -106,8 +112,8 @@ automatic simplification or resurvey in this implementation. Settlemaker owns water-union bank queries, metric layout, bounded planning, frame/coverage validation, input precedence and visible iframe diagnostics. The FMG sender does not implement those renderer behaviors or an iframe message -protocol. Before enabling production, verify those behaviors against the -supporting release in both embedded and external-open flows. +protocol. Verify those behaviors against the supporting release in both +embedded and external-open flows when changing the integration. Focused tests cover distance/units, finite lakes and islands, peninsula geometry, curve inclusion and precision, narrow channels, union shore measurement, crop diff --git a/src/services/previews/settlemaker-burg.test.ts b/src/services/previews/settlemaker-burg.test.ts index 1dc27f4689..29f05b7226 100644 --- a/src/services/previews/settlemaker-burg.test.ts +++ b/src/services/previews/settlemaker-burg.test.ts @@ -9,7 +9,10 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/generators/burg-context", () => ({ buildBurgContext: mocks.context })); vi.mock("./burg-water-survey", () => ({ surveyBurgWater: mocks.survey })); -vi.mock("./settlemaker", () => ({ buildSettlemakerUrl: mocks.url })); +vi.mock("./settlemaker", () => ({ + buildSettlemakerUrl: mocks.url, + SETTLEMAKER_BASE_URL: "https://settlemaker.com/fmg" +})); afterEach(() => { vi.unstubAllEnvs(); @@ -30,10 +33,26 @@ async function preview(population: number, endpoint?: string, surveyRadiusM?: nu } describe("measured-water rollout", () => { - it("keeps current production payloads unchanged until a supporting endpoint is configured", async () => { - await preview(10); - expect(mocks.survey).not.toHaveBeenCalled(); - expect(mocks.url.mock.calls[0][1]).toEqual({ urbanDensity: 8, trade: true }); + it.each([ + [10, 3000], + [1000, 3000], + [16973, 4000], + [173976, 8000] + ])("exports measured water by default for population %s", async (population, radius) => { + await preview(population); + expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], radius); + expect(mocks.url.mock.calls[0][1]).toMatchObject({ + urbanDensity: 8, + trade: true, + baseUrl: "https://settlemaker.com/fmg", + waterSurvey: { waterContext: { version: 1 } } + }); + }); + + it("uses measured production water when the endpoint override is empty", async () => { + await preview(173976, ""); + expect(mocks.survey).toHaveBeenCalledOnce(); + expect(mocks.url.mock.calls[0][1]).toHaveProperty("baseUrl", "https://settlemaker.com/fmg"); }); it("enables the versioned survey through the configured renderer for population 1000", async () => { @@ -45,15 +64,17 @@ describe("measured-water rollout", () => { }); }); - it.each([1001, 16973, 100000])( - "surveys cities of population %s through the configured renderer", - async population => { - const result = await preview(population, "https://water.example/fmg"); - expect(mocks.survey).toHaveBeenCalledWith(expect.objectContaining({ x: 10, y: 20 }), [], 4000); - expect(mocks.url.mock.calls[0][1]).toHaveProperty("baseUrl", "https://water.example/fmg"); - expect(result.link).toBe(result.preview); - } - ); + it.each([ + [1001, 4000], + [16973, 4000], + [100000, 7000], + [500000, 13000] + ])("surveys cities of population %s through the configured renderer", async (population, radius) => { + const result = await preview(population, "https://water.example/fmg"); + expect(mocks.survey).toHaveBeenCalledWith(expect.objectContaining({ x: 10, y: 20 }), [], radius); + expect(mocks.url.mock.calls[0][1]).toHaveProperty("baseUrl", "https://water.example/fmg"); + expect(result.link).toBe(result.preview); + }); it("accepts an explicit survey radius when the renderer requests more coverage", async () => { await preview(16973, "https://water.example/fmg", 7000); @@ -88,7 +109,7 @@ describe("measured-water rollout", () => { describe("harbour water body", () => { it.each(["river", "lake", "ocean"] as const)("exports the intended river ID only for a %s port", async kind => { - await preview(9987, "http://127.0.0.1:5228/fmg"); + await preview(9987); vi.stubGlobal("pack", { cells: { haven: [1], harbor: [kind === "river" ? 0 : 1], f: [0, 2], r: [653] }, features: [{}, { type: "ocean" }, { type: kind }] diff --git a/src/services/previews/settlemaker-burg.ts b/src/services/previews/settlemaker-burg.ts index b7242297a4..db8f5df567 100644 --- a/src/services/previews/settlemaker-burg.ts +++ b/src/services/previews/settlemaker-burg.ts @@ -1,14 +1,17 @@ import { buildBurgContext } from "@/generators/burg-context"; import type { Burg } from "@/generators/burgs-generator"; import { surveyBurgWater } from "./burg-water-survey"; -import { type AzgaarBurgInput, buildSettlemakerUrl } from "./settlemaker"; +import { type AzgaarBurgInput, buildSettlemakerUrl, SETTLEMAKER_BASE_URL } from "./settlemaker"; -/** Set only for a deployed renderer supporting measured water on both city and village layouts. */ -export const MEASURED_WATER_RENDERER = import.meta.env.VITE_SETTLEMAKER_WATER_CONTEXT_URL as string | undefined; +/** Override the supporting production renderer for local testing or another deployment. */ +export const MEASURED_WATER_RENDERER = + (import.meta.env.VITE_SETTLEMAKER_WATER_CONTEXT_URL as string | undefined) || SETTLEMAKER_BASE_URL; export function waterSurveyRadius(population: number, override?: number): number { const configured = import.meta.env.VITE_SETTLEMAKER_WATER_SURVEY_RADIUS_M as string | undefined; - const radius = override ?? (configured ? Number(configured) : population > 1000 ? 4000 : 3000); + // Allow for the city's cultivated envelope, its diagonal and a navigation margin. + const cityRadius = Math.max(4000, Math.ceil((Math.sqrt(population * 240) + 1500) / 1000) * 1000); + const radius = override ?? (configured ? Number(configured) : population > 1000 ? cityRadius : 3000); if (!Number.isFinite(radius) || radius < 3000) throw new Error("The measured water survey radius must be at least 3000 metres"); return radius; @@ -28,13 +31,12 @@ export async function createSettlemakerPreview( surveyOptions: { surveyRadiusM?: number } = {} ): Promise<{ link: string; preview: string }> { const ctx = buildBurgContext(burg); - const measured = MEASURED_WATER_RENDERER && ctx.burg.population >= 1; const opts = { urbanDensity: options.map.units.population.urbanization.density, trade: burg.tradeRole === "hub" }; - if (!measured) return buildSettlemakerUrl(ctx, opts); - const endpoint = new URL(MEASURED_WATER_RENDERER!); + if (ctx.burg.population < 1) return buildSettlemakerUrl(ctx, opts); + const endpoint = new URL(MEASURED_WATER_RENDERER); if ( endpoint.search || endpoint.hash || diff --git a/src/services/previews/water-coordinate-pack.test.ts b/src/services/previews/water-coordinate-pack.test.ts new file mode 100644 index 0000000000..72e44a5877 --- /dev/null +++ b/src/services/previews/water-coordinate-pack.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { packWaterCoordinates, WATER_COORDINATE_ERROR_M } from "./water-coordinate-pack"; + +const box = (x: number, y: number, w: number, h: number) => [ + { x, y }, + { x: x + w, y }, + { x: x + w, y: y + h }, + { x, y: y + h } +]; +describe("bounded water coordinate packing", () => { + it("shortens numeric tails within a nanometre-scale budget without mutating source", () => { + const source = [box(0.123456789123, 10.0123456789, 100, 100)], + before = structuredClone(source), + packed = packWaterCoordinates(source); + expect(packed).not.toEqual(source); + expect(source).toEqual(before); + for (let i = 0; i < 4; i++) + expect(Math.hypot(packed[0][i].x - source[0][i].x, packed[0][i].y - source[0][i].y)).toBeLessThan( + WATER_COORDINATE_ERROR_M + ); + }); + it("retains exact geometry if rounding would close a narrow channel", () => { + const source = [box(0, 0, 10, 100), box(10 + 1e-10, 0, 10, 100)]; + expect(packWaterCoordinates(source)).toBe(source); + }); + it("retains thin rings rather than collapsing them", () => { + const source = [box(0, 0, 1e-10, 100)]; + expect(packWaterCoordinates(source)).toBe(source); + }); + it("keeps microscopic source edges while compacting unrelated vertices", () => { + const source = [ + [ + { x: -12.093654374897246, y: -1104.742361250057 }, + { x: 12.408731422223605, y: -1126.610817121642 }, + { x: 12.4087314222236, y: -1126.610817121642 }, + { x: 36.48981735362183, y: -1148.5428133008445 }, + { x: 100.123456789123, y: 0.123456789123 } + ] + ]; + const packed = packWaterCoordinates(source); + expect(packed[0][1]).toEqual(source[0][1]); + expect(packed[0][2]).toEqual(source[0][2]); + expect(packed[0][4]).not.toEqual(source[0][4]); + expect(packWaterCoordinates(packed)).toEqual(packed); + }); + it("retains holes and coincident piece joins", () => { + const source = [box(0, 0, 100, 20), box(0, 80, 100, 20), box(0, 20, 20, 60), box(80, 20, 20, 60)]; + expect(packWaterCoordinates(source)).toEqual(source); + }); +}); diff --git a/src/services/previews/water-coordinate-pack.ts b/src/services/previews/water-coordinate-pack.ts new file mode 100644 index 0000000000..eb58447213 --- /dev/null +++ b/src/services/previews/water-coordinate-pack.ts @@ -0,0 +1,97 @@ +import type { WaterPoint as Point } from "./water-context-types"; + +export const WATER_COORDINATE_ERROR_M = 1e-8; +const same = (a: Point, b: Point) => a.x === b.x && a.y === b.y; +const orient = (a: Point, b: Point, c: Point) => Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)); +const on = (p: Point, a: Point, b: Point) => + p.x >= Math.min(a.x, b.x) && p.x <= Math.max(a.x, b.x) && p.y >= Math.min(a.y, b.y) && p.y <= Math.max(a.y, b.y); +const area = (ring: Point[]) => + ring.reduce((sum, p, i) => { + const q = ring[(i + 1) % ring.length]; + return sum + p.x * q.y - q.x * p.y; + }, 0); + +function relation(a: Point, b: Point, c: Point, d: Point): string { + const abC = orient(a, b, c), + abD = orient(a, b, d), + cdA = orient(c, d, a), + cdB = orient(c, d, b); + if (abC * abD < 0 && cdA * cdB < 0) return "cross"; + if (!abC && !abD && !cdA && !cdB) { + const axis = a.x !== b.x ? "x" : "y"; + const overlap = + Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis])) - + Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis])); + return overlap > 0 ? "overlap" : overlap === 0 ? "touch" : "none"; + } + return (!abC && on(c, a, b)) || (!abD && on(d, a, b)) || (!cdA && on(a, c, d)) || (!cdB && on(b, c, d)) + ? "touch" + : "none"; +} + +function containment(point: Point, ring: Point[]): number { + let winding = 0; + for (let i = 0; i < ring.length; i++) { + const a = ring[i], + b = ring[(i + 1) % ring.length], + side = orient(a, b, point); + if (!side && on(point, a, b)) return 0; + if (a.y <= point.y && b.y > point.y && side > 0) winding++; + if (a.y > point.y && b.y <= point.y && side < 0) winding--; + } + return winding ? 1 : -1; +} + +/** Shorten arithmetic tails while retaining sensitive vertices at their original precision. */ +export function packWaterCoordinates(polygons: Point[][]): Point[][] { + const rounded = polygons.map(ring => ring.map(p => ({ x: Number(p.x.toFixed(8)), y: Number(p.y.toFixed(8)) }))); + const edges = polygons.flatMap((ring, r) => + ring.map((a, i) => ({ r, i, j: (i + 1) % ring.length, a, b: ring[(i + 1) % ring.length] })) + ); + let changed = true; + while (changed) { + changed = false; + const retain = (r: number, i: number) => { + if (same(rounded[r][i], polygons[r][i])) return; + rounded[r][i] = polygons[r][i]; + changed = true; + }; + for (let r = 0; r < polygons.length; r++) { + if (Math.sign(area(polygons[r])) !== Math.sign(area(rounded[r]))) + for (let i = 0; i < polygons[r].length; i++) retain(r, i); + } + for (const e of edges) { + if (Math.hypot(e.a.x - rounded[e.r][e.i].x, e.a.y - rounded[e.r][e.i].y) > WATER_COORDINATE_ERROR_M) + retain(e.r, e.i); + if (same(rounded[e.r][e.i], rounded[e.r][e.j]) && !same(e.a, e.b)) { + retain(e.r, e.i); + retain(e.r, e.j); + } + } + for (let i = 0; i < edges.length; i++) + for (let j = i + 1; j < edges.length; j++) { + const a = edges[i], + b = edges[j]; + if ( + relation(a.a, a.b, b.a, b.b) === + relation(rounded[a.r][a.i], rounded[a.r][a.j], rounded[b.r][b.i], rounded[b.r][b.j]) + ) + continue; + retain(a.r, a.i); + retain(a.r, a.j); + retain(b.r, b.i); + retain(b.r, b.j); + } + for (let r = 0; r < polygons.length; r++) + for (let q = r + 1; q < polygons.length; q++) { + if ( + containment(polygons[r][0], polygons[q]) === containment(rounded[r][0], rounded[q]) && + containment(polygons[q][0], polygons[r]) === containment(rounded[q][0], rounded[r]) + ) + continue; + for (let i = 0; i < polygons[r].length; i++) retain(r, i); + for (let i = 0; i < polygons[q].length; i++) retain(q, i); + } + } + return rounded.every((ring, r) => ring.every((p, i) => same(p, polygons[r][i]))) ? polygons : rounded; +} diff --git a/src/services/previews/water-polygon-pack.test.ts b/src/services/previews/water-polygon-pack.test.ts new file mode 100644 index 0000000000..d2363d943f --- /dev/null +++ b/src/services/previews/water-polygon-pack.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { WaterPoint as Point } from "./water-context-types"; +import { packWaterPolygons } from "./water-polygon-pack"; + +const rectangle = (left: number, bottom: number, right: number, top: number): Point[] => [ + { x: left, y: bottom }, + { x: right, y: bottom }, + { x: right, y: top }, + { x: left, y: top } +]; +const area = (rings: Point[][]) => + rings.reduce( + (sum, ring) => + sum + + Math.abs( + ring.reduce((a, p, i) => { + const q = ring[(i + 1) % ring.length]; + return a + p.x * q.y - q.x * p.y; + }, 0) + ) / + 2, + 0 + ); +const wet = (rings: Point[][], x: number, y: number) => + rings.some(ring => { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const a = ring[i], + b = ring[j]; + if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) inside = !inside; + } + return inside; + }); + +describe("lossless water polygon packing", () => { + it("collapses adjacent scanline slabs without rounding the coast", () => { + const rings = Array.from({ length: 100 }, (_, i) => [ + { x: Math.sin(i) + 0.0000003, y: i }, + { x: 10, y: i }, + { x: 10, y: i + 1 }, + { x: Math.sin(i + 1) + 0.0000003, y: i + 1 } + ]); + const packed = packWaterPolygons(rings); + expect(packed).toHaveLength(1); + expect(packed[0]).toHaveLength(103); + expect(area(packed)).toBeCloseTo(area(rings), 10); + for (const ring of rings) expect(packed[0]).toContainEqual(ring[0]); + }); + + it("splits partially shared horizontal boundaries at existing endpoints", () => { + const rings = [rectangle(0, 0, 10, 2), rectangle(2, 2, 8, 4)]; + const packed = packWaterPolygons(rings); + expect(packed).toHaveLength(1); + expect(packed[0]).toHaveLength(8); + expect(area(packed)).toBe(32); + expect(wet(packed, 1, 3)).toBe(false); + expect(wet(packed, 3, 3)).toBe(true); + }); + + it("keeps islands dry by retaining separate positive fill pieces around holes", () => { + const rings = [rectangle(0, 0, 10, 2), rectangle(0, 2, 2, 8), rectangle(8, 2, 10, 8), rectangle(0, 8, 10, 10)]; + const packed = packWaterPolygons(rings); + expect(packed.length).toBeGreaterThan(1); + expect(packed.length).toBeLessThan(rings.length); + expect(area(packed)).toBe(64); + for (let x = 0.25; x < 10; x += 0.5) + for (let y = 0.25; y < 10; y += 0.5) expect(wet(packed, x, y)).toBe(wet(rings, x, y)); + expect(wet(packed, 5, 5)).toBe(false); + }); + + it("does not join disconnected water or pieces touching at only one point", () => { + const rings = [rectangle(0, 0, 2, 2), rectangle(2, 2, 4, 4), rectangle(7, 0, 8, 2)]; + expect(packWaterPolygons(rings)).toHaveLength(3); + }); + + it("preserves a narrow dry gap instead of snapping nearly matching boundaries", () => { + const rings = [rectangle(0, 0, 2, 2), rectangle(0, 2.000000001, 2, 4)]; + expect(packWaterPolygons(rings)).toHaveLength(2); + expect(wet(packWaterPolygons(rings), 1, 2.0000000005)).toBe(false); + }); + + it("removes interpolation roundoff but retains genuine small shoreline bends", () => { + const point = { x: 1000 + Number.EPSILON * 512, y: 1 }; + const ring = [{ x: 1000, y: 0 }, { x: 1010, y: 0 }, { x: 1010, y: 2 }, { x: 1000, y: 2 }, point]; + expect(packWaterPolygons([ring])[0]).toHaveLength(4); + const bent = [...ring.slice(0, -1), { x: 1000.00000001, y: 1 }]; + expect(packWaterPolygons([bent])[0]).toContainEqual(bent.at(-1)); + }); + + it("retains a 0.0001m dry channel at survey-scale coordinates", () => { + const rings = [rectangle(7990, 0, 7999, 4), rectangle(7999.0001, 0, 8000, 4)]; + const packed = packWaterPolygons(rings); + expect(packed).toHaveLength(2); + expect(wet(packed, 7999.00005, 2)).toBe(false); + expect(packed).toEqual(rings); + }); + + it("retains shared contact vertices even when they look like interpolation roundoff", () => { + const contact = { x: 1000 + Number.EPSILON * 512, y: 1 }; + const first = [{ x: 1000, y: 0 }, { x: 1010, y: 0 }, { x: 1010, y: 2 }, { x: 1000, y: 2 }, contact]; + const second = [contact, { x: 999, y: 0.5 }, { x: 999, y: 1.5 }]; + const packed = packWaterPolygons([first, second]); + expect(packed).toHaveLength(2); + for (const ring of packed) expect(ring).toContainEqual(contact); + }); + + it("handles reversed winding and explicit closure without mutating input", () => { + const first = rectangle(0, 0, 4, 2).reverse(); + first.push(first[0]); + const rings = [first, rectangle(0, 2, 4, 4)]; + const original = structuredClone(rings); + const packed = packWaterPolygons(rings); + expect(packed).toHaveLength(1); + expect(packed[0]).toHaveLength(4); + expect(rings).toEqual(original); + expect(packWaterPolygons(rings)).toEqual(packed); + }); + + it("accepts empty input", () => expect(packWaterPolygons([])).toEqual([])); +}); diff --git a/src/services/previews/water-polygon-pack.ts b/src/services/previews/water-polygon-pack.ts new file mode 100644 index 0000000000..1940bdd523 --- /dev/null +++ b/src/services/previews/water-polygon-pack.ts @@ -0,0 +1,132 @@ +import type { WaterPoint as Point } from "./water-context-types"; + +interface Edge { + a: Point; + b: Point; +} + +const key = (p: Point) => `${p.x},${p.y}`; +const area = (ring: Point[]) => + ring.reduce((sum, p, i) => { + const q = ring[(i + 1) % ring.length]; + return sum + p.x * q.y - q.x * p.y; + }, 0); +const edges = (ring: Point[]): Edge[] => ring.map((a, i) => ({ a, b: ring[(i + 1) % ring.length] })); + +function clean(ring: Point[], roundoff = false, shared = new Set()): Point[] { + const result = ring.filter((p, i) => key(p) !== key(ring[(i + 1) % ring.length])); + let changed = true; + while (changed && result.length > 3) { + changed = false; + for (let i = 0; i < result.length; i++) { + const a = result[(i + result.length - 1) % result.length], + b = result[i], + c = result[(i + 1) % result.length]; + if (shared.has(key(b))) continue; + const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + const magnitude = Math.max( + 1, + Math.abs(a.x), + Math.abs(a.y), + Math.abs(b.x), + Math.abs(b.y), + Math.abs(c.x), + Math.abs(c.y) + ); + const tolerance = roundoff ? Number.EPSILON * magnitude * Math.hypot(c.x - a.x, c.y - a.y) : 0; + if (Math.abs(cross) > tolerance) continue; + if ((b.x - a.x) * (c.x - b.x) + (b.y - a.y) * (c.y - b.y) < 0) continue; + result.splice(i, 1); + changed = true; + break; + } + } + return area(result) < 0 ? result.reverse() : result; +} + +function merge(a: Point[], b: Point[]): Point[] | undefined { + const all = [...edges(a), ...edges(b)]; + const horizontalA = edges(a).filter(e => e.a.y === e.b.y); + const horizontalB = edges(b).filter(e => e.a.y === e.b.y); + if ( + !horizontalA.some(p => + horizontalB.some( + q => + p.a.y === q.a.y && + Math.min(Math.max(p.a.x, p.b.x), Math.max(q.a.x, q.b.x)) > + Math.max(Math.min(p.a.x, p.b.x), Math.min(q.a.x, q.b.x)) + ) + ) + ) + return; + + const levels = new Map>(); + for (const { a, b } of all) + if (a.y === b.y) { + if (!levels.has(a.y)) levels.set(a.y, new Set()); + levels.get(a.y)!.add(a.x).add(b.x); + } + const boundary = new Map(); + for (const edge of all) { + const points = + edge.a.y === edge.b.y + ? [...levels.get(edge.a.y)!] + .filter(x => x >= Math.min(edge.a.x, edge.b.x) && x <= Math.max(edge.a.x, edge.b.x)) + .sort((x, y) => (x - y) * Math.sign(edge.b.x - edge.a.x)) + .map(x => ({ x, y: edge.a.y })) + : [edge.a, edge.b]; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1], + b = points[i], + forward = `${key(a)}>${key(b)}`, + reverse = `${key(b)}>${key(a)}`; + if (boundary.has(reverse)) boundary.delete(reverse); + else if (boundary.has(forward)) return; + else boundary.set(forward, { a, b }); + } + } + const outgoing = new Map(), + incoming = new Set(); + for (const edge of boundary.values()) { + if (outgoing.has(key(edge.a)) || incoming.has(key(edge.b))) return; + outgoing.set(key(edge.a), edge); + incoming.add(key(edge.b)); + } + const first = boundary.values().next().value; + if (!first) return; + const ring: Point[] = []; + let current: Edge | undefined = first; + while (current) { + ring.push(current.a); + outgoing.delete(key(current.a)); + if (key(current.b) === key(first.a)) break; + current = outgoing.get(key(current.b)); + } + // Multiple loops include holes; leave those as separate positive fill pieces. + if (!current || outgoing.size || ring.length < 3) return; + return clean(ring); +} + +/** Lossless packing of non-overlapping scanline fill pieces from one water body. */ +export function packWaterPolygons(polygons: Point[][]): Point[][] { + const packed = polygons.map(ring => clean(ring)); + for (let i = 0; i < packed.length; i++) { + for (let j = i + 1; j < packed.length; j++) { + const joined = merge(packed[i], packed[j]); + if (!joined) continue; + packed[i] = joined; + packed.splice(j, 1); + j = i; + } + } + const seen = new Set(), + shared = new Set(); + for (const ring of packed) + for (const point of ring) { + const id = key(point); + if (seen.has(id)) shared.add(id); + seen.add(id); + } + // Scanline interpolation can leave collinear knots a single floating-point step off their source edge. + return packed.map(ring => clean(ring, true, shared)); +} diff --git a/src/services/previews/water-survey.test.ts b/src/services/previews/water-survey.test.ts index bd0192f5c2..69ddce2a29 100644 --- a/src/services/previews/water-survey.test.ts +++ b/src/services/previews/water-survey.test.ts @@ -122,11 +122,62 @@ describe("measured water survey", () => { expect(neck.coastlineGeometry.some(poly => inside({ x: 40 + width / 2, y: 0 }, poly))).toBe(false); }); + it("preserves a curved acute coastal tip whose adjoining edges do not overlap", () => { + const result = survey([ + { + id: 1, + kind: "island", + path: "M-2462.29632,289.68192L-48.28032,-193.12128L3000,-3000H-3000V-3000C-2000,0 -2000,100 -2462.29632,289.68192Z" + } + ]); + expect(result.waterContext.status).toBe("measured"); + expect(result.coastlineGeometry.length).toBeGreaterThan(0); + const wet = (p: Point) => result.coastlineGeometry.some(poly => inside(p, poly)); + expect(wet({ x: -2400, y: 274 })).toBe(false); + expect(wet({ x: -2400, y: 280 })).toBe(true); + }); + + it("preserves a transverse curved self-intersection using the saved path fill", () => { + const result = survey([ + { id: 1, kind: "island", path: "M-100,-100C-50,-50 50,50 100,100H-100C-50,50 50,-50 100,-100Z" } + ]); + const wet = (p: Point) => result.coastlineGeometry.some(poly => inside(p, poly)); + expect(wet({ x: 0, y: 80 })).toBe(false); + expect(wet({ x: 0, y: -80 })).toBe(false); + expect(wet({ x: 80, y: 0 })).toBe(true); + expect(result.waterContext.geometryErrorM).toBe(0.5); + }); + + it("preserves a certified curved endpoint contact on a straight boundary", () => { + const result = survey([{ id: 1, kind: "island", path: "M-100,0H100V100Q0,0 0,0Q0,100 -100,100Z" }]); + expect(result.waterContext.status).toBe("measured"); + const wet = (p: Point) => result.coastlineGeometry.some(poly => inside(p, poly)); + expect(wet({ x: -80, y: 20 })).toBe(false); + expect(wet({ x: 80, y: 20 })).toBe(false); + expect(wet({ x: 0, y: 20 })).toBe(true); + }); + + it("does not collapse a curved sliver onto a straight boundary with the same endpoints", () => { + expect(() => survey([{ id: 1, kind: "island", path: "M0,0H10Q5,.1 0,0Z" }])).toThrow("cannot preserve"); + }); + + it("rejects hidden crossings at an acute shared endpoint even when the chords separate", () => { + expect(() => survey([{ id: 1, kind: "island", path: "M0,0Q0,.2 10,0L10,1Q.1,-.15 0,0Z" }])).toThrow( + "cannot preserve" + ); + }); + + it("rejects a curved crossing whose shallow angle exceeds the intersection error budget", () => { + expect(() => + survey([{ id: 1, kind: "island", path: "M-100,-1C-50,-.6 50,.6 100,1H-100C-50,.6 50,-.6 100,-1Z" }]) + ).toThrow("cannot preserve"); + }); + it("rejects ambiguous curved channels instead of changing their topology", () => { expect(() => survey([ { id: 1, kind: "island", path: "M-50000,-50000H40V-100Q40.1,0 40,100V50000H-50000Z" }, - { id: 2, kind: "island", path: rectangle(40.15, -50000, 50000, 50000) } + { id: 2, kind: "island", path: rectangle(40.05, -50000, 50000, 50000) } ]) ).toThrow("cannot preserve"); expect(() => survey([land(50000), { id: 2, kind: "lake", path: "M40,0C40.1,.1 39.9,.1 40,0Z" }])).toThrow( diff --git a/src/services/previews/water-survey.ts b/src/services/previews/water-survey.ts index 4695af18a3..01f04f4938 100644 --- a/src/services/previews/water-survey.ts +++ b/src/services/previews/water-survey.ts @@ -1,9 +1,12 @@ import type { Bounds } from "@/utils/shoreline-geometry"; import { boundsOf, closestPoint, flattenCurve, parseShorePath, ShoreIndex } from "@/utils/shoreline-geometry"; import type { MeasuredWaterContext, WaterPoint as Point, WaterSurvey } from "./water-context-types"; +import { packWaterCoordinates, WATER_COORDINATE_ERROR_M } from "./water-coordinate-pack"; +import { packWaterPolygons } from "./water-polygon-pack"; export const WATER_SURVEY_RADIUS_M = 3000; const FLATTENING_ERROR_M = 0.2; +const GEOMETRY_ERROR_M = 0.5; const EPSILON = 1e-7; export interface SurveyBoundary { @@ -17,6 +20,7 @@ interface Edge { b: Point; feature: number; error: number; + controls: Point[]; } interface WaterBody { @@ -88,15 +92,21 @@ export class WaterSurveyIndex { maxY: burg.y + r / scale }) .flatMap(curve => { - const points = flattenCurve(curve.points.map(toLocal), FLATTENING_ERROR_M); - return points - .slice(1) - .map((b, i) => ({ - a: points[i], - b, - feature: curve.feature, - error: curve.points.length > 2 ? FLATTENING_ERROR_M : 0 - })) + const flattened: Edge[] = []; + let previous = toLocal(curve.points[0]); + flattenCurve(curve.points.map(toLocal), FLATTENING_ERROR_M, (point, parameter, controls) => { + if (parameter) { + const error = Math.max( + ...controls.map(control => { + const nearest = closestPoint(control, previous, point); + return Math.hypot(control.x - nearest.x, control.y - nearest.y); + }) + ); + flattened.push({ a: previous, b: point, feature: curve.feature, error, controls }); + } + previous = point; + }); + return flattened .filter(e => e.a.x !== e.b.x || e.a.y !== e.b.y) .filter(e => Math.min(e.a.y, e.b.y) <= r && Math.max(e.a.y, e.b.y) >= -r) .map(e => ({ @@ -179,9 +189,52 @@ export class WaterSurveyIndex { const near = Math.min(distance(a.a, b), distance(a.b, b), distance(b.a, a), distance(b.b, a)); const otherA = shared === a.a ? a.b : a.a; const otherB = shared && shared.x === b.a.x && shared.y === b.a.y ? b.b : b.a; + const vectors = (edge: Edge) => + edge.controls + .map(p => ({ x: p.x - shared!.x, y: p.y - shared!.y })) + .filter(p => Math.hypot(p.x, p.y) > EPSILON); + const hullsSeparate = () => { + const va = vectors(a), + vb = vectors(b); + return va.some(p => + vb.some(q => { + const nx = p.x / Math.hypot(p.x, p.y) + q.x / Math.hypot(q.x, q.y); + const ny = p.y / Math.hypot(p.x, p.y) + q.y / Math.hypot(q.x, q.y); + const side = (v: Point) => nx * v.y - ny * v.x; + return ( + (va.every(v => side(v) > EPSILON) && vb.every(v => side(v) < -EPSILON)) || + (va.every(v => side(v) < -EPSILON) && vb.every(v => side(v) > EPSILON)) + ); + }) + ); + }; + // A separating line through the tip certifies that the Bezier hulls cannot cross there. const foldsBack = - shared && (otherA.x - shared.x) * (otherB.x - shared.x) + (otherA.y - shared.y) * (otherB.y - shared.y) > 0; - if (crossing || foldsBack || (!shared && near <= uncertainty)) + shared && + (otherA.x - shared.x) * (otherB.x - shared.x) + (otherA.y - shared.y) * (otherB.y - shared.y) > 0 && + !hullsSeparate(); + const oneSidedCurve = (curve: Edge, straight: Edge) => { + if (straight.error) return false; + const dx = straight.b.x - straight.a.x, + dy = straight.b.y - straight.a.y; + const side = (p: Point) => (dx * (p.y - straight.a.y) - dy * (p.x - straight.a.x)) / Math.hypot(dx, dy); + if (Math.max(Math.abs(side(curve.a)), Math.abs(side(curve.b))) <= EPSILON) return false; + const distances = curve.controls.map(side); + return Math.min(...distances) >= -EPSILON || Math.max(...distances) <= EPSILON; + }; + const certifiedContact = !crossing && (oneSidedCurve(a, b) || oneSidedCurve(b, a)); + const lengthA = Math.hypot(ax, ay), + lengthB = Math.hypot(bx, by); + const intersectionError = (uncertainty * lengthA * lengthB) / Math.abs(cross); + const stableCrossing = + crossing && + intersectionError <= GEOMETRY_ERROR_M - WATER_COORDINATE_ERROR_M && + Math.min(t * lengthA, (1 - t) * lengthA, u * lengthB, (1 - u) * lengthB) > intersectionError; + if ( + (crossing && !stableCrossing) || + foldsBack || + (!shared && !stableCrossing && !certifiedContact && near <= uncertainty) + ) throw new Error( "Measured water cannot preserve this narrow or crossing shoreline within its precision budget" ); @@ -279,12 +332,21 @@ export class WaterSurveyIndex { measure(edgesFor(search), search); if (search >= fullRadius) break; } + const packedPolygons: Point[][] = []; + for (const body of bodies.values()) { + const pieces = packWaterPolygons(body.polygonIndices.map(index => polygons[index])); + body.polygonIndices = pieces.map(piece => { + const index = packedPolygons.length; + packedPolygons.push(piece); + return index; + }); + } const waterContext: MeasuredWaterContext = { version: 1, status: "measured", coordinateSpace: "burg-local-metres", surveyRadiusM: radius, - geometryErrorM: 0.5, + geometryErrorM: GEOMETRY_ERROR_M, bodies: [...bodies.values()] .sort((a, b) => a.id - b.id) .map(body => { @@ -302,6 +364,6 @@ export class WaterSurveyIndex { waterContext.omittedRivers = [...new Set(input.omittedRiverIds)] .sort((a, b) => a - b) .map(id => ({ riverId: String(id), reason: "local-width-unavailable" })); - return { waterContext, coastlineGeometry: polygons }; + return { waterContext, coastlineGeometry: packWaterCoordinates(packedPolygons) }; } } diff --git a/src/utils/shoreline-geometry.ts b/src/utils/shoreline-geometry.ts index 64430b7080..97f571c4ad 100644 --- a/src/utils/shoreline-geometry.ts +++ b/src/utils/shoreline-geometry.ts @@ -93,10 +93,10 @@ export function parseShorePath(path: string, feature: number): ShoreCurve[] { export function flattenCurve( points: Point[], tolerance: number, - onPoint?: (point: Point, parameter: number) => void + onPoint?: (point: Point, parameter: number, controls: Point[]) => void ): Point[] { const result: Point[] = [points[0]]; - onPoint?.(points[0], 0); + onPoint?.(points[0], 0, [points[0]]); function split(p: Point[], depth: number, from: number, to: number) { const a = p[0], b = p.at(-1)!; @@ -118,7 +118,7 @@ export function flattenCurve( }); if (flat) { result.push(b); - onPoint?.(b, to); + onPoint?.(b, to, p); return; } if (depth === 24) throw new Error("Shoreline exceeds the measured preview precision limit"); From 8d6daa2da88e0642e47012ad4d8c56bcd3ef0ce3 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 17 Oct 2026 23:50:31 +0100 Subject: [PATCH 2/2] Bound water coordinate precision for detailed coastal previews --- docs/domain/settlemaker-water-context.md | 2 +- src/services/previews/water-coordinate-pack.test.ts | 10 +++++----- src/services/previews/water-coordinate-pack.ts | 4 ++-- src/services/previews/water-survey.test.ts | 5 ++++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/domain/settlemaker-water-context.md b/docs/domain/settlemaker-water-context.md index 8a4c877b25..94bd1f2f3d 100644 --- a/docs/domain/settlemaker-water-context.md +++ b/docs/domain/settlemaker-water-context.md @@ -52,7 +52,7 @@ budget; it never silently replaces measured geometry with a bearing. - Convert map coordinates once using the selected distance unit. Flatten curves adaptively within 0.2 m; report a conservative combined `geometryErrorM` of 0.5 m. Pack adjacent fill pieces without filling islands or closing channels; - remove collinear arithmetic noise and shorten coordinate tails to eight decimal + remove collinear arithmetic noise and shorten coordinate tails to five decimal metres only when edge contacts, crossings and rings remain unchanged. Reserve the rounding error within the geometry budget and retain full precision when those checks fail. Reject ambiguous narrow or crossing curved shores instead diff --git a/src/services/previews/water-coordinate-pack.test.ts b/src/services/previews/water-coordinate-pack.test.ts index 72e44a5877..5be7aa886c 100644 --- a/src/services/previews/water-coordinate-pack.test.ts +++ b/src/services/previews/water-coordinate-pack.test.ts @@ -9,7 +9,7 @@ const box = (x: number, y: number, w: number, h: number) => [ { x, y: y + h } ]; describe("bounded water coordinate packing", () => { - it("shortens numeric tails within a nanometre-scale budget without mutating source", () => { + it("shortens numeric tails within a ten-micrometre budget without mutating source", () => { const source = [box(0.123456789123, 10.0123456789, 100, 100)], before = structuredClone(source), packed = packWaterCoordinates(source); @@ -20,12 +20,12 @@ describe("bounded water coordinate packing", () => { WATER_COORDINATE_ERROR_M ); }); - it("retains exact geometry if rounding would close a narrow channel", () => { - const source = [box(0, 0, 10, 100), box(10 + 1e-10, 0, 10, 100)]; + it.each([1e-10, 1e-6])("retains exact geometry if rounding would close a %sm channel", width => { + const source = [box(0, 0, 10, 100), box(10 + width, 0, 10, 100)]; expect(packWaterCoordinates(source)).toBe(source); }); - it("retains thin rings rather than collapsing them", () => { - const source = [box(0, 0, 1e-10, 100)]; + it.each([1e-10, 1e-6])("retains %sm thin rings rather than collapsing them", width => { + const source = [box(0, 0, width, 100)]; expect(packWaterCoordinates(source)).toBe(source); }); it("keeps microscopic source edges while compacting unrelated vertices", () => { diff --git a/src/services/previews/water-coordinate-pack.ts b/src/services/previews/water-coordinate-pack.ts index eb58447213..1371ebb9d4 100644 --- a/src/services/previews/water-coordinate-pack.ts +++ b/src/services/previews/water-coordinate-pack.ts @@ -1,6 +1,6 @@ import type { WaterPoint as Point } from "./water-context-types"; -export const WATER_COORDINATE_ERROR_M = 1e-8; +export const WATER_COORDINATE_ERROR_M = 1e-5; const same = (a: Point, b: Point) => a.x === b.x && a.y === b.y; const orient = (a: Point, b: Point, c: Point) => Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)); const on = (p: Point, a: Point, b: Point) => @@ -44,7 +44,7 @@ function containment(point: Point, ring: Point[]): number { /** Shorten arithmetic tails while retaining sensitive vertices at their original precision. */ export function packWaterCoordinates(polygons: Point[][]): Point[][] { - const rounded = polygons.map(ring => ring.map(p => ({ x: Number(p.x.toFixed(8)), y: Number(p.y.toFixed(8)) }))); + const rounded = polygons.map(ring => ring.map(p => ({ x: Number(p.x.toFixed(5)), y: Number(p.y.toFixed(5)) }))); const edges = polygons.flatMap((ring, r) => ring.map((a, i) => ({ r, i, j: (i + 1) % ring.length, a, b: ring[(i + 1) % ring.length] })) ); diff --git a/src/services/previews/water-survey.test.ts b/src/services/previews/water-survey.test.ts index 69ddce2a29..241f2b350f 100644 --- a/src/services/previews/water-survey.test.ts +++ b/src/services/previews/water-survey.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { closestPoint } from "@/utils/shoreline-geometry"; import type { WaterPoint as Point } from "./water-context-types"; +import { WATER_COORDINATE_ERROR_M } from "./water-coordinate-pack"; import type { SurveyBoundary } from "./water-survey"; import { WaterSurveyIndex } from "./water-survey"; @@ -89,7 +90,9 @@ describe("measured water survey", () => { return Math.hypot(q.x, q.y); }) ); - expect(Math.min(...distances)).toBeCloseTo(result.waterContext.bodies[0].distanceM, 6); + expect(Math.abs(Math.min(...distances) - result.waterContext.bodies[0].distanceM)).toBeLessThanOrEqual( + WATER_COORDINATE_ERROR_M + ); }); it("does not invent a shoreline on the crop when the burg is inside a large lake", () => {