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_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 { 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 (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -97,6 +111,24 @@ export function ConceptResultMap({
|
|||
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
|
||||
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. */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
<Massing3DViewer
|
||||
parcel={parcel}
|
||||
variant={variant}
|
||||
floorHeightM={floorHeightM}
|
||||
cadNum={cadNum}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -792,6 +919,9 @@ export default function Massing3DScene({
|
|||
? " (норматив движка)"
|
||||
: " — визуально; ТЭП и финмодель считались при 3,0 м/этаж"}
|
||||
. Подложка — карта © OpenStreetMap.
|
||||
{neighborsCount > 0
|
||||
? " Соседние здания — контуры и этажность НСПД (кадастр), высота этажа 3 м."
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ export function Section7Concept({ analysis }: Props) {
|
|||
<ConceptVariantsResult
|
||||
parcel={polygon}
|
||||
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;
|
||||
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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue