diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 9f56858..554fd8b 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1088,12 +1088,12 @@ Lecture d’objets GPF ``` Interroge un type GPF et renvoie des résultats structurés (propriétés attributaires ; les géométries ne sont pas incluses). Pour obtenir une couche cartographiable, utiliser `gpf_get_features_layer`. -Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial. +Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter`, `adjacent_feature_filter` ou `travel_time_filter`) pour le spatial. Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`. Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`. Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`. Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`. -Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`. +Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` ou bien `adjacent_feature_filter={ feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`. Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }` pour les objets atteignables en 15 minutes à pied depuis ce point. ⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé. **OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** @@ -1104,9 +1104,10 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu | Champ | Type | Requis | Description | | --- | --- | --- | --- | +| `adjacent_feature_filter` | object | non | Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux. | | `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. | | `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. | -| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. | +| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux. | | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | | `limit` | integer | non | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Valeur par défaut : 100. | | `order_by` | array | non | Liste ordonnée des critères de tri. | @@ -1281,12 +1282,12 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "typename": { "type": "string", "minLength": 1, - "description": "Type GPF du feature de référence." + "description": "Type GPF de l'objet de référence." }, "feature_id": { "type": "string", "minLength": 1, - "description": "Identifiant du feature de référence." + "description": "Identifiant de l'objet de référence." } }, "required": [ @@ -1294,7 +1295,22 @@ Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiqu "feature_id" ], "additionalProperties": false, - "description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux." + }, + "adjacent_feature_filter": { + "type": "object", + "properties": { + "feature_id": { + "type": "string", + "minLength": 1, + "description": "Identifiant de l'objet de référence." + } + }, + "required": [ + "feature_id" + ], + "additionalProperties": false, + "description": "Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux." }, "travel_time_filter": { "type": "object", @@ -1422,7 +1438,7 @@ Couche cartographiable d’objets GPF ``` Interroge un type GPF et renvoie une **URL de couche cartographiable** (`data_url`) : une URL opaque, à passer telle quelle à un outil d'affichage cartographique (MCP Carto, ...). L'ouvrir renvoie une FeatureCollection GeoJSON avec les géométries complètes. À utiliser dès qu'il faut **afficher / cartographier** des objets GPF. Pour des attributs sans géométrie, utiliser `gpf_get_features`. -Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial. +Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter`, `adjacent_feature_filter` ou `travel_time_filter`) pour le spatial. **OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Les noms de propriétés ne peuvent pas être devinés. ``` @@ -1430,9 +1446,10 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, | Champ | Type | Requis | Description | | --- | --- | --- | --- | +| `adjacent_feature_filter` | object | non | Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux. | | `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. | | `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. | -| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. | +| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux. | | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | | `limit` | integer | non | Nombre maximum d'objets à cartographier. Valeur par défaut : 5000 (plafond du service). Réduire pour alléger la carte. Maximum : 5000. Une requête produisant plus de 5000 objets sera tronquée. Valeur par défaut : 5000. | | `order_by` | array | non | Liste ordonnée des critères de tri. | @@ -1606,12 +1623,12 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "typename": { "type": "string", "minLength": 1, - "description": "Type GPF du feature de référence." + "description": "Type GPF de l'objet de référence." }, "feature_id": { "type": "string", "minLength": 1, - "description": "Identifiant du feature de référence." + "description": "Identifiant de l'objet de référence." } }, "required": [ @@ -1619,7 +1636,22 @@ Mêmes filtres que `gpf_get_features` : `select` pour choisir les propriétés, "feature_id" ], "additionalProperties": false, - "description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux." + }, + "adjacent_feature_filter": { + "type": "object", + "properties": { + "feature_id": { + "type": "string", + "minLength": 1, + "description": "Identifiant de l'objet de référence." + } + }, + "required": [ + "feature_id" + ], + "additionalProperties": false, + "description": "Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux." }, "travel_time_filter": { "type": "object", @@ -1753,7 +1785,7 @@ Décompte d’objets GPF ``` Interroge un type GPF et renvoie le nombre de résultats obtenus. -Utiliser `where` pour filtrer et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter` ou `travel_time_filter`) pour le spatial. +Utiliser `where` pour filtrer et un filtre spatial dédié (`bbox_filter`, `intersects_point_filter`, `dwithin_point_filter`, `intersects_feature_filter`, `adjacent_feature_filter` ou `travel_time_filter`) pour le spatial. Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`. Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`. Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`. @@ -1769,9 +1801,10 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* | Champ | Type | Requis | Description | | --- | --- | --- | --- | +| `adjacent_feature_filter` | object | non | Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux. | | `bbox_filter` | object | non | Filtre spatial par boîte englobante. Exclusif avec les autres filtres spatiaux. | | `dwithin_point_filter` | object | non | Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux. | -| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux. | +| `intersects_feature_filter` | object | non | Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux. | | `intersects_point_filter` | object | non | Filtre spatial par intersection avec un point. Exclusif avec les autres filtres spatiaux. | | `travel_time_filter` | object | non | Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux. | | `typename` | string | oui | Nom exact du type GPF à interroger de la forme `prefixe:nom`. Utiliser `gpf_search_types` pour trouver un `typename` valide. | @@ -1933,12 +1966,12 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "typename": { "type": "string", "minLength": 1, - "description": "Type GPF du feature de référence." + "description": "Type GPF de l'objet de référence." }, "feature_id": { "type": "string", "minLength": 1, - "description": "Identifiant du feature de référence." + "description": "Identifiant de l'objet de référence." } }, "required": [ @@ -1946,7 +1979,22 @@ Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés* "feature_id" ], "additionalProperties": false, - "description": "Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux." + "description": "Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux." + }, + "adjacent_feature_filter": { + "type": "object", + "properties": { + "feature_id": { + "type": "string", + "minLength": 1, + "description": "Identifiant de l'objet de référence." + } + }, + "required": [ + "feature_id" + ], + "additionalProperties": false, + "description": "Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux." }, "travel_time_filter": { "type": "object", diff --git a/src/tools/GpfGetFeaturesTool.ts b/src/tools/GpfGetFeaturesTool.ts index b6b6749..f68a403 100644 --- a/src/tools/GpfGetFeaturesTool.ts +++ b/src/tools/GpfGetFeaturesTool.ts @@ -33,7 +33,7 @@ class GpfGetFeaturesTool extends BaseTool { "Exemple bbox : `bbox_filter={ west: 2.1, south: 48.7, east: 2.5, north: 48.9 }`.", "Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.", "Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.", - "Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.", + "Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` ou bien `adjacent_feature_filter={ feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.", "Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.", "⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.", "**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**", diff --git a/src/wfs/features.ts b/src/wfs/features.ts index d5dd1f9..ab2b0e7 100644 --- a/src/wfs/features.ts +++ b/src/wfs/features.ts @@ -58,8 +58,9 @@ export function ensureIntersectsFeatureTargetsOtherTypename( input.typename === spatialFilter.typename ) { throw new Error( - "Le filtre `intersects_feature` sur le même `typename` retourne potentiellement plusieurs objets. " + - "Utiliser `gpf_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_filter.feature_id }` pour cibler exactement un objet.", + "Le filtre `intersects_feature` ne peut pas être utilisé sur le même `typename`. " + + "Utiliser `gpf_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_filter.feature_id }` pour cibler exactement un objet. " + + "Alternativement, utiliser le filtre `adjacent_feature_filter` pour obtenir les objets adjacents.", ); } } @@ -67,21 +68,23 @@ export function ensureIntersectsFeatureTargetsOtherTypename( // --- Reference Geometry --- /** - * Resolves the geometry of a reference feature when `intersects_feature` is used. + * Resolves the geometry of a reference feature when `intersects_feature` or `adjacent_feature` are used. * * @param input Normalized tool input. * @returns The resolved reference geometry, or `undefined` when no reference feature is needed. */ -export async function resolveIntersectsFeatureGeometry( +export async function resolveFeatureFilterGeometry( input: GpfQueryFeaturesInput, ): Promise { const spatialFilter = getSpatialFilter(input); - if (!spatialFilter || spatialFilter.operator !== "intersects_feature") { + if (!spatialFilter || (spatialFilter.operator !== "intersects_feature" && spatialFilter.operator !== "adjacent_feature")) { return undefined; } + const typename = spatialFilter.operator == "intersects_feature" ? spatialFilter.typename : input.typename; + return resolveFeatureGeometry(wfsClient, { - typename: spatialFilter.typename, + typename: typename, feature_id: spatialFilter.feature_id, }); } @@ -121,7 +124,8 @@ export async function resolveSpatialFilterGeometry( switch (spatialFilter?.operator) { case "intersects_feature": - return resolveIntersectsFeatureGeometry(input); + case "adjacent_feature": + return resolveFeatureFilterGeometry(input); case "travel_time": return resolveTravelTimeGeometry(input); default: @@ -144,7 +148,6 @@ export async function resolveSpatialFilterGeometry( export async function prepareQueryFeaturesRequest( input: GpfQueryFeaturesInput ): Promise { - // TODO: Assess if this guard does not prevent legitimate use cases. ensureIntersectsFeatureTargetsOtherTypename(input); // Get the feature type definition from the embedded catalog to access // property definitions and the geometry column name. diff --git a/src/wfs/geometry.ts b/src/wfs/geometry.ts index 329ecd1..9ada0a6 100644 --- a/src/wfs/geometry.ts +++ b/src/wfs/geometry.ts @@ -2,7 +2,7 @@ * GeoJSON geometry serialization helpers for the structured WFS engine. * * This module converts GeoJSON-like geometries to EWKT so they can be reused - * in spatial CQL predicates such as `intersects_feature`. + * in spatial CQL predicates such as `intersects_feature` or `adjacent_feature`. */ import { Geometry } from "geojson"; @@ -44,6 +44,6 @@ export function geometryToEwkt(geometry: Geometry) { case "MultiPolygon": return `SRID=4326;MULTIPOLYGON(${(geometry.coordinates as [number, number][][][]).map((polygon) => `(${polygon.map((ring) => `(${ring.map(positionToWkt).join(",")})`).join(",")})`).join(",")})`; default: - throw new Error(`Le type de géométrie '${geometry.type}' n'est pas supporté pour \`intersects_feature\`.`); + throw new Error(`Le type de géométrie '${geometry.type}' n'est pas supporté pour \`intersects_feature\` et \`adjacent_feature\`.`); } } diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts index 865230b..81f6ef6 100644 --- a/src/wfs/queryPreparation.ts +++ b/src/wfs/queryPreparation.ts @@ -39,6 +39,7 @@ import { compileBboxSpatialFilter, compileDwithinSpatialFilter, compileIntersectsFeatureSpatialFilter, + compileAdjacentFeatureSpatialFilter, compileIntersectsPointSpatialFilter, } from "./spatialCql.js"; import { Geometry } from "geojson"; @@ -172,12 +173,23 @@ function compileOrderByClause(featureType: GpfFeatureType, clause: OrderByClause // --- Query Compilation --- +function resolvedGeometry( + operator : string, + geometryKind: string, + resolvedGeometryRef? : Geometry, +) : Geometry { + if (!resolvedGeometryRef) { + throw new Error(`Le filtre spatial \`${operator}\` exige la résolution préalable de la géométrie ${geometryKind}.`); + } + return resolvedGeometryRef +} + /** * Compiles normalized tool input into query fragments ready to be turned into a WFS request. * * @param input Normalized tool input. * @param featureType Feature type definition loaded from the embedded catalog. - * @param resolvedGeometryRef Optional resolved reference geometry for `intersects_feature`. + * @param resolvedGeometryRef Optional resolved reference geometry for `intersects_feature` and `adjacent_feature`. * @returns Compiled query parts used by request builders. */ export function compileQueryParts( @@ -190,6 +202,7 @@ export function compileQueryParts( const spatialFilter = getSpatialFilter(input); const spatialExtras = isGetFeatures ? input.spatial_extras : []; const fragments: string[] = []; + let resolved : Geometry; // Keep the spatial predicate first: the GeoPlateforme GeoServer is sensitive // to filter ordering and may reject equivalent filters when attributes come first. @@ -206,16 +219,16 @@ export function compileQueryParts( fragments.push(compileDwithinSpatialFilter(geometryName, spatialFilter)); break; case "intersects_feature": - if (!resolvedGeometryRef) { - throw new Error("Le filtre spatial `intersects_feature` exige la résolution préalable de la géométrie de référence."); - } - fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef)); + resolved = resolvedGeometry(spatialFilter.operator, "de référence", resolvedGeometryRef) + fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolved)); + break; + case "adjacent_feature": + resolved = resolvedGeometry(spatialFilter.operator, "de référence", resolvedGeometryRef) + fragments.push(compileAdjacentFeatureSpatialFilter(geometryName, resolved)); break; case "travel_time": - if (!resolvedGeometryRef) { - throw new Error("Le filtre spatial `travel_time` exige la résolution préalable de la géométrie d'isochrone."); - } - fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef)); + resolved = resolvedGeometry(spatialFilter.operator, "d'isochrone", resolvedGeometryRef) + fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolved)); break; } } else if (spatialExtras.length > 0) { diff --git a/src/wfs/schema.ts b/src/wfs/schema.ts index 7816514..b22e7fa 100644 --- a/src/wfs/schema.ts +++ b/src/wfs/schema.ts @@ -87,10 +87,14 @@ const dwithinPointFilterSchema = z.object({ }).strict().describe("Filtre les objets situés à une distance maximale d'un point."); const intersectsFeatureFilterSchema = z.object({ - typename: z.string().trim().min(1).describe("Type GPF du feature de référence."), - feature_id: z.string().trim().min(1).describe("Identifiant du feature de référence."), + typename: z.string().trim().min(1).describe("Type GPF de l'objet de référence."), + feature_id: z.string().trim().min(1).describe("Identifiant de l'objet de référence."), }).strict().describe("Filtre les objets dont la géométrie intersecte celle d'un objet GPF de référence."); +const adjacentFeatureFilterSchema = z.object({ + feature_id: z.string().trim().min(1).describe("Identifiant de l'objet de référence."), +}).strict().describe("Filtre les objets adjacents à un objet GPF de référence, partageant le même `typename`."); + const travelTimeFilterSchema = z.object({ lon: lonSchema.describe("Longitude du point de départ en WGS84 `lon/lat`."), lat: latSchema.describe("Latitude du point de départ en WGS84 `lon/lat`."), @@ -135,7 +139,10 @@ const gpfSpatialFilterInputSchema = z.object({ .describe("Filtre spatial par distance à un point. Exclusif avec les autres filtres spatiaux."), intersects_feature_filter: intersectsFeatureFilterSchema .optional() - .describe("Filtre spatial par intersection avec un feature GPF de référence. Exclusif avec les autres filtres spatiaux."), + .describe("Filtre spatial par intersection avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux."), + adjacent_feature_filter: adjacentFeatureFilterSchema + .optional() + .describe("Filtre spatial par adjacence avec un objet GPF de référence. Exclusif avec les autres filtres spatiaux."), travel_time_filter: travelTimeFilterSchema .optional() .describe("Filtre spatial par temps de trajet depuis un point (`profile` voiture ou piéton). Exclusif avec les autres filtres spatiaux."), diff --git a/src/wfs/spatialCql.ts b/src/wfs/spatialCql.ts index dd81783..19b7c57 100644 --- a/src/wfs/spatialCql.ts +++ b/src/wfs/spatialCql.ts @@ -5,10 +5,80 @@ * can be combined with attribute predicates in the final query. */ -import { Geometry } from "geojson"; import type { SpatialFilter } from "./schema.js"; +import GeoJSONReader from 'jsts/org/locationtech/jts/io/GeoJSONReader.js' +import GeometryFactory from 'jsts/org/locationtech/jts/geom/GeometryFactory.js' +import InteriorPointArea from 'jsts/org/locationtech/jts/algorithm/InteriorPointArea.js' +import { Geometry, LineString, MultiPoint, Point, Polygon } from "geojson"; import { geometryToEwkt } from "./geometry.js"; + +/** + * Return a Geometry with one inner point per disjoint sub-geometry of the input. + * + * @param geometry Input geometry possibly composed of multiple sub-geometries + * @returns A Geometry of type either "Point" or "MultiPoint". + */ +function findInnerPoints(geometry: LineString | Polygon): Point; +function findInnerPoints(geometry: Geometry): Point | MultiPoint; +function findInnerPoints(geometry: Geometry) : Point | MultiPoint { + let ret: MultiPoint; + switch (geometry.type) { + case "Point": + return geometry; + case "MultiPoint": + ret = geometry; + break; + case "LineString": { + const jts = (new GeoJSONReader(new GeometryFactory())).read(geometry); + const point = InteriorPointArea.getInteriorPoint(jts) + return { + type: "Point", + coordinates: [point.x, point.y] + }; + } + case "MultiLineString": { + const coords = geometry.coordinates as [number, number][][] + ret = { + type: "MultiPoint", + coordinates: coords.map(linecoords => (findInnerPoints({ + type: "LineString", + coordinates: linecoords, + })).coordinates) + }; + break; + } + case "Polygon": { + const jts = (new GeoJSONReader(new GeometryFactory())).read(geometry); + const point = InteriorPointArea.getInteriorPoint(jts) + return { + type: "Point", + coordinates: [point.x, point.y] + }; + } + case "MultiPolygon": { + const coords = geometry.coordinates as [number, number][][][] + ret = { + type: "MultiPoint", + coordinates: coords.map(polygoncoords => (findInnerPoints({ + type: "Polygon", + coordinates: polygoncoords, + })).coordinates) + }; + break; + } + default: + throw new Error(`Le type de géométrie '${geometry.type}' n'est pas supporté pour \`intersects_feature\` et \`adjacent_feature\`.`); + } + if (ret.coordinates.length == 1) { + return { + type: "Point", + coordinates: ret.coordinates[0] + } + } + return ret; +} + // --- Spatial Predicate Compilation --- /** @@ -60,3 +130,15 @@ export function compileDwithinSpatialFilter(geometryName: string, spatialFilter: export function compileIntersectsFeatureSpatialFilter(geometryName: string, geometry: Geometry) { return `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`; } + +/** + * Compiles an `adjacent_feature` spatial filter once the reference geometry is already serialized. + * + * @param geometryName Geometry property already resolved for the feature type. + * @param geometry The actual geometry and its EWKT serialization. + * @returns A CQL predicate composed of an "intersect" and a "not intersects". + */ +export function compileAdjacentFeatureSpatialFilter(geometryName: string, geometry: Geometry) { + const innerPoints = geometryToEwkt(findInnerPoints(geometry)); + return `INTERSECTS(${geometryName},${geometryToEwkt(geometry)}) AND NOT INTERSECTS(${geometryName},${innerPoints})`; +} diff --git a/src/wfs/spatialExtras.ts b/src/wfs/spatialExtras.ts index fa41e18..4dc8106 100644 --- a/src/wfs/spatialExtras.ts +++ b/src/wfs/spatialExtras.ts @@ -29,6 +29,7 @@ function spatialFilterToGeometry(spatialFilter: SpatialFilter, resolvedGeometryR const point = [spatialFilter.lon, spatialFilter.lat]; return { type: "Point", coordinates: point }; } + case "adjacent_feature": case "intersects_feature": case "travel_time": return resolvedGeometryRef!; @@ -44,6 +45,7 @@ function spatialFilterToCentroid(spatialFilter: SpatialFilter, resolvedGeometryR case "intersects_point": { return { type: "Point", coordinates: [spatialFilter.lon, spatialFilter.lat] }; } + case "adjacent_feature": case "intersects_feature": case "travel_time": case "bbox": @@ -96,9 +98,11 @@ function intersection3DWithSpatialFilter(geom: Geometry, spatialFilter: SpatialF switch (spatialFilter.operator) { case "dwithin_point" : return geo; // By definition, if "geo" is within the spatial filter, their intersection is geo. + case "adjacent_feature": case "intersects_point": { - return null; // The intersection is a point, so its interior is null; + return null; // The intersection is either a line (adjacent_feature) or a point (intersects_point), so its interior is null; } + case "adjacent_feature": case "intersects_feature": case "travel_time": { const spatialFilterGeometry = spatialFilterToGeometry(spatialFilter, resolvedGeometryRef) diff --git a/test/proxy/transport.test.ts b/test/proxy/transport.test.ts index 102873c..7fe23d4 100644 --- a/test/proxy/transport.test.ts +++ b/test/proxy/transport.test.ts @@ -113,7 +113,7 @@ describe("proxy/transport · resolveProxyTravelTimeGeometry", () => { travel_time_filter: { lon: 2.35, lat: 48.85, minutes: 15, profile: "pedestrian" }, }; - it("resolves the isochrone through the BOUNDED fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES) and returns EWKT", async () => { + it("resolves the isochrone through the BOUNDED fetch (PROXY_UPSTREAM_TIMEOUT + PROXY_MAX_RESPONSE_BYTES)", async () => { // The real NavigationIsochroneClient runs; only its fetcher is mocked. This is // the regression guard: the travel_time leg must NOT use the unbounded // fetchJSONGet (HTTP_TIMEOUT only) — it must go through fetchJSONGetWithLimit diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index 2562a47..af6e08f 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -647,7 +647,25 @@ describe("Test GpfGetFeaturesTool", () => { expect(results.features[0].geometry_name).toBeUndefined(); }); - it("should resolve intersects_feature from MultiPoint references", async () => { + it.each([ + { + name: "intersects_feature", + filter: { + intersects_feature_filter: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + feature_id: "localisant.1", + } + } + }, + { + name: "adjacent_feature", + filter: { + adjacent_feature_filter: { + feature_id: "localisant.1", + } + } + } + ])("should resolve $name from MultiPoint references", async ({ filter }) => { const tool = new GpfGetFeaturesTool(); mockFeatureTypes({ [COMMUNE_TYPENAME]: polygonFeatureType, @@ -671,10 +689,7 @@ describe("Test GpfGetFeaturesTool", () => { name: "gpf_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_filter: { - typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - feature_id: "localisant.1", - }, + ...filter, }, }, }); @@ -685,7 +700,25 @@ describe("Test GpfGetFeaturesTool", () => { expect(requests.some((request) => request.body.includes("MULTIPOINT"))).toBe(true); }); - it("should report missing reference features clearly for intersects_feature", async () => { + it.each([ + { + name: "intersects_feature", + filter: { + intersects_feature_filter: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + feature_id: "localisant.404", + } + } + }, + { + name: "adjacent_feature", + filter: { + adjacent_feature_filter: { + feature_id: "localisant.404", + } + } + } + ])("should report missing reference features clearly for $name", async ({ filter }) => { const tool = new GpfGetFeaturesTool(); mockFeatureTypes({ [COMMUNE_TYPENAME]: polygonFeatureType, @@ -702,10 +735,7 @@ describe("Test GpfGetFeaturesTool", () => { name: "gpf_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_filter: { - typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - feature_id: "localisant.404", - }, + ...filter, }, }, }); @@ -725,7 +755,7 @@ describe("Test GpfGetFeaturesTool", () => { }); }); - it("should reject intersects_feature on the same typename and guide to by-id tool", async () => { + it("should reject intersects_feature on the same typename and guide to by-id tool or adjacent_feature_filter", async () => { const tool = new GpfGetFeaturesTool(); const requests = captureRequests(featureCollection); @@ -748,6 +778,7 @@ describe("Test GpfGetFeaturesTool", () => { throw new Error("expected text content"); } expect(textContent.text).toContain("gpf_get_feature_by_id"); + expect(textContent.text).toContain("adjacent_feature"); expect(textContent.text).toContain("intersects_feature"); expect(response.structuredContent).toMatchObject({ type: "urn:geocontext:problem:execution-error", diff --git a/test/wfs/geometry.test.ts b/test/wfs/geometry.test.ts index b5fab2a..d76eed1 100644 --- a/test/wfs/geometry.test.ts +++ b/test/wfs/geometry.test.ts @@ -97,7 +97,7 @@ describe("geometryToEwkt", () => { it("should throw for an unsupported geometry type", () => { expect(() => geometryToEwkt({ type: "GeometryCollection", geometries: [] }), - ).toThrow("Le type de géométrie 'GeometryCollection' n'est pas supporté pour `intersects_feature`."); + ).toThrow("Le type de géométrie 'GeometryCollection' n'est pas supporté pour `intersects_feature` et `adjacent_feature`."); }); }); diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts index de7f659..d9e69e5 100644 --- a/test/wfs/queryPreparation.test.ts +++ b/test/wfs/queryPreparation.test.ts @@ -115,6 +115,27 @@ describe("gpfGetFeatures/queryPreparation", () => { expect(dwithin.cqlFilter).toEqual("DWITHIN(geometrie,SRID=4326;POINT(2.3522 48.8566),250,meters)"); }); + const multipolygon_example = { + type: "MultiPolygon" as const, + coordinates: [[[ + [2, 48 ], + [2.2, 48 ], + [2.2, 48.2], + [2, 48.2], + [2, 48 ], + ]]] + } + + const polygon_example = { + type: "Polygon" as const, + coordinates: [[ + [2, 48 ], + [2.2, 48 ], + [2.2, 48.2], + [2, 48 ], + ]] + } + it("should compile intersects_feature with resolved geometry", () => { const compiled = compileQueryParts({ ...baseInput, @@ -122,12 +143,20 @@ describe("gpfGetFeatures/queryPreparation", () => { typename: "ADMINEXPRESS-COG.LATEST:commune", feature_id: "commune.1", }, - }, wrappedFeatureType, { - type: "MultiPolygon" as const, - coordinates: [[[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48], [2, 48]]]] - }); + }, wrappedFeatureType, multipolygon_example); + + expect(compiled.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48.2,2 48))))"); + }); + + it("should compile adjacent_feature with resolved geometry", () => { + const compiled = compileQueryParts({ + ...baseInput, + adjacent_feature_filter: { + feature_id: "commune.1", + }, + }, wrappedFeatureType, multipolygon_example); - expect(compiled.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48,2 48))))"); + expect(compiled.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48.2,2 48)))) AND NOT INTERSECTS(geometrie,SRID=4326;POINT(2.1 48.1))"); }); it("should propagate resolvedGeometryRef in GetFeatures output for intersects_feature", () => { @@ -157,12 +186,7 @@ describe("gpfGetFeatures/queryPreparation", () => { minutes: 15, profile: "pedestrian", }, - }, wrappedFeatureType, - { - type: "Polygon" as const, - coordinates: [[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]] - } - ); + }, wrappedFeatureType, polygon_example); expect(compiled.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;POLYGON((2 48,2.2 48,2.2 48.2,2 48)))"); });