feat(ptica): 3D building-massing module (Three.js) [PR#5] #1835
7 changed files with 1036 additions and 41 deletions
58
frontend/package-lock.json
generated
58
frontend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "^10.53.1",
|
"@sentry/nextjs": "^10.53.1",
|
||||||
"@tanstack/react-query": "^5.50.0",
|
"@tanstack/react-query": "^5.50.0",
|
||||||
|
"@types/three": "^0.184.1",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -475,6 +477,12 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
|
|
@ -3876,6 +3884,12 @@
|
||||||
"@testing-library/dom": ">=7.21.4"
|
"@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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
|
|
@ -4039,6 +4053,12 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/tedious": {
|
||||||
"version": "4.0.14",
|
"version": "4.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
|
||||||
|
|
@ -4048,6 +4068,26 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|
@ -13412,6 +13458,12 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
|
@ -14871,6 +14923,12 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "^10.53.1",
|
"@sentry/nextjs": "^10.53.1",
|
||||||
"@tanstack/react-query": "^5.50.0",
|
"@tanstack/react-query": "^5.50.0",
|
||||||
|
"@types/three": "^0.184.1",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1598,6 +1598,172 @@
|
||||||
color: var(--text-soft);
|
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) */
|
/* drawer sub-tabs (.dtabs / .dtab-panel) */
|
||||||
.dtabs {
|
.dtabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1690,4 +1856,13 @@
|
||||||
.drawer {
|
.drawer {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
.massingSliders {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.massingMetrics {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.massingViewport {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
adaptOksDrawer,
|
adaptOksDrawer,
|
||||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||||
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
|
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
|
||||||
|
import { MassingViewer } from "@/components/site-finder/ptica/massing/MassingViewer";
|
||||||
import {
|
import {
|
||||||
DrawerSection,
|
DrawerSection,
|
||||||
SummaryBox,
|
SummaryBox,
|
||||||
|
|
@ -48,9 +49,22 @@ import {
|
||||||
ConfPill,
|
ConfPill,
|
||||||
SourceLine,
|
SourceLine,
|
||||||
DrawerActions,
|
DrawerActions,
|
||||||
SoonCard,
|
|
||||||
} from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
|
} 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";
|
const SRC_UPDATED = "источник: /analyze";
|
||||||
|
|
||||||
// ── shared helpers ────────────────────────────────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DrawerSection>
|
<DrawerSection>
|
||||||
<SoonCard
|
<SummaryBox>
|
||||||
text="Расчёт инсоляции и теней (КЕО, продолжительность инсоляции по СанПиН) по 3D-массе застройки и окружению."
|
Генеративная масса по регламенту: КСИТ-цель {MASSING_MAX_FAR} · пятно
|
||||||
roadmap="Roadmap · после 3D-модуля (PR#5)"
|
и высота подбираются под целевую ёмкость, GFA и КСИТ-факт считаются от
|
||||||
|
вводных. Орбита, зум и солнце — интерактивные.
|
||||||
|
</SummaryBox>
|
||||||
|
</DrawerSection>
|
||||||
|
<DrawerSection>
|
||||||
|
<MassingViewer
|
||||||
|
areaM2={massingAreaM2(analysis)}
|
||||||
|
maxFar={MASSING_MAX_FAR}
|
||||||
/>
|
/>
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
<DrawerSection heading="ЧТО БУДЕТ В МОДУЛЕ">
|
<SourceLine source="Песочница массинга · КСИТ-цель регламент-дефолт (НСПД)" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InsolationContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerSection>
|
||||||
|
<SummaryBox>
|
||||||
|
Предпросмотр инсоляции на 3D-массе: солнце-источник теней управляется
|
||||||
|
ползунком «Время суток» (06:00–20:00). Полный расчёт КЕО и
|
||||||
|
продолжительности инсоляции по СанПиН — в следующей версии.
|
||||||
|
</SummaryBox>
|
||||||
|
</DrawerSection>
|
||||||
|
<DrawerSection>
|
||||||
|
<MassingViewer
|
||||||
|
areaM2={massingAreaM2(analysis)}
|
||||||
|
maxFar={MASSING_MAX_FAR}
|
||||||
|
/>
|
||||||
|
</DrawerSection>
|
||||||
|
<DrawerSection heading="ЧТО ДОБАВИТСЯ В МОДУЛЕ">
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Карта инсоляции фасадов"
|
label="Карта инсоляции фасадов"
|
||||||
state="в разработке"
|
state="в разработке"
|
||||||
|
|
@ -818,36 +860,7 @@ export function InsolationContent() {
|
||||||
tone="warn"
|
tone="warn"
|
||||||
/>
|
/>
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
</>
|
<SourceLine source="Тень от солнца = базовый предпросмотр инсоляции" />
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Future3dContent() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DrawerSection>
|
|
||||||
<SoonCard
|
|
||||||
text="Объёмная модель застройки по КСИТ / пятну / высоте с проверкой норм и предпросмотром продукта."
|
|
||||||
roadmap="Roadmap · PR#5 (Three.js)"
|
|
||||||
/>
|
|
||||||
</DrawerSection>
|
|
||||||
<DrawerSection heading="СОСТАВ МОДУЛЯ">
|
|
||||||
<StatusRow
|
|
||||||
label="Генеративная масса по регламенту"
|
|
||||||
state="в разработке"
|
|
||||||
tone="warn"
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Этажность и плотность 3D"
|
|
||||||
state="в разработке"
|
|
||||||
tone="warn"
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Экспорт в DXF / IFC"
|
|
||||||
state="в разработке"
|
|
||||||
tone="warn"
|
|
||||||
/>
|
|
||||||
</DrawerSection>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,14 +138,14 @@ export function getDrawerEntry(
|
||||||
case "insolation":
|
case "insolation":
|
||||||
return {
|
return {
|
||||||
title: "Инсоляционная матрица",
|
title: "Инсоляционная матрица",
|
||||||
sub: "Планируется в следующей версии",
|
sub: "Предпросмотр теней на 3D-массе · солнце по времени суток",
|
||||||
content: <InsolationContent />,
|
content: <InsolationContent analysis={analysis} />,
|
||||||
};
|
};
|
||||||
case "future3d":
|
case "future3d":
|
||||||
return {
|
return {
|
||||||
title: "3D-масса застройки",
|
title: "3D-масса застройки",
|
||||||
sub: "Генеративная масса по регламенту",
|
sub: "Генеративная масса по регламенту · интерактивно",
|
||||||
content: <Future3dContent />,
|
content: <Future3dContent analysis={analysis} />,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<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,
|
||||||
|
}: 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)} · регламент-дефолт, источник НСПД ·
|
||||||
|
масса генеративная (этажи/секции/время — исследовательские вводные)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: () => (
|
||||||
|
<div className={styles.massingViewport}>
|
||||||
|
<div className={styles.massingSkeleton} aria-hidden="true">
|
||||||
|
Загрузка 3D-сцены…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function MassingViewer(props: MassingSceneProps): React.JSX.Element {
|
||||||
|
return <MassingScene {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MassingViewer;
|
||||||
Loading…
Add table
Reference in a new issue