944 lines
34 KiB
TypeScript
944 lines
34 KiB
TypeScript
"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>
|
||
);
|
||
}
|