feat(concept): 3D-макет окружения — соседние здания из кадастра в §7 (#2180) (#2210)
Some checks failed
Some checks failed
This commit is contained in:
parent
ca473a0012
commit
a7ad1764c1
8 changed files with 721 additions and 2 deletions
|
|
@ -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_BASE = "https://api.openrouteservice.org/v2/isochrones"
|
||||||
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
|
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
|
||||||
|
|
||||||
|
|
|
||||||
244
backend/tests/api/v1/test_parcel_neighbor_buildings.py
Normal file
244
backend/tests/api/v1/test_parcel_neighbor_buildings.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -14,6 +14,8 @@ import L from "leaflet";
|
||||||
|
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
import { useNeighborBuildings } from "@/hooks/useNeighborBuildings";
|
||||||
|
|
||||||
// ── Bounds helper ─────────────────────────────────────────────────────────────
|
// ── Bounds helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null {
|
function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null {
|
||||||
|
|
@ -59,6 +61,11 @@ interface Props {
|
||||||
/** Keying makes react-leaflet remount the buildings layer on variant switch. */
|
/** Keying makes react-leaflet remount the buildings layer on variant switch. */
|
||||||
variantKey: string;
|
variantKey: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/**
|
||||||
|
* Кадастровый номер участка — для слоя соседних зданий (макет квартала, #2180).
|
||||||
|
* Отсутствует на standalone /concept без кадастра → слой соседей не грузится.
|
||||||
|
*/
|
||||||
|
cadNum?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConceptResultMap({
|
export function ConceptResultMap({
|
||||||
|
|
@ -66,6 +73,7 @@ export function ConceptResultMap({
|
||||||
buildings,
|
buildings,
|
||||||
variantKey,
|
variantKey,
|
||||||
height = 360,
|
height = 360,
|
||||||
|
cadNum,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Memoize so FitBounds only re-fits when the parcel geometry actually
|
// Memoize so FitBounds only re-fits when the parcel geometry actually
|
||||||
// changes — recomputing inline returns a new array each render, which would
|
// changes — recomputing inline returns a new array each render, which would
|
||||||
|
|
@ -76,6 +84,12 @@ export function ConceptResultMap({
|
||||||
);
|
);
|
||||||
const hasBuildings = buildings.features.length > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -97,6 +111,24 @@ export function ConceptResultMap({
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Neighbour context buildings (#2180) — neutral grey макет квартала под
|
||||||
|
слоем корпусов. Не интерактивный (interactive: false), без popup.
|
||||||
|
Рендерится ПЕРВЫМ → уходит под слои участка и корпусов. Цвета: fill
|
||||||
|
#E5E7EB, обводка #9CA3AF (нейтральные серые, ≈--fg-tertiary). */}
|
||||||
|
{hasNeighbors && neighborsData && (
|
||||||
|
<GeoJSON
|
||||||
|
key="neighbors"
|
||||||
|
data={neighborsData}
|
||||||
|
interactive={false}
|
||||||
|
style={{
|
||||||
|
color: "#9CA3AF", // нейтральный серый — обводка соседей
|
||||||
|
weight: 1,
|
||||||
|
fillColor: "#E5E7EB", // нейтральный серый — заливка соседей
|
||||||
|
fillOpacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Parcel boundary — accent-soft fill + accent outline (#2179) so the
|
{/* Parcel boundary — accent-soft fill + accent outline (#2179) so the
|
||||||
lot reads at a glance; buildings (opacity 0.55) still read on top of
|
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. */}
|
the lighter 0.35 parcel fill. Colours mirror --accent / --accent-soft. */}
|
||||||
|
|
|
||||||
|
|
@ -218,9 +218,20 @@ interface PanelProps {
|
||||||
variant: ConceptVariant;
|
variant: ConceptVariant;
|
||||||
/** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */
|
/** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */
|
||||||
floorHeightM?: number;
|
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;
|
const { teap, financial } = variant;
|
||||||
|
|
||||||
// Placement view: «План (2D)» (Leaflet) ↔ «Объём (3D)» (Three.js). Desktop
|
// Placement view: «План (2D)» (Leaflet) ↔ «Объём (3D)» (Three.js). Desktop
|
||||||
|
|
@ -345,12 +356,14 @@ function VariantPanel({ parcel, variant, floorHeightM = 3.0 }: PanelProps) {
|
||||||
parcel={parcel}
|
parcel={parcel}
|
||||||
buildings={variant.buildings_geojson}
|
buildings={variant.buildings_geojson}
|
||||||
variantKey={variant.strategy}
|
variantKey={variant.strategy}
|
||||||
|
cadNum={cadNum}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Massing3DViewer
|
<Massing3DViewer
|
||||||
parcel={parcel}
|
parcel={parcel}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
floorHeightM={floorHeightM}
|
floorHeightM={floorHeightM}
|
||||||
|
cadNum={cadNum}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
@ -718,12 +731,20 @@ interface Props {
|
||||||
* a contract change (the ТЭП / финмодель stay computed at 3.0 m/этаж).
|
* a contract change (the ТЭП / финмодель stay computed at 3.0 m/этаж).
|
||||||
*/
|
*/
|
||||||
floorHeightM?: number;
|
floorHeightM?: number;
|
||||||
|
/**
|
||||||
|
* Кадастровый номер участка — прокидывается в 2D-план и 3D-сцену для загрузки
|
||||||
|
* соседних зданий (макет квартала, #2180). В отчёте берётся из analysis.cad_num;
|
||||||
|
* на standalone /concept (границы рисуются / резолвятся вручную) не передаётся →
|
||||||
|
* соседи не грузятся, сцена работает как раньше.
|
||||||
|
*/
|
||||||
|
cadNum?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConceptVariantsResult({
|
export function ConceptVariantsResult({
|
||||||
parcel,
|
parcel,
|
||||||
variants,
|
variants,
|
||||||
floorHeightM = 3.0,
|
floorHeightM = 3.0,
|
||||||
|
cadNum,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
|
||||||
|
|
@ -798,6 +819,7 @@ export function ConceptVariantsResult({
|
||||||
parcel={parcel}
|
parcel={parcel}
|
||||||
variant={activeVariant}
|
variant={activeVariant}
|
||||||
floorHeightM={floorHeightM}
|
floorHeightM={floorHeightM}
|
||||||
|
cadNum={cadNum}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,14 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
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 { Box, RotateCw } from "lucide-react";
|
||||||
|
|
||||||
import type { ConceptVariant } from "@/lib/concept-api";
|
import type { ConceptVariant } from "@/lib/concept-api";
|
||||||
|
import {
|
||||||
|
useNeighborBuildings,
|
||||||
|
type NeighborBuildingsResponse,
|
||||||
|
} from "@/hooks/useNeighborBuildings";
|
||||||
import {
|
import {
|
||||||
makeProjector,
|
makeProjector,
|
||||||
osmTileRangeForBbox,
|
osmTileRangeForBbox,
|
||||||
|
|
@ -46,6 +50,14 @@ const HOUR_MIN = 6;
|
||||||
const HOUR_MAX = 20;
|
const HOUR_MAX = 20;
|
||||||
const FALLBACK_FLOORS = 12;
|
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.
|
// Colours — Three.js wants numeric hex; these mirror the UI tokens by value.
|
||||||
const COLOR_BG = 0xf6f7f9; // --bg-app
|
const COLOR_BG = 0xf6f7f9; // --bg-app
|
||||||
const COLOR_SLATE = 0x33424f; // building body
|
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_PARCEL_FILL = 0xdbeafe; // --accent-soft (parcel fill — #2179)
|
||||||
const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt
|
const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt
|
||||||
const COLOR_GRID = 0xe6e8ec; // --border-card
|
const COLOR_GRID = 0xe6e8ec; // --border-card
|
||||||
|
const COLOR_NEIGHBOR = 0x9ca3af; // близко к --fg-tertiary — нейтральный серый соседей (#2180)
|
||||||
|
|
||||||
const TILE_TIMEOUT_MS = 6000;
|
const TILE_TIMEOUT_MS = 6000;
|
||||||
|
|
||||||
|
|
@ -68,6 +81,11 @@ export interface Massing3DSceneProps {
|
||||||
floorHeightM?: number;
|
floorHeightM?: number;
|
||||||
/** Optional initial sun hour; default 13. */
|
/** Optional initial sun hour; default 13. */
|
||||||
hour?: number;
|
hour?: number;
|
||||||
|
/**
|
||||||
|
* Кадастровый номер участка — для подгрузки соседних зданий (макет квартала,
|
||||||
|
* #2180). Отсутствует на standalone /concept без кадастра → соседи не грузятся.
|
||||||
|
*/
|
||||||
|
cadNum?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── derived model (pure, memoized) ────────────────────────────────────────────
|
// ── derived model (pure, memoized) ────────────────────────────────────────────
|
||||||
|
|
@ -332,6 +350,88 @@ function buildParcelHighlight(model: MassingModel): THREE.Group {
|
||||||
return 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) ──────────────────────────
|
// ── OSM ground tile (fetch + stitch + CanvasTexture) ──────────────────────────
|
||||||
|
|
||||||
interface GroundTexResult {
|
interface GroundTexResult {
|
||||||
|
|
@ -430,6 +530,7 @@ export default function Massing3DScene({
|
||||||
variant,
|
variant,
|
||||||
floorHeightM = DEFAULT_FLOOR_HEIGHT,
|
floorHeightM = DEFAULT_FLOOR_HEIGHT,
|
||||||
hour: initialHour = DEFAULT_HOUR,
|
hour: initialHour = DEFAULT_HOUR,
|
||||||
|
cadNum,
|
||||||
}: Massing3DSceneProps): React.JSX.Element {
|
}: Massing3DSceneProps): React.JSX.Element {
|
||||||
const [hour, setHour] = useState(initialHour);
|
const [hour, setHour] = useState(initialHour);
|
||||||
const [autoRotate, setAutoRotate] = useState(true);
|
const [autoRotate, setAutoRotate] = useState(true);
|
||||||
|
|
@ -446,6 +547,14 @@ export default function Massing3DScene({
|
||||||
[parcel, variant, floorHeightM],
|
[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).
|
// place the sun from the current hour (light-only; no rebuild).
|
||||||
const placeSun = useCallback(
|
const placeSun = useCallback(
|
||||||
(h: number) => {
|
(h: number) => {
|
||||||
|
|
@ -662,6 +771,24 @@ export default function Massing3DScene({
|
||||||
if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
|
if (controlsRef.current) controlsRef.current.autoRotate = 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) {
|
if (webglFailed) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -792,6 +919,9 @@ export default function Massing3DScene({
|
||||||
? " (норматив движка)"
|
? " (норматив движка)"
|
||||||
: " — визуально; ТЭП и финмодель считались при 3,0 м/этаж"}
|
: " — визуально; ТЭП и финмодель считались при 3,0 м/этаж"}
|
||||||
. Подложка — карта © OpenStreetMap.
|
. Подложка — карта © OpenStreetMap.
|
||||||
|
{neighborsCount > 0
|
||||||
|
? " Соседние здания — контуры и этажность НСПД (кадастр), высота этажа 3 м."
|
||||||
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ export function Section7Concept({ analysis }: Props) {
|
||||||
<ConceptVariantsResult
|
<ConceptVariantsResult
|
||||||
parcel={polygon}
|
parcel={polygon}
|
||||||
variants={concept.data.variants}
|
variants={concept.data.variants}
|
||||||
|
cadNum={analysis.cad_num}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
70
frontend/src/hooks/useNeighborBuildings.ts
Normal file
70
frontend/src/hooks/useNeighborBuildings.ts
Normal file
|
|
@ -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<NeighborBuildingsResponse>({
|
||||||
|
queryKey: ["neighbor-buildings", cadNum, radiusM],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<NeighborBuildingsResponse>(
|
||||||
|
`/api/v1/parcels/${encodeURIComponent(cadNum!)}/neighbor-buildings?radius_m=${radiusM}`,
|
||||||
|
),
|
||||||
|
enabled: !!cadNum && enabled,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -469,6 +469,43 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/parcels/{cad_num}/isochrones": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
get_isochrones_api_v1_parcels__cad_num__isochrones_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue