fix(concept): §7 massing-сцена осознаёт весь MultiPolygon (соседи уезжали до 1.6км) (#2180) #2463

Merged
bot-backend merged 1 commit from fix/massing-multipolygon-whole-parcel into main 2026-07-07 08:10:28 +00:00
8 changed files with 405 additions and 79 deletions

View file

@ -9,7 +9,12 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import type { FeatureCollection, Polygon, Position } from "geojson"; import type {
MultiPolygon,
Polygon,
FeatureCollection,
Position,
} from "geojson";
import L from "leaflet"; import L from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
@ -18,7 +23,16 @@ import { useNeighborBuildings } from "@/hooks/useNeighborBuildings";
// ── Bounds helper ───────────────────────────────────────────────────────────── // ── Bounds helper ─────────────────────────────────────────────────────────────
function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null { /**
* Union LatLng bounds over the parcel geometry. Handles Polygon (rings =
* Position[][]) AND MultiPolygon (parts = Position[][][], flattened to all rings)
* so a multi-part parcel fits to its WHOLE extent, not just part 0.
*/
function geometryBounds(
geom: Polygon | MultiPolygon,
): L.LatLngBoundsExpression | null {
const rings: Position[][] =
geom.type === "MultiPolygon" ? geom.coordinates.flat() : geom.coordinates;
let minLat = Infinity; let minLat = Infinity;
let maxLat = -Infinity; let maxLat = -Infinity;
let minLon = Infinity; let minLon = Infinity;
@ -56,7 +70,8 @@ function FitBounds({ bounds }: FitBoundsProps) {
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
interface Props { interface Props {
parcel: Polygon; /** Parcel geometry — Polygon OR MultiPolygon (all parts drawn + fit to). */
parcel: Polygon | MultiPolygon;
buildings: FeatureCollection; buildings: FeatureCollection;
/** Keying makes react-leaflet remount the buildings layer on variant switch. */ /** Keying makes react-leaflet remount the buildings layer on variant switch. */
variantKey: string; variantKey: string;
@ -78,10 +93,7 @@ export function ConceptResultMap({
// Memoize so FitBounds only re-fits when the parcel geometry actually // Memoize so FitBounds only re-fits when the parcel geometry actually
// changes — recomputing inline returns a new array each render, which would // changes — recomputing inline returns a new array each render, which would
// make the fitBounds effect fire on every re-render and discard manual zoom/pan. // make the fitBounds effect fire on every re-render and discard manual zoom/pan.
const bounds = useMemo( const bounds = useMemo(() => geometryBounds(parcel), [parcel]);
() => ringBounds(parcel.coordinates as Position[][]),
[parcel.coordinates],
);
const hasBuildings = buildings.features.length > 0; const hasBuildings = buildings.features.length > 0;
// Соседние здания (#2180): контекст квартала под слоем корпусов. enabled только // Соседние здания (#2180): контекст квартала под слоем корпусов. enabled только

View file

@ -20,7 +20,7 @@ import {
type ConceptVariant, type ConceptVariant,
type FinancialModel, type FinancialModel,
} from "@/lib/concept-api"; } from "@/lib/concept-api";
import type { Polygon } from "geojson"; import type { MultiPolygon, Polygon } from "geojson";
import { ConceptExportButtons } from "./ConceptExportButtons"; import { ConceptExportButtons } from "./ConceptExportButtons";
@ -214,7 +214,8 @@ function PlacementTab({
// ── Variant panel ───────────────────────────────────────────────────────────── // ── Variant panel ─────────────────────────────────────────────────────────────
interface PanelProps { interface PanelProps {
parcel: Polygon; /** Parcel geometry — Polygon OR MultiPolygon (whole geometry, all parts). */
parcel: Polygon | MultiPolygon;
variant: ConceptVariant; variant: ConceptVariant;
/** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */ /** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */
floorHeightM?: number; floorHeightM?: number;
@ -723,7 +724,14 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
interface Props { interface Props {
parcel: Polygon; /**
* Parcel geometry for the placement views Polygon OR MultiPolygon. The §7
* report passes the WHOLE geometry (`extractParcelGeometry`) so the 2D/3D
* scenes render every part and stay geo-aligned with backend-anchored
* neighbours; the standalone /concept page passes its single drawn/resolved
* Polygon.
*/
parcel: Polygon | MultiPolygon;
variants: ConceptVariant[]; variants: ConceptVariant[];
/** /**
* Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's * Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's

View file

@ -33,8 +33,9 @@ import {
makeProjector, makeProjector,
osmTileRangeForBbox, osmTileRangeForBbox,
padBounds, padBounds,
ringBounds, polygonOuterRings,
ringCentroid, ringsBounds,
ringsCentroid,
type Bounds, type Bounds,
type Projector, type Projector,
} from "@/lib/geo-local"; } from "@/lib/geo-local";
@ -73,8 +74,13 @@ const TILE_TIMEOUT_MS = 6000;
// ── props ───────────────────────────────────────────────────────────────────── // ── props ─────────────────────────────────────────────────────────────────────
export interface Massing3DSceneProps { export interface Massing3DSceneProps {
/** WGS84 parcel polygon — outer ring used for centroid + outline. */ /**
parcel: Polygon; * WGS84 parcel geometry Polygon OR MultiPolygon. Every part's outer ring
* drives the projection origin, OSM ground bounds and outline, so a multi-part
* parcel shares the whole-geometry frame the backend anchors neighbours to
* (otherwise context around a distant part lands offset from part 0).
*/
parcel: Polygon | MultiPolygon;
/** The variant — `buildings_geojson` is the single source of corpuses. */ /** The variant — `buildings_geojson` is the single source of corpuses. */
variant: ConceptVariant; variant: ConceptVariant;
/** Visual floor height in metres; default 3.0 (the backend's constant). */ /** Visual floor height in metres; default 3.0 (the backend's constant). */
@ -97,8 +103,9 @@ interface BuildingLocal {
interface MassingModel { interface MassingModel {
buildings: BuildingLocal[]; buildings: BuildingLocal[];
parcelRing: { x: number; z: number }[]; /** Every parcel part's outer ring, projected to local metres (one per part). */
/** Local-metre bbox over all building rings (falls back to parcel ring). */ parcelRings: { x: number; z: number }[][];
/** Local-metre bbox over all parcel parts + building rings. */
bbox: { minX: number; maxX: number; minZ: number; maxZ: number }; bbox: { minX: number; maxX: number; minZ: number; maxZ: number };
centerX: number; centerX: number;
centerZ: number; centerZ: number;
@ -121,12 +128,13 @@ function featureFloors(
} }
function buildModel( function buildModel(
parcel: Polygon, parcel: Polygon | MultiPolygon,
variant: ConceptVariant, variant: ConceptVariant,
floorHeightM: number, floorHeightM: number,
): MassingModel { ): MassingModel {
const parcelRingWgs = (parcel.coordinates?.[0] ?? []) as Position[]; // Whole-parcel-aware: outer ring of EVERY part (a bare Polygon → one ring).
const [lon0, lat0] = ringCentroid(parcelRingWgs); const parcelRingsWgs = polygonOuterRings(parcel);
const [lon0, lat0] = ringsCentroid(parcelRingsWgs);
const project = makeProjector(lon0, lat0); const project = makeProjector(lon0, lat0);
const features = variant.buildings_geojson?.features ?? []; const features = variant.buildings_geojson?.features ?? [];
@ -155,15 +163,15 @@ function buildModel(
}); });
} }
const parcelRing = parcelRingWgs.map(project); const parcelRings = parcelRingsWgs.map((ring) => ring.map(project));
// bbox over building rings (preferred) or the parcel ring as a fallback. // bbox over EVERY parcel part + all building rings, so the camera frames the
// whole lot and its corpuses (a distant part is no longer cropped out).
let minX = Infinity; let minX = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let minZ = Infinity; let minZ = Infinity;
let maxZ = -Infinity; let maxZ = -Infinity;
const pts = const pts = [...parcelRings.flat(), ...buildings.flatMap((b) => b.ring)];
buildings.length > 0 ? buildings.flatMap((b) => b.ring) : parcelRing;
for (const p of pts) { for (const p of pts) {
if (p.x < minX) minX = p.x; if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x; if (p.x > maxX) maxX = p.x;
@ -186,13 +194,15 @@ function buildModel(
// Pad the ground tile generously (0.7 = +70% context each side, #2179) so the // 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 // OSM substrate extends well beyond the parcel; the 4×4 tile cap below keeps it
// sharp at the same zoom. // sharp at the same zoom. Union bounds over ALL parts → the tile covers every
const rawBounds = ringBounds(parcelRingWgs); // part so neighbours around any of them land on it (a genuinely disjoint
// multi-part parcel yields a large, lower-detail tile — correct, not a bug).
const rawBounds = ringsBounds(parcelRingsWgs);
const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null; const groundBounds = rawBounds ? padBounds(rawBounds, 0.7) : null;
return { return {
buildings, buildings,
parcelRing, parcelRings,
bbox: { minX, maxX, minZ, maxZ }, bbox: { minX, maxX, minZ, maxZ },
centerX, centerX,
centerZ, centerZ,
@ -295,23 +305,33 @@ function buildBuildingsGroup(model: MassingModel): THREE.Group {
/** /**
* Parcel highlight (#2179): a translucent accent-soft FILL just above the ground * 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 * plane plus a prominent accent OUTLINE drawn for EVERY parcel part (a bare
* object. WebGL ignores LineBasicMaterial.linewidth, so the outline is doubled at * Polygon one ring; a MultiPolygon one fill + outline per part) so the whole
* two heights (y=0.05, y=0.10) to read thick from any camera angle without a new * lot reads, not just part 0. 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. * dependency.
*/ */
function buildParcelHighlight(model: MassingModel): THREE.Group { function buildParcelHighlight(model: MassingModel): THREE.Group {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = "parcel"; group.name = "parcel";
// Fill: THREE.Shape from the parcel ring → ShapeGeometry, laid into the ground // One shared outline material across every part (linewidth is a no-op in WebGL,
// so each part's outline is doubled at y=0.05 and y=0.10 to read prominently).
const outlineMat = new THREE.LineBasicMaterial({
color: COLOR_PARCEL_OUTLINE,
});
for (const ring of model.parcelRings) {
if (ring.length < 3) continue;
// Fill: THREE.Shape from the part ring → ShapeGeometry, laid into the ground
// plane (rotate −π/2 about X so the XY shape sits on the XZ world plane) and // 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). // 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(); const shape = new THREE.Shape();
shape.moveTo(model.parcelRing[0].x, model.parcelRing[0].z); shape.moveTo(ring[0].x, ring[0].z);
for (let i = 1; i < model.parcelRing.length; i++) { for (let i = 1; i < ring.length; i++) {
shape.lineTo(model.parcelRing[i].x, model.parcelRing[i].z); shape.lineTo(ring[i].x, ring[i].z);
} }
shape.closePath(); shape.closePath();
const fillGeo = new THREE.ShapeGeometry(shape); const fillGeo = new THREE.ShapeGeometry(shape);
@ -330,22 +350,18 @@ function buildParcelHighlight(model: MassingModel): THREE.Group {
fill.rotation.x = -Math.PI / 2; fill.rotation.x = -Math.PI / 2;
fill.position.y = 0.03; fill.position.y = 0.03;
group.add(fill); group.add(fill);
}
// Outline: accent LineLoop, doubled at y=0.05 and y=0.10 (linewidth is a no-op // Outline: accent LineLoop, doubled at y=0.05 and y=0.10.
// 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]) { for (const y of [0.05, 0.1]) {
const pts: number[] = []; const pts: number[] = [];
for (const p of model.parcelRing) { for (const p of ring) {
pts.push(p.x, y, p.z); pts.push(p.x, y, p.z);
} }
const geo = new THREE.BufferGeometry(); const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
group.add(new THREE.LineLoop(geo, outlineMat)); group.add(new THREE.LineLoop(geo, outlineMat));
} }
}
return group; return group;
} }

View file

@ -43,6 +43,7 @@ import {
type ConceptProgramFormState, type ConceptProgramFormState,
} from "@/components/concept/ConceptProgramForm"; } from "@/components/concept/ConceptProgramForm";
import { import {
extractParcelGeometry,
extractPolygon, extractPolygon,
polygonAreaSqm, polygonAreaSqm,
useCreateConcept, useCreateConcept,
@ -169,13 +170,25 @@ export function Section7Concept({
onProgramApplied, onProgramApplied,
onPendingChange, onPendingChange,
}: Props) { }: Props) {
// Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon / // Parcel polygon for the /concepts REQUEST — a single Polygon (MultiPolygon
// null → null; MultiPolygon collapses to its first ring per extractPolygon). // collapses to its LARGEST-area part per extractPolygon, so the generated corpus
// sits on the dominant part, not a sliver).
const polygon = useMemo( const polygon = useMemo(
() => extractPolygon(analysis.geom_geojson), () => extractPolygon(analysis.geom_geojson),
[analysis.geom_geojson], [analysis.geom_geojson],
); );
// WHOLE parcel geometry for the 2D/3D placement scenes — Polygon OR
// MultiPolygon preserving every part. The scenes derive their projection origin,
// OSM tile bounds and outline over ALL parts so neighbour context (anchored by
// the backend to the entire parcel geometry) lands geo-aligned rather than
// offset from part 0. Null only when the geometry itself is missing (same as
// `polygon`), so the parcel-present branch below always has both.
const parcelGeometry = useMemo(
() => extractParcelGeometry(analysis.geom_geojson),
[analysis.geom_geojson],
);
// Seed defaults from the analysis: inferred class/type + cadastral value. // Seed defaults from the analysis: inferred class/type + cadastral value.
// financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет // financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет
// зоны) — fall back to comfort / mid_rise so a manual run is still possible. // зоны) — fall back to comfort / mid_rise so a manual run is still possible.
@ -455,7 +468,7 @@ export function Section7Concept({
{concept.isSuccess && ( {concept.isSuccess && (
<ConceptVariantsResult <ConceptVariantsResult
parcel={polygon} parcel={parcelGeometry ?? polygon}
variants={concept.data.variants} variants={concept.data.variants}
cadNum={analysis.cad_num} cadNum={analysis.cad_num}
/> />

View file

@ -0,0 +1,102 @@
/**
* Unit tests for the parcel-geometry narrowers in concept-api.ts.
*
* These guard the §7 «Размещение застройки» multi-part fix:
* `extractPolygon` (the /concepts REQUEST geometry) must collapse a
* MultiPolygon to its LARGEST-area part, not `coordinates[0]` (which can be a
* sliver), so the generated corpus sits on the dominant part.
* `extractParcelGeometry` (the SCENE geometry) must preserve EVERY part so the
* outline / projection origin / OSM tile cover the whole parcel and stay
* geo-aligned with backend-anchored neighbours.
*/
import type { MultiPolygon, Polygon, Position } from "geojson";
import { describe, expect, it } from "vitest";
import { extractParcelGeometry, extractPolygon } from "../concept-api";
// Part 0: a small sliver near the origin (≈55 m × 55 m).
const SLIVER: Position[] = [
[60.6, 56.83],
[60.6005, 56.83],
[60.6005, 56.8305],
[60.6, 56.8305],
[60.6, 56.83],
];
// Part 1: a much larger square ~1 km east (the dominant part).
const MAIN: Position[] = [
[60.616, 56.83],
[60.62, 56.83],
[60.62, 56.834],
[60.616, 56.834],
[60.616, 56.83],
];
const MULTI: MultiPolygon = {
type: "MultiPolygon",
coordinates: [[SLIVER], [MAIN]],
};
describe("extractPolygon — /concepts request geometry", () => {
it("returns a bare Polygon unchanged", () => {
const poly: Polygon = { type: "Polygon", coordinates: [MAIN] };
expect(extractPolygon(poly)).toEqual(poly);
});
it("unwraps a Feature wrapping a Polygon", () => {
const result = extractPolygon({
type: "Feature",
properties: {},
geometry: { type: "Polygon", coordinates: [MAIN] },
});
expect(result).toEqual({ type: "Polygon", coordinates: [MAIN] });
});
it("collapses a MultiPolygon to its LARGEST part, not coordinates[0]", () => {
const result = extractPolygon(MULTI);
expect(result).not.toBeNull();
expect(result?.type).toBe("Polygon");
// Must pick MAIN (part 1), NOT the SLIVER at index 0.
expect(result?.coordinates).toEqual([MAIN]);
expect(result?.coordinates).not.toEqual([SLIVER]);
});
it("returns null on a non-polygonal / malformed shape", () => {
expect(extractPolygon(null)).toBeNull();
expect(extractPolygon({ type: "Point", coordinates: [1, 2] })).toBeNull();
expect(extractPolygon("nope")).toBeNull();
});
});
describe("extractParcelGeometry — whole scene geometry", () => {
it("returns a bare Polygon unchanged", () => {
const poly: Polygon = { type: "Polygon", coordinates: [MAIN] };
expect(extractParcelGeometry(poly)).toEqual(poly);
});
it("preserves EVERY part of a MultiPolygon (both parts, not just part 0)", () => {
const result = extractParcelGeometry(MULTI);
expect(result).not.toBeNull();
expect(result?.type).toBe("MultiPolygon");
const mp = result as MultiPolygon;
expect(mp.coordinates).toHaveLength(2);
expect(mp.coordinates[0]).toEqual([SLIVER]);
expect(mp.coordinates[1]).toEqual([MAIN]);
});
it("unwraps a Feature wrapping a MultiPolygon", () => {
const result = extractParcelGeometry({
type: "Feature",
properties: {},
geometry: MULTI,
});
expect(result?.type).toBe("MultiPolygon");
expect((result as MultiPolygon).coordinates).toHaveLength(2);
});
it("returns null on a non-polygonal / malformed shape", () => {
expect(extractParcelGeometry(null)).toBeNull();
expect(
extractParcelGeometry({ type: "LineString", coordinates: [] }),
).toBeNull();
});
});

View file

@ -15,8 +15,11 @@ import {
lon2tile, lon2tile,
makeProjector, makeProjector,
osmTileRangeForBbox, osmTileRangeForBbox,
polygonOuterRings,
ringBounds, ringBounds,
ringCentroid, ringCentroid,
ringsBounds,
ringsCentroid,
tile2lat, tile2lat,
tile2lon, tile2lon,
} from "../geo-local"; } from "../geo-local";
@ -84,6 +87,77 @@ describe("ringCentroid + ringBounds", () => {
}); });
}); });
describe("whole-parcel helpers (multi-part MultiPolygon)", () => {
// A MultiPolygon whose FIRST part is a small sliver at the origin and whose
// SECOND part is a larger square ~1 km east — the shape that displaced context
// in the §7 massing scene when only part 0 drove the frame (163 prod parcels,
// max 1611 m offset). Rings are closed (first == last).
const PART0_SLIVER: Position[] = [
[60.6, 56.83],
[60.6005, 56.83],
[60.6005, 56.8305],
[60.6, 56.8305],
[60.6, 56.83],
];
// Larger square ~1 km east (lon +0.016 ≈ 970 m at this latitude).
const PART1_MAIN: Position[] = [
[60.616, 56.83],
[60.62, 56.83],
[60.62, 56.834],
[60.616, 56.834],
[60.616, 56.83],
];
const RINGS = [PART0_SLIVER, PART1_MAIN];
it("polygonOuterRings returns every part's outer ring for a MultiPolygon", () => {
const rings = polygonOuterRings({
type: "MultiPolygon",
coordinates: [[PART0_SLIVER], [PART1_MAIN]],
});
expect(rings).toHaveLength(2);
expect(rings[0]).toBe(PART0_SLIVER);
expect(rings[1]).toBe(PART1_MAIN);
});
it("polygonOuterRings returns the single outer ring for a bare Polygon", () => {
const rings = polygonOuterRings({
type: "Polygon",
coordinates: [PART1_MAIN],
});
expect(rings).toHaveLength(1);
expect(rings[0]).toBe(PART1_MAIN);
});
it("ringsCentroid is the WHOLE-geometry centre, well east of part 0's", () => {
const whole = ringsCentroid(RINGS);
const part0 = ringCentroid(PART0_SLIVER);
// Part 0 sits at lon ≈ 60.60025; the whole-geometry centroid is pulled east
// toward the larger second part — the core of the fix.
expect(part0[0]).toBeCloseTo(60.60025, 4);
expect(whole[0]).toBeGreaterThan(part0[0] + 0.005);
// It must fall between the two parts, not on either alone.
expect(whole[0]).toBeGreaterThan(60.6);
expect(whole[0]).toBeLessThan(60.62);
});
it("ringsBounds unions ALL parts (so the OSM tile covers every part)", () => {
const b = ringsBounds(RINGS);
expect(b).not.toBeNull();
// Spans from part 0's west edge to part 1's east edge.
expect(b?.minLon).toBeCloseTo(60.6, 6);
expect(b?.maxLon).toBeCloseTo(60.62, 6);
expect(b?.minLat).toBeCloseTo(56.83, 6);
expect(b?.maxLat).toBeCloseTo(56.834, 6);
// A part-0-only bound would stop at 60.6005 — assert we went well past it.
expect(b?.maxLon).toBeGreaterThan(60.6005 + 0.01);
});
it("ringBounds / ringCentroid still delegate correctly for one ring", () => {
expect(ringBounds(PART1_MAIN)).toEqual(ringsBounds([PART1_MAIN]));
expect(ringCentroid(PART1_MAIN)).toEqual(ringsCentroid([PART1_MAIN]));
});
});
describe("slippy-tile math", () => { describe("slippy-tile math", () => {
it("known tile indices at z=0 / z=1", () => { it("known tile indices at z=0 / z=1", () => {
// The whole world is one tile at z=0. // The whole world is one tile at z=0.

View file

@ -15,7 +15,12 @@
*/ */
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { Feature, FeatureCollection, Polygon } from "geojson"; import type {
Feature,
FeatureCollection,
MultiPolygon,
Polygon,
} from "geojson";
import { apiFetch, apiFetchWithStatus } from "@/lib/api"; import { apiFetch, apiFetchWithStatus } from "@/lib/api";
@ -377,8 +382,15 @@ export function useCadastreGeom() {
/** /**
* Narrows an unknown GeoJSON value (e.g. the analyze endpoint's `geom_geojson`) * Narrows an unknown GeoJSON value (e.g. the analyze endpoint's `geom_geojson`)
* into a Polygon. Accepts a bare Polygon, a Feature wrapping a Polygon, or the * into a single Polygon the geometry POSTed as `parcel_geojson` to /concepts.
* first polygon ring of a MultiPolygon. Returns null on any other shape. * Accepts a bare Polygon, a Feature wrapping one, or a MultiPolygon (collapsed to
* its LARGEST-area sub-polygon, NOT `coordinates[0]`: for a multi-part parcel the
* first part can be a sliver, so the generative corpus must sit on the dominant
* part). Returns null on any other shape.
*
* NB: the massing SCENE (outline / projection origin / OSM tile) must instead use
* the WHOLE geometry via `extractParcelGeometry`, so it shares the frame the
* backend neighbour endpoint anchors context to.
*/ */
export function extractPolygon(geom: unknown): Polygon | null { export function extractPolygon(geom: unknown): Polygon | null {
if (geom == null || typeof geom !== "object") return null; if (geom == null || typeof geom !== "object") return null;
@ -398,8 +410,55 @@ export function extractPolygon(geom: unknown): Polygon | null {
}; };
} }
if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) { if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
const first = (g.coordinates as Polygon["coordinates"][])[0]; const parts = g.coordinates as Polygon["coordinates"][];
if (first) return { type: "Polygon", coordinates: first }; let best: Polygon["coordinates"] | null = null;
let bestArea = -Infinity;
for (const part of parts) {
if (!Array.isArray(part) || part.length === 0) continue;
const area = polygonAreaSqm({ type: "Polygon", coordinates: part });
if (area > bestArea) {
bestArea = area;
best = part;
}
}
if (best) return { type: "Polygon", coordinates: best };
}
return null;
}
/**
* Narrows an unknown GeoJSON value into the WHOLE parcel geometry a Polygon or a
* MultiPolygon preserving ALL sub-polygons (unlike `extractPolygon`, which drops
* to one part for the /concepts request). The §7 massing scene feeds this to
* derive its projection origin, OSM ground-tile bounds and drawn outline over
* every part, so neighbour context (anchored by the backend to the entire parcel
* geometry) lands geo-aligned instead of offset from part 0. Returns null on any
* non-polygonal shape.
*/
export function extractParcelGeometry(
geom: unknown,
): Polygon | MultiPolygon | null {
if (geom == null || typeof geom !== "object") return null;
const g = geom as {
type?: unknown;
coordinates?: unknown;
geometry?: unknown;
};
if (g.type === "Feature") {
return extractParcelGeometry((g as Feature).geometry);
}
if (g.type === "Polygon" && Array.isArray(g.coordinates)) {
return {
type: "Polygon",
coordinates: g.coordinates as Polygon["coordinates"],
};
}
if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
return {
type: "MultiPolygon",
coordinates: g.coordinates as MultiPolygon["coordinates"],
};
} }
return null; return null;
} }

View file

@ -12,7 +12,7 @@
* `polygonAreaSqm` in concept-api.ts (WGS84 equatorial radius) for consistency. * `polygonAreaSqm` in concept-api.ts (WGS84 equatorial radius) for consistency.
*/ */
import type { Position } from "geojson"; import type { MultiPolygon, Polygon, Position } from "geojson";
/** WGS84 equatorial radius (m) — same constant as `polygonAreaSqm`. */ /** WGS84 equatorial radius (m) — same constant as `polygonAreaSqm`. */
export const R = 6_378_137; export const R = 6_378_137;
@ -27,42 +27,84 @@ export interface Bounds {
maxLat: number; maxLat: number;
} }
/** Axis-aligned WGS84 bounds of a ring (or rings). Null on an empty/degenerate ring. */ /** Axis-aligned WGS84 union bounds over several rings. Null when all are empty. */
export function ringBounds(ring: Position[]): Bounds | null { export function ringsBounds(rings: Position[][]): Bounds | null {
let minLon = Infinity; let minLon = Infinity;
let minLat = Infinity; let minLat = Infinity;
let maxLon = -Infinity; let maxLon = -Infinity;
let maxLat = -Infinity; let maxLat = -Infinity;
for (const ring of rings) {
for (const [lon, lat] of ring) { for (const [lon, lat] of ring) {
if (lon < minLon) minLon = lon; if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon; if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat; if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat; if (lat > maxLat) maxLat = lat;
} }
}
if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null; if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null;
return { minLon, minLat, maxLon, maxLat }; 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 * Ring-average of unique vertices (skips the closing vertex), mirroring
* `polygonCentroidWkt`. Returns [lon, lat]. Falls back to [0, 0] on an empty ring. * `polygonCentroidWkt`. Returns [lon, lat]. Falls back to [0, 0] on an empty ring.
*/ */
export function ringCentroid(ring: Position[]): [number, number] { export function ringCentroid(ring: Position[]): [number, number] {
if (ring.length === 0) return [0, 0]; return ringsCentroid([ring]);
// 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];
/**
* 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) ───────────────────────────────────────── // ── projection (WGS84 → local metres) ─────────────────────────────────────────