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