From 9815e0607e452f448428e9d916a32df5f71a936b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 7 Jul 2026 13:07:09 +0500 Subject: [PATCH] =?UTF-8?q?fix(concept):=20=C2=A77=20massing-=D1=81=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=81=D0=BE=D0=B7=D0=BD=D0=B0=D1=91?= =?UTF-8?q?=D1=82=20=D0=B2=D0=B5=D1=81=D1=8C=20MultiPolygon=20=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractPolygon схлопывал MultiPolygon до coordinates[0], поэтому 3D/2D massing-сцена брала начало проекции, bbox OSM-тайла и контур от ПЕРВОЙ части, а бэкенд анкорит соседей к ЦЕЛОЙ геометрии — для многочастных участков контекст уезжал до ~1.6км от part[0] (163 участка на проде, max 1611м). Сцена теперь считает origin (ringsCentroid), ground bounds (ringsBounds) и рисует контур по ВСЕМ частям (polygonOuterRings) целого Polygon|MultiPolygon; камера охватывает все части + корпуса. Запрос /concepts по-прежнему шлёт один Polygon, но берёт НАИБОЛЬШУЮ по площади часть, не coordinates[0] (побочный латентный баг — part[0] мог быть осколком). 2D Leaflet-карта фитится по всему экстенту. Одночастные участки не затронуты (helpers делегируют к прежней логике, байт-в-байт). ptica-песочница area-based → не затронута. Refs #2180 --- .../components/concept/ConceptResultMap.tsx | 26 +++-- .../concept/ConceptVariantsResult.tsx | 14 ++- .../src/components/concept/Massing3DScene.tsx | 94 +++++++++------- .../site-finder/analysis/Section7Concept.tsx | 19 +++- .../src/lib/__tests__/concept-api.test.ts | 102 ++++++++++++++++++ frontend/src/lib/__tests__/geo-local.test.ts | 74 +++++++++++++ frontend/src/lib/concept-api.ts | 69 +++++++++++- frontend/src/lib/geo-local.ts | 86 +++++++++++---- 8 files changed, 405 insertions(+), 79 deletions(-) create mode 100644 frontend/src/lib/__tests__/concept-api.test.ts diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx index 2c246f5a..5de1a096 100644 --- a/frontend/src/components/concept/ConceptResultMap.tsx +++ b/frontend/src/components/concept/ConceptResultMap.tsx @@ -9,7 +9,12 @@ import { useEffect, useMemo } from "react"; 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 "leaflet/dist/leaflet.css"; @@ -18,7 +23,16 @@ import { useNeighborBuildings } from "@/hooks/useNeighborBuildings"; // ── 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 maxLat = -Infinity; let minLon = Infinity; @@ -56,7 +70,8 @@ function FitBounds({ bounds }: FitBoundsProps) { // ── Component ───────────────────────────────────────────────────────────────── interface Props { - parcel: Polygon; + /** Parcel geometry — Polygon OR MultiPolygon (all parts drawn + fit to). */ + parcel: Polygon | MultiPolygon; buildings: FeatureCollection; /** Keying makes react-leaflet remount the buildings layer on variant switch. */ variantKey: string; @@ -78,10 +93,7 @@ export function ConceptResultMap({ // Memoize so FitBounds only re-fits when the parcel geometry actually // 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. - const bounds = useMemo( - () => ringBounds(parcel.coordinates as Position[][]), - [parcel.coordinates], - ); + const bounds = useMemo(() => geometryBounds(parcel), [parcel]); const hasBuildings = buildings.features.length > 0; // Соседние здания (#2180): контекст квартала под слоем корпусов. enabled только diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 1e9fffc2..14448afb 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -20,7 +20,7 @@ import { type ConceptVariant, type FinancialModel, } from "@/lib/concept-api"; -import type { Polygon } from "geojson"; +import type { MultiPolygon, Polygon } from "geojson"; import { ConceptExportButtons } from "./ConceptExportButtons"; @@ -214,7 +214,8 @@ function PlacementTab({ // ── Variant panel ───────────────────────────────────────────────────────────── interface PanelProps { - parcel: Polygon; + /** Parcel geometry — Polygon OR MultiPolygon (whole geometry, all parts). */ + parcel: Polygon | MultiPolygon; variant: ConceptVariant; /** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */ floorHeightM?: number; @@ -723,7 +724,14 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { // ── Component ───────────────────────────────────────────────────────────────── 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[]; /** * Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's diff --git a/frontend/src/components/concept/Massing3DScene.tsx b/frontend/src/components/concept/Massing3DScene.tsx index 01ebf706..5ac45945 100644 --- a/frontend/src/components/concept/Massing3DScene.tsx +++ b/frontend/src/components/concept/Massing3DScene.tsx @@ -33,8 +33,9 @@ import { makeProjector, osmTileRangeForBbox, padBounds, - ringBounds, - ringCentroid, + polygonOuterRings, + ringsBounds, + ringsCentroid, type Bounds, type Projector, } from "@/lib/geo-local"; @@ -73,8 +74,13 @@ const TILE_TIMEOUT_MS = 6000; // ── props ───────────────────────────────────────────────────────────────────── 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. */ variant: ConceptVariant; /** Visual floor height in metres; default 3.0 (the backend's constant). */ @@ -97,8 +103,9 @@ interface BuildingLocal { interface MassingModel { buildings: BuildingLocal[]; - parcelRing: { x: number; z: number }[]; - /** Local-metre bbox over all building rings (falls back to parcel ring). */ + /** Every parcel part's outer ring, projected to local metres (one per part). */ + parcelRings: { x: number; z: number }[][]; + /** Local-metre bbox over all parcel parts + building rings. */ bbox: { minX: number; maxX: number; minZ: number; maxZ: number }; centerX: number; centerZ: number; @@ -121,12 +128,13 @@ function featureFloors( } function buildModel( - parcel: Polygon, + parcel: Polygon | MultiPolygon, variant: ConceptVariant, floorHeightM: number, ): MassingModel { - const parcelRingWgs = (parcel.coordinates?.[0] ?? []) as Position[]; - const [lon0, lat0] = ringCentroid(parcelRingWgs); + // Whole-parcel-aware: outer ring of EVERY part (a bare Polygon → one ring). + const parcelRingsWgs = polygonOuterRings(parcel); + const [lon0, lat0] = ringsCentroid(parcelRingsWgs); const project = makeProjector(lon0, lat0); 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 maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity; - const pts = - buildings.length > 0 ? buildings.flatMap((b) => b.ring) : parcelRing; + const pts = [...parcelRings.flat(), ...buildings.flatMap((b) => b.ring)]; for (const p of pts) { if (p.x < minX) minX = 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 // OSM substrate extends well beyond the parcel; the 4×4 tile cap below keeps it - // sharp at the same zoom. - const rawBounds = ringBounds(parcelRingWgs); + // sharp at the same zoom. Union bounds over ALL parts → the tile covers every + // 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; return { buildings, - parcelRing, + parcelRings, bbox: { minX, maxX, minZ, maxZ }, centerX, centerZ, @@ -295,23 +305,33 @@ function buildBuildingsGroup(model: MassingModel): THREE.Group { /** * 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 - * 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 + * plane plus a prominent accent OUTLINE — drawn for EVERY parcel part (a bare + * Polygon → one ring; a MultiPolygon → one fill + outline per part) so the whole + * 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. */ function buildParcelHighlight(model: MassingModel): THREE.Group { const group = new THREE.Group(); group.name = "parcel"; - // Fill: THREE.Shape from the parcel 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). - if (model.parcelRing.length >= 3) { + // 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 + // lifted to y≈0.03 (above the ground substrate at y=−0.02, under the outline). const shape = new THREE.Shape(); - shape.moveTo(model.parcelRing[0].x, model.parcelRing[0].z); - for (let i = 1; i < model.parcelRing.length; i++) { - shape.lineTo(model.parcelRing[i].x, model.parcelRing[i].z); + shape.moveTo(ring[0].x, ring[0].z); + for (let i = 1; i < ring.length; i++) { + shape.lineTo(ring[i].x, ring[i].z); } shape.closePath(); const fillGeo = new THREE.ShapeGeometry(shape); @@ -330,21 +350,17 @@ function buildParcelHighlight(model: MassingModel): THREE.Group { fill.rotation.x = -Math.PI / 2; fill.position.y = 0.03; group.add(fill); - } - // Outline: accent LineLoop, doubled at y=0.05 and y=0.10 (linewidth is a no-op - // 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]) { - const pts: number[] = []; - for (const p of model.parcelRing) { - pts.push(p.x, y, p.z); + // Outline: accent LineLoop, doubled at y=0.05 and y=0.10. + for (const y of [0.05, 0.1]) { + const pts: number[] = []; + for (const p of ring) { + pts.push(p.x, y, p.z); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); + 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; diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx index 58871800..474055ad 100644 --- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -43,6 +43,7 @@ import { type ConceptProgramFormState, } from "@/components/concept/ConceptProgramForm"; import { + extractParcelGeometry, extractPolygon, polygonAreaSqm, useCreateConcept, @@ -169,13 +170,25 @@ export function Section7Concept({ onProgramApplied, onPendingChange, }: Props) { - // Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon / - // null → null; MultiPolygon collapses to its first ring per extractPolygon). + // Parcel polygon for the /concepts REQUEST — a single Polygon (MultiPolygon + // collapses to its LARGEST-area part per extractPolygon, so the generated corpus + // sits on the dominant part, not a sliver). const polygon = useMemo( () => extractPolygon(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. // financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет // зоны) — fall back to comfort / mid_rise so a manual run is still possible. @@ -455,7 +468,7 @@ export function Section7Concept({ {concept.isSuccess && ( diff --git a/frontend/src/lib/__tests__/concept-api.test.ts b/frontend/src/lib/__tests__/concept-api.test.ts new file mode 100644 index 00000000..2769ea00 --- /dev/null +++ b/frontend/src/lib/__tests__/concept-api.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/__tests__/geo-local.test.ts b/frontend/src/lib/__tests__/geo-local.test.ts index 2e61d688..7e4c3f24 100644 --- a/frontend/src/lib/__tests__/geo-local.test.ts +++ b/frontend/src/lib/__tests__/geo-local.test.ts @@ -15,8 +15,11 @@ import { lon2tile, makeProjector, osmTileRangeForBbox, + polygonOuterRings, ringBounds, ringCentroid, + ringsBounds, + ringsCentroid, tile2lat, tile2lon, } 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", () => { it("known tile indices at z=0 / z=1", () => { // The whole world is one tile at z=0. diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index c9c2aab6..a3e4ff60 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -15,7 +15,12 @@ */ 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"; @@ -377,8 +382,15 @@ export function useCadastreGeom() { /** * 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 - * first polygon ring of a MultiPolygon. Returns null on any other shape. + * into a single Polygon — the geometry POSTed as `parcel_geojson` to /concepts. + * 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 { 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)) { - const first = (g.coordinates as Polygon["coordinates"][])[0]; - if (first) return { type: "Polygon", coordinates: first }; + const parts = g.coordinates as Polygon["coordinates"][]; + 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; } diff --git a/frontend/src/lib/geo-local.ts b/frontend/src/lib/geo-local.ts index 6ba06e61..6ffcfc54 100644 --- a/frontend/src/lib/geo-local.ts +++ b/frontend/src/lib/geo-local.ts @@ -12,7 +12,7 @@ * `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`. */ export const R = 6_378_137; @@ -27,42 +27,84 @@ export interface Bounds { maxLat: number; } -/** Axis-aligned WGS84 bounds of a ring (or rings). Null on an empty/degenerate ring. */ -export function ringBounds(ring: Position[]): Bounds | null { +/** 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 [lon, lat] of ring) { - if (lon < minLon) minLon = lon; - if (lon > maxLon) maxLon = lon; - if (lat < minLat) minLat = lat; - if (lat > maxLat) maxLat = lat; + 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] { - 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 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; } - return [sLon / pts.length, sLat / pts.length]; + const outer = geom.coordinates[0]; + return outer && outer.length > 0 ? [outer] : []; } // ── projection (WGS84 → local metres) ───────────────────────────────────────── -- 2.45.3