fix(tours): a lake shortcut costs no more than the stretch of path it replaces

A shortcut was held to the dearer of its two end cells, so an end out on open water lifted the ceiling just as a bank end did before: a leg from a point out on a lake was pulled straight across, through water the search had gone round, for a SUP beyond its reach and for the shore style beyond its band. The same happened midway once a kept vertex lay in water past the boat's reach. A shortcut is now priced along its line on open water and taken only when it costs no more than the stretch of cell path it replaces (with 5 % to spare); the shore distance check stays as it was.

Measured on the WATER-005 lake and a larger one with a bay and a spit: legs from bank to bank are as before within a few metres, except a kayak no longer crosses 409 m of water past its reach that the search avoided; from a point 500 m out a SUP covers 321 m past its reach (300 m cannot be avoided) instead of 1301 m, a canoe 133 m instead of 1132 m, and the shore style follows the bank. WATER-012 holds the case from a point out on the lake.
This commit is contained in:
Maurice
2026-10-18 22:54:41 +00:00
parent 7907963e03
commit e29aa99269
2 changed files with 79 additions and 41 deletions
@@ -80,10 +80,11 @@ function weightOf(shore: number, profile: LakeProfile): number {
/**
* {@link weightOf} without the clearance: what the water costs for how far
* out it lies. A shortcut is held to this, and to the path's own distance
* from the shore (see `pull`), so a leg that starts or ends at the bank, as
* every one from a river does, cannot lift the ceiling with the bank's cost
* and pull a route along the shore straight across the open water.
* out it lies. A shortcut is priced with this against the stretch of path it
* replaces, and held to that stretch's distance from the shore (see `pull`),
* so neither a leg's end at the bank, as every one from a river has, nor one
* out on the lake lets a route the search kept along the shore, or within a
* boat's reach, be pulled straight across the open water.
*/
function openWeightOf(shore: number, profile: LakeProfile): number {
const weight = 1 + bandOf(shore, profile);
@@ -333,8 +334,8 @@ export class LakeField {
/**
* The line from one cell to another as a paddler would take it: the
* cheapest cell path, then pulled straight wherever a straight line stays on
* open water no more costly than the cells it joins and no nearer the shore
* than the path it replaces.
* water, costs no more than the stretch it replaces and comes no nearer the
* shore than that stretch.
*/
path(from: number, to: number, profile: LakeProfile): Array<[number, number]> | null {
const cells = astar(this.fine(profile), from, to);
@@ -343,20 +344,34 @@ export class LakeField {
}
/**
* Straightens a cell path. A shortcut may not run nearer the shore than the
* stretch of path it replaces (up to the style's clearance): pulling a line
* taut round a bend otherwise draws it onto the bank the path kept off.
* Straightens a cell path. A shortcut may cost no more on open water than
* the stretch of path it replaces, and may not run nearer the shore than
* that stretch (up to the style's clearance): pulling a line taut otherwise
* draws it onto the bank the path kept off, or straight over the water the
* search went round.
*/
private pull(cells: number[], profile: LakeProfile): number[] {
const shore = this.shoreDistance();
const clearance = CLEARANCE_M[profile.style];
const depth = (cell: number) => Math.min(clearance, shore[cell] as number);
const open = (cell: number) => openWeightOf(shore[cell] as number, profile);
const { width, cell: size } = this.lake;
// What the path costs on open water from its first cell up to each cell.
const along = [0];
for (let i = 1; i < cells.length; i++) {
const from = cells[i - 1] as number;
const to = cells[i] as number;
const step =
Math.abs((to % width) - (from % width)) + Math.abs(Math.floor(to / width) - Math.floor(from / width));
along.push((along[i - 1] as number) + ((open(from) + open(to)) / 2) * size * (step === 2 ? Math.SQRT2 : 1));
}
const kept = [cells[0] as number];
let anchor = 0;
let floor = depth(cells[0] as number);
for (let i = 2; i < cells.length; i++) {
floor = Math.min(floor, depth(cells[i - 1] as number), depth(cells[i] as number));
if (!this.clear(cells[anchor] as number, cells[i] as number, profile, floor)) {
const budget = ((along[i] as number) - (along[anchor] as number)) * 1.05;
if (!this.clear(cells[anchor] as number, cells[i] as number, profile, floor, budget)) {
kept.push(cells[i - 1] as number);
anchor = i - 1;
floor = Math.min(depth(cells[i - 1] as number), depth(cells[i] as number));
@@ -367,11 +382,12 @@ export class LakeField {
}
/**
* Whether a straight line between two cells runs on open water no more
* costly than its ends ({@link openWeightOf}) and nowhere nearer the shore
* than `floor` (less half a cell, for the line's rounding onto cells).
* Whether a straight line between two cells runs on water, costs no more
* than `budget` on open water ({@link openWeightOf}) and comes nowhere
* nearer the shore than `floor` (less half a cell, for the line's rounding
* onto cells).
*/
private clear(a: number, b: number, profile: LakeProfile, floor: number): boolean {
private clear(a: number, b: number, profile: LakeProfile, floor: number, budget: number): boolean {
const shore = this.shoreDistance();
const open = (cell: number) => openWeightOf(shore[cell] as number, profile);
const least = floor - this.lake.cell / 2;
@@ -380,13 +396,16 @@ export class LakeField {
const ar = (a - ac) / width;
const bc = b % width;
const br = (b - bc) / width;
const ceiling = Math.max(open(a), open(b)) * 1.05;
const steps = Math.max(Math.abs(bc - ac), Math.abs(br - ar)) * 2;
// Each sample stands for its share of the line; the two ends for half a share each.
const share = (Math.hypot(bc - ac, br - ar) * this.lake.cell) / steps;
let cost = ((open(a) + open(b)) / 2) * share;
for (let s = 1; s < steps; s++) {
const c = Math.round(ac + ((bc - ac) * s) / steps);
const r = Math.round(ar + ((br - ar) * s) / steps);
const cell = r * width + c;
if (!this.isWater(cell) || open(cell) > ceiling || (shore[cell] as number) < least) return false;
cost += open(cell) * share;
if (!this.isWater(cell) || cost > budget || (shore[cell] as number) < least) return false;
}
return true;
}
@@ -47,6 +47,35 @@ function ok(leg: PaddleRouteLeg): Extract<PaddleRouteLeg, { status: 'ok' }> {
const kinds = (leg: PaddleRouteLeg) => [...new Set(ok(leg).segments.map((segment) => segment.kind))];
/** In from the north, a lake 1 km wide and 3 km long (the Woblitz), out to the south. */
function woblitz(): WaterWorld {
const world = new WaterWorld();
world.line(
[
[0, 6000],
[0, 4000],
],
{ waterway: 'river', name: 'Havel' },
);
world.ring(
[
[-500, 4000],
[500, 4000],
[500, 1000],
[-500, 1000],
],
{ natural: 'water', name: 'Woblitz' },
);
world.line(
[
[0, 1000],
[0, -1000],
],
{ waterway: 'river', name: 'Havel' },
);
return world;
}
/** A river 6 km long, flowing south, its centre line at east 0, a node every 100 m (node `k` at north 6000 - 100k). */
function river(world: WaterWorld, tags: Record<string, string> = {}) {
return world.line(
@@ -166,31 +195,7 @@ describe('water routing from an extract (PAD-11)', () => {
});
it('WATER-005: crosses a lake straight over, along its shore when asked, and a SUP keeps to the shore anyway', async () => {
const world = new WaterWorld();
// In from the north, a lake 1 km wide and 3 km long, out to the south.
world.line(
[
[0, 6000],
[0, 4000],
],
{ waterway: 'river', name: 'Havel' },
);
world.ring(
[
[-500, 4000],
[500, 4000],
[500, 1000],
[-500, 1000],
],
{ natural: 'water', name: 'Woblitz' },
);
world.line(
[
[0, 1000],
[0, -1000],
],
{ waterway: 'river', name: 'Havel' },
);
const world = woblitz();
const { router, stats } = await built(world);
expect(stats.lakes).toBe(1);
@@ -207,6 +212,20 @@ describe('water routing from an extract (PAD-11)', () => {
expect(router.route(world.at(200, 2500), to, KAYAK).status).toBe('ok');
});
it('WATER-012: from a point out on a lake a SUP makes for the shore and the shore style follows it, neither drawn straight across', async () => {
const world = woblitz();
const { router } = await built(world);
// 500 m off both side banks: past a SUP's reach of open water, and beyond the shore style's band.
const from = world.at(0, 2500);
const to = world.at(0, 0);
const direct = ok(router.route(from, to, KAYAK));
expect(direct.distance_m).toBeLessThan(2550);
expect(ok(router.route(from, to, { ...KAYAK, lakeStyle: 'shore' })).distance_m).toBeGreaterThan(
direct.distance_m + 200,
);
expect(ok(router.route(from, to, SUP)).distance_m).toBeGreaterThan(direct.distance_m + 200);
});
it('WATER-006: goes round an island of a multipolygon lake, not across it', async () => {
const world = new WaterWorld();
const outer = world.ring([