Skip to content

feat: use precise and fast area intersection algorithm - #202

Merged
esgn merged 6 commits into
136/more-spatial_extrasfrom
136/more_spatial_extras/intersection_area
Oct 1, 2026
Merged

esgn merged 6 commits into
136/more-spatial_extrasfrom
136/more_spatial_extras/intersection_area

Conversation

@LionelZoubritzky-IGN

Copy link
Copy Markdown
Contributor

Description

Change the area intersection algorithm used for the intersection_area spatial_extra to make it faster and more precise.

Related issues (if applicable)

Related to #187, #136

Motivation

Review of #136: if intersection_area is required on a GpfGetFeatures query with many large geometries, the computation takes very long and stalls the entire MCP.

Implementation

For each returned geometry : take the filter, clip it to the bbox of the geometry. Then, triangulate the geometry (with earcut) and for each triangle, add the area of the intersection of the triangle with the clipped filter. This approach is much faster than relying on @turf/area over @turf/intersect, because:

  1. We do not need to compute the shape of the complete intersection;
  2. The intersection of the filter with a triangle is done with the Sutherland–Hodgman algorithm, which is fast.

The area algorithm itself is also reimplemented to make sure it works on degenerate cases. This is necessary because the Sutherland–Hodgman algorithm can produce degenerate edges along a triangle side.

Testing

Typecheck OK, 471 + 35 unit tests all green, docs regenerated (unchanged).

TODOs

This does not fix the fact that the MCP stalls while computing the intersection, but in practice it should make performance fast enough for that not to matter... To be checked afterwards.

Checklist

  • The PR is focused and of a reasonable size.
  • The commit history is clean.
  • Relevant documentation has been updated.
  • Relevant tests have been added or updated.

@LionelZoubritzky-IGN
LionelZoubritzky-IGN force-pushed the 136/more_spatial_extras/intersection_area branch from 2894470 to 4048fd8 Compare September 29, 2026 15:02
@LionelZoubritzky-IGN
LionelZoubritzky-IGN changed the base branch from 136/more-spatial_extras to esgn/187-validations September 29, 2026 15:02
@LionelZoubritzky-IGN
LionelZoubritzky-IGN force-pushed the 136/more_spatial_extras/intersection_area branch from 4048fd8 to 0f2f0fb Compare September 29, 2026 15:32
@LionelZoubritzky-IGN
LionelZoubritzky-IGN force-pushed the 136/more_spatial_extras/intersection_area branch from 0f2f0fb to 0cd3d89 Compare September 29, 2026 16:07
@esgn
esgn force-pushed the esgn/187-validations branch from fe9006a to 66bb1f0 Compare September 30, 2026 08:23
@LionelZoubritzky-IGN
LionelZoubritzky-IGN changed the base branch from esgn/187-validations to 136/more-spatial_extras September 30, 2026 09:02
@LionelZoubritzky-IGN
LionelZoubritzky-IGN force-pushed the 136/more_spatial_extras/intersection_area branch from 0cd3d89 to 293b00c Compare September 30, 2026 09:03
LionelZoubritzky-IGN and others added 6 commits October 1, 2026 10:23
…ox split

Replace the per-feature triangle clipping (FilterClipper and box halving)
with src/helpers/intersectionArea.ts:

- the filter rings are copied once into flat arrays; the tile cache clips
  them with an exact axis-aligned Sutherland-Hodgman instead of bboxClip
- the feature is clipped to the bbox of the filter near it, and the common
  box is split in halves until one side has few positions; a box that one
  side covers entirely, or not at all, ends the recursion
- at the leaves, the smaller side is triangulated and the other side's
  rings are clipped by each triangle; areas come from winding numbers,
  exact on the sphere and computed relative to a local origin
- self-intersecting rings and oversized leaves fall back on
  @turf/intersect, which restores the bow tie test removed by 3615e1c

Median time per request on real Geoplateforme data, 3615e1c -> this
commit: 2.1 s -> 0.47 s (communes x departement 35), 1.9 s -> 0.36 s
(departements x region 24), 636 -> 223 ms (5000 vegetation zones x
departement 35). Results match GEOS 3.13 within 1e-8 of the feature area.

- move dropEmptyRings to helpers/geojson.ts
- drop clipRingToTriangle and triangulate from helpers/area.ts, now unused
npm run bench runs test/wfs/intersectionArea.bench.ts (vitest bench)
through transformFeatureCollectionResponse, as for one request: small
polygons inside and across a detailed boundary, large polygons with holes
across it, and sectors sharing it position for position.
@esgn
esgn force-pushed the 136/more_spatial_extras/intersection_area branch from 71602ad to 0655b8b Compare October 1, 2026 08:24
@esgn
esgn merged commit 0209fdc into 136/more-spatial_extras Oct 1, 2026
@LionelZoubritzky-IGN
LionelZoubritzky-IGN deleted the 136/more_spatial_extras/intersection_area branch October 1, 2026 09:11
esgn added a commit that referenced this pull request Oct 1, 2026
…intersection_area) (#187)

* chore: remove internal types and simplify code of byId

* feat: introduce queryIsGetFeaturesInput

* feat: better guidance towards gpf_search_types when the LLM uses wrong typenames or field values

* chore: avoid spatial filter names replication in code

* chore: use Geometry instead of GeoJsonGeometryLike

* refactor: defer EWKT serialization to CQL building

* chore: thread full input to deriveFromGeometry

* chore: thread resolvedGeometryRef to deriveFromGeometry

* feat: add new options to spatial_extras

* fix: exclude spatial_extra requiring spatial_filter from ById requests

* chore: misc fixes

* fix:  clean invalid GeoJSON resulting from bboxClip

* fix: minor error message fix

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* perf: precompute spatial context

* fix: ensure spatial_extras is mandatory in input schemas

* fix: distinguish non-areal spatial filters in intersectionAreaWithSpatialFilter

* fix: set verbatimModuleSyntax to import type instead of just import

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* refactor: limit drift risk by avoiding a manual object copy

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* feat: add type guard for spatial filters in compileQueryParts

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* test: re-add isGeometryLike test for completely unknown type

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* docs: update docstring of compileIntersectsFeatureSpatialFilter

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* refactor: make FeatureCollectionPostProcessInput better-defined

* test: make sure a spatial_extra that cannot be computed is returned as null

* fix: make spatialFilterToGeometry properly error on missing resolved geometry

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* docs: improve error case description of spatial_extra

* refactor: update SpatialContext type to enforce non-nullable properties

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>

* fix: simplify geometryToPolygons

* fix(spatial_extras): null when not computable, 0 only for an empty overlap

- intersection_area: null when the feature or the filter is not areal, or when
  the filter geometry could not be prepared; 0 only when both are areal and
  do not overlap
- every extra is null on a missing or empty geometry
- length sums the linear parts of a GeometryCollection; area of an empty
  Polygon is null
- measures sum the parts of a GeometryCollection, overlaps included (JTS)
- state the contract in deriveFromGeometry and in the LLM-facing descriptions
- regenerate docs/mcp-tools.md

* fix(spatial_extras): validate filter-dependent extras in the input schema

- move "requires a spatial filter" from compileQueryParts to the zod
  refinement: invalid-tool-params instead of execution-error, raised before
  any catalog or network call
- reject distance_to_filter with intersects_point_filter (always 0)
- list only the compatible filters in the error message
- state the rule in the spatial_extras description, regenerate docs/mcp-tools.md
- move the related tests from compileQueryParts to the tool

* fix(properties): skip spatial_extras validation when none is requested

buildPropertyNameWithGeometry always passes an empty spatial_extras list, and
the `!spatial_extras` guard let it through: the by-id layer tool and the proxy
by-id resolve then read the geometry format and threw on an unlisted or missing
one. Return early on an empty list, so already minted by-id layer URLs do not
depend on the catalog format values.

* fix(gpf_get_features): tell the LLM that spatial_extras only cover returned features

- tool and spatial_extras descriptions: extras are computed after the query, on
  the returned features only, and cannot be used in where/order_by; a ranking
  or a sum needs numberReturned == numberMatched
- "property does not exist" error: explain when the name is a spatial extra
  (message only, a real catalog column with that name is still accepted)

* fix(spatial_extras): rename distance_to_filter to distance_to_filter_center for clarity

* fix(spatial_extras): align descriptions and errors with the #136 brief

- centroid: may fall outside a concave geometry, so an intersects_point_filter on it may return neither the feature nor what contains it
- distance_to_filter_center: distance from the filter center to the closest point of the feature, 0 when the feature contains the center; list the center of each filter
- intersection_area: null when the feature or the intersects_feature_filter reference has no areal part, 0 when they do not overlap; name the areal filters (box, disc, isochrone, reference)
- rankings and sums: raise limit or restrict the spatial filter; for the N closest, use dwithin_point_filter with distance_to_filter_center, sort, and widen distance_m when fewer than N come back
- catalog check: report every incompatible extra in a single error, in the order of the request, instead of one per call
- bbox on a Point layer: fix the double period and the missing comma in the error message
- regenerate docs/mcp-tools.md

* fix(properties): do not send the LLM to gpf_get_features in the spatial extra hint

The hint is shared by every tool that validates property names. From gpf_get_feature_by_id, which has its own spatial_extras, it pointed to another tool instead of the same call's spatial_extras; from gpf_count_features and the layer tools, it suggested a tool switch that cannot help. Say only that the name is computed via spatial_extras and is not usable in select, where or order_by.

* docs(spatial_extras): give the bbox order and its mapping to bbox_filter

- bbox: [ouest, sud, est, nord] in WGS84 lon/lat, matching the west, south, east and north fields of bbox_filter
- regenerate docs/mcp-tools.md

* fix(properties): word the bbox-on-Point error for several returned features

gpf_get_features returns several features, so "La géométrie de l'objet" read as if a single one came back. Say "La géométrie renvoyée", as the other catalog errors of the same check already do.

* fix(spatial_extras): reject extras that non-areal or single-point references rule out

- intersection_area needs an areal intersects_feature reference: a linear, MultiPoint or Point reference is now an error instead of null for every feature
- distance_to_filter_center is always 0 from a reference reduced to a single position (a Point, or a MultiPoint whose points are all the same): now an error
- check the reference against its catalog format before fetching it, then against its fetched geometry, which alone settles a geometry-any type or a single-position MultiPoint
- run the queried layer check before any network call (reference or isochrone fetch), so its errors now come before select, where and order_by errors
- intersection_area description: the reference must be areal; regenerate docs/mcp-tools.md

* feat: use precise and fast area intersection algorithm (#202)

* feat: use precise and fast area intersection algorithm

* perf: add FilterClipper for intersection_area computation

* perf: subdivide boxes for intersection_area

* perf(intersection_area): integrate both geometries over a recursive box split

Replace the per-feature triangle clipping (FilterClipper and box halving)
with src/helpers/intersectionArea.ts:

- the filter rings are copied once into flat arrays; the tile cache clips
  them with an exact axis-aligned Sutherland-Hodgman instead of bboxClip
- the feature is clipped to the bbox of the filter near it, and the common
  box is split in halves until one side has few positions; a box that one
  side covers entirely, or not at all, ends the recursion
- at the leaves, the smaller side is triangulated and the other side's
  rings are clipped by each triangle; areas come from winding numbers,
  exact on the sphere and computed relative to a local origin
- self-intersecting rings and oversized leaves fall back on
  @turf/intersect, which restores the bow tie test removed by 3615e1c

Median time per request on real Geoplateforme data, 3615e1c -> this
commit: 2.1 s -> 0.47 s (communes x departement 35), 1.9 s -> 0.36 s
(departements x region 24), 636 -> 223 ms (5000 vegetation zones x
departement 35). Results match GEOS 3.13 within 1e-8 of the feature area.

- move dropEmptyRings to helpers/geojson.ts
- drop clipRingToTriangle and triangulate from helpers/area.ts, now unused

* test(intersection_area): add a benchmark on synthetic geometries

npm run bench runs test/wfs/intersectionArea.bench.ts (vitest bench)
through transformFeatureCollectionResponse, as for one request: small
polygons inside and across a detailed boundary, large polygons with holes
across it, and sectors sharing it position for position.

* perf: return null instead of forwarding to turf on invalid geometries

---------

Co-authored-by: esgn <5435148+esgn@users.noreply.github.com>

* test: fix obsolete test requiring too high precision

* fix(spatial_extras): compute the bbox intersection_area with the shared engine

bbox_filter clipped each feature with bboxClip while the engine prepared in prepareSpatialContext for the same box went unused. Values match on valid geometries, rounding aside; a self-intersecting feature now gives null, as with the other filters. Drop @turf/bbox-clip.

* fix(spatial_extras): do not cite get_features filters in the by-id description

The centroid and bbox lines, shared by both spatial_extras descriptions, pointed to intersects_point_filter and bbox_filter, which gpf_get_feature_by_id rejects. They now only do so in gpf_get_features, whose text is unchanged.

* docs(dev): list npm run bench in the commands table

---------

Co-authored-by: Emmanuel S. <5435148+esgn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants