"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 { MultiPolygon, Polygon, Position } from "geojson"; import { Box, RotateCw } from "lucide-react"; import type { ConceptVariant } from "@/lib/concept-api"; import { useNeighborBuildings, type NeighborBuildingsResponse, } from "@/hooks/useNeighborBuildings"; 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; // Neighbours (#2180): existing context buildings around the parcel, rendered as // low-poly grey masses so the concept reads inside a block. Height comes from // НСПД floors×3 m; when floors is unknown fall back to ~2 storeys so the mass // still lands. Cap the count and simplify (no edges) — they're backdrop, the // programme corpuses (accent) must dominate. const NEIGHBOR_FALLBACK_HEIGHT_M = 6; // ≈2 этажа при неизвестной этажности НСПД const NEIGHBOR_MAX_RENDER = 200; // cap: features уже отсортированы по расстоянию // 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_PARCEL_OUTLINE = 0x1d4ed8; // --accent (parcel outline — accent, #2179) const COLOR_PARCEL_FILL = 0xdbeafe; // --accent-soft (parcel fill — #2179) const COLOR_GROUND_FALLBACK = 0xfafbfc; // --bg-card-alt const COLOR_GRID = 0xe6e8ec; // --border-card const COLOR_NEIGHBOR = 0x9ca3af; // близко к --fg-tertiary — нейтральный серый соседей (#2180) 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; /** * Кадастровый номер участка — для подгрузки соседних зданий (макет квартала, * #2180). Отсутствует на standalone /concept без кадастра → соседи не грузятся. */ cadNum?: string | null; } // ── 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, ); // Pad the ground tile generously (0.7 = +70% context each side, #2179) so the // OSM substrate extends well beyond the parcel; the 4×4 tile cap below keeps it // sharp at the same zoom. const rawBounds = ringBounds(parcelRingWgs); const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : 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 highlight (#2179): a translucent accent-soft FILL just above the ground * plane plus a prominent accent OUTLINE. Returns a Group so the caller adds one * object. WebGL ignores LineBasicMaterial.linewidth, so the outline is doubled at * two heights (y=0.05, y=0.10) to read thick from any camera angle without a new * dependency. */ function buildParcelHighlight(model: MassingModel): THREE.Group { const group = new THREE.Group(); group.name = "parcel"; // Fill: THREE.Shape from the parcel ring → ShapeGeometry, laid into the ground // plane (rotate −π/2 about X so the XY shape sits on the XZ world plane) and // lifted to y≈0.03 (above the ground substrate at y=−0.02, under the outline). if (model.parcelRing.length >= 3) { const shape = new THREE.Shape(); shape.moveTo(model.parcelRing[0].x, model.parcelRing[0].z); for (let i = 1; i < model.parcelRing.length; i++) { shape.lineTo(model.parcelRing[i].x, model.parcelRing[i].z); } shape.closePath(); const fillGeo = new THREE.ShapeGeometry(shape); const fill = new THREE.Mesh( fillGeo, new THREE.MeshBasicMaterial({ color: COLOR_PARCEL_FILL, // --accent-soft transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide, }), ); // Shape is built in XY; rotate to lie in the ground (XZ) plane. After the // −π/2 X-rotation shape.y (= parcel z) maps to +z, so no mirroring. fill.rotation.x = -Math.PI / 2; fill.position.y = 0.03; group.add(fill); } // Outline: accent LineLoop, doubled at y=0.05 and y=0.10 (linewidth is a no-op // in WebGL) so the parcel edge reads prominently. const outlineMat = new THREE.LineBasicMaterial({ color: COLOR_PARCEL_OUTLINE, }); for (const y of [0.05, 0.1]) { const pts: number[] = []; for (const p of model.parcelRing) { pts.push(p.x, y, p.z); } const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); group.add(new THREE.LineLoop(geo, outlineMat)); } return group; } // ── neighbour context buildings (#2180) ──────────────────────────────────────── /** Outer ring (index 0) of one GeoJSON polygon, projected to local metres. */ function projectOuterRing( polygon: Position[][], project: Projector, ): { x: number; z: number }[] { const ringWgs = (polygon?.[0] ?? []) as Position[]; return ringWgs.map(project); } /** * Build the neighbours Group from the fetched FeatureCollection, projected with * the SCENE's projector (`model.project`) so the context sits geo-aligned around * the parcel. Each footprint → a THREE.Shape (Polygon → its outer ring; * MultiPolygon → one shape per polygon) extruded to `height_m` (НСПД floors×3 m), * falling back to ~2 storeys when the height is unknown. * * Neutral grey (COLOR_NEIGHBOR), opacity 0.55 / transparent, NO edges — a backdrop * that must not compete with the accent programme corpuses. Rotated −π/2 about X * (like `buildBuildingsGroup`) so footprints lie on y=0 and height grows +y. * Returns null when there's nothing renderable, so the caller can skip adding it. */ function buildNeighborsGroup( data: NeighborBuildingsResponse, model: MassingModel, ): THREE.Group | null { const features = data.features.slice(0, NEIGHBOR_MAX_RENDER); if (features.length === 0) return null; const group = new THREE.Group(); group.name = "neighbors"; group.rotation.x = -Math.PI / 2; // One shared material for the whole context — grey backdrop, semi-transparent. const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(COLOR_NEIGHBOR), roughness: 0.9, metalness: 0.0, transparent: true, opacity: 0.55, }); // Extrude one grey mass per footprint ring at that feature's height. const extrudeRing = (ring: { x: number; z: number }[], heightM: number) => { if (ring.length < 4) return; // need a closed ring (≥3 verts + closing point) const shape = new THREE.Shape(); shape.moveTo(ring[0].x, ring[0].z); for (let i = 1; i < ring.length; i++) shape.lineTo(ring[i].x, ring[i].z); shape.closePath(); const geo = new THREE.ExtrudeGeometry(shape, { depth: heightM, bevelEnabled: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.castShadow = true; mesh.receiveShadow = true; group.add(mesh); }; for (const f of features) { const geom = f.geometry; if (!geom) continue; const heightM = typeof f.properties.height_m === "number" && f.properties.height_m > 0 ? f.properties.height_m : NEIGHBOR_FALLBACK_HEIGHT_M; if (geom.type === "Polygon") { // Polygon → its outer ring (index 0). extrudeRing(projectOuterRing(geom.coordinates, model.project), heightM); } else { // MultiPolygon → one mass per polygon (outer ring only). for (const poly of geom.coordinates) { extrudeRing(projectOuterRing(poly, model.project), heightM); } } } if (group.children.length === 0) return null; return group; } // ── 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; // Allow up to a 4×4 (16-tile) block (#2179): a larger extent WITHOUT dropping // zoom, so the substrate stays sharp while covering more surroundings. const range = osmTileRangeForBbox(model.groundBounds, { maxTiles: 16 }); 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, cadNum, }: 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], ); // Neighbour context buildings (#2180). enabled только при наличии cadNum // (standalone /concept без кадастра → соседи не грузятся). Данные приходят // async; отдельный effect ниже дорисовывает их в уже построенную сцену без // полного rebuild. const neighbors = useNeighborBuildings(cadNum); const neighborsData = neighbors.data; const neighborsCount = neighborsData?.count ?? 0; // 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 highlight (accent-soft fill + accent outline, #2179) const parcel = buildParcelHighlight(model); scene.add(parcel); // 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]); // Neighbour context (#2180): add the grey backdrop masses to the LIVE scene // without a full rebuild. Runs when the neighbours data lands (async, after // first paint) and re-runs after a model rebuild (new scene ref) — declared // AFTER the main scene effect so on a `model` change the new scene already // exists. Cleanup removes + disposes the group (the main teardown also // disposeObject(scene)s it as a belt-and-braces if unmount races this). useEffect(() => { const scene = sceneRef.current; if (!scene || !neighborsData) return; const group = buildNeighborsGroup(neighborsData, model); if (!group) return; scene.add(group); return () => { scene.remove(group); disposeObject(group); }; }, [model, neighborsData]); if (webglFailed) { return (
3D-просмотр недоступен

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

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

); }