diff --git a/backend/app/services/generative/geometry.py b/backend/app/services/generative/geometry.py index 6c282950..8a9df8f8 100644 --- a/backend/app/services/generative/geometry.py +++ b/backend/app/services/generative/geometry.py @@ -30,7 +30,7 @@ from dataclasses import dataclass from typing import Any from pyproj import CRS, Transformer -from shapely.geometry import Polygon, mapping, shape +from shapely.geometry import MultiPolygon, Polygon, mapping, shape from shapely.geometry.base import BaseGeometry from shapely.ops import transform as shapely_transform @@ -142,6 +142,19 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon: except (KeyError, TypeError, ValueError, AttributeError) as exc: raise ParcelGeometryError(f"cannot parse GeoJSON geometry: {exc}") from exc + # Многоконтурный участок (MultiPolygon): берём крупнейший контур. У некоторых + # КН-участков в Росреестре несколько разрозненных полигонов — концепцию строим + # по основному (наибольшему по площади) контуру; остальные обычно вкрапления. + if isinstance(geom, MultiPolygon): + if geom.is_empty or not geom.geoms: + raise ParcelGeometryError("parcel polygon is empty") + n_contours = len(geom.geoms) + geom = max(geom.geoms, key=lambda g: g.area) + logger.info( + "parse_parcel: MultiPolygon → крупнейший контур из %d", + n_contours, + ) + if geom.geom_type != "Polygon": raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}") if geom.is_empty: diff --git a/backend/tests/services/generative/test_geometry.py b/backend/tests/services/generative/test_geometry.py index f57f9a4f..877c8292 100644 --- a/backend/tests/services/generative/test_geometry.py +++ b/backend/tests/services/generative/test_geometry.py @@ -100,13 +100,76 @@ def test_tiny_parcel_rejected_after_setback() -> None: def test_non_polygon_rejected() -> None: + payload = _payload(parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]}) + with pytest.raises(ParcelGeometryError): + geometry.parse_parcel(payload) + + +def test_linestring_rejected() -> None: + # LineString не имеет площади — прежняя ошибка сохраняется. payload = _payload( - parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]} + parcel_geojson={ + "type": "LineString", + "coordinates": [[60.60, 56.830], [60.6045, 56.8328]], + } ) with pytest.raises(ParcelGeometryError): geometry.parse_parcel(payload) +# Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка. +_SMALL_CONTOUR = [ + [60.610, 56.840], + [60.6112, 56.840], + [60.6112, 56.8404], + [60.610, 56.8404], + [60.610, 56.840], +] + + +def test_multipolygon_picks_largest_contour() -> None: + # Многоконтурный участок: основной прямоугольник + мелкое вкрапление поодаль. + # Берётся крупнейший контур → площадь == площади одиночного основного полигона. + multi = _payload( + parcel_geojson={ + "type": "MultiPolygon", + "coordinates": [[_PARCEL_COORDS], [_SMALL_CONTOUR]], + } + ) + single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]}) + parcel_multi = geometry.parse_parcel(multi) + parcel_single = geometry.parse_parcel(single) + # Результат многоконтурного == основному контуру (крупнейший выбран). + assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-6) + # Крупнейший контур на масштабе квартала, мелкое вкрапление отброшено. + assert parcel_multi.site_area_sqm > 50_000 + + +def test_multipolygon_single_contour_equals_polygon() -> None: + # MultiPolygon с одним контуром эквивалентен голому Polygon. + multi = _payload( + parcel_geojson={ + "type": "MultiPolygon", + "coordinates": [[_PARCEL_COORDS]], + } + ) + single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]}) + parcel_multi = geometry.parse_parcel(multi) + parcel_single = geometry.parse_parcel(single) + assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-9) + assert math.isclose( + parcel_multi.buildable_area_sqm, + parcel_single.buildable_area_sqm, + rel_tol=1e-9, + ) + + +def test_empty_multipolygon_rejected() -> None: + payload = _payload(parcel_geojson={"type": "MultiPolygon", "coordinates": []}) + with pytest.raises(ParcelGeometryError): + geometry.parse_parcel(payload) + + def test_build_placement_grid_anchors_are_step_aligned() -> None: # Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек. square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)])