gendesign/frontend/src/lib/geo-local.ts
bot-backend 1fa99812af
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m23s
fix(concept): §7 massing-сцена осознаёт весь MultiPolygon (соседи уезжали до 1.6км) (#2180) (#2463)
2026-07-07 08:10:27 +00:00

234 lines
7.8 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 { MultiPolygon, Polygon, 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 union bounds over several rings. Null when all are empty. */
export function ringsBounds(rings: Position[][]): Bounds | null {
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const ring of rings) {
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 };
}
/** Axis-aligned WGS84 bounds of a single ring. Null on an empty/degenerate ring. */
export function ringBounds(ring: Position[]): Bounds | null {
return ringsBounds([ring]);
}
/**
* Average of unique vertices (skips each ring's closing vertex) across ALL rings,
* so a multi-part parcel projects about its whole-geometry centre rather than one
* part's — the frame the backend neighbour endpoint anchors context to. Vertex-
* count weighted (good enough as a projection origin at lot scale). Falls back to
* [0, 0] when every ring is empty.
*/
export function ringsCentroid(rings: Position[][]): [number, number] {
let sLon = 0;
let sLat = 0;
let n = 0;
for (const ring of rings) {
if (ring.length === 0) continue;
// 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;
for (const [lon, lat] of pts) {
sLon += lon;
sLat += lat;
n++;
}
}
if (n === 0) return [0, 0];
return [sLon / n, sLat / n];
}
/**
* 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] {
return ringsCentroid([ring]);
}
/**
* Outer ring(s) of a parcel geometry — one per Polygon part. A bare Polygon
* yields its single outer ring; a MultiPolygon yields EVERY part's outer ring, so
* the massing scene can project origin, ground bounds and outline over the WHOLE
* parcel (the frame the neighbour endpoint anchors to). Inner rings (holes) are
* dropped — origin/bounds/outline only need the outer boundary.
*/
export function polygonOuterRings(geom: Polygon | MultiPolygon): Position[][] {
if (geom.type === "MultiPolygon") {
const out: Position[][] = [];
for (const part of geom.coordinates) {
const outer = part[0];
if (outer && outer.length > 0) out.push(outer);
}
return out;
}
const outer = geom.coordinates[0];
return outer && outer.length > 0 ? [outer] : [];
}
// ── 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,
* clamped to [minZ, maxZ]. Starts at maxZ and steps the zoom down until the tile
* count fits. `maxTiles` defaults to 4 (a 2×2 cap); the massing scene passes 16
* (a 4×4 cap, #2179) so a larger surrounding extent renders WITHOUT dropping zoom.
*
* 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,
};
}