gendesign/frontend/src/components/concept/Massing3DScene.tsx
bot-backend 1fa99812af
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m23s
fix(concept): §7 massing-сцена осознаёт весь MultiPolygon (соседи уезжали до 1.6км) (#2180) (#2463)
2026-07-07 08:10:27 +00:00

944 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* Massing3DScene — client-only Three.js viewer that renders the ACTUAL placed
* corpuses from `variant.buildings_geojson` as extruded masses standing on an
* OSM raster tile of the parcel area («карта как подставка-земля»), lit by a
* time-of-day sun that casts shadows, with an auto-rotating OrbitControls rig.
*
* Read-only (no slider rebuild of the program — the program is whatever the
* backend placed). The ONE live knob is `floorHeightM` (visual extrusion height,
* default 3.0 = the backend's `FLOOR_HEIGHT_M`). Light theme: inline styles + CSS
* var tokens only — this is a report object, NOT the dark cockpit, so it must NOT
* import ptica.module.css.
*
* Loaded exclusively through Massing3DViewer via `dynamic(ssr:false)` so Three.js
* never touches the server. The renderer/scene/RAF live in one effect with full
* teardown (cancel RAF, disconnect RO, dispose controls + geometries/materials +
* the OSM CanvasTexture + renderer, remove the canvas) on unmount.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
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,
padBounds,
polygonOuterRings,
ringsBounds,
ringsCentroid,
type Bounds,
type Projector,
} from "@/lib/geo-local";
import { sunFromHour } from "@/components/site-finder/ptica/massing/sun";
import { disposeObject } from "@/components/site-finder/ptica/massing/three-utils";
// ── constants (scene units = metres) ──────────────────────────────────────────
const DEFAULT_FLOOR_HEIGHT = 3.0; // backend `FLOOR_HEIGHT_M` (placement.py)
const DEFAULT_HOUR = 13; // midday — first paint already shows shadows
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
const COLOR_ACCENT = 0x1d4ed8; // --accent (edge lines)
const COLOR_PARCEL_OUTLINE = 0x1d4ed8; // --accent (parcel outline — accent, #2179)
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;
// ── props ─────────────────────────────────────────────────────────────────────
export interface Massing3DSceneProps {
/**
* WGS84 parcel geometry — Polygon OR MultiPolygon. Every part's outer ring
* drives the projection origin, OSM ground bounds and outline, so a multi-part
* parcel shares the whole-geometry frame the backend anchors neighbours to
* (otherwise context around a distant part lands offset from part 0).
*/
parcel: Polygon | MultiPolygon;
/** The variant — `buildings_geojson` is the single source of corpuses. */
variant: ConceptVariant;
/** Visual floor height in metres; default 3.0 (the backend's constant). */
floorHeightM?: number;
/** Optional initial sun hour; default 13. */
hour?: number;
/**
* Кадастровый номер участка — для подгрузки соседних зданий (макет квартала,
* #2180). Отсутствует на standalone /concept без кадастра → соседи не грузятся.
*/
cadNum?: string | null;
}
// ── derived model (pure, memoized) ────────────────────────────────────────────
interface BuildingLocal {
ring: { x: number; z: number }[];
height: number;
}
interface MassingModel {
buildings: BuildingLocal[];
/** Every parcel part's outer ring, projected to local metres (one per part). */
parcelRings: { x: number; z: number }[][];
/** Local-metre bbox over all parcel parts + building rings. */
bbox: { minX: number; maxX: number; minZ: number; maxZ: number };
centerX: number;
centerZ: number;
diag: number;
maxHeight: number;
/** WGS84 padded bounds for the OSM ground tile. */
groundBounds: Bounds | null;
/** Shared projector — buildings AND ground tile corners use the same one. */
project: Projector;
}
/** Per-feature floors, guarded (`properties` is loosely typed GeoJsonProperties). */
function featureFloors(
props: Record<string, unknown> | null | undefined,
fallback: number,
): number {
const raw = props?.floors;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function buildModel(
parcel: Polygon | MultiPolygon,
variant: ConceptVariant,
floorHeightM: number,
): MassingModel {
// Whole-parcel-aware: outer ring of EVERY part (a bare Polygon → one ring).
const parcelRingsWgs = polygonOuterRings(parcel);
const [lon0, lat0] = ringsCentroid(parcelRingsWgs);
const project = makeProjector(lon0, lat0);
const features = variant.buildings_geojson?.features ?? [];
// Fallback floors = the tallest declared corpus, else a sensible default.
const declaredMax = features.reduce((m, f) => {
const n = Number(f.properties?.floors);
return Number.isFinite(n) && n > m ? n : m;
}, 0);
const fallbackFloors = declaredMax > 0 ? declaredMax : FALLBACK_FLOORS;
const buildings: BuildingLocal[] = [];
for (const f of features) {
const geom = f.geometry;
if (!geom || geom.type !== "Polygon") continue;
const ringWgs = (geom.coordinates?.[0] ?? []) as Position[];
if (ringWgs.length < 4) continue;
const ring = ringWgs.map(project);
const floors = featureFloors(
f.properties as Record<string, unknown> | null,
fallbackFloors,
);
buildings.push({
ring,
height: Math.max(floors * floorHeightM, floorHeightM),
});
}
const parcelRings = parcelRingsWgs.map((ring) => ring.map(project));
// bbox over EVERY parcel part + all building rings, so the camera frames the
// whole lot and its corpuses (a distant part is no longer cropped out).
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
const pts = [...parcelRings.flat(), ...buildings.flatMap((b) => b.ring)];
for (const p of pts) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
}
if (!Number.isFinite(minX)) {
minX = -50;
maxX = 50;
minZ = -50;
maxZ = 50;
}
const centerX = (minX + maxX) / 2;
const centerZ = (minZ + maxZ) / 2;
const diag = Math.max(Math.hypot(maxX - minX, maxZ - minZ), 40);
const maxHeight = buildings.reduce(
(m, b) => Math.max(m, b.height),
floorHeightM,
);
// Pad the ground tile generously (0.7 = +70% context each side, #2179) so the
// OSM substrate extends well beyond the parcel; the 4×4 tile cap below keeps it
// sharp at the same zoom. Union bounds over ALL parts → the tile covers every
// part so neighbours around any of them land on it (a genuinely disjoint
// multi-part parcel yields a large, lower-detail tile — correct, not a bug).
const rawBounds = ringsBounds(parcelRingsWgs);
const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null;
return {
buildings,
parcelRings,
bbox: { minX, maxX, minZ, maxZ },
centerX,
centerZ,
diag,
maxHeight,
groundBounds,
project,
};
}
// ── building meshes ────────────────────────────────────────────────────────────
/**
* Build the buildings Group from the local model. Each corpus → a THREE.Shape in
* the XY plane (x → shape.x, z → shape.y), ExtrudeGeometry(depth = height), with
* the whole Group rotated −π/2 about X so footprints lie on y=0 and height grows
* +y. Degenerate rings (≈0 area) fall back to a BoxGeometry at the ring bbox.
*/
function buildBuildingsGroup(model: MassingModel): THREE.Group {
const group = new THREE.Group();
group.name = "buildings";
group.rotation.x = -Math.PI / 2;
const slate = new THREE.Color(COLOR_SLATE);
const accent = new THREE.Color(COLOR_ACCENT);
for (const b of model.buildings) {
if (b.ring.length < 3) continue;
// Footprint area (shoelace) to detect a degenerate ring.
let area2 = 0;
for (let i = 0; i < b.ring.length; i++) {
const a = b.ring[i];
const c = b.ring[(i + 1) % b.ring.length];
area2 += a.x * c.z - c.x * a.z;
}
const area = Math.abs(area2) / 2;
let geo: THREE.ExtrudeGeometry | THREE.BoxGeometry;
let basePos: { x: number; y: number } | null = null;
if (area < 1) {
// Degenerate → block sized to the ring bbox at its centre.
let bx0 = Infinity;
let bx1 = -Infinity;
let bz0 = Infinity;
let bz1 = -Infinity;
for (const p of b.ring) {
if (p.x < bx0) bx0 = p.x;
if (p.x > bx1) bx1 = p.x;
if (p.z < bz0) bz0 = p.z;
if (p.z > bz1) bz1 = p.z;
}
const w = Math.max(bx1 - bx0, 4);
const d = Math.max(bz1 - bz0, 4);
geo = new THREE.BoxGeometry(w, d, b.height);
basePos = { x: (bx0 + bx1) / 2, y: (bz0 + bz1) / 2 };
} else {
const shape = new THREE.Shape();
shape.moveTo(b.ring[0].x, b.ring[0].z);
for (let i = 1; i < b.ring.length; i++) {
shape.lineTo(b.ring[i].x, b.ring[i].z);
}
shape.closePath();
geo = new THREE.ExtrudeGeometry(shape, {
depth: b.height,
bevelEnabled: false,
});
}
const mat = new THREE.MeshStandardMaterial({
color: slate,
roughness: 0.82,
metalness: 0.05,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
mesh.receiveShadow = true;
if (basePos) {
// BoxGeometry is centred at origin; lift it to sit on y=0 (group-local z).
mesh.position.set(basePos.x, basePos.y, b.height / 2);
}
group.add(mesh);
// Accent edges for building-read.
const edges = new THREE.EdgesGeometry(geo);
const line = new THREE.LineSegments(
edges,
new THREE.LineBasicMaterial({
color: accent,
transparent: true,
opacity: 0.55,
}),
);
if (basePos) line.position.copy(mesh.position);
group.add(line);
}
return group;
}
/**
* Parcel highlight (#2179): a translucent accent-soft FILL just above the ground
* plane plus a prominent accent OUTLINE — drawn for EVERY parcel part (a bare
* Polygon → one ring; a MultiPolygon → one fill + outline per part) so the whole
* lot reads, not just part 0. Returns a Group so the caller adds one object.
* WebGL ignores LineBasicMaterial.linewidth, so the outline is doubled at two
* heights (y=0.05, y=0.10) to read thick from any camera angle without a new
* dependency.
*/
function buildParcelHighlight(model: MassingModel): THREE.Group {
const group = new THREE.Group();
group.name = "parcel";
// One shared outline material across every part (linewidth is a no-op in WebGL,
// so each part's outline is doubled at y=0.05 and y=0.10 to read prominently).
const outlineMat = new THREE.LineBasicMaterial({
color: COLOR_PARCEL_OUTLINE,
});
for (const ring of model.parcelRings) {
if (ring.length < 3) continue;
// Fill: THREE.Shape from the part ring → ShapeGeometry, laid into the ground
// plane (rotate −π/2 about X so the XY shape sits on the XZ world plane) and
// lifted to y≈0.03 (above the ground substrate at y=0.02, under the outline).
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 fillGeo = new THREE.ShapeGeometry(shape);
const fill = new THREE.Mesh(
fillGeo,
new THREE.MeshBasicMaterial({
color: COLOR_PARCEL_FILL, // --accent-soft
transparent: true,
opacity: 0.35,
depthWrite: false,
side: THREE.DoubleSide,
}),
);
// Shape is built in XY; rotate to lie in the ground (XZ) plane. After the
// −π/2 X-rotation shape.y (= parcel z) maps to +z, so no mirroring.
fill.rotation.x = -Math.PI / 2;
fill.position.y = 0.03;
group.add(fill);
// Outline: accent LineLoop, doubled at y=0.05 and y=0.10.
for (const y of [0.05, 0.1]) {
const pts: number[] = [];
for (const p of ring) {
pts.push(p.x, y, p.z);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
group.add(new THREE.LineLoop(geo, outlineMat));
}
}
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 {
texture: THREE.CanvasTexture;
/** World-space placement of the geo-pinned plane. */
planeW: number;
planeH: number;
centerX: number;
centerZ: number;
}
/** Load one OSM tile into an Image; resolves null on error/abort. */
function loadTile(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous"; // OSM tiles are CORS-clean → canvas not tainted
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = url;
});
}
/**
* Fetch + stitch the OSM tile block for the ground bounds into a CanvasTexture,
* geo-pinned via the shared projector. Races a ~6 s timeout; resolves null on any
* failed tile / timeout so the caller can drop to the flat fallback plane.
*/
async function loadGroundTexture(
model: MassingModel,
signal: { cancelled: boolean },
): Promise<GroundTexResult | null> {
if (!model.groundBounds) return null;
// Allow up to a 4×4 (16-tile) block (#2179): a larger extent WITHOUT dropping
// zoom, so the substrate stays sharp while covering more surroundings.
const range = osmTileRangeForBbox(model.groundBounds, { maxTiles: 16 });
const canvas = document.createElement("canvas");
canvas.width = 256 * range.cols;
canvas.height = 256 * range.rows;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const loads: Promise<{
img: HTMLImageElement | null;
col: number;
row: number;
}>[] = [];
for (let x = range.x0; x <= range.x1; x++) {
for (let y = range.y0; y <= range.y1; y++) {
const url = `https://tile.openstreetmap.org/${range.z}/${x}/${y}.png`;
const col = x - range.x0;
const row = y - range.y0;
loads.push(loadTile(url).then((img) => ({ img, col, row })));
}
}
const timeout = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), TILE_TIMEOUT_MS),
);
const all = Promise.all(loads);
const result = await Promise.race([all, timeout]);
if (signal.cancelled || result === null) return null;
// Any tile failed → fall back (geo-pin needs the full block).
if (result.some((r) => r.img === null)) return null;
for (const { img, col, row } of result) {
if (img) ctx.drawImage(img, col * 256, row * 256);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
// Geo-pin: project the stitched block's exact WGS84 corners with the SAME
// projector as the buildings. Texture is drawn north→south; after the plane's
// −π/2 X-rotation, V maps to z (north), so UVs align without flipping.
const nw = model.project([range.westLon, range.northLat]);
const se = model.project([range.eastLon, range.southLat]);
const planeW = se.x - nw.x;
const planeH = se.z - nw.z;
const centerX = (nw.x + se.x) / 2;
const centerZ = (nw.z + se.z) / 2;
return { texture, planeW, planeH, centerX, centerZ };
}
// ── formatting ────────────────────────────────────────────────────────────────
function fmtHour(hour: number): string {
return `${String(Math.floor(hour)).padStart(2, "0")}:00`;
}
// ── component ─────────────────────────────────────────────────────────────────
export default function Massing3DScene({
parcel,
variant,
floorHeightM = DEFAULT_FLOOR_HEIGHT,
hour: initialHour = DEFAULT_HOUR,
cadNum,
}: Massing3DSceneProps): React.JSX.Element {
const [hour, setHour] = useState(initialHour);
const [autoRotate, setAutoRotate] = useState(true);
const [webglFailed, setWebglFailed] = useState(false);
const mountRef = useRef<HTMLDivElement | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const sunRef = useRef<THREE.DirectionalLight | null>(null);
const groundTexRef = useRef<THREE.CanvasTexture | null>(null);
const model = useMemo(
() => buildModel(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).
const placeSun = useCallback(
(h: number) => {
const sun = sunRef.current;
if (!sun) return;
const p = sunFromHour(h);
sun.position.set(p.x, p.y, p.z);
sun.target.position.set(model.centerX, 10, model.centerZ);
sun.intensity = 0.45 + 0.85 * Math.max(0.05, Math.sin(p.elevation));
sun.color.setHSL(
0.11 - 0.04 * (1 - p.warmth),
0.55 * p.warmth + 0.12,
0.62,
);
},
[model.centerX, model.centerZ],
);
// ── scene lifecycle (rebuilds on model identity change) ─────────────────────
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
// WebGL availability guard.
let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
try {
const probe = document.createElement("canvas");
gl =
probe.getContext("webgl2") ??
(probe.getContext("webgl") as WebGLRenderingContext | null);
} catch {
gl = null;
}
if (!gl) {
setWebglFailed(true);
return;
}
let width = mount.clientWidth || 600;
let height = mount.clientHeight || 420;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
} catch {
setWebglFailed(true);
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(width, height, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.domElement.style.display = "block";
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(COLOR_BG);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(42, width / height, 0.5, 4000);
const target = new THREE.Vector3(
model.centerX,
0.4 * model.maxHeight,
model.centerZ,
);
const dir = new THREE.Vector3(1, 0.9, 1.15).normalize();
camera.position.copy(target).addScaledVector(dir, 1.5 * model.diag);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.maxPolarAngle = Math.PI / 2 - 0.04;
controls.minDistance = 0.4 * model.diag;
controls.maxDistance = 4 * model.diag;
controls.autoRotate = autoRotate;
controls.autoRotateSpeed = 0.6;
controls.target.copy(target);
controls.update();
controlsRef.current = controls;
// ── ground: flat fallback now, swap to OSM tile when (if) it loads ────────
const planeW = model.bbox.maxX - model.bbox.minX || 100;
const planeH = model.bbox.maxZ - model.bbox.minZ || 100;
const fallbackSize = Math.max(planeW, planeH) * 2.2 + 60;
const groundGeo = new THREE.PlaneGeometry(fallbackSize, fallbackSize);
const groundMat = new THREE.MeshStandardMaterial({
color: COLOR_GROUND_FALLBACK,
roughness: 1,
metalness: 0,
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.set(model.centerX, -0.02, model.centerZ);
ground.receiveShadow = true;
scene.add(ground);
// faint grid as part of the fallback context (removed when the tile lands).
const grid = new THREE.GridHelper(fallbackSize, 24, COLOR_GRID, COLOR_GRID);
grid.position.set(model.centerX, 0.01, model.centerZ);
const gridMat = grid.material as THREE.Material;
gridMat.opacity = 0.5;
gridMat.transparent = true;
scene.add(grid);
// parcel highlight (accent-soft fill + accent outline, #2179)
const parcel = buildParcelHighlight(model);
scene.add(parcel);
// buildings
const buildings = buildBuildingsGroup(model);
scene.add(buildings);
// ── lighting (sun rig reused via sun.ts) ──────────────────────────────────
const hemi = new THREE.HemisphereLight(0xffffff, 0xb8c2cc, 0.55);
scene.add(hemi);
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.15);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
const extent = 0.6 * model.diag + 20;
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 4 * extent;
sun.shadow.camera.left = -extent;
sun.shadow.camera.right = extent;
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = -0.0005;
scene.add(sun);
scene.add(sun.target);
sunRef.current = sun;
placeSun(hour);
// ── async OSM ground ──────────────────────────────────────────────────────
const tileSignal = { cancelled: false };
loadGroundTexture(model, tileSignal).then((res) => {
if (!res || tileSignal.cancelled || !sceneRef.current) {
if (res) res.texture.dispose();
return;
}
// Swap the fallback colour for the geo-pinned tiled plane.
groundMat.map = res.texture;
groundMat.color.set(0xffffff);
groundMat.needsUpdate = true;
groundTexRef.current = res.texture;
// Resize/reposition the plane to the geo-pinned block.
ground.geometry.dispose();
ground.geometry = new THREE.PlaneGeometry(
Math.abs(res.planeW),
Math.abs(res.planeH),
);
ground.position.set(res.centerX, -0.02, res.centerZ);
grid.visible = false;
});
// resize handling
const ro = new ResizeObserver(() => {
const w = mount.clientWidth;
const h = mount.clientHeight;
if (!w || !h) return;
width = w;
height = h;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
});
ro.observe(mount);
// render loop
let alive = true;
let raf = 0;
const tick = () => {
if (!alive) return;
raf = requestAnimationFrame(tick);
controls.update();
renderer.render(scene, camera);
};
raf = requestAnimationFrame(tick);
// full teardown
return () => {
alive = false;
tileSignal.cancelled = true;
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
controls.dispose();
controlsRef.current = null;
disposeObject(scene);
if (groundTexRef.current) {
groundTexRef.current.dispose();
groundTexRef.current = null;
}
sceneRef.current = null;
sunRef.current = null;
renderer.dispose();
const canvas = renderer.domElement;
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
// model identity (variant / floorHeightM) drives a full rebuild; hour and
// autoRotate are applied by the dedicated effects below without re-init.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model]);
// apply time-of-day changes by repositioning the sun (no rebuild).
useEffect(() => {
placeSun(hour);
}, [hour, placeSun]);
// apply auto-rotate toggle.
useEffect(() => {
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
style={{
height: 420,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
borderRadius: 12,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 24,
textAlign: "center",
}}
>
<div
style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-primary)" }}
>
3D-просмотр недоступен
</div>
<p style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}>
Браузер не поддерживает WebGL объёмную модель отрисовать нельзя.
Откройте план застройки на вкладке «План (2D)».
</p>
</div>
);
}
return (
<div>
{/* Controls bar — light-themed, inline styles (NOT ptica.module.css). */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 24,
flexWrap: "wrap",
marginBottom: 12,
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-secondary)",
}}
>
<span
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
>
Время суток
<span
style={{
fontWeight: 600,
color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums",
minWidth: 44,
}}
>
{fmtHour(hour)}
</span>
</span>
<input
type="range"
min={HOUR_MIN}
max={HOUR_MAX}
step={1}
value={hour}
onChange={(e) => setHour(Number(e.target.value))}
aria-label="Время суток (положение солнца и теней)"
style={{ accentColor: "var(--accent)", width: 180 }}
/>
</label>
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-secondary)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={autoRotate}
onChange={(e) => setAutoRotate(e.target.checked)}
style={{ accentColor: "var(--accent)" }}
/>
<RotateCw size={16} strokeWidth={1.5} aria-hidden="true" />
Автоповорот
</label>
</div>
{/* Viewport */}
<div
ref={mountRef}
style={{
height: 420,
background: "var(--bg-app)",
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
}}
/>
{/* Caption — source + honesty. */}
<p
style={{
margin: "8px 0 0",
fontSize: 12,
lineHeight: "16px",
color: "var(--fg-tertiary)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Box size={16} strokeWidth={1.5} aria-hidden="true" />
Объёмы по фактически размещённым корпусам; высота этажа{" "}
{floorHeightM.toLocaleString("ru")} м
{floorHeightM === DEFAULT_FLOOR_HEIGHT
? " (норматив движка)"
: " — визуально; ТЭП и финмодель считались при 3,0 м/этаж"}
. Подложка карта © OpenStreetMap.
{neighborsCount > 0
? " Соседние здания — контуры и этажность НСПД (кадастр), высота этажа 3 м."
: ""}
</p>
</div>
);
}