fix(concept): §7 massing-сцена осознаёт весь MultiPolygon (соседи уезжали до 1.6км) (#2180) #2463
8 changed files with 405 additions and 79 deletions
|
|
@ -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 только
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
// plane (rotate −π/2 about X so the XY shape sits on the XZ world plane) and
|
// so each part's outline is doubled at y=0.05 and y=0.10 to read prominently).
|
||||||
// lifted to y≈0.03 (above the ground substrate at y=−0.02, under the outline).
|
const outlineMat = new THREE.LineBasicMaterial({
|
||||||
if (model.parcelRing.length >= 3) {
|
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
|
||||||
|
// lifted to y≈0.03 (above the ground substrate at y=−0.02, under the outline).
|
||||||
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,21 +350,17 @@ 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.
|
for (const y of [0.05, 0.1]) {
|
||||||
const outlineMat = new THREE.LineBasicMaterial({
|
const pts: number[] = [];
|
||||||
color: COLOR_PARCEL_OUTLINE,
|
for (const p of ring) {
|
||||||
});
|
pts.push(p.x, y, p.z);
|
||||||
for (const y of [0.05, 0.1]) {
|
}
|
||||||
const pts: number[] = [];
|
const geo = new THREE.BufferGeometry();
|
||||||
for (const p of model.parcelRing) {
|
geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
|
||||||
pts.push(p.x, y, p.z);
|
group.add(new THREE.LineLoop(geo, outlineMat));
|
||||||
}
|
}
|
||||||
const geo = new THREE.BufferGeometry();
|
|
||||||
geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
|
|
||||||
group.add(new THREE.LineLoop(geo, outlineMat));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
102
frontend/src/lib/__tests__/concept-api.test.ts
Normal file
102
frontend/src/lib/__tests__/concept-api.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 [lon, lat] of ring) {
|
for (const ring of rings) {
|
||||||
if (lon < minLon) minLon = lon;
|
for (const [lon, lat] of ring) {
|
||||||
if (lon > maxLon) maxLon = lon;
|
if (lon < minLon) minLon = lon;
|
||||||
if (lat < minLat) minLat = lat;
|
if (lon > maxLon) maxLon = lon;
|
||||||
if (lat > maxLat) maxLat = lat;
|
if (lat < minLat) minLat = 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] &&
|
* Outer ring(s) of a parcel geometry — one per Polygon part. A bare Polygon
|
||||||
ring[0][1] === ring[ring.length - 1][1];
|
* yields its single outer ring; a MultiPolygon yields EVERY part's outer ring, so
|
||||||
const pts = closed ? ring.slice(0, ring.length - 1) : ring;
|
* the massing scene can project origin, ground bounds and outline over the WHOLE
|
||||||
if (pts.length === 0) return [0, 0];
|
* parcel (the frame the neighbour endpoint anchors to). Inner rings (holes) are
|
||||||
let sLon = 0;
|
* dropped — origin/bounds/outline only need the outer boundary.
|
||||||
let sLat = 0;
|
*/
|
||||||
for (const [lon, lat] of pts) {
|
export function polygonOuterRings(geom: Polygon | MultiPolygon): Position[][] {
|
||||||
sLon += lon;
|
if (geom.type === "MultiPolygon") {
|
||||||
sLat += lat;
|
const out: Position[][] = [];
|
||||||
|
for (const part of geom.coordinates) {
|
||||||
|
const outer = part[0];
|
||||||
|
if (outer && outer.length > 0) out.push(outer);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
return [sLon / pts.length, sLat / pts.length];
|
const outer = geom.coordinates[0];
|
||||||
|
return outer && outer.length > 0 ? [outer] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── projection (WGS84 → local metres) ─────────────────────────────────────────
|
// ── projection (WGS84 → local metres) ─────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue