gendesign/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx
Light1YT 167fcbace2
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 51s
CI / openapi-codegen-check (pull_request) Successful in 1m46s
feat(ptica): wire real ПЗЗ regulation (КСИТ/height/zone/density) into cockpit
Read nspd_zoning.{max_far,max_height_m,max_floors,max_building_pct,
regulation_zone_index,regulation_source} (backend PR #1847) into the urban
scan-card, urban/buildability/potential drawers and the 3D massing КСИТ-target.
Defensive: every read is optional-chained + != null guarded → identical "—"
placeholders when fields absent, so the cockpit is unchanged until #1847 deploys,
then auto-lights-up. КСИТ density = area × max_far; пятно = area × max_building_pct
(percent 0..100, verified vs backend parser); sellable labeled estimate. 3D maxFar
= nspd_zoning.max_far ?? 3.5 (finite/>0 guarded). NspdZoning type +7 optional fields.
TS strict, no any.
2026-06-20 23:33:41 +05:00

716 lines
24 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";
/**
* MassingScene — client-only Three.js building-massing sandbox for the ПТИЦА
* cockpit. React port of the prototype `ptica-redesign/massing.js`.
*
* Builds a generative building mass from the parcel area + a regulation КСИТ
* (FAR) target and user-controlled study inputs (этажность / секций / время
* суток). A shadow-casting sun driven by the time-of-day slider doubles as a
* basic insolation preview. All metrics (GFA, КСИТ-факт) are computed from the
* inputs — это интерактивная песочница, not parcel-specific regulation data.
*
* Loaded exclusively through MassingViewer via `dynamic(ssr:false)` so Three.js
* never touches the server. The renderer/scene/RAF live in a single effect with
* full teardown (cancel RAF, dispose geometries/materials/renderer, remove the
* canvas, disconnect ResizeObserver) on unmount, so closing the drawer frees GPU
* resources.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
export interface MassingSceneProps {
/** Real parcel area in m² when available (fallback ≈ 6800). */
areaM2?: number;
/** КСИТ-цель (FAR target) — real НСПД-регламент when present, else default. */
maxFar?: number;
/** True when `maxFar` is the real НСПД ПЗЗ value (drives the caption). */
farIsReal?: boolean;
}
// ── constants (scene units = metres) ──────────────────────────────────────────
const FLOOR_H = 3; // metres per floor
const SETBACK = 7; // metres kept clear from parcel edges
const PODIUM_H = 4; // stylobate height (metres)
const DEFAULT_AREA = 6800; // fallback parcel area (m²)
const FALLBACK_FAR = 3.5;
const FLOORS_MIN = 3;
const FLOORS_MAX = 40;
const SECTIONS_MIN = 1;
const SECTIONS_MAX = 6;
const HOUR_MIN = 6;
const HOUR_MAX = 20;
// ── derived massing model ─────────────────────────────────────────────────────
interface MassingModel {
floors: number;
sections: number;
height: number;
totalFootprint: number;
towerFootprints: number[];
gfa: number;
farActual: number;
maxFar: number;
parcelArea: number;
parcelW: number;
parcelD: number;
}
/**
* Pure model: parcel area + FAR target + slider inputs → geometry params + metrics.
* Footprint per floor is sized to reach the КСИТ target GFA, capped by the
* buildable footprint (usable area inside the setbacks).
*/
function computeModel(
parcelArea: number,
maxFar: number,
floors: number,
sections: number,
): MassingModel {
const f = Math.max(1, Math.round(floors));
const n = Math.max(1, Math.round(sections));
// approximate a square parcel from its area (scene is a study model, not a
// surveyed polygon) and derive the usable buildable area inside the setbacks.
const side = Math.sqrt(parcelArea);
const parcelW = side;
const parcelD = side;
const usableW = Math.max(8, parcelW - 2 * SETBACK);
const usableD = Math.max(8, parcelD - 2 * SETBACK);
const maxFootprint = usableW * usableD;
const targetGFA = parcelArea * maxFar; // КСИТ ёмкость
const neededPerFloor = targetGFA / f;
const totalFootprint = Math.min(neededPerFloor, maxFootprint);
// split footprint across N towers with gentle deterministic variation
// (weights sum to 1 → Σ footprints == totalFootprint).
const weights: number[] = [];
let wsum = 0;
for (let i = 0; i < n; i++) {
const w = 1 + 0.18 * Math.sin(i * 2.4 + 0.7);
weights.push(w);
wsum += w;
}
const towerFootprints = weights.map((w) => (totalFootprint * w) / wsum);
const height = f * FLOOR_H;
const gfa = totalFootprint * f;
const farActual = gfa / parcelArea;
return {
floors: f,
sections: n,
height,
totalFootprint,
towerFootprints,
gfa,
farActual,
maxFar,
parcelArea,
parcelW,
parcelD,
};
}
// ── sun slider → direction + colour ───────────────────────────────────────────
interface SunState {
x: number;
y: number;
z: number;
elevation: number;
warmth: number;
}
/** hour 6..20 → an east→south→west azimuth sweep plus an elevation arc. */
function sunFromHour(hour: number): SunState {
const h = Math.max(HOUR_MIN, Math.min(HOUR_MAX, hour));
const t = (h - HOUR_MIN) / (HOUR_MAX - HOUR_MIN); // 0 at 06:00 → 1 at 20:00
const elevation = Math.sin(t * Math.PI) * (Math.PI / 2) * 0.92 + 0.06;
const azimuth = (t - 0.5) * Math.PI * 1.15;
const R = 160;
const cosEl = Math.cos(elevation);
const x = Math.sin(azimuth) * cosEl * R;
const z = Math.cos(azimuth) * cosEl * R * -1;
const y = Math.sin(elevation) * R + 6;
const warmth = 1 - Math.sin(t * Math.PI);
return { x, y, z, elevation, warmth };
}
// ── formatting ────────────────────────────────────────────────────────────────
function fmtRu(n: number): string {
return Math.round(n).toLocaleString("ru-RU");
}
function fmtHour(hour: number): string {
const h = Math.floor(hour);
const m = Math.round((hour - h) * 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
// ── building meshes ───────────────────────────────────────────────────────────
const COLOR_ACCENT = 0x7fd0ee; // --accent-cyan
const COLOR_SLATE = 0x33424f;
const BG_COLOR = 0x060f16; // --bg
/** Returns a THREE.Group with podium + towers + edge accents for the model. */
function buildMass(model: MassingModel): THREE.Group {
const group = new THREE.Group();
group.name = "mass";
const accent = new THREE.Color(COLOR_ACCENT);
const slate = new THREE.Color(COLOR_SLATE);
const usableW = Math.max(8, model.parcelW - 2 * SETBACK);
const usableD = Math.max(8, model.parcelD - 2 * SETBACK);
// thin podium / stylobate covering the usable area
const podiumGeo = new THREE.BoxGeometry(usableW, PODIUM_H, usableD);
const podiumMat = new THREE.MeshStandardMaterial({
color: slate.clone().multiplyScalar(0.75),
roughness: 0.95,
metalness: 0,
});
const podium = new THREE.Mesh(podiumGeo, podiumMat);
podium.position.y = PODIUM_H / 2;
podium.castShadow = true;
podium.receiveShadow = true;
group.add(podium);
const n = model.sections;
for (let i = 0; i < n; i++) {
const fp = model.towerFootprints[i] ?? model.totalFootprint / n;
const side = Math.sqrt(fp);
const towerW = Math.min(side * 1.05, usableW * 0.78);
const towerD = Math.min(fp / towerW, (usableD / n) * 0.82);
const slot = n === 1 ? 0 : i / (n - 1) - 0.5; // -0.5 .. 0.5
const zPos = slot * (usableD - towerD) * 0.92;
const xJitter = Math.sin(i * 1.7 + 0.3) * (usableW * 0.12);
const geo = new THREE.BoxGeometry(towerW, model.height, towerD);
const mat = new THREE.MeshStandardMaterial({
color: slate,
roughness: 0.82,
metalness: 0.05,
emissive: accent,
emissiveIntensity: 0.045,
});
const tower = new THREE.Mesh(geo, mat);
tower.position.set(xJitter, PODIUM_H + model.height / 2, zPos);
tower.castShadow = true;
tower.receiveShadow = true;
group.add(tower);
// cyan accent outline of the tower volume
const edges = new THREE.EdgesGeometry(geo);
const line = new THREE.LineSegments(
edges,
new THREE.LineBasicMaterial({
color: accent,
transparent: true,
opacity: 0.55,
}),
);
line.position.copy(tower.position);
group.add(line);
// floor banding (faint horizontal lines) — cheap visual storeys
const bandCount = Math.min(model.floors, 30);
if (bandCount > 1) {
const bandPts: number[] = [];
const hw = towerW / 2 + 0.05;
const hd = towerD / 2 + 0.05;
for (let b = 1; b < bandCount; b++) {
const y = PODIUM_H + (model.height * b) / bandCount;
bandPts.push(-hw, y, -hd, hw, y, -hd);
bandPts.push(hw, y, -hd, hw, y, hd);
bandPts.push(hw, y, hd, -hw, y, hd);
bandPts.push(-hw, y, hd, -hw, y, -hd);
}
const bandGeo = new THREE.BufferGeometry();
bandGeo.setAttribute(
"position",
new THREE.Float32BufferAttribute(bandPts, 3),
);
const bands = new THREE.LineSegments(
bandGeo,
new THREE.LineBasicMaterial({
color: accent,
transparent: true,
opacity: 0.12,
}),
);
bands.position.x = xJitter;
bands.position.z = zPos;
group.add(bands);
}
}
return group;
}
/** Dispose every geometry/material under an Object3D. */
function disposeObject(obj: THREE.Object3D): void {
obj.traverse((o) => {
const mesh = o as Partial<THREE.Mesh> & Partial<THREE.Line>;
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
const mats = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
mats.forEach((m) => m.dispose());
}
});
}
// ── live metrics (React-rendered, derived from the same model) ─────────────────
interface Metrics {
floors: number;
height: number;
footprint: number;
gfa: number;
farActual: number;
over: boolean;
}
function modelMetrics(model: MassingModel): Metrics {
return {
floors: model.floors,
height: model.height,
footprint: model.totalFootprint,
gfa: model.gfa,
farActual: model.farActual,
over: model.farActual > model.maxFar + 0.001,
};
}
// ── component ─────────────────────────────────────────────────────────────────
export default function MassingScene({
areaM2,
maxFar,
farIsReal = false,
}: MassingSceneProps): React.JSX.Element {
const parcelArea =
areaM2 && Number.isFinite(areaM2) && areaM2 > 0 ? areaM2 : DEFAULT_AREA;
const farTarget =
maxFar && Number.isFinite(maxFar) && maxFar > 0 ? maxFar : FALLBACK_FAR;
const [floors, setFloors] = useState(25);
const [sections, setSections] = useState(2);
const [hour, setHour] = useState(13);
const [autoRotate, setAutoRotate] = useState(true);
const [webglFailed, setWebglFailed] = useState(false);
const mountRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const sunRef = useRef<THREE.DirectionalLight | null>(null);
const massGroupRef = useRef<THREE.Group | null>(null);
const rafRef = useRef<number>(0);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const model = computeModel(parcelArea, farTarget, floors, sections);
const metrics = modelMetrics(model);
// rebuild + reposition the mass whenever floors/sections change.
const rebuildMass = useCallback((next: MassingModel) => {
const scene = sceneRef.current;
const controls = controlsRef.current;
if (!scene) return;
if (massGroupRef.current) {
scene.remove(massGroupRef.current);
disposeObject(massGroupRef.current);
massGroupRef.current = null;
}
const group = buildMass(next);
massGroupRef.current = group;
scene.add(group);
if (controls) {
controls.target.set(0, Math.min(next.height * 0.45 + PODIUM_H, 120), 0);
}
}, []);
// place the sun from the current hour.
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(0, 10, 0);
const intensity = 0.45 + 0.85 * Math.max(0.05, Math.sin(p.elevation));
sun.intensity = intensity;
sun.color.setHSL(
0.11 - 0.04 * (1 - p.warmth),
0.55 * p.warmth + 0.12,
0.62,
);
}, []);
// ── scene lifecycle (mount once) ────────────────────────────────────────────
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 || 380;
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);
rendererRef.current = renderer;
const scene = new THREE.Scene();
scene.background = new THREE.Color(BG_COLOR);
scene.fog = new THREE.Fog(BG_COLOR, 220, 520);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(42, width / height, 0.5, 2000);
camera.position.set(120, 110, 140);
cameraRef.current = camera;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 60;
controls.maxDistance = 420;
controls.maxPolarAngle = Math.PI / 2 - 0.04;
controls.target.set(0, 24, 0);
controls.autoRotate = autoRotate;
controls.autoRotateSpeed = 0.6;
controls.update();
controlsRef.current = controls;
// ground / context plane
const ctxGeo = new THREE.PlaneGeometry(600, 600);
const ctxMat = new THREE.MeshStandardMaterial({
color: 0x0a1820,
roughness: 1,
metalness: 0,
});
const ctxPlane = new THREE.Mesh(ctxGeo, ctxMat);
ctxPlane.rotation.x = -Math.PI / 2;
ctxPlane.position.y = -0.04;
ctxPlane.receiveShadow = true;
scene.add(ctxPlane);
// parcel plane (slightly lighter than the context)
const side = Math.sqrt(parcelArea);
const parcelGeo = new THREE.PlaneGeometry(side, side);
const parcelMat = new THREE.MeshStandardMaterial({
color: 0x12222e,
roughness: 1,
metalness: 0,
});
const parcelPlane = new THREE.Mesh(parcelGeo, parcelMat);
parcelPlane.rotation.x = -Math.PI / 2;
parcelPlane.position.y = 0;
parcelPlane.receiveShadow = true;
scene.add(parcelPlane);
// cyan parcel outline
const hw = side / 2;
const outlinePts = [
-hw,
0.05,
-hw,
hw,
0.05,
-hw,
hw,
0.05,
hw,
-hw,
0.05,
hw,
-hw,
0.05,
-hw,
];
const outlineGeo = new THREE.BufferGeometry();
outlineGeo.setAttribute(
"position",
new THREE.Float32BufferAttribute(outlinePts, 3),
);
const outline = new THREE.Line(
outlineGeo,
new THREE.LineBasicMaterial({ color: 0x8ed3ff }),
);
scene.add(outline);
// subtle grid helper
const grid = new THREE.GridHelper(side, 16, 0x2f6f9f, 0x1c3340);
grid.position.y = 0.02;
const gridMat = grid.material as THREE.Material;
gridMat.opacity = 0.32;
gridMat.transparent = true;
scene.add(grid);
// lighting
const hemi = new THREE.HemisphereLight(0x9fc4dd, 0x0a141c, 0.55);
scene.add(hemi);
const ambient = new THREE.AmbientLight(0xffffff, 0.18);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xfff2d8, 1.15);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 1;
sun.shadow.camera.far = 500;
const sExtent = 110;
sun.shadow.camera.left = -sExtent;
sun.shadow.camera.right = sExtent;
sun.shadow.camera.top = sExtent;
sun.shadow.camera.bottom = -sExtent;
sun.shadow.bias = -0.0005;
scene.add(sun);
scene.add(sun.target);
sunRef.current = sun;
// initial mass + sun
rebuildMass(computeModel(parcelArea, farTarget, floors, sections));
placeSun(hour);
// 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);
resizeObserverRef.current = ro;
// render loop
let alive = true;
const tick = () => {
if (!alive) return;
rafRef.current = requestAnimationFrame(tick);
controls.update();
renderer.render(scene, camera);
};
rafRef.current = requestAnimationFrame(tick);
// full teardown on unmount
return () => {
alive = false;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
controls.dispose();
controlsRef.current = null;
scene.traverse((o) => {
const mesh = o as Partial<THREE.Mesh> & Partial<THREE.Line>;
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
const mats = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
mats.forEach((m) => m.dispose());
}
});
massGroupRef.current = null;
sceneRef.current = null;
cameraRef.current = null;
sunRef.current = null;
renderer.dispose();
const canvas = renderer.domElement;
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
rendererRef.current = null;
};
// mount-once effect: parcel area / FAR are stable per-render; slider state is
// applied through the dedicated effects below so the scene is not rebuilt.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// apply floors / sections changes by rebuilding the mass (no full re-init).
useEffect(() => {
if (!sceneRef.current) return;
rebuildMass(computeModel(parcelArea, farTarget, floors, sections));
}, [floors, sections, parcelArea, farTarget, rebuildMass]);
// apply time-of-day changes by repositioning the sun.
useEffect(() => {
placeSun(hour);
}, [hour, placeSun]);
// apply auto-rotate toggle.
useEffect(() => {
if (controlsRef.current) controlsRef.current.autoRotate = autoRotate;
}, [autoRotate]);
if (webglFailed) {
return (
<div className={styles.massingViewport}>
<div className={styles.massingFallback}>
<div className={styles.massingFallbackTitle}>
3D-просмотр недоступен
</div>
<p>
Браузер не поддерживает WebGL объёмная масса не может быть
отрисована.
</p>
</div>
</div>
);
}
return (
<div className={styles.massingWrap}>
<div ref={mountRef} className={styles.massingViewport} />
<div className={styles.massingControls}>
<div className={styles.massingSliders}>
<label className={styles.massingSlider}>
<span className={styles.massingSliderLabel}>
Этажность
<span className={styles.massingSliderValue}>{floors}</span>
</span>
<input
type="range"
min={FLOORS_MIN}
max={FLOORS_MAX}
step={1}
value={floors}
onChange={(e) => setFloors(Number(e.target.value))}
aria-label="Этажность"
/>
</label>
<label className={styles.massingSlider}>
<span className={styles.massingSliderLabel}>
Секций
<span className={styles.massingSliderValue}>{sections}</span>
</span>
<input
type="range"
min={SECTIONS_MIN}
max={SECTIONS_MAX}
step={1}
value={sections}
onChange={(e) => setSections(Number(e.target.value))}
aria-label="Секций"
/>
</label>
<label className={styles.massingSlider}>
<span className={styles.massingSliderLabel}>
Время суток
<span className={styles.massingSliderValue}>{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="Время суток (инсоляция)"
/>
</label>
<label className={styles.massingToggle}>
<input
type="checkbox"
checked={autoRotate}
onChange={(e) => setAutoRotate(e.target.checked)}
/>
<span>Автоповорот</span>
</label>
</div>
<div className={styles.massingMetrics}>
<div className={styles.massingMetric}>
<span className={styles.massingMetricLabel}>Этажей</span>
<span className={styles.massingMetricValue}>{metrics.floors}</span>
</div>
<div className={styles.massingMetric}>
<span className={styles.massingMetricLabel}>Высота</span>
<span className={styles.massingMetricValue}>
{fmtRu(metrics.height)} м
</span>
</div>
<div className={styles.massingMetric}>
<span className={styles.massingMetricLabel}>Пятно</span>
<span className={styles.massingMetricValue}>
{fmtRu(metrics.footprint)} м²
</span>
</div>
<div className={styles.massingMetric}>
<span className={styles.massingMetricLabel}>GFA</span>
<span className={styles.massingMetricValue}>
{fmtRu(metrics.gfa)} м²
</span>
</div>
<div className={styles.massingMetric}>
<span className={styles.massingMetricLabel}>КСИТ-факт</span>
<span
className={`${styles.massingMetricValue} ${
metrics.over ? styles.massingMetricOver : styles.massingMetricOk
}`}
>
{metrics.farActual.toFixed(2)}
</span>
</div>
</div>
<div className={styles.massingCaption}>
КСИТ-цель {farTarget.toFixed(1)} ·{" "}
{farIsReal ? "регламент НСПД" : "регламент-дефолт, источник НСПД"} ·
масса генеративная (этажи/секции/время исследовательские вводные)
</div>
</div>
</div>
);
}