gendesign/frontend/src/lib/geo-local.ts
Light1YT 5339aeb72e
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m1s
CI / openapi-codegen-check (pull_request) Successful in 1m52s
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-тайлы — отдельный эпик).
2026-06-30 16:55:06 +05:00

191 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};
}