From 9f717a6a721df2e852e145b5d642ce54da8d1960 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 09:27:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EB=B6=84=EC=84=9D=20=EC=A7=80?= =?UTF-8?q?=ED=98=95=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=80=94=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=8B=A4=20=EC=9E=88=EB=8A=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=8F=84=20=EC=88=98=EC=B9=98=EC=A7=80?= =?UTF-8?q?=ED=98=95=EB=8F=84=EB=A1=9C=20=EB=B6=84=EC=84=9D(133-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 편집 모달 「분석 지형: 라이다 · 수치지형도」 고르기 · 라이다와 수치지형도 서피스가 둘 다 있을 때만 보임 · 기본 라이다 · 값은 화면 상태 pref(routeedit-terrain) - 고르면 등고선 · 구간 기울기 · 거리 재기 지반고 · 횡단 미리보기 · 종단 띠를 고른 지형으로 다시 그림 · 노선 추천도 고른 지형으로 요청 - 서버 통로(elevations · cross-preview · profile-preview · initial)가 terrain(las · sheet) 을 받아 sampler 를 고름 · GET route/terrain-sources 새 길 - 확정 params · [확인] 재계산 · 종 · 횡단 · 3D 정본은 안 바뀜 - 지표면 허용 목록에 sheet_* 여섯 넣음 — 54-22 뒤 LAS 없는 프로젝트 sheet_laplace 가 막혀 있던 길도 풀림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- A00_Common/b_page_state.ts | 2 + B05_Profile/B05_Profile_Api_Replan.ts | 37 +++- B05_Profile/B05_Profile_Api_RouteInitial.ts | 7 +- B05_Profile/B05_Profile_Router_Replan.py | 2 + .../B05_Profile_Router_RouteInitial.py | 29 ++- B05_Profile/B05_Profile_Router_Terrain.py | 32 +++- B05_Profile/B05_Profile_UI_RouteEdit.ts | 75 ++++++-- .../B05_Profile_UI_RouteEdit_Chrome.ts | 11 ++ .../B05_Profile_UI_RouteEdit_ProfileStrip.ts | 3 +- B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts | 1 + .../B05_Profile_UI_Style_RouteEdit.css | 13 ++ .../common_util_surface_confirmation.py | 29 ++- common_util/common_util_surface_sampler.py | 15 +- .../tester/test_133_1_analysis_terrain.py | 172 ++++++++++++++++++ resources/tester/test_75_profile_strip.py | 4 +- 15 files changed, 396 insertions(+), 36 deletions(-) create mode 100644 resources/tester/test_133_1_analysis_terrain.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index a25dc6edc..f2aa42d78 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -76,6 +76,8 @@ export const STATE_REGISTRY = { "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ "routeedit-locks": { bucket: "pref", scope: "project" }, + /** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */ + "routeedit-terrain": { bucket: "pref", scope: "project" }, "masshaul-open": { bucket: "pref", scope: "global", diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 25c80fe49..1a12fc0cc 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -6,12 +6,37 @@ * POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 * POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠) + * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) + * + * 편집 창 분석(지반고 · 횡단 미리보기 · 종단 띠 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 + * 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음. * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) * 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { readState, writeState } from "../A00_Common/b_page_state"; + +/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ +export type AnalysisTerrain = "las" | "sheet"; + +/** 고른 분석 지형 — 화면 상태 보관소 pref 통(노선 편집 고정과 같은 자리) · 기본 las. */ +export const analysisTerrain = (projectId: string): AnalysisTerrain => + readState("routeedit-terrain", projectId) === "sheet" ? "sheet" : "las"; + +export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void => + writeState("routeedit-terrain", terrain, projectId); + +/** 이 프로젝트가 고를 수 있는 분석 지형 — 둘이면 고르기를 띄운다. */ +export async function fetchTerrainSources(projectId: string): Promise { + const payload = await requestJson<{ sources: AnalysisTerrain[] }>( + `/projects/${projectId}/route/terrain-sources`, + { method: "GET" }, + 60000, + ); + return payload.sources ?? []; +} /** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */ const REPLAN_TIMEOUT_MS = 15 * 60 * 1000; @@ -153,7 +178,7 @@ export async function fetchRouteElevations( ): Promise> { const payload = await requestJson<{ z: Array }>( `/projects/${projectId}/route/elevations`, - { method: "POST", body: JSON.stringify({ points }) }, + { method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) }, 60000, ); return payload.z; @@ -196,7 +221,10 @@ export async function fetchCrossPreview( ): Promise { return requestJson( `/projects/${projectId}/route/cross-preview`, - { method: "POST", body: JSON.stringify(request) }, + { + method: "POST", + body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), + }, 120000, ); } @@ -219,7 +247,10 @@ export async function fetchProfilePreview( ): Promise { const payload = await requestJson<{ profile: unknown }>( `/projects/${projectId}/route/profile-preview`, - { method: "POST", body: JSON.stringify({ vertices, criteria }) }, + { + method: "POST", + body: JSON.stringify({ vertices, criteria, terrain: analysisTerrain(projectId) }), + }, 120000, ); return payload.profile; diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 83781dede..e2fbfa402 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -9,7 +9,7 @@ * 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다. * ========================================================================== */ -import { requestJson } from "./B05_Profile_Api_Replan"; +import { analysisTerrain, requestJson } from "./B05_Profile_Api_Replan"; /** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */ const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; @@ -56,7 +56,10 @@ export async function fetchRouteInitial( try { return await requestJson( `/projects/${projectId}/route/initial`, - { method: "POST", body: JSON.stringify(request) }, + { + method: "POST", + body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), + }, INITIAL_TIMEOUT_MS, ); } catch (error) { diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index dcdf6faf8..c928db5aa 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -120,6 +120,8 @@ class RouteInitialRequest(BaseModel): criteria: RouteInitialCriteria | None = None # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. with_profile: bool = True + # 분석 지형(PLAN 133-1) — 추천만 고른 지형으로 · [확인] 재계산은 확정 지표면 그대로. + terrain: Literal["las", "sheet"] = "las" class RouteReplanRequest(BaseModel): diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 120510fb1..2e8c91200 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -9,7 +9,7 @@ import functools import logging import time from pathlib import Path -from typing import Any +from typing import Any, Literal from uuid import UUID from fastapi.responses import JSONResponse @@ -38,7 +38,10 @@ async def initial_route( load_terrain_and_streams, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes - from common_util.common_util_surface_confirmation import get_surface_confirmation_params + from common_util.common_util_surface_confirmation import ( + analysis_terrain_params, + get_surface_confirmation_params, + ) weights = request.weights.model_dump() if request.weights else None if request.mode == "weighted" and weights and abs(sum(weights.values()) - 100.0) > 0.5: @@ -77,6 +80,7 @@ async def initial_route( pool = get_db_pool() async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) + selection = analysis_terrain_params(selection, request.terrain) started = time.perf_counter() try: terrain, streams = await asyncio.to_thread( @@ -207,12 +211,19 @@ async def balanced_profile_of( points: list[dict[str, float]], criteria: Any, design_curves: list[dict[str, Any]] | None = None, + terrain: Literal["las", "sheet"] = "las", ) -> dict[str, Any] | None: - """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀.""" + """노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀. + + `terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — [확인] 체인은 기본(확정 지표면). + """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile - from common_util.common_util_surface_confirmation import get_surface_confirmation_params + from common_util.common_util_surface_confirmation import ( + analysis_terrain_params, + get_surface_confirmation_params, + ) grade_class, design_speed, terrain_type = await replan._road_settings(project_id) max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0 @@ -221,15 +232,16 @@ async def balanced_profile_of( pool = get_db_pool() async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) + selection = analysis_terrain_params(selection, terrain) try: - terrain, _streams = await asyncio.to_thread( + surface, _streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) profile = await asyncio.to_thread( functools.partial( route_profile, [(p["x"], p["y"]) for p in points], - terrain, + surface, max_grade=max_grade, design_speed=design_speed or 20, design_curves=design_curves, @@ -281,6 +293,7 @@ class ProfilePreviewRequest(BaseModel): vertices: list[replan.RouteVertexInput] = Field(..., min_length=2) criteria: replan.RouteInitialCriteria | None = None + terrain: Literal["las", "sheet"] = "las" @replan.router.post("/{project_id}/route/profile-preview", response_model=None) @@ -308,7 +321,9 @@ async def profile_preview( # [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로. points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices] curves = [curve.as_dict() for curve in built.curves] - profile = await balanced_profile_of(project_id, project_root, points, request.criteria, curves) + profile = await balanced_profile_of( + project_id, project_root, points, request.criteria, curves, request.terrain + ) if profile is None: return JSONResponse( status_code=409, diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 2d0f8547b..7f55fb0ad 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -10,6 +10,10 @@ POST /api/projects/{id}/route/elevations → 점 묶음의 지반고 POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기 + GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) + +분석 지형(`terrain`, PLAN 133-1) — las = 확정 지표면 · sheet = 수치지형도 서피스. 모달 분석만 +고른 지형을 쓴다 — 확정 지표면 · 종 · 횡단 정본은 안 바뀐다. """ import asyncio @@ -30,7 +34,12 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import ( from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from common_util.common_util_route_polyline import build_planned_polyline from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_surface_confirmation import get_surface_confirmation_params +from common_util.common_util_surface_confirmation import ( + AnalysisTerrain, + analysis_terrain_params, + analysis_terrain_sources, + get_surface_confirmation_params, +) from common_util.common_util_surface_sampler import build_surface_sampler from config.config_db import get_db_pool @@ -47,6 +56,7 @@ class ElevationRequest(BaseModel): """사업지 좌표계(m) 점 묶음 [[x, y], …].""" points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS) + terrain: AnalysisTerrain = "las" def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]): @@ -81,6 +91,7 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, ) params = await get_surface_confirmation_params(connection, str(project_id)) + params = analysis_terrain_params(params, request.terrain) project_root = Path(resolve_stored_project_path(stored)) sampled = await asyncio.to_thread(_sample, project_root, params, request.points) @@ -120,6 +131,7 @@ class CrossPreviewRequest(BaseModel): station_interval_m: float | None = None #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. cross_half_width_m: float | None = Field(None, gt=0) + terrain: AnalysisTerrain = "las" def _cross_preview( @@ -213,6 +225,7 @@ async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, ) params = await get_surface_confirmation_params(connection, str(project_id)) + params = analysis_terrain_params(params, request.terrain) project_root = Path(resolve_stored_project_path(stored)) preview = await asyncio.to_thread(_cross_preview, project_root, params, request) @@ -225,3 +238,20 @@ async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> }, ) return {"status": "success", "project_id": str(project_id), **preview} + + +@router.get("/{project_id}/route/terrain-sources", response_model=None) +async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: + """편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김.""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, + ) + params = await get_surface_confirmation_params(connection, str(project_id)) + models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR + sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir) + return {"status": "success", "project_id": str(project_id), "sources": sources} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 12224f0cf..a5bb94258 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,7 +28,13 @@ import { type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; -import { fetchRoutePlan } from "./B05_Profile_Api_Replan"; +import { + analysisTerrain, + fetchRoutePlan, + fetchTerrainSources, + setAnalysisTerrain, + type AnalysisTerrain, +} from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import { buildEditedPolyline, @@ -103,7 +109,7 @@ export async function openRouteEditModal( const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; - const { minorContourButton } = chrome; + const { minorContourButton, terrainSelect } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -527,9 +533,10 @@ export async function openRouteEditModal( // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── try { - const [plan, drainage] = await Promise.all([ + const [plan, drainage, sources] = await Promise.all([ fetchRoutePlan(projectId), fetchDrainageLayers(projectId, () => {}), + fetchTerrainSources(projectId).catch((): AnalysisTerrain[] => []), ]); if (closed) return; expected = plan.expected as Vertex[]; @@ -553,28 +560,60 @@ export async function openRouteEditModal( syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; - // 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. - const background = await loadRouteEditBackground(projectId, drainage, { - surfaceModelId: options.surfaceModelId ?? null, - intervalM: options.contourIntervalM ?? 1, - smooth: options.smooth ?? false, - }); - otherSheets = background.otherSheets; - contours = background.contours; + // 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 기본 라이다. + const choosable = sources.includes("las") && sources.includes("sheet"); + terrainSelect.closest("label")!.hidden = !choosable; + if (!choosable && analysisTerrain(projectId) === "sheet") setAnalysisTerrain(projectId, "las"); + terrainSelect.value = analysisTerrain(projectId); + // 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. + const loadBackground = () => + loadRouteEditBackground(projectId, drainage, { + surfaceModelId: + analysisTerrain(projectId) === "sheet" ? null : (options.surfaceModelId ?? null), + intervalM: options.contourIntervalM ?? 1, + smooth: options.smooth ?? false, + }); + const useBackground = (background: Awaited>): void => { + otherSheets = background.otherSheets; + contours = background.contours; + pickedContour = -1; + // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. + minorContourButton.disabled = !contours?.minor; + minorContourButton.title = contours?.minor + ? "1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)" + : "LAS 등고선이 없어 1m 선이 없습니다"; + }; + const showStatus = (): void => { + status.textContent = + `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + + `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + + curveHint(); + }; + useBackground(await loadBackground()); if (closed) return; - // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. - minorContourButton.disabled = !contours?.minor; - if (!contours?.minor) minorContourButton.title = "LAS 등고선이 없어 1m 선이 없습니다"; + // 고르면 모달 안 분석을 고른 지형으로 다시 그림 — 등고선 · 기울기 · 종단 띠 · 횡단 · 잰 값. + let terrainTicket = 0; + terrainSelect.addEventListener("change", () => { + setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las"); + const mine = ++terrainTicket; + void loadBackground().then((background) => { + if (closed || mine !== terrainTicket) return; + useBackground(background); + measure.clear(); + slope.refresh(); + strip.refresh(); + void crossPreview.refresh(); + showStatus(); + draw(); + }); + }); resize(); // 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다. const opening = openingView(projectId, meta, view, planned); view = opening.view; rotation.set(opening.rotationRad); viewReady = true; - status.textContent = - `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + - `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + - curveHint(); + showStatus(); draw(); strip.refresh(); } catch (error) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 2a4ae3fda..51cc3a763 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -36,6 +36,8 @@ export interface RouteEditChrome { measureText: HTMLElement; measureButton: HTMLButtonElement; minorContourButton: HTMLButtonElement; + /** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */ + terrainSelect: HTMLSelectElement; } /** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ @@ -51,6 +53,13 @@ export function createRouteEditChrome(): RouteEditChrome { + @@ -126,6 +135,7 @@ export function createRouteEditChrome(): RouteEditChrome { const minorContourButton = overlay.querySelector( '[data-act="contour-minor"]', )!; + const terrainSelect = overlay.querySelector('[data-act="terrain"]')!; return { overlay, @@ -136,5 +146,6 @@ export function createRouteEditChrome(): RouteEditChrome { measureText, measureButton, minorContourButton, + terrainSelect, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts index 493f72db0..96b4da321 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import { + analysisTerrain, fetchProfilePreview, fetchRouteElevations, type RouteReplanVertex, @@ -68,7 +69,7 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip { function refresh(): void { const on = params.withProfile(); const { vertices, line, criteria } = params.now(); - const key = JSON.stringify([on, vertices, criteria]); + const key = JSON.stringify([on, vertices, criteria, analysisTerrain(projectId)]); if (vertices.length < 2 || line.length < 2 || key === lastKey) return; lastKey = key; pending = draw(on, vertices, line, criteria); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts index 3f79c5cba..e35789f81 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts @@ -141,6 +141,7 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { return { refresh() { asking = null; + cachedLine = null; // 분석 지형을 바꿔도 다시 묻게. }, draw(context, args) { let first = ""; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 941650876..04c5d95c6 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -241,6 +241,19 @@ background: var(--color-border); } +/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임. */ +.b05-routeedit__terrain { + display: inline-flex; + align-items: center; + gap: var(--spacing-4, 4px); + font-size: 12px; + color: var(--color-text-muted, inherit); +} + +.b05-routeedit__terrain[hidden] { + display: none; +} + /* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */ .b05-routeedit__measure { position: absolute; diff --git a/common_util/common_util_surface_confirmation.py b/common_util/common_util_surface_confirmation.py index 193d4a38a..5c429adaa 100644 --- a/common_util/common_util_surface_confirmation.py +++ b/common_util/common_util_surface_confirmation.py @@ -1,17 +1,24 @@ """WF1 지표면 확정 선택값의 기본값·DB 스냅샷 처리.""" import json -from typing import Any +from pathlib import Path +from typing import Any, Literal import aiomysql from config.config_system import ( + SHEET_SURFACE_DEFAULT_METHOD, SURFACE_CONFIRM_DEFAULT_FILTER, SURFACE_CONFIRM_DEFAULT_METHOD, SURFACE_CONFIRM_DEFAULT_SMOOTH, SURFACE_CONTOUR_INTERVAL_M, ) +#: B05 계획노선 편집 모달의 분석 지형 — las = 확정 지표면 그대로 · sheet = 수치지형도 서피스 +AnalysisTerrain = Literal["las", "sheet"] +#: 수치지형도 분석 지형 — LAS 없는 프로젝트 자동 확정(WF1)과 같은 모델 +SHEET_ANALYSIS_FILTER = f"sheet_{SHEET_SURFACE_DEFAULT_METHOD}" + SURFACE_CONFIRM_PARAM_KEYS = ( "source_filter", "method", @@ -63,6 +70,26 @@ async def get_surface_confirmation_params( return resolved +def _is_sheet(params: dict[str, Any]) -> bool: + return str(params.get("source_filter") or "").startswith("sheet_") + + +def analysis_terrain_params(params: dict[str, Any], terrain: AnalysisTerrain) -> dict[str, Any]: + """편집 모달 분석에 쓸 지표면 params — 확정 params 는 안 고침(정본 · 종 · 횡단 그대로).""" + if terrain != "sheet" or _is_sheet(params): + return params + return {**params, "source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": True} + + +def analysis_terrain_sources(params: dict[str, Any], models_dir: Path) -> list[str]: + """고를 수 있는 분석 지형 — 확정이 LAS 이고 수치지형도 서피스가 있을 때만 둘.""" + if _is_sheet(params): + return ["sheet"] + if (Path(models_dir) / f"dtm_{SHEET_ANALYSIS_FILTER}.npz").is_file(): + return ["las", "sheet"] + return ["las"] + + async def update_contour_interval_param( connection: aiomysql.Connection, project_id: str, diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index fddba295a..d07c9ca63 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -19,7 +19,20 @@ import numpy as np from scipy.interpolate import RegularGridInterpolator #: 지면 필터 · 지표면 표현 허용 목록 — 파일 이름에 들어감(B04 `_FILTERS` · `MODEL_METHODS`) -SurfaceFilter = Literal["classification", "grid_min_z", "csf", "pmf", "ransac"] +#: `sheet_*` = 수치지형도 등고선 서피스(B04 `SHEET_METHOD_LABELS` 키 앞에 `sheet_`) +SurfaceFilter = Literal[ + "classification", + "grid_min_z", + "csf", + "pmf", + "ransac", + "sheet_tin_sheet", + "sheet_tin", + "sheet_biharmonic", + "sheet_anudem", + "sheet_multires", + "sheet_laplace", +] SurfaceMethod = Literal["dtm", "tin", "nurbs", "implicit", "meshfree"] diff --git a/resources/tester/test_133_1_analysis_terrain.py b/resources/tester/test_133_1_analysis_terrain.py new file mode 100644 index 000000000..1d201148f --- /dev/null +++ b/resources/tester/test_133_1_analysis_terrain.py @@ -0,0 +1,172 @@ +"""PLAN 133-1 — B05 계획노선 편집 분석 지형(라이다 · 수치지형도) 고르기. + +라이다 있는 프로젝트 → 두 지형 다 고름 · sheet 고르면 dtm_sheet 로 표고 · 라이다 없는 프로젝트 → +고르기 안 보임(지형 하나) · 허용 목록 밖 거절 · 확정 params 는 안 바뀜. +""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path + +import numpy as np +import pytest +from pydantic import ValidationError + +from B05_Profile import B05_Profile_Router_Replan as replan +from B05_Profile import B05_Profile_Router_RouteInitial as initial +from B05_Profile import B05_Profile_Router_Terrain as terrain_router +from common_util.common_util_surface_confirmation import ( + SHEET_ANALYSIS_FILTER, + analysis_terrain_params, + analysis_terrain_sources, +) +from common_util.common_util_surface_sampler import build_surface_sampler, check_surface_keys + +ROOT = Path(__file__).resolve().parents[2] +LAS_PARAMS = {"source_filter": "csf", "method": "dtm", "smooth": True, "contour_interval_m": 1.0} +SHEET_PARAMS = { + "source_filter": SHEET_ANALYSIS_FILTER, + "method": "dtm", + "smooth": True, + "contour_interval_m": 1.0, +} +LAS_Z = 100.0 +SHEET_Z = 250.0 + + +def _dtm(path: Path, z: float) -> None: + x = np.arange(0.0, 11.0) + y = np.arange(0.0, 11.0) + np.savez( + path, + x=x, + y=y, + z=np.full((len(y), len(x)), z), + valid_mask=np.ones((len(y), len(x)), dtype=bool), + ) + + +def _project(tmp_path: Path, *, las: bool, sheet: bool) -> Path: + models = tmp_path / "B04_PreProcess" / "models" + models.mkdir(parents=True) + if las: + _dtm(models / "dtm_csf_smooth.npz", LAS_Z) + if sheet: # B04 처럼 원본 · 평활 둘 다(평활 쪽을 읽음) + _dtm(models / f"dtm_{SHEET_ANALYSIS_FILTER}.npz", SHEET_Z - 1.0) + _dtm(models / f"dtm_{SHEET_ANALYSIS_FILTER}_smooth.npz", SHEET_Z) + return tmp_path + + +def _patch_db(monkeypatch, project_root: Path, params: dict) -> None: + class _Pool: + @asynccontextmanager + async def acquire(self): + yield object() + + async def stored(_connection, _project_id): + return "x" + + async def confirmed(_connection, _project_id): + return dict(params) + + monkeypatch.setattr(terrain_router, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(terrain_router, "get_project_storage_relative_path", stored) + monkeypatch.setattr(terrain_router, "get_surface_confirmation_params", confirmed) + monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: project_root) + + +PID = "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a" + + +def _elevations(terrain: str) -> dict: + request = terrain_router.ElevationRequest(points=[(5.0, 5.0)], terrain=terrain) + return asyncio.run(terrain_router.read_route_elevations(PID, request)) + + +def test_라이다_있는_프로젝트_두_지형_다_고름(tmp_path, monkeypatch): + root = _project(tmp_path, las=True, sheet=True) + models = root / "B04_PreProcess" / "models" + assert analysis_terrain_sources(LAS_PARAMS, models) == ["las", "sheet"] + _patch_db(monkeypatch, root, LAS_PARAMS) + answer = asyncio.run(terrain_router.read_terrain_sources(PID)) + assert answer["sources"] == ["las", "sheet"] + + +def test_sheet_고르면_dtm_sheet_로_표고(tmp_path, monkeypatch): + root = _project(tmp_path, las=True, sheet=True) + _patch_db(monkeypatch, root, LAS_PARAMS) + assert _elevations("las")["z"] == [LAS_Z] + assert _elevations("sheet")["z"] == [SHEET_Z] + # 기본값 = 지금 동작(확정 지표면 · 라이다) + request = terrain_router.ElevationRequest(points=[(5.0, 5.0)]) + assert request.terrain == "las" + + +def test_확정_params_는_안_바뀜(): + params = dict(LAS_PARAMS) + picked = analysis_terrain_params(params, "sheet") + assert picked["source_filter"] == SHEET_ANALYSIS_FILTER == "sheet_laplace" + assert picked["method"] == "dtm" and picked["smooth"] is True + assert params == LAS_PARAMS + assert analysis_terrain_params(params, "las") is params + + +def test_라이다_없는_프로젝트_고르기_안_보임(tmp_path, monkeypatch): + root = _project(tmp_path, las=False, sheet=True) + models = root / "B04_PreProcess" / "models" + assert analysis_terrain_sources(SHEET_PARAMS, models) == ["sheet"] + # 확정이 이미 수치지형도 — 어느 값을 받아도 같은 서피스 + assert analysis_terrain_params(SHEET_PARAMS, "sheet") is SHEET_PARAMS + _patch_db(monkeypatch, root, SHEET_PARAMS) + assert asyncio.run(terrain_router.read_terrain_sources(PID))["sources"] == ["sheet"] + # 허용 목록에 sheet_* 가 들어가 LAS 없는 프로젝트 지반고가 읽힘(54-22 뒤 막혀 있던 길) + assert _elevations("las")["z"] == [SHEET_Z] + + +def test_수치지형도_서피스_없으면_라이다만(tmp_path): + root = _project(tmp_path, las=True, sheet=False) + assert analysis_terrain_sources(LAS_PARAMS, root / "B04_PreProcess" / "models") == ["las"] + + +@pytest.mark.parametrize("bad", ["lidar", "SHEET", "../x", ""]) +def test_허용_목록_밖_거절(bad): + with pytest.raises(ValidationError): + terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad) + with pytest.raises(ValidationError): + terrain_router.CrossPreviewRequest( + vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad + ) + with pytest.raises(ValidationError): + initial.ProfilePreviewRequest(vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], terrain=bad) + with pytest.raises(ValidationError): + replan.RouteInitialRequest(terrain=bad) + + +@pytest.mark.parametrize("bad", ["sheet", "sheet_../x", "sheet_LAPLACE", "dtm_sheet_laplace"]) +def test_sheet_필터도_허용_목록_검사(bad, tmp_path): + with pytest.raises(ValueError): + check_surface_keys(bad) + with pytest.raises(ValueError): + build_surface_sampler(tmp_path, bad, "dtm", True) + check_surface_keys("sheet_laplace") + + +def test_화면_고르기_배선(): + page = ROOT / "B05_Profile" + chrome = (page / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8") + main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + api = (page / "B05_Profile_Api_Replan.ts").read_text(encoding="utf-8") + initial_api = (page / "B05_Profile_Api_RouteInitial.ts").read_text(encoding="utf-8") + # 기본 숨김 · 둘 다 있을 때만 보임 · 기본 라이다 + assert '