From e6a420788f8fdffba4e641af2128c13470c78508 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 12:51:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(635-5):=20B05=20=EA=B2=BD=EC=9C=84?= =?UTF-8?q?=EB=8F=84=20=E2=86=94=20=EC=82=AC=EC=97=85=EC=A7=80=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20=EC=84=9C=EB=B2=84=20=EB=B3=80=ED=99=98=20=EA=B8=B8?= =?UTF-8?q?=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=EC=9D=B4=EB=8F=99=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=20=C2=B7=20=EC=86=8D=EC=84=B1=20=ED=8C=90=20=EA=B2=BD?= =?UTF-8?q?=EC=9C=84=EB=8F=84(=EC=9A=94=EA=B5=AC=20546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/projects/{id}/route/coords — 작업 좌표계(resolve_project_crs · WKT 그대로) pyproj 묶음 변환 - 찾기 이동 = 서버 변환 좌표 · 바탕 tm_grid 늘려 쓰기(ponytail) 걷음 - 키트 lonLat = 노드 고를 때 한 번 묻기 · 같은 점 캐시 - test_635_3 찾기 줄 = 서버 변환 부름으로 고침(옛 tm_grid 부름 걷힘) - 실측 검증_sub6 B: 50.4 km · 181.2 km 찾기 이동 어긋남 0.000 m · 경위도 = pyproj Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_Router_Coords.py | 67 +++++++++++++++++++ B05_Profile/B05_Profile_UI_ExpectedDraw.ts | 36 +++++----- B05_Profile/B05_Profile_UI_Panel_Plan.ts | 39 +++++++++++ main.py | 2 + resources/tester/test_635_3_b05_plan_panel.py | 2 +- resources/tester/test_635_5_b05_coords.py | 62 +++++++++++++++++ 6 files changed, 186 insertions(+), 22 deletions(-) create mode 100644 B05_Profile/B05_Profile_Router_Coords.py create mode 100644 resources/tester/test_635_5_b05_coords.py diff --git a/B05_Profile/B05_Profile_Router_Coords.py b/B05_Profile/B05_Profile_Router_Coords.py new file mode 100644 index 000000000..12e3a2269 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Coords.py @@ -0,0 +1,67 @@ +"""경위도 ↔ 사업지 좌표 묶음 변환(635-5 · 요구 546) — B05 위치 찾기 이동 · 속성 판 경위도. + +계산 자리 ③ 서버 단독 · 읽기만. 사업지 좌표계 = 작업 좌표계 창구(`resolve_project_crs` · 설계 노선 +`crs_input` 먼저 · WKT 원문 그대로 = 지역 TOWGS84 보존) · pyproj 한 번에 여러 점. +화면은 찾기 · 노드 고름 때만 부름(끌기 중 아님). + + POST /api/projects/{id}/route/coords {to: "plan" | "lonlat", points: [[a, b], …]} + → {points: [[a, b], …]} plan = 경위도 [lon, lat] → [x, y] m · lonlat = 반대 + 404 프로젝트 없음 · 422 점 꼴 틀림 +""" + +from __future__ import annotations + +import asyncio +from functools import lru_cache +from pathlib import Path +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from pyproj import Transformer + +from B05_Profile.B05_Profile_Router_Replan import _project_paths + +router = APIRouter(prefix="/api/projects", tags=["B05 Route Coords"]) + +MAX_POINTS = 5000 + + +class CoordsRequest(BaseModel): + to: Literal["plan", "lonlat"] + points: list[tuple[float, float]] = Field(max_length=MAX_POINTS) + + +def project_crs_text(root: Path) -> str: + """작업 좌표계 pyproj 입력(`EPSG:n` · WKT) — 라이다 `project_epsg` 와 같은 사다리.""" + from common_util.common_util_crs import resolve_project_crs + from common_util.common_util_route_geometry import load_design_route + + planned = load_design_route(root) + return resolve_project_crs(root, route_crs_input=planned.crs_input if planned else None) + + +@lru_cache(maxsize=32) +def _transformer(crs_text: str, to: str) -> Transformer: + pair = ("EPSG:4326", crs_text) if to == "plan" else (crs_text, "EPSG:4326") + return Transformer.from_crs(*pair, always_xy=True) + + +def convert(crs_text: str, to: str, points: list[tuple[float, float]]) -> list[list[float]]: + """점 묶음 변환 — 경위도 순서 [lon, lat] · 사업지 [x 동, y 북].""" + if not points: + return [] + xs, ys = zip(*points) + a, b = _transformer(crs_text, to).transform(xs, ys) + return [[float(p), float(q)] for p, q in zip(a, b)] + + +@router.post("/{project_id}/route/coords", response_model=None) +async def route_coords(project_id: UUID, body: CoordsRequest) -> dict: + paths = await _project_paths(project_id) + if paths is None: + raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") + crs_text = await asyncio.to_thread(project_crs_text, paths[0]) + points = await asyncio.to_thread(convert, crs_text, body.to, body.points) + return {"points": points} diff --git a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts index b0b04c79b..1f165a257 100644 --- a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts +++ b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts @@ -19,13 +19,8 @@ import { askConfirm } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { themeColor } from "@ui/ui_template_palette"; -import { - createTmNormalizer, - normalizedToScreen, - type ViewState, -} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { normOfLonLat } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan"; import { bindPlanInput, createPlanKit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit"; @@ -33,7 +28,7 @@ import type { ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site"; import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; -import { fillPlanPanel } from "./B05_Profile_UI_Panel_Plan"; +import { createPlanCoords, fillPlanPanel } from "./B05_Profile_UI_Panel_Plan"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; import { polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; @@ -119,22 +114,20 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] }; }; - // ── 위치 칸 — 찾기 → 지도 가운데로(지형 받기 · 기준점 없음 · 기능 표 「terrain」 숨김) ── - // ponytail: 경위도 → 화면 = 바탕 `tm_grid` 늘려 씀(50 km 28 m · 180 km 1.7 km 어긋남) — 먼 곳까지 맞출 땐 서버 변환. + // ── 위치 칸 — 찾기 → 지도 가운데로(지형 받기 · 기준점 없음 · 기능 표 「terrain」 숨김) · 좌표 = 서버 변환(635-5) ── + const coords = createPlanCoords(params.projectId, () => kit.props.render()); const find = createPlaceFind((hit) => { - const { meta, setView } = params.frame(); - const view = params.view(); - if (!meta || view.width <= 0) return; - const [sx, sy] = normalizedToScreen( - view, - ...normOfLonLat(createTmNormalizer(meta), hit.lon, hit.lat), - ); - setView({ - ...view, - offsetX: view.offsetX + view.width / 2 - sx, - offsetY: view.offsetY + view.height / 2 - sy, + void coords.toPlan(hit.lon, hit.lat).then((at) => { + const view = params.view(); + if (!at || view.width <= 0) return; + const [sx, sy] = params.toScreen(at); + params.frame().setView({ + ...view, + offsetX: view.offsetX + view.width / 2 - sx, + offsetY: view.offsetY + view.height / 2 - sy, + }); + params.redraw(); }); - params.redraw(); }); // ── 예정노선 키트(긋기 · 고르기 · 메뉴 · 속성 판 · 칸 = 예정노선 페이지와 같은 한 벌) ── @@ -147,6 +140,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { params.redraw(); }, heightAt: () => z, + lonLat: coords.lonLat, editable: () => open && !busy, idle: () => open, onMode, diff --git a/B05_Profile/B05_Profile_UI_Panel_Plan.ts b/B05_Profile/B05_Profile_UI_Panel_Plan.ts index 70374a5d0..769258e6c 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Plan.ts @@ -3,9 +3,13 @@ * 종단 보기 왼쪽 「위치」 · 「그리기」 칸 자리(PLAN 635-3 · 요구 546) — 예정노선 키트(`createPlanKit` 「b05」) 칸을 앉힘. * 칸은 페이지가 먼저 세우고 편집기는 노선을 읽은 뒤 섬 → 칸 = 빈 자리(order 1 · 2) · 편집기가 `fillPlanPanel` 로 채움. * 늦게 온 쪽이 붙임 · 새 칸 자리(페이지 다시 세움) = 떠난 편집기 칸(문서 밖) 버림 · 비면 자리 숨김. + * 좌표 변환(635-5) = 서버(`POST /route/coords` · 사업지 좌표계 pyproj) — 찾기 이동 · 속성 판 경위도 때만 부름. * ========================================================================== */ import { registerPanelSection } from "../A00_Common/design/design_panel"; +import { requestJson } from "./B05_Profile_Api_Replan"; + +type Vertex = [number, number]; type Slot = "site" | "draw"; const holders: Partial> = {}; @@ -45,3 +49,38 @@ export function fillPlanPanel(next: Record): void { attach(slot); } } + +export interface PlanCoords { + /** 경위도 → 사업지 [x, y] m(못 받으면 null). */ + toPlan: (lon: number, lat: number) => Promise; + /** 사업지 → 경위도 — 받은 점이면 바로 · 처음이면 null 주고 한 번 물음(오면 `onLonLat`). */ + lonLat: (x: number, y: number) => Vertex | null; +} + +/** 경위도 ↔ 사업지 좌표 서버 변환 · 같은 점은 캐시(끌기 중엔 안 부름 — 판은 고름 · 놓음 때만 그림). */ +export function createPlanCoords(projectId: string, onLonLat: () => void): PlanCoords { + const ask = (to: "plan" | "lonlat", point: Vertex): Promise => + requestJson<{ points: Vertex[] }>( + `/projects/${projectId}/route/coords`, + { method: "POST", body: JSON.stringify({ to, points: [point] }) }, + 15_000, + ).then( + (answer) => answer.points[0] ?? null, + () => null, + ); + const known = new Map(); + return { + toPlan: (lon, lat) => ask("plan", [lon, lat]), + lonLat(x, y) { + const key = `${x},${y}`; + if (known.has(key)) return known.get(key) ?? null; + known.set(key, null); // 묻는 중 + void ask("lonlat", [x, y]).then((at) => { + if (at) known.set(key, at); + else known.delete(key); // 실패 = 다음 고름 때 다시 + if (at) onLonLat(); + }); + return null; + }, + }; +} diff --git a/main.py b/main.py index 5e9fee2e8..cdc6beb4a 100644 --- a/main.py +++ b/main.py @@ -45,6 +45,7 @@ from B04_PreProcess.B04_PreProcess_Router_GIS import tiles_router from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_router from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router from B05_Profile.B05_Profile_Router import router as b05_route_router +from B05_Profile.B05_Profile_Router_Coords import router as b05_coords_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router from B05_Profile.B05_Profile_Router_Lidar import router as b05_route_lidar_router from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router @@ -516,6 +517,7 @@ app.include_router(e06_stab_rock_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_plan_source_router, dependencies=protected_with_company) +app.include_router(b05_coords_router, dependencies=protected_with_company) app.include_router(b05_route_terrain_router, dependencies=protected_with_company) app.include_router(b05_route_lidar_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) diff --git a/resources/tester/test_635_3_b05_plan_panel.py b/resources/tester/test_635_3_b05_plan_panel.py index 6d6fa454a..bc072670e 100644 --- a/resources/tester/test_635_3_b05_plan_panel.py +++ b/resources/tester/test_635_3_b05_plan_panel.py @@ -38,7 +38,7 @@ def test_키트_칸_위치_찾기_이동만_원본_꼭짓점(): body = ui.split("*/", 1)[1] assert 'page: "b05",' in body and "cards: { site: { parts: [find.root] } }," in body assert "fillPlanPanel({" in body and "createPlaceFind(" in body - assert "normOfLonLat(createTmNormalizer(meta), hit.lon, hit.lat)" in body # 지도 이동만 + assert "coords.toPlan(hit.lon, hit.lat)" in body # 지도 이동만(서버 변환 · 635-5) for banned in ("createPlanTerrain", "ensureCovered", "createPlanSite", "createFloatPanel"): assert banned not in body, banned assert "/route/plan/source" in body and "expected_source" in body diff --git a/resources/tester/test_635_5_b05_coords.py b/resources/tester/test_635_5_b05_coords.py new file mode 100644 index 000000000..b850ccc8b --- /dev/null +++ b/resources/tester/test_635_5_b05_coords.py @@ -0,0 +1,62 @@ +"""635-5 B05 경위도 ↔ 사업지 좌표 서버 변환(요구 546) — 찾기 이동 · 속성 판 경위도. + +- 서버 `POST /route/coords` = 작업 좌표계(`resolve_project_crs` · WKT 그대로) pyproj 묶음 변환. +- 화면 = 찾기 이동 · 노드 고름 때만 부름 · 같은 점 캐시 · 바탕 `tm_grid` 늘려 쓰기 걷음. + +./venv/Scripts/python.exe -m pytest resources/tester/test_635_5_b05_coords.py -q +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pyproj import Transformer + +from B05_Profile.B05_Profile_Router_Coords import CoordsRequest, convert + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" + +# 서울 근처 · 50 km · 180 km 떨어진 점(경위도) +POINTS = [(127.0, 37.5), (127.5, 37.6), (129.0, 36.5)] + + +@pytest.mark.parametrize("crs", ["EPSG:5176", "EPSG:5186", "EPSG:5179"]) +def test_묶음_변환_pyproj_대조_왕복(crs): + plan = convert(crs, "plan", POINTS) + want = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + for (lon, lat), (x, y) in zip(POINTS, plan): + wx, wy = want.transform(lon, lat) + assert abs(x - wx) < 1e-6 and abs(y - wy) < 1e-6 + back = convert(crs, "lonlat", [tuple(p) for p in plan]) + for (lon, lat), (blon, blat) in zip(POINTS, back): + assert abs(lon - blon) < 1e-7 and abs(lat - blat) < 1e-7 # 1 cm 안(Bessel 왕복) + + +def test_빈_묶음_점_꼴(): + assert convert("EPSG:5186", "plan", []) == [] + with pytest.raises(ValueError): + CoordsRequest(to="plan", points=[[1.0]]) + with pytest.raises(ValueError): + CoordsRequest(to="tm", points=[]) + + +def test_라우터_등록(): + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b05_coords_router, dependencies=protected_with_company)" in main + router = (B05 / "B05_Profile_Router_Coords.py").read_text(encoding="utf-8") + assert '@router.post("/{project_id}/route/coords"' in router + assert "resolve_project_crs(root, route_crs_input=" in router + + +def test_화면_서버_변환_찾기_고름만(): + panel = (B05 / "B05_Profile_UI_Panel_Plan.ts").read_text(encoding="utf-8") + assert "`/projects/${projectId}/route/coords`" in panel # requestJson 이 /api 를 붙임 + assert "known.has(key)" in panel # 같은 점 캐시 + ui = (B05 / "B05_Profile_UI_ExpectedDraw.ts").read_text(encoding="utf-8") + assert "coords.toPlan(hit.lon, hit.lat)" in ui and "lonLat: coords.lonLat," in ui + assert "normOfLonLat" not in ui and "tm_grid` 늘려" not in ui + # 끌기 중엔 판을 안 그림 — 속성 판은 고름 · 놓음(commit) 때만 + kit = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Kit.ts").read_text(encoding="utf-8") + assert "lonLat: params.lonLat," in kit