From dd8ba562d29906306eaac3cce7cfa92bceaafff9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 15:18:55 -0700 Subject: [PATCH] fix: find fire perimeters that overlap an area Fire perimeters were placed at their average vertex, so a fire enclosing the whole requested area was missed. Polygons now count when a vertex is inside the area, an edge crosses it, or the polygon contains it outside any hole. --- src/tools/area.js | 35 ++++++++++++++++++++++++++ src/tools/area.test.mjs | 21 ++++++++++++++++ src/tools/queries/environment.js | 4 ++- src/tools/queries/environment.test.mjs | 29 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/tools/area.js b/src/tools/area.js index b8eef612b..dbd3aabb6 100644 --- a/src/tools/area.js +++ b/src/tools/area.js @@ -296,3 +296,38 @@ export function lineTouchesArea(coords, area) { } return false; } + +/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */ +function ringContains(ring, point) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if ( + yi > point.lat !== yj > point.lat && + point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi + ) + inside = !inside; + } + return inside; +} + +/** + * Whether polygons (each a list of `[lon, lat]` rings, outer ring first) + * overlap an area: a vertex inside it, an edge crossing it, or the area's + * center inside a polygon and outside its holes. + */ +export function polygonsTouchArea(polygons, area) { + const center = areaCenter(area); + return (polygons || []).some((rings) => { + if (!Array.isArray(rings) || !rings.length) return false; + const closed = rings.map((ring) => + ring.length && ring[0] !== ring.at(-1) ? [...ring, ring[0]] : ring, + ); + if (closed.some((ring) => lineTouchesArea(ring, area))) return true; + return ( + ringContains(rings[0], center) && + !rings.slice(1).some((hole) => ringContains(hole, center)) + ); + }); +} diff --git a/src/tools/area.test.mjs b/src/tools/area.test.mjs index dee34f402..d901813b5 100644 --- a/src/tools/area.test.mjs +++ b/src/tools/area.test.mjs @@ -5,6 +5,7 @@ import { areaContains, distanceKm, lineTouchesArea, + polygonsTouchArea, resolveArea, } from './area.js'; import { placeFromGeocodeResult } from './places.js'; @@ -224,3 +225,23 @@ test('lines touch an area when a segment crosses it with no vertex inside', asyn ), ); }); + +test('polygons touch an area by vertex, crossing edge or enclosing it', async () => { + const box = await resolveArea({ bbox: [-1, -1, 1, 1] }); + const square = (half, x = 0) => [ + [ + [x - half, -half], + [x + half, -half], + [x + half, half], + [x - half, half], + ], + ]; + assert.ok(polygonsTouchArea([square(5)], box), 'encloses the area'); + assert.ok(polygonsTouchArea([square(0.5)], box), 'inside the area'); + assert.ok(polygonsTouchArea([square(1, 1.5)], box), 'overlaps an edge'); + assert.ok(!polygonsTouchArea([square(0.5, 5)], box), 'elsewhere'); + const ring = [...square(5), ...square(3)]; + assert.ok(!polygonsTouchArea([ring], box), 'the area sits in a hole'); + const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 }); + assert.ok(polygonsTouchArea([square(5)], circle)); +}); diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 7def1f87d..141a8e70c 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -10,6 +10,7 @@ import { areaCenter, areaContains, distanceKm, + polygonsTouchArea, resolveArea, resolvePoint, } from '../area.js'; @@ -237,8 +238,9 @@ export const getFirePerimeters = defineTool({ async run(args, { services, signal }) { const area = await resolveArea(args.area, { services, signal }); const rows = (await services.perimeters.getSnapshot({ signal })) + .filter((fire) => polygonsTouchArea(fire.polygons, area)) .map((fire) => ({ fire, point: polygonCenter(fire.polygons) })) - .filter(({ point }) => point && areaContains(area, point)) + .filter(({ point }) => point) .map(({ fire, point }) => ({ id: fire.stableId, name: fire.name, diff --git a/src/tools/queries/environment.test.mjs b/src/tools/queries/environment.test.mjs index 2a7a82982..b26c615bf 100644 --- a/src/tools/queries/environment.test.mjs +++ b/src/tools/queries/environment.test.mjs @@ -665,3 +665,32 @@ test('brief sections use a named place at its own point', async () => { assert.deepEqual(queries[0], { latitude: 35.6769, longitude: 139.7639 }); assert.match(result.data.sections.aircraft.summary, /in Tokyo, Japan\.$/); }); + +test('a fire perimeter enclosing the whole area is found', async () => { + const perimeters = { + getSnapshot: async () => [ + { + stableId: 'big', + name: 'Big Fire', + acres: 250000, + polygons: [ + [ + [ + [-122, 38], + [-120, 38], + [-120, 40], + [-122, 40], + ], + ], + ], + }, + ], + }; + const result = await catalogWith({ perimeters }).call('get_fire_perimeters', { + area: { bbox: [-121.2, 38.9, -120.8, 39.1] }, + }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['big'], + ); +});