feat(635-5): B05 경위도 ↔ 사업지 좌표 서버 변환 길 · 찾기 이동 정확 · 속성 판 경위도(요구 546)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 12:51:03 +09:00
co-authored by Claude Opus 5.5
parent 946304e9bb
commit e6a420788f
6 changed files with 186 additions and 22 deletions
+67
View File
@@ -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}
+15 -21
View File
@@ -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,
+39
View File
@@ -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<Record<Slot, HTMLElement>> = {};
@@ -45,3 +49,38 @@ export function fillPlanPanel(next: Record<Slot, HTMLElement | null>): void {
attach(slot);
}
}
export interface PlanCoords {
/** 경위도 → 사업지 [x, y] m(못 받으면 null). */
toPlan: (lon: number, lat: number) => Promise<Vertex | null>;
/** 사업지 → 경위도 — 받은 점이면 바로 · 처음이면 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<Vertex | null> =>
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<string, Vertex | null>();
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;
},
};
}
+2
View File
@@ -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)
@@ -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
+62
View File
@@ -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