From 964eb9008d3dab2b663fca7a602bd5efb49707dc Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 3 Jul 2026 00:17:23 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(concept):=203D-=D0=BC=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D1=81=D0=BE=D1=81=D0=B5=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=B4=D0=B0=D1=81=D1=82=D1=80=D0=B0=20=D0=B2=20?= =?UTF-8?q?=C2=A77=20(#2180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: GET /parcels/{cad}/neighbor-buildings?radius_m=300 → GeoJSON footprint'ов cad_buildings вокруг участка (ST_DWithin geography, GIST, EXPLAIN 28мс на плотном участке; NOT ST_Intersects с самим участком; SimplifyPreserveTopology; LIMIT 300 по расстоянию + truncated; 404 только при отсутствии геометрии участка). Свойства: floors, height_m (этаж 3 м), building_name, year_built. Frontend: hook useNeighborBuildings (TanStack) + серые низкополигональные экструзии в Massing3DScene (0.55 opacity, fallback 6 м, cap 200, отдельная Group с корректным dispose) + неинтерактивный слой в 2D-плане под корпусами; cadNum прокинут из Section7Concept; standalone /concept без кадастра работает как раньше. Подпись про НСПД-этажность при count>0. --- backend/app/api/v1/parcels.py | 148 +++++++++++ .../api/v1/test_parcel_neighbor_buildings.py | 244 ++++++++++++++++++ .../components/concept/ConceptResultMap.tsx | 32 +++ .../concept/ConceptVariantsResult.tsx | 24 +- .../src/components/concept/Massing3DScene.tsx | 132 +++++++++- .../site-finder/analysis/Section7Concept.tsx | 1 + frontend/src/hooks/useNeighborBuildings.ts | 70 +++++ 7 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 backend/tests/api/v1/test_parcel_neighbor_buildings.py create mode 100644 frontend/src/hooks/useNeighborBuildings.ts diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index fe848ea8..7b7489d5 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3951,6 +3951,154 @@ def get_parcel_connection_capacity( ) +# ── neighbor-buildings (Forgejo #2180) ────────────────────────────────────── +# Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены «Размещение +# застройки» (§7 отчёта ПТИЦА). Отдаёт GeoJSON FeatureCollection упрощённых +# полигонов cad_buildings вокруг участка. Кэш не нужен — один GIST-запрос. +# +# Модуль-level `text(...)` (а не inline в хендлере) чтобы integration-EXPLAIN- +# gate мог проверить SQL против прод-PG (psycopg v3 `:x::type`-антипаттерн, +# несуществующие PostGIS-функции). ::geography приклеен к ЗАКРЫВАЮЩЕЙ скобке — +# это разрешённая форма (не `:bind::type`), bind-числа идут через CAST(:x AS float). +_NEIGHBOR_BUILDINGS_SQL = text(""" + WITH parcel AS ( + SELECT geom + FROM cad_parcels_geom + WHERE cad_num = CAST(:cad AS text) + AND geom IS NOT NULL + LIMIT 1 + ) + SELECT + cb.cad_num, + cb.floors, + cb.building_name, + cb.year_built, + ST_AsGeoJSON(ST_SimplifyPreserveTopology(cb.geom, 0.000005)) AS geojson, + ST_Distance(cb.geom::geography, (SELECT geom FROM parcel)::geography) AS distance_m + FROM cad_buildings cb, parcel + WHERE ST_DWithin( + cb.geom::geography, + parcel.geom::geography, + CAST(:radius AS float) + ) + AND NOT ST_Intersects(cb.geom, parcel.geom) + ORDER BY distance_m ASC + LIMIT 301 +""") + +# Высота этажа для грубой 3D-экструзии footprint'а (§7). Типовой МКД ≈ 3 м/этаж — +# достаточная точность для превью-сцены «Размещение застройки». +_NEIGHBOR_FLOOR_HEIGHT_M = 3.0 +# Отдаём максимум 300 соседей (LIMIT 301 в SQL — «взяли на один больше» чтобы +# без COUNT(*) понять, обрезали ли выборку → truncated). +_NEIGHBOR_MAX_FEATURES = 300 + + +@router.get( + "/{cad_num}/neighbor-buildings", + summary="Соседние здания (footprint'ы) для 3D-сцены §7 (Forgejo #2180)", +) +def get_parcel_neighbor_buildings( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + radius_m: Annotated[int, Query(ge=50, le=1000)] = 300, +) -> dict[str, Any]: + """GeoJSON FeatureCollection footprint'ов соседних зданий вокруг участка. + + Для 3D-сцены «Размещение застройки» (§7 отчёта ПТИЦА): рисуем существующую + застройку контекста, чтобы концепция вписывалась в окружение. Здания берём из + `cad_buildings` в радиусе `radius_m` (ST_DWithin по geography). Здания, + ПЕРЕСЕКАЮЩИЕ сам участок, исключаем — на его месте строим мы (их footprint'ы + только мешали бы сцене). + + Геометрия упрощается (ST_SimplifyPreserveTopology, ~0.5 м) — фронту не нужна + кадастровая точность контура для превью. properties каждой фичи: + cad_num, floors (int|null), height_m (floors×3 если floors>0, иначе null), + building_name (null если пусто), year_built (int|null), is_neighbor=true. + + Отдаём максимум 300 ближайших (ORDER BY расстоянию); `truncated=true` если в + радиусе их больше. Участок не найден (нет geom) → 404. + + Query params: + radius_m: радиус поиска, 50–1000 м (default 300). + """ + rows = ( + db.execute( + _NEIGHBOR_BUILDINGS_SQL, + {"cad": cad_num, "radius": float(radius_m)}, + ) + .mappings() + .all() + ) + + # Пустой результат может значить И «участка нет в БД», И «участок есть, но + # соседей в радиусе нет» — надо различать (404 vs пустой 200). Отдельный + # быстрый EXISTS-пробник участка: без него не отличить «нет geom» от «нет + # соседей». Только когда rows пуст (в норме — 1 дешёвый запрос сверху). + if not rows: + parcel_exists = db.execute( + text( + "SELECT 1 FROM cad_parcels_geom " + "WHERE cad_num = CAST(:cad AS text) AND geom IS NOT NULL LIMIT 1" + ), + {"cad": cad_num}, + ).first() + if parcel_exists is None: + raise HTTPException( + status_code=404, + detail=( + f"Геометрия участка {cad_num} не найдена в БД. " + "Запустите POST /analyze для дозагрузки." + ), + ) + + truncated = len(rows) > _NEIGHBOR_MAX_FEATURES + kept = rows[:_NEIGHBOR_MAX_FEATURES] + + features: list[dict[str, Any]] = [] + for r in kept: + # geojson — строка из ST_AsGeoJSON; парсим в dict для валидного вложения + # в FeatureCollection (иначе фронт получит geometry как escaped-строку). + geom_raw = r.get("geojson") + if not geom_raw: + # Топологически битый polygon → ST_AsGeoJSON(NULL). Пропускаем: одна + # плохая геометрия не должна ронять всю сцену. + continue + geometry = json.loads(geom_raw) + + floors = r.get("floors") + floors_int = int(floors) if floors is not None and int(floors) > 0 else None + year_built = r.get("year_built") + building_name = (r.get("building_name") or "").strip() or None + + features.append( + { + "type": "Feature", + "geometry": geometry, + "properties": { + "cad_num": r.get("cad_num"), + "floors": floors_int, + "height_m": ( + round(floors_int * _NEIGHBOR_FLOOR_HEIGHT_M, 1) + if floors_int is not None + else None + ), + "building_name": building_name, + "year_built": int(year_built) if year_built is not None else None, + "is_neighbor": True, + }, + } + ) + + return { + "type": "FeatureCollection", + "features": features, + "count": len(features), + "radius_m": radius_m, + "truncated": truncated, + } + + _ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" _ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) diff --git a/backend/tests/api/v1/test_parcel_neighbor_buildings.py b/backend/tests/api/v1/test_parcel_neighbor_buildings.py new file mode 100644 index 00000000..1797b5e5 --- /dev/null +++ b/backend/tests/api/v1/test_parcel_neighbor_buildings.py @@ -0,0 +1,244 @@ +"""Тесты GET /{cad_num}/neighbor-buildings (Forgejo #2180). + +Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены §7 «Размещение +застройки»: GeoJSON FeatureCollection упрощённых полигонов cad_buildings вокруг +участка. + +Механика моков: get_db переопределяем через app.dependency_overrides (FastAPI +Depends держит оригинальную ссылку — unittest.mock.patch неэффективен). Хендлер +делает один основной `db.execute(...).mappings().all()` (соседи), и ТОЛЬКО при +пустом результате — второй `db.execute(...).first()` (EXISTS-пробник участка, +404 vs пустой 200). Мок покрывает обе формы. Очистку dependency_overrides делает +autouse-фикстура в tests/conftest.py. RBAC-гейт в test-mode (settings.testing) +байпасится — auth-заголовок не нужен. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.core.db import get_db +from app.main import app + +_VALID_CAD = "66:41:0204016:10" + +# Валидный GeoJSON-полигон footprint'а — как отдаёт ST_AsGeoJSON (строка). +_POLYGON_GEOJSON = json.dumps( + { + "type": "Polygon", + "coordinates": [ + [ + [60.640, 56.830], + [60.641, 56.830], + [60.641, 56.831], + [60.640, 56.831], + [60.640, 56.830], + ] + ], + } +) + + +def _make_building_row( + cad_num: str = "66:41:0204016:100", + floors: int | None = 9, + building_name: str | None = "Жилой дом", + year_built: int | None = 1998, + distance_m: float = 42.0, + geojson: str | None = _POLYGON_GEOJSON, +) -> dict[str, Any]: + """Одна строка результата _NEIGHBOR_BUILDINGS_SQL (mappings()).""" + return { + "cad_num": cad_num, + "floors": floors, + "building_name": building_name, + "year_built": year_built, + "geojson": geojson, + "distance_m": distance_m, + } + + +def _build_mock_db( + rows: list[dict[str, Any]], + *, + parcel_exists: bool = True, +) -> MagicMock: + """Mock Session. + + Основной запрос соседей → .mappings().all() = rows. + Fallback EXISTS-пробник (вызывается лишь при пустом rows) → .first() = + (1,) если parcel_exists иначе None. + """ + mock_db = MagicMock() + + mappings_mock = MagicMock() + mappings_mock.all.return_value = rows + + execute_result = MagicMock() + execute_result.mappings.return_value = mappings_mock + # .first() дёргается только на EXISTS-пробнике (тот же execute_result — ок, + # т.к. пробник запускается только когда rows пуст). + execute_result.first.return_value = (1,) if parcel_exists else None + + mock_db.execute.return_value = execute_result + return mock_db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app) + + +# ── happy path: фичи + floors → height_m ────────────────────────────────── + + +def test_neighbor_buildings_happy_path(client: TestClient) -> None: + rows = [ + _make_building_row("66:41:0204016:100", floors=9, distance_m=30.0), + _make_building_row("66:41:0204016:101", floors=16, distance_m=85.0), + _make_building_row( + "66:41:0204016:102", + floors=None, # floors неизвестны → height_m null + building_name="", # пустое имя → null + year_built=None, + distance_m=120.0, + ), + ] + app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["type"] == "FeatureCollection" + assert body["count"] == 3 + assert body["radius_m"] == 300 + assert body["truncated"] is False + assert len(body["features"]) == 3 + + f0 = body["features"][0] + assert f0["type"] == "Feature" + assert f0["geometry"]["type"] == "Polygon" # распарсен из строки, не escaped + props0 = f0["properties"] + assert props0["cad_num"] == "66:41:0204016:100" + assert props0["floors"] == 9 + assert props0["height_m"] == pytest.approx(27.0) # 9 × 3.0 + assert props0["building_name"] == "Жилой дом" + assert props0["year_built"] == 1998 + assert props0["is_neighbor"] is True + + # floors=16 → height_m 48.0 + assert body["features"][1]["properties"]["height_m"] == pytest.approx(48.0) + + # floors=None → height_m null, building_name пустой → null, year_built null + props2 = body["features"][2]["properties"] + assert props2["floors"] is None + assert props2["height_m"] is None + assert props2["building_name"] is None + assert props2["year_built"] is None + assert props2["is_neighbor"] is True + + +# ── пустой результат (участок есть, соседей нет) → 200 empty ─────────────── + + +def test_neighbor_buildings_empty_ok(client: TestClient) -> None: + """Участок в БД есть (EXISTS=true), но соседей в радиусе нет → 200 пустой.""" + app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=True)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["type"] == "FeatureCollection" + assert body["features"] == [] + assert body["count"] == 0 + assert body["truncated"] is False + + +# ── участок не найден (нет geom) → 404 ───────────────────────────────────── + + +def test_neighbor_buildings_parcel_not_found_404(client: TestClient) -> None: + """Пустой результат + EXISTS-пробник вернул None (нет geom) → 404.""" + app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=False)) + + r = client.get("/api/v1/parcels/66:41:9999999:1/neighbor-buildings") + + assert r.status_code == 404 + assert "не найдена" in r.json()["detail"] + + +# ── truncated: >300 соседей → флаг + обрезка до 300 ──────────────────────── + + +def test_neighbor_buildings_truncated(client: TestClient) -> None: + """SQL LIMIT 301 → если пришёл 301, отдаём 300 + truncated=true.""" + rows = [ + _make_building_row(f"66:41:0204016:{i}", floors=(i % 20) + 1, distance_m=float(i)) + for i in range(301) + ] + app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["count"] == 300 + assert len(body["features"]) == 300 + assert body["truncated"] is True + + +# ── radius валидация → 422 ───────────────────────────────────────────────── + + +def test_neighbor_buildings_radius_too_small_422(client: TestClient) -> None: + """radius_m=10 (< min 50) → 422.""" + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=10") + assert r.status_code == 422 + + +def test_neighbor_buildings_radius_too_large_422(client: TestClient) -> None: + """radius_m=2000 (> max 1000) → 422.""" + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=2000") + assert r.status_code == 422 + + +def test_neighbor_buildings_radius_passthrough(client: TestClient) -> None: + """radius_m из query отражается в ответе (и внутри — в bind SQL).""" + app.dependency_overrides[get_db] = _override_db(_build_mock_db([_make_building_row()])) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=500") + + assert r.status_code == 200, r.text + assert r.json()["radius_m"] == 500 + + +# ── битая геометрия (ST_AsGeoJSON NULL) пропускается, не роняет сцену ─────── + + +def test_neighbor_buildings_skips_null_geometry(client: TestClient) -> None: + rows = [ + _make_building_row("66:41:0204016:100", geojson=None), # битый polygon + _make_building_row("66:41:0204016:101", geojson=_POLYGON_GEOJSON), + ] + app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) + + r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") + + assert r.status_code == 200, r.text + body = r.json() + assert body["count"] == 1 + assert body["features"][0]["properties"]["cad_num"] == "66:41:0204016:101" diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx index e4563827..2c246f5a 100644 --- a/frontend/src/components/concept/ConceptResultMap.tsx +++ b/frontend/src/components/concept/ConceptResultMap.tsx @@ -14,6 +14,8 @@ import L from "leaflet"; import "leaflet/dist/leaflet.css"; +import { useNeighborBuildings } from "@/hooks/useNeighborBuildings"; + // ── Bounds helper ───────────────────────────────────────────────────────────── function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null { @@ -59,6 +61,11 @@ interface Props { /** Keying makes react-leaflet remount the buildings layer on variant switch. */ variantKey: string; height?: number; + /** + * Кадастровый номер участка — для слоя соседних зданий (макет квартала, #2180). + * Отсутствует на standalone /concept без кадастра → слой соседей не грузится. + */ + cadNum?: string | null; } export function ConceptResultMap({ @@ -66,6 +73,7 @@ export function ConceptResultMap({ buildings, variantKey, height = 360, + cadNum, }: Props) { // Memoize so FitBounds only re-fits when the parcel geometry actually // changes — recomputing inline returns a new array each render, which would @@ -76,6 +84,12 @@ export function ConceptResultMap({ ); const hasBuildings = buildings.features.length > 0; + // Соседние здания (#2180): контекст квартала под слоем корпусов. enabled только + // при наличии cadNum (standalone /concept без кадастра → не грузим). + const neighbors = useNeighborBuildings(cadNum); + const neighborsData = neighbors.data; + const hasNeighbors = (neighborsData?.count ?? 0) > 0; + return (
+ {/* Neighbour context buildings (#2180) — neutral grey макет квартала под + слоем корпусов. Не интерактивный (interactive: false), без popup. + Рендерится ПЕРВЫМ → уходит под слои участка и корпусов. Цвета: fill + #E5E7EB, обводка #9CA3AF (нейтральные серые, ≈--fg-tertiary). */} + {hasNeighbors && neighborsData && ( + + )} + {/* Parcel boundary — accent-soft fill + accent outline (#2179) so the lot reads at a glance; buildings (opacity 0.55) still read on top of the lighter 0.35 parcel fill. Colours mirror --accent / --accent-soft. */} diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 57faf676..1e9fffc2 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -218,9 +218,20 @@ interface PanelProps { variant: ConceptVariant; /** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */ floorHeightM?: number; + /** + * Кадастровый номер участка — для подгрузки соседних зданий (макет квартала) + * в 2D-план / 3D-сцену (#2180). Отсутствует на standalone /concept без + * кадастра → соседи не грузятся. + */ + cadNum?: string | null; } -function VariantPanel({ parcel, variant, floorHeightM = 3.0 }: PanelProps) { +function VariantPanel({ + parcel, + variant, + floorHeightM = 3.0, + cadNum, +}: PanelProps) { const { teap, financial } = variant; // Placement view: «План (2D)» (Leaflet) ↔ «Объём (3D)» (Three.js). Desktop @@ -345,12 +356,14 @@ function VariantPanel({ parcel, variant, floorHeightM = 3.0 }: PanelProps) { parcel={parcel} buildings={variant.buildings_geojson} variantKey={variant.strategy} + cadNum={cadNum} /> ) : ( )} @@ -718,12 +731,20 @@ interface Props { * a contract change (the ТЭП / финмодель stay computed at 3.0 m/этаж). */ floorHeightM?: number; + /** + * Кадастровый номер участка — прокидывается в 2D-план и 3D-сцену для загрузки + * соседних зданий (макет квартала, #2180). В отчёте берётся из analysis.cad_num; + * на standalone /concept (границы рисуются / резолвятся вручную) не передаётся → + * соседи не грузятся, сцена работает как раньше. + */ + cadNum?: string | null; } export function ConceptVariantsResult({ parcel, variants, floorHeightM = 3.0, + cadNum, }: Props) { const [active, setActive] = useState(0); @@ -798,6 +819,7 @@ export function ConceptVariantsResult({ parcel={parcel} variant={activeVariant} floorHeightM={floorHeightM} + cadNum={cadNum} />
); diff --git a/frontend/src/components/concept/Massing3DScene.tsx b/frontend/src/components/concept/Massing3DScene.tsx index 21596f31..01ebf706 100644 --- a/frontend/src/components/concept/Massing3DScene.tsx +++ b/frontend/src/components/concept/Massing3DScene.tsx @@ -21,10 +21,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; -import type { Polygon, Position } from "geojson"; +import type { MultiPolygon, Polygon, Position } from "geojson"; import { Box, RotateCw } from "lucide-react"; import type { ConceptVariant } from "@/lib/concept-api"; +import { + useNeighborBuildings, + type NeighborBuildingsResponse, +} from "@/hooks/useNeighborBuildings"; import { makeProjector, osmTileRangeForBbox, @@ -46,6 +50,14 @@ const HOUR_MIN = 6; const HOUR_MAX = 20; const FALLBACK_FLOORS = 12; +// Neighbours (#2180): existing context buildings around the parcel, rendered as +// low-poly grey masses so the concept reads inside a block. Height comes from +// НСПД floors×3 m; when floors is unknown fall back to ~2 storeys so the mass +// still lands. Cap the count and simplify (no edges) — they're backdrop, the +// programme corpuses (accent) must dominate. +const NEIGHBOR_FALLBACK_HEIGHT_M = 6; // ≈2 этажа при неизвестной этажности НСПД +const NEIGHBOR_MAX_RENDER = 200; // cap: features уже отсортированы по расстоянию + // Colours — Three.js wants numeric hex; these mirror the UI tokens by value. const COLOR_BG = 0xf6f7f9; // --bg-app const COLOR_SLATE = 0x33424f; // building body @@ -54,6 +66,7 @@ const COLOR_PARCEL_OUTLINE = 0x1d4ed8; // --accent (parcel outline — accent, # const COLOR_PARCEL_FILL = 0xdbeafe; // --accent-soft (parcel fill — #2179) const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt const COLOR_GRID = 0xe6e8ec; // --border-card +const COLOR_NEIGHBOR = 0x9ca3af; // близко к --fg-tertiary — нейтральный серый соседей (#2180) const TILE_TIMEOUT_MS = 6000; @@ -68,6 +81,11 @@ export interface Massing3DSceneProps { floorHeightM?: number; /** Optional initial sun hour; default 13. */ hour?: number; + /** + * Кадастровый номер участка — для подгрузки соседних зданий (макет квартала, + * #2180). Отсутствует на standalone /concept без кадастра → соседи не грузятся. + */ + cadNum?: string | null; } // ── derived model (pure, memoized) ──────────────────────────────────────────── @@ -332,6 +350,88 @@ function buildParcelHighlight(model: MassingModel): THREE.Group { return group; } +// ── neighbour context buildings (#2180) ──────────────────────────────────────── + +/** Outer ring (index 0) of one GeoJSON polygon, projected to local metres. */ +function projectOuterRing( + polygon: Position[][], + project: Projector, +): { x: number; z: number }[] { + const ringWgs = (polygon?.[0] ?? []) as Position[]; + return ringWgs.map(project); +} + +/** + * Build the neighbours Group from the fetched FeatureCollection, projected with + * the SCENE's projector (`model.project`) so the context sits geo-aligned around + * the parcel. Each footprint → a THREE.Shape (Polygon → its outer ring; + * MultiPolygon → one shape per polygon) extruded to `height_m` (НСПД floors×3 m), + * falling back to ~2 storeys when the height is unknown. + * + * Neutral grey (COLOR_NEIGHBOR), opacity 0.55 / transparent, NO edges — a backdrop + * that must not compete with the accent programme corpuses. Rotated −π/2 about X + * (like `buildBuildingsGroup`) so footprints lie on y=0 and height grows +y. + * Returns null when there's nothing renderable, so the caller can skip adding it. + */ +function buildNeighborsGroup( + data: NeighborBuildingsResponse, + model: MassingModel, +): THREE.Group | null { + const features = data.features.slice(0, NEIGHBOR_MAX_RENDER); + if (features.length === 0) return null; + + const group = new THREE.Group(); + group.name = "neighbors"; + group.rotation.x = -Math.PI / 2; + + // One shared material for the whole context — grey backdrop, semi-transparent. + const mat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(COLOR_NEIGHBOR), + roughness: 0.9, + metalness: 0.0, + transparent: true, + opacity: 0.55, + }); + + // Extrude one grey mass per footprint ring at that feature's height. + const extrudeRing = (ring: { x: number; z: number }[], heightM: number) => { + if (ring.length < 4) return; // need a closed ring (≥3 verts + closing point) + const shape = new THREE.Shape(); + shape.moveTo(ring[0].x, ring[0].z); + for (let i = 1; i < ring.length; i++) shape.lineTo(ring[i].x, ring[i].z); + shape.closePath(); + const geo = new THREE.ExtrudeGeometry(shape, { + depth: heightM, + bevelEnabled: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.castShadow = true; + mesh.receiveShadow = true; + group.add(mesh); + }; + + for (const f of features) { + const geom = f.geometry; + if (!geom) continue; + const heightM = + typeof f.properties.height_m === "number" && f.properties.height_m > 0 + ? f.properties.height_m + : NEIGHBOR_FALLBACK_HEIGHT_M; + if (geom.type === "Polygon") { + // Polygon → its outer ring (index 0). + extrudeRing(projectOuterRing(geom.coordinates, model.project), heightM); + } else { + // MultiPolygon → one mass per polygon (outer ring only). + for (const poly of geom.coordinates) { + extrudeRing(projectOuterRing(poly, model.project), heightM); + } + } + } + + if (group.children.length === 0) return null; + return group; +} + // ── OSM ground tile (fetch + stitch + CanvasTexture) ────────────────────────── interface GroundTexResult { @@ -430,6 +530,7 @@ export default function Massing3DScene({ variant, floorHeightM = DEFAULT_FLOOR_HEIGHT, hour: initialHour = DEFAULT_HOUR, + cadNum, }: Massing3DSceneProps): React.JSX.Element { const [hour, setHour] = useState(initialHour); const [autoRotate, setAutoRotate] = useState(true); @@ -446,6 +547,14 @@ export default function Massing3DScene({ [parcel, variant, floorHeightM], ); + // Neighbour context buildings (#2180). enabled только при наличии cadNum + // (standalone /concept без кадастра → соседи не грузятся). Данные приходят + // async; отдельный effect ниже дорисовывает их в уже построенную сцену без + // полного rebuild. + const neighbors = useNeighborBuildings(cadNum); + const neighborsData = neighbors.data; + const neighborsCount = neighborsData?.count ?? 0; + // place the sun from the current hour (light-only; no rebuild). const placeSun = useCallback( (h: number) => { @@ -662,6 +771,24 @@ export default function Massing3DScene({ if (controlsRef.current) controlsRef.current.autoRotate = autoRotate; }, [autoRotate]); + // Neighbour context (#2180): add the grey backdrop masses to the LIVE scene + // without a full rebuild. Runs when the neighbours data lands (async, after + // first paint) and re-runs after a model rebuild (new scene ref) — declared + // AFTER the main scene effect so on a `model` change the new scene already + // exists. Cleanup removes + disposes the group (the main teardown also + // disposeObject(scene)s it as a belt-and-braces if unmount races this). + useEffect(() => { + const scene = sceneRef.current; + if (!scene || !neighborsData) return; + const group = buildNeighborsGroup(neighborsData, model); + if (!group) return; + scene.add(group); + return () => { + scene.remove(group); + disposeObject(group); + }; + }, [model, neighborsData]); + if (webglFailed) { return (
0 + ? " Соседние здания — контуры и этажность НСПД (кадастр), высота этажа 3 м." + : ""}

); diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx index 7824c007..b80c5228 100644 --- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -347,6 +347,7 @@ export function Section7Concept({ analysis }: Props) { )} diff --git a/frontend/src/hooks/useNeighborBuildings.ts b/frontend/src/hooks/useNeighborBuildings.ts new file mode 100644 index 00000000..e34f408c --- /dev/null +++ b/frontend/src/hooks/useNeighborBuildings.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { FeatureCollection, MultiPolygon, Polygon } from "geojson"; + +import { apiFetch } from "@/lib/api"; + +/** + * Радиус поиска соседних зданий вокруг участка для 3D-сцены / 2D-плана §7 + * («Размещение застройки»). Дефолт 300 м — согласован с бэком + * (GET /neighbor-buildings?radius_m=…, диапазон 50–1000). Держим отдельной + * константой, чтобы подпись и запрос не расходились. + */ +export const NEIGHBOR_BUILDINGS_RADIUS_M = 300; + +/** + * properties одной соседней постройки. Форма — единственный источник истины + * бэкенд (parcels.py::get_parcel_neighbor_buildings): endpoint отдаёт plain + * GeoJSON FeatureCollection (handler-return `dict[str, Any]`, НЕ Pydantic-модель), + * поэтому в api-types.ts codegen'ом он не попадает — типизуем вручную, как и + * соседний `NeighborBuilding` в types/site-finder.ts. + */ +export interface NeighborBuildingProps { + cad_num: string | null; + /** Этажность НСПД (int) либо null, если не заполнена в кадастре. */ + floors: number | null; + /** Высота = floors × 3 м (бэк), либо null при неизвестной этажности. */ + height_m: number | null; + building_name: string | null; + year_built: number | null; + is_neighbor: true; +} + +export interface NeighborBuildingsResponse extends FeatureCollection< + Polygon | MultiPolygon, + NeighborBuildingProps +> { + /** Число возвращённых фич (после cap на бэке). */ + count: number; + /** Радиус, по которому реально искали (echo query-параметра). */ + radius_m: number; + /** true, если в радиусе зданий было больше лимита (300) и выборку обрезали. */ + truncated: boolean; +} + +/** + * Соседние здания (footprint'ы) вокруг участка для сцены «Размещение застройки» + * (§7 отчёта ПТИЦА, Forgejo #2180): существующая застройка контекста, чтобы + * концепция читалась в окружении квартала. + * + * Зеркалит useConnectionCapacity: apiFetch, queryKey-массив, staleTime 5 мин, + * enabled по cadNum. Пустой / отсутствующий cadNum (например standalone-страница + * /concept без кадастра) → запрос не идёт (enabled=false), сцена работает как + * раньше без соседей. + */ +export function useNeighborBuildings( + cadNum: string | null | undefined, + radiusM: number = NEIGHBOR_BUILDINGS_RADIUS_M, + enabled = true, +) { + return useQuery({ + queryKey: ["neighbor-buildings", cadNum, radiusM], + queryFn: () => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum!)}/neighbor-buildings?radius_m=${radiusM}`, + ), + enabled: !!cadNum && enabled, + staleTime: 5 * 60 * 1000, + }); +} -- 2.45.3 From 87583fef605c627c0007fc21ca8cabfad0997bc6 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Fri, 3 Jul 2026 00:17:56 +0500 Subject: [PATCH 2/2] =?UTF-8?q?chore(frontend):=20regen=20api-types.ts=20?= =?UTF-8?q?=E2=80=94=20neighbor-buildings=20route=20(#2180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/api-types.ts | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index c162fcb5..a7ec5733 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -469,6 +469,43 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/parcels/{cad_num}/neighbor-buildings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Соседние здания (footprint'ы) для 3D-сцены §7 (Forgejo #2180) + * @description GeoJSON FeatureCollection footprint'ов соседних зданий вокруг участка. + * + * Для 3D-сцены «Размещение застройки» (§7 отчёта ПТИЦА): рисуем существующую + * застройку контекста, чтобы концепция вписывалась в окружение. Здания берём из + * `cad_buildings` в радиусе `radius_m` (ST_DWithin по geography). Здания, + * ПЕРЕСЕКАЮЩИЕ сам участок, исключаем — на его месте строим мы (их footprint'ы + * только мешали бы сцене). + * + * Геометрия упрощается (ST_SimplifyPreserveTopology, ~0.5 м) — фронту не нужна + * кадастровая точность контура для превью. properties каждой фичи: + * cad_num, floors (int|null), height_m (floors×3 если floors>0, иначе null), + * building_name (null если пусто), year_built (int|null), is_neighbor=true. + * + * Отдаём максимум 300 ближайших (ORDER BY расстоянию); `truncated=true` если в + * радиусе их больше. Участок не найден (нет geom) → 404. + * + * Query params: + * radius_m: радиус поиска, 50–1000 м (default 300). + */ + get: operations["get_parcel_neighbor_buildings_api_v1_parcels__cad_num__neighbor_buildings_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/parcels/{cad_num}/isochrones": { parameters: { query?: never; @@ -5808,6 +5845,41 @@ export interface operations { }; }; }; + get_parcel_neighbor_buildings_api_v1_parcels__cad_num__neighbor_buildings_get: { + parameters: { + query?: { + radius_m?: number; + }; + header?: never; + path: { + cad_num: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_isochrones_api_v1_parcels__cad_num__isochrones_get: { parameters: { query?: { -- 2.45.3