feat(concept): 3D-макет окружения — соседние здания из кадастра в §7 (#2180) (#2210)
Some checks failed
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-02 19:33:44 +00:00
parent ca473a0012
commit a7ad1764c1
8 changed files with 721 additions and 2 deletions

View file

@ -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: радиус поиска, 501000 м (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"})

View 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"

View file

@ -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. */}

View file

@ -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>
); );

View file

@ -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>
); );

View file

@ -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}
/> />
)} )}
</> </>

View 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=, диапазон 501000). Держим отдельной
* константой, чтобы подпись и запрос не расходились.
*/
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,
});
}

View file

@ -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: радиус поиска, 501000 м (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?: {