Skip to content

Commit 473fadc

Browse files
committed
fix: mixed-mode area exit targets the real next waypoint, not an arbitrary geometric corner (#64)
The coverage trail's chosen endpoint always used the "farthest box/ lasso corner from the approach start" — even when the caller already knows the real destination (a subsequent manual waypoint / exitRoute). Picking a geometrically arbitrary corner that has no relation to where the route is actually headed forces the exit bridge to travel further than necessary, increasing the chance it retraces the coverage trail's own tail. Now targets the real next waypoint directly when one exists, falling back to the geometric-corner heuristic only for a one-shot sweep with no known destination. Caveat: a synthetic 2-odd-node test topology couldn't discriminate old vs new behavior (both converge to the same trail when there are only two candidate natural endpoints) — validated by code review and the full suite instead. Needs live confirmation on a real dense-grid case.
1 parent 57cf50a commit 473fadc

4 files changed

Lines changed: 83 additions & 23 deletions

File tree

‎lib/graph.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,50 @@ describe('StreetGraph', () => {
432432
}
433433
});
434434

435+
test('#64 (exit side): mixed-mode area targets a real subsequent waypoint instead of an arbitrary geometric corner', () => {
436+
// Grid: A(0,0) - B(0,0.001) - C(0,0.002)
437+
// | | |
438+
// D(0.001,0)-E(0.001,0.001)-F(0.001,0.002)
439+
// B and E are the only natural odd-degree (3) nodes. Approach enters near A
440+
// (top-left). The real 2nd waypoint (endPoint) sits right next to B — the
441+
// OPPOSITE side of the grid from the geometric far corner (near F/C), which
442+
// the old code would've blindly targeted regardless of where the route
443+
// actually needs to go next.
444+
const mockData: OverpassResponse = {
445+
version: 0.6,
446+
generator: 'test',
447+
osm3s: { timestamp_osm_base: '', copyright: '' },
448+
elements: [
449+
{ type: 'node', id: 1, lat: 0, lon: 0 }, // A
450+
{ type: 'node', id: 2, lat: 0, lon: 0.001 }, // B — odd
451+
{ type: 'node', id: 3, lat: 0, lon: 0.002 }, // C
452+
{ type: 'node', id: 4, lat: 0.001, lon: 0 }, // D
453+
{ type: 'node', id: 5, lat: 0.001, lon: 0.001 }, // E — odd
454+
{ type: 'node', id: 6, lat: 0.001, lon: 0.002 }, // F
455+
{ type: 'way', id: 100, nodes: [1, 2, 3], tags: { highway: 'residential' } },
456+
{ type: 'way', id: 101, nodes: [4, 5, 6], tags: { highway: 'residential' } },
457+
{ type: 'way', id: 102, nodes: [1, 4], tags: { highway: 'residential' } },
458+
{ type: 'way', id: 103, nodes: [2, 5], tags: { highway: 'residential' } },
459+
{ type: 'way', id: 104, nodes: [3, 6], tags: { highway: 'residential' } },
460+
]
461+
};
462+
463+
graph.buildFromOSM(mockData);
464+
465+
const manualRoute: [number, number][] = [[-0.001, 0], [0, 0]]; // approach arrives at A
466+
const selectionBoxes = [{ north: 0.0011, south: -0.0001, east: 0.0021, west: -0.0001 }];
467+
const realExitTarget = { lat: 0.0001, lon: 0.001 }; // right next to B
468+
469+
const result = graph.solveCPP(undefined, realExitTarget, manualRoute, selectionBoxes);
470+
471+
// The route should end near the real exit target (B), not the geometric far
472+
// corner on the opposite side of the grid (near C/F).
473+
const end = result[result.length - 1];
474+
const distToB = Math.hypot(end.lat - 0, end.lon - 0.001);
475+
const distToFarCorner = Math.hypot(end.lat - 0.0011, end.lon - 0.0021);
476+
expect(distToB).toBeLessThan(distToFarCorner);
477+
});
478+
435479
describe('trimBridgeOverlap', () => {
436480
const p = (lat: number, lon: number) => ({ lat, lon });
437481

‎lib/graph.ts‎

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,26 +1172,42 @@ export class StreetGraph {
11721172
// approach→post-area segment would otherwise route around the area instead of through it.
11731173
const approachStart = startPoint ?? { lat: manualRoute[0][1], lon: manualRoute[0][0] };
11741174

1175-
// Find the corner of the selection area farthest from approachStart.
1176-
// Passing it as endPoint forces the CPP to produce an open Euler path
1177-
// (entry → cover all streets → far corner) instead of a circuit.
1178-
// Consider both box corners and polygon (lasso) vertices so lasso areas
1179-
// get the same open-path treatment as boxes.
1180-
const areaCorners: { lat: number; lon: number }[] = [];
1181-
for (const box of (selectionBoxes ?? [])) {
1182-
areaCorners.push(
1183-
{ lat: box.north, lon: box.west }, { lat: box.north, lon: box.east },
1184-
{ lat: box.south, lon: box.west }, { lat: box.south, lon: box.east },
1185-
);
1186-
}
1187-
for (const poly of (selectionPolygons ?? [])) {
1188-
for (const [lat, lon] of poly) areaCorners.push({ lat, lon });
1189-
}
1190-
let farCorner = { lat: 0, lon: 0 };
1191-
let maxCornerDist = -Infinity;
1192-
for (const c of areaCorners) {
1193-
const d = this.haversine(approachStart.lat, approachStart.lon, c.lat, c.lon);
1194-
if (d > maxCornerDist) { maxCornerDist = d; farCorner = c; }
1175+
// If the caller already knows where the route goes after the area (a real
1176+
// subsequent waypoint / exitRoute, or an explicit endPoint), target the
1177+
// coverage trail's end AT that real destination instead of an arbitrary
1178+
// geometric corner — this directly minimizes the exit bridge's distance
1179+
// (and thus its risk of retracing the trail's own tail, see #64). Only
1180+
// fall back to the "farthest corner from approach" heuristic when there's
1181+
// no real destination to aim for (a one-shot sweep that ends wherever).
1182+
const realExitTarget = (exitRoute && exitRoute.length > 0)
1183+
? { lat: exitRoute[0][1], lon: exitRoute[0][0] }
1184+
: endPoint;
1185+
1186+
let farCorner: { lat: number; lon: number };
1187+
if (realExitTarget) {
1188+
farCorner = realExitTarget;
1189+
} else {
1190+
// Find the corner of the selection area farthest from approachStart.
1191+
// Passing it as endPoint forces the CPP to produce an open Euler path
1192+
// (entry → cover all streets → far corner) instead of a circuit.
1193+
// Consider both box corners and polygon (lasso) vertices so lasso areas
1194+
// get the same open-path treatment as boxes.
1195+
const areaCorners: { lat: number; lon: number }[] = [];
1196+
for (const box of (selectionBoxes ?? [])) {
1197+
areaCorners.push(
1198+
{ lat: box.north, lon: box.west }, { lat: box.north, lon: box.east },
1199+
{ lat: box.south, lon: box.west }, { lat: box.south, lon: box.east },
1200+
);
1201+
}
1202+
for (const poly of (selectionPolygons ?? [])) {
1203+
for (const [lat, lon] of poly) areaCorners.push({ lat, lon });
1204+
}
1205+
farCorner = { lat: 0, lon: 0 };
1206+
let maxCornerDist = -Infinity;
1207+
for (const c of areaCorners) {
1208+
const d = this.haversine(approachStart.lat, approachStart.lon, c.lat, c.lon);
1209+
if (d > maxCornerDist) { maxCornerDist = d; farCorner = c; }
1210+
}
11951211
}
11961212

11971213
let areaPath = this.solveCPP(approachStart, farCorner, undefined, selectionBoxes, undefined, undefined, false, riddenPenalty, selectionPolygons, boxElasticityMeters, true);

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "street-sweep",
3-
"version": "0.6.37",
3+
"version": "0.6.38",
44
"private": true,
55
"scripts": {
66
"dev": "next dev -p 3888",

0 commit comments

Comments
 (0)