"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 & Partial; 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(null); const rendererRef = useRef(null); const sceneRef = useRef(null); const cameraRef = useRef(null); const controlsRef = useRef(null); const sunRef = useRef(null); const massGroupRef = useRef(null); const rafRef = useRef(0); const resizeObserverRef = useRef(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 & Partial; 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 (
3D-просмотр недоступен

Браузер не поддерживает WebGL — объёмная масса не может быть отрисована.

); } return (
Этажей {metrics.floors}
Высота {fmtRu(metrics.height)} м
Пятно {fmtRu(metrics.footprint)} м²
GFA {fmtRu(metrics.gfa)} м²
КСИТ-факт {metrics.farActual.toFixed(2)}
КСИТ-цель {farTarget.toFixed(1)} ·{" "} {farIsReal ? "регламент НСПД" : "регламент-дефолт, источник НСПД"} · масса генеративная (этажи/секции/время — исследовательские вводные)
); }