diff --git a/docs/domain/settlemaker-water-context.md b/docs/domain/settlemaker-water-context.md index 95870352f..94bd1f2f3 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 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 + 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 1dc27f468..29f05b722 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 b7242297a..db8f5df56 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 000000000..5be7aa886 --- /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 ten-micrometre 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.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.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", () => { + 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 000000000..1371ebb9d --- /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-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) => + 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(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] })) + ); + 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 000000000..d2363d943 --- /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 000000000..1940bdd52 --- /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 bd0192f5c..241f2b350 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", () => { @@ -122,11 +125,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 4695af18a..01f04f493 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 64430b708..97f571c4a 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");