feat(concept): 3D-масса корпусов на карте с тенями (§7) (#1953)
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-тайлы — отдельный эпик).
This commit is contained in:
parent
53cfd3c3b8
commit
5339aeb72e
8 changed files with 1332 additions and 57 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Box, Map as MapIcon } from "lucide-react";
|
||||||
|
|
||||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||||
import { Section } from "@/components/analytics/Section";
|
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: () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 420,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка 3D-сцены…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 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) ─────────────────────────────────────────────────
|
// ── Formatters (ru microcopy) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
||||||
|
|
@ -137,15 +171,63 @@ function formatPayback(months: number | null): string {
|
||||||
return `${months.toFixed(1)} мес`;
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
background: active ? "var(--accent)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: active ? "#fff" : "var(--fg-secondary)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Variant panel ─────────────────────────────────────────────────────────────
|
// ── Variant panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
parcel: Polygon;
|
parcel: Polygon;
|
||||||
variant: ConceptVariant;
|
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;
|
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 =
|
const netPositive =
|
||||||
financial.net_profit_rub > 0
|
financial.net_profit_rub > 0
|
||||||
? true
|
? true
|
||||||
|
|
@ -224,18 +306,53 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Placement map */}
|
{/* Placement — 2D plan / 3D massing tab switch (same FeatureCollection). */}
|
||||||
<Section
|
<Section
|
||||||
title="Размещение застройки"
|
title="Размещение застройки"
|
||||||
subtitle={`Полигон участка (пунктир) и сгенерированные корпуса (заливка) — ${formatCorpuses(
|
subtitle={`Полигон участка и ${formatCorpuses(
|
||||||
buildingCount(variant),
|
buildingCount(variant),
|
||||||
)}.`}
|
)} — 2D-план или объёмная модель по программе.`}
|
||||||
|
right={
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Вид размещения"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
gap: 4,
|
||||||
|
padding: 4,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlacementTab
|
||||||
|
active={view === "2d"}
|
||||||
|
icon={<MapIcon size={16} strokeWidth={1.5} aria-hidden="true" />}
|
||||||
|
label="План (2D)"
|
||||||
|
onClick={() => setView("2d")}
|
||||||
|
/>
|
||||||
|
<PlacementTab
|
||||||
|
active={view === "3d"}
|
||||||
|
icon={<Box size={16} strokeWidth={1.5} aria-hidden="true" />}
|
||||||
|
label="Объём (3D)"
|
||||||
|
onClick={() => setView("3d")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ConceptResultMap
|
{view === "2d" ? (
|
||||||
parcel={parcel}
|
<ConceptResultMap
|
||||||
buildings={variant.buildings_geojson}
|
parcel={parcel}
|
||||||
variantKey={variant.strategy}
|
buildings={variant.buildings_geojson}
|
||||||
/>
|
variantKey={variant.strategy}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Massing3DViewer
|
||||||
|
parcel={parcel}
|
||||||
|
variant={variant}
|
||||||
|
floorHeightM={floorHeightM}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ТЭП — KPI grid */}
|
{/* ТЭП — KPI grid */}
|
||||||
|
|
@ -595,9 +712,19 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
||||||
interface Props {
|
interface Props {
|
||||||
parcel: Polygon;
|
parcel: Polygon;
|
||||||
variants: ConceptVariant[];
|
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);
|
const [active, setActive] = useState(0);
|
||||||
|
|
||||||
if (variants.length === 0) {
|
if (variants.length === 0) {
|
||||||
|
|
@ -667,7 +794,11 @@ export function ConceptVariantsResult({ parcel, variants }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<VariantPanel parcel={parcel} variant={activeVariant} />
|
<VariantPanel
|
||||||
|
parcel={parcel}
|
||||||
|
variant={activeVariant}
|
||||||
|
floorHeightM={floorHeightM}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
749
frontend/src/components/concept/Massing3DScene.tsx
Normal file
749
frontend/src/components/concept/Massing3DScene.tsx
Normal file
|
|
@ -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<string, unknown> | 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<string, unknown> | 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<HTMLImageElement | null> {
|
||||||
|
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<GroundTexResult | null> {
|
||||||
|
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<null>((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<HTMLDivElement | null>(null);
|
||||||
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
|
const sunRef = useRef<THREE.DirectionalLight | null>(null);
|
||||||
|
const groundTexRef = useRef<THREE.CanvasTexture | null>(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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 420,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: 24,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-primary)" }}
|
||||||
|
>
|
||||||
|
3D-просмотр недоступен
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||||
|
Браузер не поддерживает WebGL — объёмную модель отрисовать нельзя.
|
||||||
|
Откройте план застройки на вкладке «План (2D)».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Controls bar — light-themed, inline styles (NOT ptica.module.css). */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
Время суток
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
minWidth: 44,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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="Время суток (положение солнца и теней)"
|
||||||
|
style={{ accentColor: "var(--accent)", width: 180 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRotate}
|
||||||
|
onChange={(e) => setAutoRotate(e.target.checked)}
|
||||||
|
style={{ accentColor: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
<RotateCw size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||||
|
Автоповорот
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viewport */}
|
||||||
|
<div
|
||||||
|
ref={mountRef}
|
||||||
|
style={{
|
||||||
|
height: 420,
|
||||||
|
background: "var(--bg-app)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Caption — source + honesty. */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "8px 0 0",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: "16px",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||||
|
Объёмы по фактически размещённым корпусам; высота этажа{" "}
|
||||||
|
{floorHeightM.toLocaleString("ru")} м
|
||||||
|
{floorHeightM === DEFAULT_FLOOR_HEIGHT
|
||||||
|
? " (норматив движка)"
|
||||||
|
: " — визуально; ТЭП и финмодель считались при 3,0 м/этаж"}
|
||||||
|
. Подложка — карта © OpenStreetMap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/concept/Massing3DViewer.tsx
Normal file
43
frontend/src/components/concept/Massing3DViewer.tsx
Normal file
|
|
@ -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: () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 420,
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка 3D-сцены…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Massing3DViewer(props: Massing3DSceneProps): React.JSX.Element {
|
||||||
|
return <Massing3DScene {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Massing3DViewer;
|
||||||
|
|
@ -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 styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||||
|
|
||||||
|
import { sunFromHour } from "./sun";
|
||||||
|
import { disposeObject } from "./three-utils";
|
||||||
|
|
||||||
export interface MassingSceneProps {
|
export interface MassingSceneProps {
|
||||||
/** Real parcel area in m² when available (fallback ≈ 6800). */
|
/** Real parcel area in m² when available (fallback ≈ 6800). */
|
||||||
areaM2?: number;
|
areaM2?: number;
|
||||||
|
|
@ -134,29 +137,8 @@ function computeModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── sun slider → direction + colour ───────────────────────────────────────────
|
// ── sun slider → direction + colour ───────────────────────────────────────────
|
||||||
|
// `sunFromHour` + `SunState` now live in ./sun (shared with the §7 «Концепция»
|
||||||
interface SunState {
|
// 3D viewer). `placeSun` below imports it.
|
||||||
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 ────────────────────────────────────────────────────────────────
|
// ── formatting ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -273,19 +255,8 @@ function buildMass(model: MassingModel): THREE.Group {
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dispose every geometry/material under an Object3D. */
|
// `disposeObject` now lives in ./three-utils (shared with the §7 «Концепция» 3D
|
||||||
function disposeObject(obj: THREE.Object3D): void {
|
// viewer); imported at the top of this module.
|
||||||
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) ─────────────────
|
// ── live metrics (React-rendered, derived from the same model) ─────────────────
|
||||||
|
|
||||||
|
|
@ -582,16 +553,7 @@ export default function MassingScene({
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
controlsRef.current = null;
|
controlsRef.current = null;
|
||||||
|
|
||||||
scene.traverse((o) => {
|
disposeObject(scene);
|
||||||
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;
|
massGroupRef.current = null;
|
||||||
sceneRef.current = null;
|
sceneRef.current = null;
|
||||||
cameraRef.current = null;
|
cameraRef.current = null;
|
||||||
|
|
|
||||||
37
frontend/src/components/site-finder/ptica/massing/sun.ts
Normal file
37
frontend/src/components/site-finder/ptica/massing/sun.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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<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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
138
frontend/src/lib/__tests__/geo-local.test.ts
Normal file
138
frontend/src/lib/__tests__/geo-local.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
frontend/src/lib/geo-local.ts
Normal file
191
frontend/src/lib/geo-local.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue