diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d4fb1ef..82b9b63f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@sentry/nextjs": "^10.53.1", "@tanstack/react-query": "^5.50.0", + "@types/three": "^0.184.1", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "leaflet": "^1.9.4", @@ -19,6 +20,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", + "three": "^0.184.0", "tslib": "^2.8.1" }, "devDependencies": { @@ -475,6 +477,12 @@ "node": ">=18" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "dev": true, "optional": true @@ -3876,6 +3884,12 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "dev": true, "optional": true @@ -4039,6 +4053,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -4048,6 +4068,26 @@ "@types/node": "*" } }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", @@ -12695,6 +12735,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -13412,6 +13458,12 @@ "node": ">= 0.4" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", @@ -14871,6 +14923,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 82ac2dec..e6ef4dc6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "dependencies": { "@sentry/nextjs": "^10.53.1", "@tanstack/react-query": "^5.50.0", + "@types/three": "^0.184.1", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "leaflet": "^1.9.4", @@ -24,6 +25,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", + "three": "^0.184.0", "tslib": "^2.8.1" }, "devDependencies": { diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index 30ba3dab..3c1c16f0 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -1598,6 +1598,172 @@ color: var(--text-soft); } +/* ===================== 3D MASSING SANDBOX (Three.js) ===================== */ +.massingWrap { + display: flex; + flex-direction: column; + gap: 12px; +} +.massingViewport { + position: relative; + width: 100%; + height: 380px; + border: var(--line) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-inset); + overflow: hidden; +} +.massingViewport canvas { + display: block; +} +.massingSkeleton, +.massingFallback { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + text-align: center; + color: var(--text-soft); + font-size: 11px; +} +.massingFallbackTitle { + font-size: 12px; + font-weight: 600; + color: var(--accent-yellow); +} +.massingFallback p { + margin: 0; + max-width: 280px; + line-height: 1.5; +} + +.massingControls { + display: flex; + flex-direction: column; + gap: 12px; +} +.massingSliders { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + align-items: end; +} +.massingSlider { + display: flex; + flex-direction: column; + gap: 6px; +} +.massingSliderLabel { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-soft); +} +.massingSliderValue { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0; + text-transform: none; + color: var(--accent-cyan); +} + +/* dark range inputs */ +.massingControls input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + border-radius: 2px; + background: var(--border); + outline: none; + cursor: pointer; +} +.massingControls input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--accent-cyan); + border: var(--line) solid var(--bg); + cursor: pointer; +} +.massingControls input[type="range"]::-moz-range-thumb { + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--accent-cyan); + border: var(--line) solid var(--bg); + cursor: pointer; +} +.massingControls input[type="range"]:focus-visible { + box-shadow: 0 0 0 2px var(--glow-strong); +} + +.massingToggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + user-select: none; +} +.massingToggle input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent-cyan); + cursor: pointer; +} + +.massingMetrics { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; + padding: 10px 12px; + background: var(--surface-2); + border: var(--line) solid var(--border-faint); + border-radius: var(--radius-sm); +} +.massingMetric { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.massingMetricLabel { + font-size: 8.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-soft); +} +.massingMetricValue { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-strong); + white-space: nowrap; +} +.massingMetricOk { + color: var(--accent-green); +} +.massingMetricOver { + color: var(--accent-yellow); +} +.massingCaption { + font-size: 9px; + line-height: 1.5; + color: var(--text-soft); + font-family: var(--font-mono); +} + /* drawer sub-tabs (.dtabs / .dtab-panel) */ .dtabs { display: flex; @@ -1690,4 +1856,13 @@ .drawer { width: 100vw; } + .massingSliders { + grid-template-columns: 1fr; + } + .massingMetrics { + grid-template-columns: repeat(2, 1fr); + } + .massingViewport { + height: 320px; + } } diff --git a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx index ab3fb60d..a630d064 100644 --- a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx +++ b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx @@ -37,6 +37,7 @@ import { adaptOksDrawer, } from "@/components/site-finder/ptica/ptica-adapt"; import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; +import { MassingViewer } from "@/components/site-finder/ptica/massing/MassingViewer"; import { DrawerSection, SummaryBox, @@ -48,9 +49,22 @@ import { ConfPill, SourceLine, DrawerActions, - SoonCard, } from "@/components/site-finder/ptica/drawers/DrawerPrimitives"; +/** КСИТ-цель (FAR) — regulation default, see caption in the 3D sandbox. */ +const MASSING_MAX_FAR = 3.5; +/** Fallback parcel area (m²) when geometry_suitability.area_ha is absent. */ +const MASSING_FALLBACK_AREA = 6800; + +/** Real parcel area in m² from analysis (area_ha × 10000), with fallback. */ +function massingAreaM2(analysis: ParcelAnalysis): number { + const areaHa = analysis.geometry_suitability?.area_ha; + if (typeof areaHa === "number" && Number.isFinite(areaHa) && areaHa > 0) { + return areaHa * 10000; + } + return MASSING_FALLBACK_AREA; +} + const SRC_UPDATED = "источник: /analyze"; // ── shared helpers ──────────────────────────────────────────────────────────── @@ -789,19 +803,47 @@ export function OksContent() { } // ══════════════════════════════════════════════════════════════════════════════ -// insolation / future3d — СКОРО (3D module lands in PR#5) +// insolation / future3d — interactive 3D massing sandbox (Three.js) // ══════════════════════════════════════════════════════════════════════════════ -export function InsolationContent() { +export function Future3dContent({ analysis }: { analysis: ParcelAnalysis }) { return ( <> - + Генеративная масса по регламенту: КСИТ-цель {MASSING_MAX_FAR} · пятно + и высота подбираются под целевую ёмкость, GFA и КСИТ-факт считаются от + вводных. Орбита, зум и солнце — интерактивные. + + + + - + + + ); +} + +export function InsolationContent({ analysis }: { analysis: ParcelAnalysis }) { + return ( + <> + + + Предпросмотр инсоляции на 3D-массе: солнце-источник теней управляется + ползунком «Время суток» (06:00–20:00). Полный расчёт КЕО и + продолжительности инсоляции по СанПиН — в следующей версии. + + + + + + - - ); -} - -export function Future3dContent() { - return ( - <> - - - - - - - - + ); } diff --git a/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx b/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx index 35d568ac..ea509cc3 100644 --- a/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx +++ b/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx @@ -138,14 +138,14 @@ export function getDrawerEntry( case "insolation": return { title: "Инсоляционная матрица", - sub: "Планируется в следующей версии", - content: , + sub: "Предпросмотр теней на 3D-массе · солнце по времени суток", + content: , }; case "future3d": return { title: "3D-масса застройки", - sub: "Генеративная масса по регламенту", - content: , + sub: "Генеративная масса по регламенту · интерактивно", + content: , }; } } diff --git a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx new file mode 100644 index 00000000..015a9024 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx @@ -0,0 +1,712 @@ +"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) — regulation default, not parcel-specific. */ + maxFar?: number; +} + +// ── 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, +}: 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)} · регламент-дефолт, источник НСПД · + масса генеративная (этажи/секции/время — исследовательские вводные) +
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx b/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx new file mode 100644 index 00000000..84c4333c --- /dev/null +++ b/frontend/src/components/site-finder/ptica/massing/MassingViewer.tsx @@ -0,0 +1,35 @@ +"use client"; + +/** + * MassingViewer — the only entry point the drawer imports for the 3D massing + * sandbox. Loads MassingScene (Three.js) client-side only via + * `dynamic(ssr:false)` so WebGL/Three.js never run on the server, with a dark + * skeleton while the chunk loads. The drawer gates its children behind + * `{open && ...}`, so the scene mounts on open and the MassingScene effect + * cleanup disposes the renderer/scene on close. + */ + +import dynamic from "next/dynamic"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { MassingSceneProps } from "@/components/site-finder/ptica/massing/MassingScene"; + +const MassingScene = dynamic( + () => import("@/components/site-finder/ptica/massing/MassingScene"), + { + ssr: false, + loading: () => ( +
+ +
+ ), + }, +); + +export function MassingViewer(props: MassingSceneProps): React.JSX.Element { + return ; +} + +export default MassingViewer;