From 5339aeb72e479f0c1db0b7a650922b3b3ad7cc62 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 30 Jun 2026 16:55:06 +0500 Subject: [PATCH] =?UTF-8?q?feat(concept):=203D-=D0=BC=D0=B0=D1=81=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5=20=D1=81=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=D0=BC=D0=B8=20(=C2=A77)=20(#1953)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anton: «куда делась 3D? хочу подробную 3D-модель, крутится на карте (карта как подставка-земля), видеть тень». Вернул 3D — но привязанный к ЕДИНОЙ программе. - Massing3DScene (three, ssr:false): рисует РОВНО размещённые корпуса из variant.buildings_geojson (высота = floors × высота этажа), на ground-плоскости с растровым OSM-тайлом участка («карта как земля»), солнце по времени суток отбрасывает тени (slider «Время суток»), OrbitControls + автоповорот. - geo-local.ts: WGS84→локальные метры (equirectangular о центроиде) + slippy-tile математика (выбор зума ≤2×2, geo-pin плоскости теми же углами тайл-блока) — 11 vitest. Без новых зависимостей (three+leaflet уже есть). - Вкладка 2D/3D в «Размещение застройки»: «План (2D)» = существующая Leaflet-карта, «Объём (3D)» = новый вьюер. Десктоп — 3D по умолчанию, моб (<768) — 2D + 3D по тапу. - sun.ts / three-utils.ts: sunFromHour + disposeObject вынесены из MassingScene (verbatim), кокпит репойнтнут на них (без изменения поведения). - Полный teardown (RAF/RO/dispose geom+mat+CanvasTexture+renderer), WebGL-guard, fallback на плоскую плашку+grid при сбое тайла/таймауте 6с, честный caption (высота этажа норматив движка; подложка © OpenStreetMap). Отложено (follow-up): live-ползунок высоты этажа (Phase 2); полноценный 3D-рельеф местности (нужны DEM/elevation-тайлы — отдельный эпик). --- .../concept/ConceptVariantsResult.tsx | 153 +++- .../src/components/concept/Massing3DScene.tsx | 749 ++++++++++++++++++ .../components/concept/Massing3DViewer.tsx | 43 + .../ptica/massing/MassingScene.tsx | 54 +- .../site-finder/ptica/massing/sun.ts | 37 + .../site-finder/ptica/massing/three-utils.ts | 24 + frontend/src/lib/__tests__/geo-local.test.ts | 138 ++++ frontend/src/lib/geo-local.ts | 191 +++++ 8 files changed, 1332 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/concept/Massing3DScene.tsx create mode 100644 frontend/src/components/concept/Massing3DViewer.tsx create mode 100644 frontend/src/components/site-finder/ptica/massing/sun.ts create mode 100644 frontend/src/components/site-finder/ptica/massing/three-utils.ts create mode 100644 frontend/src/lib/__tests__/geo-local.test.ts create mode 100644 frontend/src/lib/geo-local.ts diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index c8ee27e2..57faf676 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -8,6 +8,7 @@ import dynamic from "next/dynamic"; import { useState } from "react"; +import { Box, Map as MapIcon } from "lucide-react"; import { KpiCard } from "@/components/analytics/KpiCard"; import { Section } from "@/components/analytics/Section"; @@ -48,6 +49,39 @@ const ConceptResultMap = dynamic( }, ); +// 3D massing viewer — Three.js, client-only (ssr:false), lazy so it never bloats +// the report bundle and only the active placement tab mounts its engine. +const Massing3DViewer = dynamic( + () => + import("./Massing3DViewer").then((m) => ({ default: m.Massing3DViewer })), + { + ssr: false, + loading: () => ( +
+ Загрузка 3D-сцены… +
+ ), + }, +); + +/** Desktop defaults to 3D; phones (<768px) default to the lighter 2D plan. */ +function defaultPlacementView(): "2d" | "3d" { + if (typeof window === "undefined") return "3d"; + return window.innerWidth < 768 ? "2d" : "3d"; +} + // ── Formatters (ru microcopy) ───────────────────────────────────────────────── const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); @@ -137,15 +171,63 @@ function formatPayback(months: number | null): string { return `${months.toFixed(1)} мес`; } +// ── Placement view tab (2D / 3D) ────────────────────────────────────────────── + +function PlacementTab({ + active, + icon, + label, + onClick, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + // ── Variant panel ───────────────────────────────────────────────────────────── interface PanelProps { parcel: Polygon; variant: ConceptVariant; + /** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */ + floorHeightM?: number; } -function VariantPanel({ parcel, variant }: PanelProps) { +function VariantPanel({ parcel, variant, floorHeightM = 3.0 }: PanelProps) { const { teap, financial } = variant; + + // Placement view: «План (2D)» (Leaflet) ↔ «Объём (3D)» (Three.js). Desktop + // defaults to 3D; phones default to 2D and mount 3D only on tap. Only the + // active tab is rendered, so the inactive engine frees its GPU/Leaflet + // resources via its teardown effect on switch. + const [view, setView] = useState<"2d" | "3d">(defaultPlacementView); const netPositive = financial.net_profit_rub > 0 ? true @@ -224,18 +306,53 @@ function VariantPanel({ parcel, variant }: PanelProps) { )} - {/* Placement map */} + {/* Placement — 2D plan / 3D massing tab switch (same FeatureCollection). */}
+
{/* ТЭП — KPI grid */} @@ -595,9 +712,19 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { interface Props { parcel: Polygon; variants: ConceptVariant[]; + /** + * Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's + * `_FLOOR_HEIGHT`. Optional so Phase 2 can thread the form's live knob without + * a contract change (the ТЭП / финмодель stay computed at 3.0 m/этаж). + */ + floorHeightM?: number; } -export function ConceptVariantsResult({ parcel, variants }: Props) { +export function ConceptVariantsResult({ + parcel, + variants, + floorHeightM = 3.0, +}: Props) { const [active, setActive] = useState(0); if (variants.length === 0) { @@ -667,7 +794,11 @@ export function ConceptVariantsResult({ parcel, variants }: Props) { )} - + ); } diff --git a/frontend/src/components/concept/Massing3DScene.tsx b/frontend/src/components/concept/Massing3DScene.tsx new file mode 100644 index 00000000..48bcea65 --- /dev/null +++ b/frontend/src/components/concept/Massing3DScene.tsx @@ -0,0 +1,749 @@ +"use client"; + +/** + * Massing3DScene — client-only Three.js viewer that renders the ACTUAL placed + * corpuses from `variant.buildings_geojson` as extruded masses standing on an + * OSM raster tile of the parcel area («карта как подставка-земля»), lit by a + * time-of-day sun that casts shadows, with an auto-rotating OrbitControls rig. + * + * Read-only (no slider rebuild of the program — the program is whatever the + * backend placed). The ONE live knob is `floorHeightM` (visual extrusion height, + * default 3.0 = the backend's `FLOOR_HEIGHT_M`). Light theme: inline styles + CSS + * var tokens only — this is a report object, NOT the dark cockpit, so it must NOT + * import ptica.module.css. + * + * Loaded exclusively through Massing3DViewer via `dynamic(ssr:false)` so Three.js + * never touches the server. The renderer/scene/RAF live in one effect with full + * teardown (cancel RAF, disconnect RO, dispose controls + geometries/materials + + * the OSM CanvasTexture + renderer, remove the canvas) on unmount. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import type { Polygon, Position } from "geojson"; +import { Box, RotateCw } from "lucide-react"; + +import type { ConceptVariant } from "@/lib/concept-api"; +import { + makeProjector, + osmTileRangeForBbox, + padBounds, + ringBounds, + ringCentroid, + type Bounds, + type Projector, +} from "@/lib/geo-local"; + +import { sunFromHour } from "@/components/site-finder/ptica/massing/sun"; +import { disposeObject } from "@/components/site-finder/ptica/massing/three-utils"; + +// ── constants (scene units = metres) ────────────────────────────────────────── + +const DEFAULT_FLOOR_HEIGHT = 3.0; // backend `FLOOR_HEIGHT_M` (placement.py) +const DEFAULT_HOUR = 13; // midday — first paint already shows shadows +const HOUR_MIN = 6; +const HOUR_MAX = 20; +const FALLBACK_FLOORS = 12; + +// Colours — Three.js wants numeric hex; these mirror the UI tokens by value. +const COLOR_BG = 0xf6f7f9; // --bg-app +const COLOR_SLATE = 0x33424f; // building body +const COLOR_ACCENT = 0x1d4ed8; // --accent (edge lines) +const COLOR_OUTLINE = 0xd1d5db; // --border-strong (parcel outline) +const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt +const COLOR_GRID = 0xe6e8ec; // --border-card + +const TILE_TIMEOUT_MS = 6000; + +// ── props ───────────────────────────────────────────────────────────────────── + +export interface Massing3DSceneProps { + /** WGS84 parcel polygon — outer ring used for centroid + outline. */ + parcel: Polygon; + /** The variant — `buildings_geojson` is the single source of corpuses. */ + variant: ConceptVariant; + /** Visual floor height in metres; default 3.0 (the backend's constant). */ + floorHeightM?: number; + /** Optional initial sun hour; default 13. */ + hour?: number; +} + +// ── derived model (pure, memoized) ──────────────────────────────────────────── + +interface BuildingLocal { + ring: { x: number; z: number }[]; + height: number; +} + +interface MassingModel { + buildings: BuildingLocal[]; + parcelRing: { x: number; z: number }[]; + /** Local-metre bbox over all building rings (falls back to parcel ring). */ + bbox: { minX: number; maxX: number; minZ: number; maxZ: number }; + centerX: number; + centerZ: number; + diag: number; + maxHeight: number; + /** WGS84 padded bounds for the OSM ground tile. */ + groundBounds: Bounds | null; + /** Shared projector — buildings AND ground tile corners use the same one. */ + project: Projector; +} + +/** Per-feature floors, guarded (`properties` is loosely typed GeoJsonProperties). */ +function featureFloors( + props: Record | null | undefined, + fallback: number, +): number { + const raw = props?.floors; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function buildModel( + parcel: Polygon, + variant: ConceptVariant, + floorHeightM: number, +): MassingModel { + const parcelRingWgs = (parcel.coordinates?.[0] ?? []) as Position[]; + const [lon0, lat0] = ringCentroid(parcelRingWgs); + const project = makeProjector(lon0, lat0); + + const features = variant.buildings_geojson?.features ?? []; + + // Fallback floors = the tallest declared corpus, else a sensible default. + const declaredMax = features.reduce((m, f) => { + const n = Number(f.properties?.floors); + return Number.isFinite(n) && n > m ? n : m; + }, 0); + const fallbackFloors = declaredMax > 0 ? declaredMax : FALLBACK_FLOORS; + + const buildings: BuildingLocal[] = []; + for (const f of features) { + const geom = f.geometry; + if (!geom || geom.type !== "Polygon") continue; + const ringWgs = (geom.coordinates?.[0] ?? []) as Position[]; + if (ringWgs.length < 4) continue; + const ring = ringWgs.map(project); + const floors = featureFloors( + f.properties as Record | null, + fallbackFloors, + ); + buildings.push({ + ring, + height: Math.max(floors * floorHeightM, floorHeightM), + }); + } + + const parcelRing = parcelRingWgs.map(project); + + // bbox over building rings (preferred) or the parcel ring as a fallback. + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + const pts = + buildings.length > 0 ? buildings.flatMap((b) => b.ring) : parcelRing; + for (const p of pts) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.z < minZ) minZ = p.z; + if (p.z > maxZ) maxZ = p.z; + } + if (!Number.isFinite(minX)) { + minX = -50; + maxX = 50; + minZ = -50; + maxZ = 50; + } + const centerX = (minX + maxX) / 2; + const centerZ = (minZ + maxZ) / 2; + const diag = Math.max(Math.hypot(maxX - minX, maxZ - minZ), 40); + const maxHeight = buildings.reduce( + (m, b) => Math.max(m, b.height), + floorHeightM, + ); + + const rawBounds = ringBounds(parcelRingWgs); + const groundBounds = rawBounds ? padBounds(rawBounds, 0.3) : null; + + return { + buildings, + parcelRing, + bbox: { minX, maxX, minZ, maxZ }, + centerX, + centerZ, + diag, + maxHeight, + groundBounds, + project, + }; +} + +// ── building meshes ──────────────────────────────────────────────────────────── + +/** + * Build the buildings Group from the local model. Each corpus → a THREE.Shape in + * the XY plane (x → shape.x, z → shape.y), ExtrudeGeometry(depth = height), with + * the whole Group rotated −π/2 about X so footprints lie on y=0 and height grows + * +y. Degenerate rings (≈0 area) fall back to a BoxGeometry at the ring bbox. + */ +function buildBuildingsGroup(model: MassingModel): THREE.Group { + const group = new THREE.Group(); + group.name = "buildings"; + group.rotation.x = -Math.PI / 2; + + const slate = new THREE.Color(COLOR_SLATE); + const accent = new THREE.Color(COLOR_ACCENT); + + for (const b of model.buildings) { + if (b.ring.length < 3) continue; + + // Footprint area (shoelace) to detect a degenerate ring. + let area2 = 0; + for (let i = 0; i < b.ring.length; i++) { + const a = b.ring[i]; + const c = b.ring[(i + 1) % b.ring.length]; + area2 += a.x * c.z - c.x * a.z; + } + const area = Math.abs(area2) / 2; + + let geo: THREE.ExtrudeGeometry | THREE.BoxGeometry; + let basePos: { x: number; y: number } | null = null; + if (area < 1) { + // Degenerate → block sized to the ring bbox at its centre. + let bx0 = Infinity; + let bx1 = -Infinity; + let bz0 = Infinity; + let bz1 = -Infinity; + for (const p of b.ring) { + if (p.x < bx0) bx0 = p.x; + if (p.x > bx1) bx1 = p.x; + if (p.z < bz0) bz0 = p.z; + if (p.z > bz1) bz1 = p.z; + } + const w = Math.max(bx1 - bx0, 4); + const d = Math.max(bz1 - bz0, 4); + geo = new THREE.BoxGeometry(w, d, b.height); + basePos = { x: (bx0 + bx1) / 2, y: (bz0 + bz1) / 2 }; + } else { + const shape = new THREE.Shape(); + shape.moveTo(b.ring[0].x, b.ring[0].z); + for (let i = 1; i < b.ring.length; i++) { + shape.lineTo(b.ring[i].x, b.ring[i].z); + } + shape.closePath(); + geo = new THREE.ExtrudeGeometry(shape, { + depth: b.height, + bevelEnabled: false, + }); + } + + const mat = new THREE.MeshStandardMaterial({ + color: slate, + roughness: 0.82, + metalness: 0.05, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.castShadow = true; + mesh.receiveShadow = true; + if (basePos) { + // BoxGeometry is centred at origin; lift it to sit on y=0 (group-local z). + mesh.position.set(basePos.x, basePos.y, b.height / 2); + } + group.add(mesh); + + // Accent edges for building-read. + const edges = new THREE.EdgesGeometry(geo); + const line = new THREE.LineSegments( + edges, + new THREE.LineBasicMaterial({ + color: accent, + transparent: true, + opacity: 0.55, + }), + ); + if (basePos) line.position.copy(mesh.position); + group.add(line); + } + + return group; +} + +/** Parcel outline as a LineLoop at y=0.05 in world space. */ +function buildParcelOutline(model: MassingModel): THREE.Line { + const pts: number[] = []; + for (const p of model.parcelRing) { + pts.push(p.x, 0.05, p.z); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); + return new THREE.Line( + geo, + new THREE.LineBasicMaterial({ color: COLOR_OUTLINE }), + ); +} + +// ── OSM ground tile (fetch + stitch + CanvasTexture) ────────────────────────── + +interface GroundTexResult { + texture: THREE.CanvasTexture; + /** World-space placement of the geo-pinned plane. */ + planeW: number; + planeH: number; + centerX: number; + centerZ: number; +} + +/** Load one OSM tile into an Image; resolves null on error/abort. */ +function loadTile(url: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; // OSM tiles are CORS-clean → canvas not tainted + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = url; + }); +} + +/** + * Fetch + stitch the OSM tile block for the ground bounds into a CanvasTexture, + * geo-pinned via the shared projector. Races a ~6 s timeout; resolves null on any + * failed tile / timeout so the caller can drop to the flat fallback plane. + */ +async function loadGroundTexture( + model: MassingModel, + signal: { cancelled: boolean }, +): Promise { + if (!model.groundBounds) return null; + const range = osmTileRangeForBbox(model.groundBounds); + + const canvas = document.createElement("canvas"); + canvas.width = 256 * range.cols; + canvas.height = 256 * range.rows; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const loads: Promise<{ + img: HTMLImageElement | null; + col: number; + row: number; + }>[] = []; + for (let x = range.x0; x <= range.x1; x++) { + for (let y = range.y0; y <= range.y1; y++) { + const url = `https://tile.openstreetmap.org/${range.z}/${x}/${y}.png`; + const col = x - range.x0; + const row = y - range.y0; + loads.push(loadTile(url).then((img) => ({ img, col, row }))); + } + } + + const timeout = new Promise((resolve) => + setTimeout(() => resolve(null), TILE_TIMEOUT_MS), + ); + const all = Promise.all(loads); + const result = await Promise.race([all, timeout]); + if (signal.cancelled || result === null) return null; + // Any tile failed → fall back (geo-pin needs the full block). + if (result.some((r) => r.img === null)) return null; + + for (const { img, col, row } of result) { + if (img) ctx.drawImage(img, col * 256, row * 256); + } + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + + // Geo-pin: project the stitched block's exact WGS84 corners with the SAME + // projector as the buildings. Texture is drawn north→south; after the plane's + // −π/2 X-rotation, V maps to −z (north), so UVs align without flipping. + const nw = model.project([range.westLon, range.northLat]); + const se = model.project([range.eastLon, range.southLat]); + const planeW = se.x - nw.x; + const planeH = se.z - nw.z; + const centerX = (nw.x + se.x) / 2; + const centerZ = (nw.z + se.z) / 2; + + return { texture, planeW, planeH, centerX, centerZ }; +} + +// ── formatting ──────────────────────────────────────────────────────────────── + +function fmtHour(hour: number): string { + return `${String(Math.floor(hour)).padStart(2, "0")}:00`; +} + +// ── component ───────────────────────────────────────────────────────────────── + +export default function Massing3DScene({ + parcel, + variant, + floorHeightM = DEFAULT_FLOOR_HEIGHT, + hour: initialHour = DEFAULT_HOUR, +}: Massing3DSceneProps): React.JSX.Element { + const [hour, setHour] = useState(initialHour); + const [autoRotate, setAutoRotate] = useState(true); + const [webglFailed, setWebglFailed] = useState(false); + + const mountRef = useRef(null); + const sceneRef = useRef(null); + const controlsRef = useRef(null); + const sunRef = useRef(null); + const groundTexRef = useRef(null); + + const model = useMemo( + () => buildModel(parcel, variant, floorHeightM), + [parcel, variant, floorHeightM], + ); + + // place the sun from the current hour (light-only; no rebuild). + const placeSun = useCallback( + (h: number) => { + const sun = sunRef.current; + if (!sun) return; + const p = sunFromHour(h); + sun.position.set(p.x, p.y, p.z); + sun.target.position.set(model.centerX, 10, model.centerZ); + sun.intensity = 0.45 + 0.85 * Math.max(0.05, Math.sin(p.elevation)); + sun.color.setHSL( + 0.11 - 0.04 * (1 - p.warmth), + 0.55 * p.warmth + 0.12, + 0.62, + ); + }, + [model.centerX, model.centerZ], + ); + + // ── scene lifecycle (rebuilds on model identity change) ───────────────────── + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + + // WebGL availability guard. + let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null; + try { + const probe = document.createElement("canvas"); + gl = + probe.getContext("webgl2") ?? + (probe.getContext("webgl") as WebGLRenderingContext | null); + } catch { + gl = null; + } + if (!gl) { + setWebglFailed(true); + return; + } + + let width = mount.clientWidth || 600; + let height = mount.clientHeight || 420; + + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + } catch { + setWebglFailed(true); + return; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(width, height, false); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.domElement.style.display = "block"; + renderer.domElement.style.width = "100%"; + renderer.domElement.style.height = "100%"; + mount.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(COLOR_BG); + sceneRef.current = scene; + + const camera = new THREE.PerspectiveCamera(42, width / height, 0.5, 4000); + const target = new THREE.Vector3( + model.centerX, + 0.4 * model.maxHeight, + model.centerZ, + ); + const dir = new THREE.Vector3(1, 0.9, 1.15).normalize(); + camera.position.copy(target).addScaledVector(dir, 1.5 * model.diag); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.maxPolarAngle = Math.PI / 2 - 0.04; + controls.minDistance = 0.4 * model.diag; + controls.maxDistance = 4 * model.diag; + controls.autoRotate = autoRotate; + controls.autoRotateSpeed = 0.6; + controls.target.copy(target); + controls.update(); + controlsRef.current = controls; + + // ── ground: flat fallback now, swap to OSM tile when (if) it loads ──────── + const planeW = model.bbox.maxX - model.bbox.minX || 100; + const planeH = model.bbox.maxZ - model.bbox.minZ || 100; + const fallbackSize = Math.max(planeW, planeH) * 2.2 + 60; + const groundGeo = new THREE.PlaneGeometry(fallbackSize, fallbackSize); + const groundMat = new THREE.MeshStandardMaterial({ + color: COLOR_GROUND_FALLBACK, + roughness: 1, + metalness: 0, + }); + const ground = new THREE.Mesh(groundGeo, groundMat); + ground.rotation.x = -Math.PI / 2; + ground.position.set(model.centerX, -0.02, model.centerZ); + ground.receiveShadow = true; + scene.add(ground); + + // faint grid as part of the fallback context (removed when the tile lands). + const grid = new THREE.GridHelper(fallbackSize, 24, COLOR_GRID, COLOR_GRID); + grid.position.set(model.centerX, 0.01, model.centerZ); + const gridMat = grid.material as THREE.Material; + gridMat.opacity = 0.5; + gridMat.transparent = true; + scene.add(grid); + + // parcel outline + const outline = buildParcelOutline(model); + scene.add(outline); + + // buildings + const buildings = buildBuildingsGroup(model); + scene.add(buildings); + + // ── lighting (sun rig reused via sun.ts) ────────────────────────────────── + const hemi = new THREE.HemisphereLight(0xffffff, 0xb8c2cc, 0.55); + scene.add(hemi); + const ambient = new THREE.AmbientLight(0xffffff, 0.25); + scene.add(ambient); + + const sun = new THREE.DirectionalLight(0xfff2d8, 1.15); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + const extent = 0.6 * model.diag + 20; + sun.shadow.camera.near = 1; + sun.shadow.camera.far = 4 * extent; + sun.shadow.camera.left = -extent; + sun.shadow.camera.right = extent; + sun.shadow.camera.top = extent; + sun.shadow.camera.bottom = -extent; + sun.shadow.bias = -0.0005; + scene.add(sun); + scene.add(sun.target); + sunRef.current = sun; + placeSun(hour); + + // ── async OSM ground ────────────────────────────────────────────────────── + const tileSignal = { cancelled: false }; + loadGroundTexture(model, tileSignal).then((res) => { + if (!res || tileSignal.cancelled || !sceneRef.current) { + if (res) res.texture.dispose(); + return; + } + // Swap the fallback colour for the geo-pinned tiled plane. + groundMat.map = res.texture; + groundMat.color.set(0xffffff); + groundMat.needsUpdate = true; + groundTexRef.current = res.texture; + // Resize/reposition the plane to the geo-pinned block. + ground.geometry.dispose(); + ground.geometry = new THREE.PlaneGeometry( + Math.abs(res.planeW), + Math.abs(res.planeH), + ); + ground.position.set(res.centerX, -0.02, res.centerZ); + grid.visible = false; + }); + + // resize handling + const ro = new ResizeObserver(() => { + const w = mount.clientWidth; + const h = mount.clientHeight; + if (!w || !h) return; + width = w; + height = h; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h, false); + }); + ro.observe(mount); + + // render loop + let alive = true; + let raf = 0; + const tick = () => { + if (!alive) return; + raf = requestAnimationFrame(tick); + controls.update(); + renderer.render(scene, camera); + }; + raf = requestAnimationFrame(tick); + + // full teardown + return () => { + alive = false; + tileSignal.cancelled = true; + if (raf) cancelAnimationFrame(raf); + ro.disconnect(); + controls.dispose(); + controlsRef.current = null; + disposeObject(scene); + if (groundTexRef.current) { + groundTexRef.current.dispose(); + groundTexRef.current = null; + } + sceneRef.current = null; + sunRef.current = null; + renderer.dispose(); + const canvas = renderer.domElement; + if (canvas.parentNode) canvas.parentNode.removeChild(canvas); + }; + // model identity (variant / floorHeightM) drives a full rebuild; hour and + // autoRotate are applied by the dedicated effects below without re-init. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model]); + + // apply time-of-day changes by repositioning the sun (no rebuild). + useEffect(() => { + placeSun(hour); + }, [hour, placeSun]); + + // apply auto-rotate toggle. + useEffect(() => { + if (controlsRef.current) controlsRef.current.autoRotate = autoRotate; + }, [autoRotate]); + + if (webglFailed) { + return ( +
+
+ 3D-просмотр недоступен +
+

+ Браузер не поддерживает WebGL — объёмную модель отрисовать нельзя. + Откройте план застройки на вкладке «План (2D)». +

+
+ ); + } + + return ( +
+ {/* Controls bar — light-themed, inline styles (NOT ptica.module.css). */} +
+ + + +
+ + {/* Viewport */} +
+ + {/* Caption — source + honesty. */} +

+

+
+ ); +} diff --git a/frontend/src/components/concept/Massing3DViewer.tsx b/frontend/src/components/concept/Massing3DViewer.tsx new file mode 100644 index 00000000..362cf527 --- /dev/null +++ b/frontend/src/components/concept/Massing3DViewer.tsx @@ -0,0 +1,43 @@ +"use client"; + +/** + * Massing3DViewer — the only entry point the §7 «Концепция» result imports for + * the 3D building-mass viewer. Loads Massing3DScene (Three.js) client-side only + * via `dynamic(ssr:false)` so WebGL/Three.js never run on the server, with a grey + * fade loader (no shimmer — ui-conventions) while the chunk loads. + * + * The parent (ConceptVariantsResult) renders the 3D tab conditionally, so the + * scene mounts only when «Объём (3D)» is active and the Massing3DScene teardown + * effect disposes the renderer/GPU resources when the user switches back to 2D. + */ + +import dynamic from "next/dynamic"; + +import type { Massing3DSceneProps } from "./Massing3DScene"; + +const Massing3DScene = dynamic(() => import("./Massing3DScene"), { + ssr: false, + loading: () => ( +
+ Загрузка 3D-сцены… +
+ ), +}); + +export function Massing3DViewer(props: Massing3DSceneProps): React.JSX.Element { + return ; +} + +export default Massing3DViewer; diff --git a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx index 4d4495f6..4332bb69 100644 --- a/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx +++ b/frontend/src/components/site-finder/ptica/massing/MassingScene.tsx @@ -23,6 +23,9 @@ import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import { sunFromHour } from "./sun"; +import { disposeObject } from "./three-utils"; + export interface MassingSceneProps { /** Real parcel area in m² when available (fallback ≈ 6800). */ areaM2?: number; @@ -134,29 +137,8 @@ function computeModel( } // ── 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 }; -} +// `sunFromHour` + `SunState` now live in ./sun (shared with the §7 «Концепция» +// 3D viewer). `placeSun` below imports it. // ── formatting ──────────────────────────────────────────────────────────────── @@ -273,19 +255,8 @@ function buildMass(model: MassingModel): THREE.Group { 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()); - } - }); -} +// `disposeObject` now lives in ./three-utils (shared with the §7 «Концепция» 3D +// viewer); imported at the top of this module. // ── live metrics (React-rendered, derived from the same model) ───────────────── @@ -582,16 +553,7 @@ export default function MassingScene({ 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()); - } - }); + disposeObject(scene); massGroupRef.current = null; sceneRef.current = null; cameraRef.current = null; diff --git a/frontend/src/components/site-finder/ptica/massing/sun.ts b/frontend/src/components/site-finder/ptica/massing/sun.ts new file mode 100644 index 00000000..0f3f2205 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/massing/sun.ts @@ -0,0 +1,37 @@ +/** + * sun.ts — shared sun-rig math for the Three.js massing scenes. + * + * `sunFromHour` + `SunState` were lifted VERBATIM from MassingScene.tsx so the + * ПТИЦА cockpit massing sandbox and the §7 «Концепция» 3D viewer share one + * source of truth for the time-of-day → light-direction sweep (no copy-drift). + * + * Pure (no DOM / Three.js import) so it stays trivially unit-testable and can be + * imported from any scene module. + */ + +/** Sun slider hour bounds (06:00–20:00), shared by every massing scene. */ +export const HOUR_MIN = 6; +export const HOUR_MAX = 20; + +export 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. */ +export 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 }; +} diff --git a/frontend/src/components/site-finder/ptica/massing/three-utils.ts b/frontend/src/components/site-finder/ptica/massing/three-utils.ts new file mode 100644 index 00000000..8b0cfc5c --- /dev/null +++ b/frontend/src/components/site-finder/ptica/massing/three-utils.ts @@ -0,0 +1,24 @@ +/** + * three-utils.ts — shared Three.js helpers for the massing scenes. + * + * `disposeObject` was lifted VERBATIM from MassingScene.tsx so the ПТИЦА cockpit + * sandbox and the §7 «Концепция» 3D viewer share one teardown helper (no + * copy-drift). Disposes every geometry/material under an Object3D — call before + * dropping a Group from the scene to free GPU memory. + */ + +import type * as THREE from "three"; + +/** Dispose every geometry/material under an Object3D. */ +export 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()); + } + }); +} diff --git a/frontend/src/lib/__tests__/geo-local.test.ts b/frontend/src/lib/__tests__/geo-local.test.ts new file mode 100644 index 00000000..39a20171 --- /dev/null +++ b/frontend/src/lib/__tests__/geo-local.test.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for geo-local.ts — the dependency-free projection + slippy-tile math + * behind the §7 «Концепция» 3D massing viewer. + * + * Highest-risk code: a sign/scale slip in `makeProjector` mirrors buildings or + * desyncs them from the OSM ground tile, and an off-by-one in the slippy math + * pins the ground plane to the wrong place. These cases lock the directions and + * a couple of known tile indices. + */ +import type { Position } from "geojson"; +import { describe, expect, it } from "vitest"; + +import { + lat2tile, + lon2tile, + makeProjector, + osmTileRangeForBbox, + ringBounds, + ringCentroid, + tile2lat, + tile2lon, +} from "../geo-local"; + +// EKB city centre, used as the projection origin. +const LON0 = 60.6057; +const LAT0 = 56.8389; + +describe("makeProjector — WGS84 → local metres", () => { + const project = makeProjector(LON0, LAT0); + + it("maps the origin to (0, 0)", () => { + const p = project([LON0, LAT0]); + expect(p.x).toBeCloseTo(0, 6); + expect(p.z).toBeCloseTo(0, 6); + }); + + it("east → +x, west → −x", () => { + expect(project([LON0 + 0.001, LAT0]).x).toBeGreaterThan(0); + expect(project([LON0 - 0.001, LAT0]).x).toBeLessThan(0); + }); + + it("north → −z, south → +z (Three.js right-handed)", () => { + expect(project([LON0, LAT0 + 0.001]).z).toBeLessThan(0); + expect(project([LON0, LAT0 - 0.001]).z).toBeGreaterThan(0); + }); + + it("1° of latitude ≈ 111 319 m (R·π/180)", () => { + // North by 1° → z should be −(R·π/180) ≈ −111 319.49 m (R = 6 378 137). + const p = project([LON0, LAT0 + 1]); + expect(-p.z).toBeCloseTo(111_319.49, 0); + }); + + it("a degree of longitude is shorter than a degree of latitude at this lat", () => { + // cos(56.84°) ≈ 0.547 → lon-metres ≈ 0.547 × lat-metres. + const east = project([LON0 + 1, LAT0]).x; + const north = -project([LON0, LAT0 + 1]).z; + expect(east).toBeCloseTo(north * Math.cos((LAT0 * Math.PI) / 180), 0); + }); +}); + +describe("ringCentroid + ringBounds", () => { + // A closed unit-ish square around the origin (first == last vertex). + const SQUARE: Position[] = [ + [60.6, 56.83], + [60.61, 56.83], + [60.61, 56.84], + [60.6, 56.84], + [60.6, 56.83], + ]; + + it("centroid is the average of the four unique corners", () => { + const [lon, lat] = ringCentroid(SQUARE); + expect(lon).toBeCloseTo(60.605, 6); + expect(lat).toBeCloseTo(56.835, 6); + }); + + it("bounds wrap the ring", () => { + const b = ringBounds(SQUARE); + expect(b).not.toBeNull(); + expect(b?.minLon).toBeCloseTo(60.6, 6); + expect(b?.maxLon).toBeCloseTo(60.61, 6); + expect(b?.minLat).toBeCloseTo(56.83, 6); + expect(b?.maxLat).toBeCloseTo(56.84, 6); + }); +}); + +describe("slippy-tile math", () => { + it("known tile indices at z=0 / z=1", () => { + // The whole world is one tile at z=0. + expect(lon2tile(0, 0)).toBe(0); + expect(lat2tile(0, 0)).toBe(0); + // At z=1 the prime meridian / equator sit on the (1,1) tile corner. + expect(lon2tile(0.0001, 1)).toBe(1); + expect(lat2tile(0.0001, 1)).toBe(0); // just north of equator → top half + expect(lat2tile(-0.0001, 1)).toBe(1); + }); + + it("EKB centre lands on a known z=15 tile", () => { + // Reference (computed from the standard slippy formula): + // x = floor((60.6057+180)/360 · 32768) = 21900 + // y = floor((1 − asinh(tan(56.8389°))/π)/2 · 32768) = 10065 + expect(lon2tile(LON0, 15)).toBe(21900); + expect(lat2tile(LAT0, 15)).toBe(10065); + }); + + it("tile2lon / tile2lat invert lon2tile / lat2tile (tile NW corner)", () => { + const z = 15; + const tx = lon2tile(LON0, z); + const ty = lat2tile(LAT0, z); + // The NW corner of the containing tile is ≤ the point, and the next tile's + // corner is > it. + expect(tile2lon(tx, z)).toBeLessThanOrEqual(LON0); + expect(tile2lon(tx + 1, z)).toBeGreaterThan(LON0); + expect(tile2lat(ty, z)).toBeGreaterThanOrEqual(LAT0); // north corner + expect(tile2lat(ty + 1, z)).toBeLessThan(LAT0); + }); +}); + +describe("osmTileRangeForBbox", () => { + it("caps the stitched block at ≤ 4 tiles within [16,19]", () => { + // A ~250 m lot bbox around EKB centre. + const bbox = { + minLon: LON0 - 0.002, + maxLon: LON0 + 0.002, + minLat: LAT0 - 0.0012, + maxLat: LAT0 + 0.0012, + }; + const r = osmTileRangeForBbox(bbox); + expect(r.z).toBeGreaterThanOrEqual(16); + expect(r.z).toBeLessThanOrEqual(19); + expect(r.cols * r.rows).toBeLessThanOrEqual(4); + // The stitched block must fully contain the bbox. + expect(r.westLon).toBeLessThanOrEqual(bbox.minLon); + expect(r.eastLon).toBeGreaterThanOrEqual(bbox.maxLon); + expect(r.northLat).toBeGreaterThanOrEqual(bbox.maxLat); + expect(r.southLat).toBeLessThanOrEqual(bbox.minLat); + }); +}); diff --git a/frontend/src/lib/geo-local.ts b/frontend/src/lib/geo-local.ts new file mode 100644 index 00000000..bb83b781 --- /dev/null +++ b/frontend/src/lib/geo-local.ts @@ -0,0 +1,191 @@ +/** + * geo-local.ts — dependency-free geo helpers for the §7 «Концепция» 3D massing + * viewer. + * + * Two jobs: + * 1. Project WGS84 [lon, lat] → local metres about a parcel centroid + * (equirectangular), so building footprints and the OSM ground tile can be + * placed in a shared Three.js metre-space and stay geo-aligned. + * 2. Slippy-tile (OSM XYZ) math to pick + fetch + geo-pin a raster ground tile. + * + * No Three.js / DOM imports — pure functions, trivially unit-testable. `R` matches + * `polygonAreaSqm` in concept-api.ts (WGS84 equatorial radius) for consistency. + */ + +import type { Position } from "geojson"; + +/** WGS84 equatorial radius (m) — same constant as `polygonAreaSqm`. */ +export const R = 6_378_137; +const DEG = Math.PI / 180; + +// ── bounds / centroid ───────────────────────────────────────────────────────── + +export interface Bounds { + minLon: number; + minLat: number; + maxLon: number; + maxLat: number; +} + +/** Axis-aligned WGS84 bounds of a ring (or rings). Null on an empty/degenerate ring. */ +export function ringBounds(ring: Position[]): Bounds | null { + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const [lon, lat] of ring) { + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null; + return { minLon, minLat, maxLon, maxLat }; +} + +/** + * Ring-average of unique vertices (skips the closing vertex), mirroring + * `polygonCentroidWkt`. Returns [lon, lat]. Falls back to [0, 0] on an empty ring. + */ +export function ringCentroid(ring: Position[]): [number, number] { + if (ring.length === 0) return [0, 0]; + // Drop the closing vertex when the ring is closed (first == last). + const closed = + ring.length > 1 && + ring[0][0] === ring[ring.length - 1][0] && + ring[0][1] === ring[ring.length - 1][1]; + const pts = closed ? ring.slice(0, ring.length - 1) : ring; + if (pts.length === 0) return [0, 0]; + let sLon = 0; + let sLat = 0; + for (const [lon, lat] of pts) { + sLon += lon; + sLat += lat; + } + return [sLon / pts.length, sLat / pts.length]; +} + +// ── projection (WGS84 → local metres) ───────────────────────────────────────── + +export interface LocalPoint { + /** East offset (metres), east → +x. */ + x: number; + /** South offset (metres), north → −z (Three.js right-handed). */ + z: number; +} + +export type Projector = (pos: Position) => LocalPoint; + +/** + * Equirectangular projector about (lon0, lat0). Sub-metre accurate at lot scale + * (<1 km). Returns [lon, lat] → { x (east +), z (south +) } in metres. + * + * Sign discipline: north → −z, east → +x, height → +y — matches the azimuth + * convention in `sunFromHour` so the sun arc lands east→south→west correctly. + */ +export function makeProjector(lon0: number, lat0: number): Projector { + const kx = R * DEG * Math.cos(lat0 * DEG); // metres per degree lon at this lat + const kz = R * DEG; // metres per degree lat + return ([lon, lat]: Position) => ({ + x: (lon - lon0) * kx, + z: -(lat - lat0) * kz, + }); +} + +// ── slippy-tile (OSM XYZ) math ──────────────────────────────────────────────── + +export function lon2tile(lon: number, z: number): number { + return Math.floor(((lon + 180) / 360) * 2 ** z); +} + +export function lat2tile(lat: number, z: number): number { + const r = lat * DEG; + return Math.floor( + ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * 2 ** z, + ); +} + +export function tile2lon(x: number, z: number): number { + return (x / 2 ** z) * 360 - 180; +} + +export function tile2lat(y: number, z: number): number { + const n = Math.PI - (2 * Math.PI * y) / 2 ** z; + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} + +export interface TileRange { + z: number; + x0: number; + x1: number; + y0: number; + y1: number; + cols: number; + rows: number; + /** Exact WGS84 span of the stitched tile block (NW + SE corners). */ + westLon: number; + eastLon: number; + northLat: number; + southLat: number; +} + +/** + * Pick the OSM zoom for a bbox such that the stitched tile block stays ≤ maxTiles + * (default 4 → a 2×2 cap), clamped to [minZ, maxZ]. Starts at maxZ and steps the + * zoom down until the tile count fits. + * + * Returns the integer tile range plus the EXACT WGS84 corners of the stitched + * block (tile2lon/lat of the inclusive range), which the scene projects with the + * SAME `makeProjector` as the buildings to geo-pin the ground plane. + */ +export function osmTileRangeForBbox( + bbox: Bounds, + opts: { minZ?: number; maxZ?: number; maxTiles?: number } = {}, +): TileRange { + const minZ = opts.minZ ?? 16; + const maxZ = opts.maxZ ?? 19; + const maxTiles = opts.maxTiles ?? 4; + + let z = maxZ; + let x0 = 0; + let x1 = 0; + let y0 = 0; + let y1 = 0; + for (; z >= minZ; z--) { + x0 = lon2tile(bbox.minLon, z); + x1 = lon2tile(bbox.maxLon, z); + // lat → tile is inverted (north = smaller y), so y0 (north) uses maxLat. + y0 = lat2tile(bbox.maxLat, z); + y1 = lat2tile(bbox.minLat, z); + const cols = x1 - x0 + 1; + const rows = y1 - y0 + 1; + if (cols * rows <= maxTiles || z === minZ) break; + } + const cols = x1 - x0 + 1; + const rows = y1 - y0 + 1; + return { + z, + x0, + x1, + y0, + y1, + cols, + rows, + westLon: tile2lon(x0, z), + eastLon: tile2lon(x1 + 1, z), + northLat: tile2lat(y0, z), + southLat: tile2lat(y1 + 1, z), + }; +} + +/** Pad a bbox by a fraction of its span on every side (e.g. 0.3 → +30% context). */ +export function padBounds(bbox: Bounds, frac: number): Bounds { + const dLon = (bbox.maxLon - bbox.minLon) * frac; + const dLat = (bbox.maxLat - bbox.minLat) * frac; + return { + minLon: bbox.minLon - dLon, + minLat: bbox.minLat - dLat, + maxLon: bbox.maxLon + dLon, + maxLat: bbox.maxLat + dLat, + }; +}