From 4eec3a4b37d5db07d779c7cd375cc3fe5b659493 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 21 Oct 2026 20:41:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(509-5=20=C2=B7=20509-2=20B05=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A7=80=ED=98=95):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=3D=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20=EC=B0=BD=20=EC=95=88?= =?UTF-8?q?=20=C2=B7=20=EC=A7=80=ED=98=95=EB=A7=88=EB=8B=A4=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EB=AF=B8=EB=A6=AC=20=EC=85=88=20=C2=B7=20[?= =?UTF-8?q?=EC=A0=81=EC=9A=A9]=20=3D=20=EC=B0=BD=20=EC=A7=80=ED=98=95?= =?UTF-8?q?=EC=9D=B4=20=EC=84=A4=EA=B3=84=20=EC=A7=80=ED=98=95=20=C2=B7=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=EC=9D=80=20=EC=A7=80=ED=98=95=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EB=A7=8C=20=C2=B7=20=EC=84=A4=EA=B3=84=20=EC=A7=80?= =?UTF-8?q?=ED=98=95=20=EA=B2=A9=EC=9E=90=20=ED=95=98=EB=82=98(=EC=88=98?= =?UTF-8?q?=EC=B9=98=EC=A7=80=ED=98=95=EB=8F=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=EA=B7=B8=20=EA=B2=A9=EC=9E=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전처리 미리 셈이 고를 수 있는 지형마다(설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만) 다섯 갈래 + 초기 종단 - 창 지형 바꿈 = 그 지형 저장본만 · 없으면 409 안내(+[재계산]) · 기본값 판정에서 지형 뺌 - POST /route/initial terrain = 창 지형 · [적용] draft.terrain = 창 지형 · 손 [저장]은 지형 안 바꿈(서버도 as_initial 일 때만) - 평면 분석 지형 고르기 · 화면 취향(routeedit-terrain) 걷음 · 지반고 · 묶음 초안 = 설계 지형(null) · 적용 뒤 받아 둔 격자 버림 - 시험 test_509_5 · test_509_2 · 걷은 고르기를 보던 옛 시험 새 동작으로(352_3 건너뜀) Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 2 - A00_Common/design/design_pipeline_page.ts | 8 +- B05_Profile/B05_Profile_Api_Replan.ts | 43 +---- B05_Profile/B05_Profile_Api_RouteInitial.ts | 8 +- B05_Profile/B05_Profile_Api_TerrainGrid.ts | 8 +- .../B05_Profile_Engine_RouteInitial_Store.py | 5 +- B05_Profile/B05_Profile_Router_Replan.py | 2 +- .../B05_Profile_Router_RouteInitial.py | 68 +++++--- B05_Profile/B05_Profile_Router_Terrain.py | 35 ++-- B05_Profile/B05_Profile_UI_RouteEdit.ts | 49 ++---- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 19 ++- .../B05_Profile_UI_RouteEdit_Chrome.ts | 16 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 15 +- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 2 + .../B05_Profile_UI_RouteEdit_Recommend.ts | 31 +++- .../B05_Profile_UI_Style_RouteRecommend.css | 13 ++ .../tester/helper_346_1_pipeline_page.cjs | 1 - resources/tester/helper_346_3_route_save.cjs | 13 +- .../tester/test_133_1_analysis_terrain.py | 14 +- resources/tester/test_133_2_design_terrain.py | 11 +- .../test_309_3_recommend_preprocess_only.py | 5 +- resources/tester/test_346_1_pipeline_page.py | 2 +- .../tester/test_346_3_route_save_screen.py | 11 +- resources/tester/test_351_1_recommend_gaps.py | 2 +- .../test_352_3_analysis_terrain_default.py | 5 + .../tester/test_364_4_precompute_on_open.py | 5 + resources/tester/test_430_route_edit_rules.py | 2 +- resources/tester/test_509_2_sheet_grid.py | 56 +++++++ .../tester/test_509_5_recommend_terrain.py | 155 ++++++++++++++++++ .../tester/test_b05_routeedit_initial.py | 4 +- 30 files changed, 441 insertions(+), 169 deletions(-) create mode 100644 resources/tester/test_509_2_sheet_grid.py create mode 100644 resources/tester/test_509_5_recommend_terrain.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 8d6e8082e..5462c2491 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -72,8 +72,6 @@ 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" }, /** B05 계획노선 편집 창 — 측점 스냅 간격(10 · station · 280-4) */ "routeedit-snap-step": { bucket: "pref", scope: "project" }, "masshaul-open": { diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 8be170cbc..cb2021ab2 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -61,11 +61,7 @@ import { savedPlusRows, } from "../../B05_Profile/B05_Profile_Api_PlusStations"; import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History"; -import { - analysisTerrain, - fetchRoutePlan, - type RoutePlanResponse, -} from "../../B05_Profile/B05_Profile_Api_Replan"; +import { fetchRoutePlan, type RoutePlanResponse } from "../../B05_Profile/B05_Profile_Api_Replan"; import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data"; import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types"; @@ -637,7 +633,7 @@ function createPagePipeline( const wasDraft = pipe.input("route") !== null; let draft: RouteDraft | null = null; if (edit) { - draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) }; + draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: null }; // 설계 지형(509-5) if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored; } if (draft && !wasDraft && !held) held = holdNow(); diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index e34cec101..2e4f384ee 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -6,51 +6,24 @@ * POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial) * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) * - * 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어 - * 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음. + * 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이 + * 그 지형을 설계 지형으로(509-5). * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) * 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import { readState, writeState } from "../A00_Common/b_page_state"; import { heightAt } from "../common_util/common_util_surface_sampler"; import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types"; import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits"; import type { RouteReset } from "./logic/B05_Profile_Route_Reset"; -/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */ +/** 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. 고르기는 추천 노선 창 안(509-5). */ export type AnalysisTerrain = "las" | "sheet"; -/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */ -const terrainContext = new Map(); - -export const setTerrainContext = ( - projectId: string, - choosable: boolean, - design: AnalysisTerrain, -): void => void terrainContext.set(projectId, { choosable, design }); - -/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 · - * 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다). - * 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */ -export const analysisTerrain = ( - projectId: string, - fallback: AnalysisTerrain = "las", -): AnalysisTerrain => { - const context = terrainContext.get(projectId); - if (context && !context.choosable) return context.design; - const saved = readState("routeedit-terrain", projectId); - return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback); -}; - -/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */ -export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void => - writeState("routeedit-terrain", terrain, projectId); - -/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 고르기를 띄운다)과 지금 설계 지형. */ +/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 추천 창에 고르기)과 지금 설계 지형. */ export async function fetchTerrainSources( projectId: string, ): Promise<{ sources: AnalysisTerrain[]; design: AnalysisTerrain }> { @@ -216,14 +189,14 @@ export async function replanRoute( * * 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안 * 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. - * 설계 지형 격자를 받아 두었고 분석 지형이 그것과 같으면 **브라우저 표본**(서버와 같은 값 · - * PLAN 344-1) — 아니면 서버에 물음. 자릿수는 서버 응답과 같게 mm. */ + * 설계 지형 격자를 받아 두었으면 **브라우저 표본**(서버와 같은 값 · PLAN 344-1) — 아니면 + * 서버에 물음. 자릿수는 서버 응답과 같게 mm. */ export async function fetchRouteElevations( projectId: string, points: Array<[number, number]>, ): Promise> { const local = await loadDesignTerrainGrid(projectId); - if (local && analysisTerrain(projectId) === local.design) { + if (local) { return points.map(([x, y]) => { const z = heightAt(local.grid, x, y); return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000; @@ -231,7 +204,7 @@ export async function fetchRouteElevations( } const payload = await requestJson<{ z: Array }>( `/projects/${projectId}/route/elevations`, - { method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) }, + { method: "POST", body: JSON.stringify({ points }) }, 60000, ); return payload.z; diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index b822fb7bb..3c076ed39 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -2,14 +2,14 @@ * B05_Profile_Api_RouteInitial.ts * 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다. * - * POST /projects/{id}/route/initial { mode, weights?, criteria? } → 노드 · 비교값 · 공통 제약 기준값 + * POST /projects/{id}/route/initial { mode, weights?, criteria?, terrain? } → 노드 · 비교값 · 공통 제약 기준값 * * 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고, * 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) — * 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다. * ========================================================================== */ -import { analysisTerrain, requestJson } from "./B05_Profile_Api_Replan"; +import { requestJson, type AnalysisTerrain } from "./B05_Profile_Api_Replan"; /** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */ const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; @@ -44,6 +44,8 @@ export interface RouteInitialRequest { with_profile?: boolean; /** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */ recompute?: boolean; + /** 창에서 고른 지형(509-5) — 지형마다 미리 셈 저장본 · 안 보내면 설계 지형. */ + terrain?: AnalysisTerrain; } /** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */ @@ -60,7 +62,7 @@ export async function fetchRouteInitial( `/projects/${projectId}/route/initial`, { method: "POST", - body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }), + body: JSON.stringify(request), }, INITIAL_TIMEOUT_MS, ); diff --git a/B05_Profile/B05_Profile_Api_TerrainGrid.ts b/B05_Profile/B05_Profile_Api_TerrainGrid.ts index 405d627e8..a689672d5 100644 --- a/B05_Profile/B05_Profile_Api_TerrainGrid.ts +++ b/B05_Profile/B05_Profile_Api_TerrainGrid.ts @@ -17,7 +17,7 @@ import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_s import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround"; export interface DesignTerrainGrid { - /** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */ + /** 이 격자의 지형 — 계산 묶음 노선 초안 지형(null = 설계)이 이것과 같을 때만 씀. */ design: "las" | "sheet"; grid: DtmGrid; } @@ -65,6 +65,12 @@ export function loadDesignTerrainGrid(projectId: string): Promise bool: - """지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로. + """지킬 결과 — 기본값(빈 제약 · 50/25/25) 또는 저장된 제약(`initial_choice`) 그대로 · 지형은 + 안 따짐(지형마다 미리 셈 · 509-5 · `terrain` 은 부르는 쪽 꼴 맞춤). 모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림). """ - if terrain is not None: - return False filled = {key: value for key, value in (criteria or {}).items() if value is not None} used = resolve_weights(mode, weights) if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS): diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 57f9320a7..b1104b39e 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -637,7 +637,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any if request.balanced_profile and as_initial else None ), - request.terrain, + request.terrain if as_initial else None, # 지형 바꿈 = 추천 [적용]만(509-5) (old_line, await asyncio.to_thread(_vertices_of, working_path)), as_initial, ) diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 56682676f..7e3c62b1c 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -6,6 +6,7 @@ import asyncio import functools +import itertools import json import logging import time @@ -26,7 +27,7 @@ logger = logging.getLogger(__name__) # 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜) _RUNNING: dict[str, dict[str, Any]] = {} # 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 -NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다." +NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다." @replan.router.post("/{project_id}/route/initial", response_model=None) @@ -249,7 +250,8 @@ async def project_standard(project_id: UUID) -> dict[str, Any] | None: async def precompute_defaults(project_id: UUID) -> None: - """체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯. + """체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 × + 고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만). 모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만. 다섯 갈래 모두 종단 켬(420) · 첫머리에 30일 지난 다른 판 · 소스 지문 저장본을 지운다(420-2). @@ -260,11 +262,13 @@ async def precompute_defaults(project_id: UUID) -> None: started = time.perf_counter() variants: list[tuple[Any, Any]] = [(None, None)] modes = list(DEFAULT_MODES) + terrains: list[Any] = [None] try: paths = await replan._project_paths(project_id) except LookupError: paths = None if paths is not None: + terrains = await precompute_terrains(project_id, paths[0]) removed = await asyncio.to_thread(purge_outdated, paths[0]) if removed: logger.info("추천 옛 판 저장본 지움: project_id=%s %d", project_id, removed) @@ -276,29 +280,51 @@ async def precompute_defaults(project_id: UUID) -> None: modes.sort(key=lambda mode: mode != saved["logic"] or mode == "follow") progress = _RUNNING.get(str(project_id)) if progress is not None: - progress.update(total=len(variants) * len(modes)) - for criteria, weights in variants: - for mode in modes: - if progress is not None: - progress["mode"] = mode - # 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420) - request = replan.RouteInitialRequest( - mode=mode, - with_profile=True, - criteria=criteria, - weights=weights if mode == "weighted" else None, + progress.update(total=len(terrains) * len(variants) * len(modes)) + for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes): + if progress is not None: + progress["mode"] = mode + # 화면과 같은 몸통 — 창 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420) + request = replan.RouteInitialRequest( + mode=mode, + with_profile=True, + criteria=criteria, + weights=weights if mode == "weighted" else None, + terrain=terrain, + ) + outcome = await compute_initial(project_id, request, precompute=True) + if isinstance(outcome, JSONResponse): + logger.warning( + "추천 미리 셈 멈춤(%s %s %s): project_id=%s", + terrain, + mode, + outcome.status_code, + project_id, ) - outcome = await compute_initial(project_id, request, precompute=True) - if isinstance(outcome, JSONResponse): - logger.warning( - "추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id - ) - return - if progress is not None: - progress["done"] += 1 + return + if progress is not None: + progress["done"] += 1 logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) +async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any]: + """미리 셈 지형(509-5) — 설계 지형 먼저 · 추천 창이 고를 수 있는 다른 지형.""" + from B05_Profile.B05_Profile_Router_Terrain import _MODELS_SUBDIR, choosable_terrains + from common_util.common_util_surface_confirmation import ( + design_terrain, + get_surface_confirmation_params, + ) + + pool = get_db_pool() + async with pool.acquire() as connection: + params = await get_surface_confirmation_params(connection, str(project_id)) + sources = await choosable_terrains( + connection, project_id, params, Path(project_root) / _MODELS_SUBDIR + ) + design = design_terrain(params) + return [design, *[source for source in sources if source != design]] + + async def run_precompute(project_id: UUID) -> None: """미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음).""" key = str(project_id) diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index b17af5d7e..27faaa26c 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -13,7 +13,7 @@ GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag) 분석 지형(`terrain`, PLAN 133-1) — las = 라이다 · sheet = 수치지형도 · None = 지금 설계 지형. -모달 분석만 고른 지형을 쓴다 — 설계 지형(확정)은 [확인] 때만 바뀜(133-2 · `/route/replan`). +고르기는 추천 노선 창 안(509-5) — 설계 지형(확정)은 추천 [적용] 때만 바뀜(`/route/replan`). """ import asyncio @@ -116,11 +116,7 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> @router.get("/{project_id}/route/terrain-sources", response_model=None) async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: - """편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김. - - 설계 지형 아닌 쪽은 DB 완료 모델이 있어야 둠 — [확인]이 그 모델로 바꾸므로(505-2 5d).""" - from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection - + """추천 창이 고를 수 있는 분석 지형(509-5 · 둘이면 고르기) · 설계 지형 · 설계 지형 격자 자리.""" pool = get_db_pool() async with pool.acquire() as connection: stored = await get_project_storage_relative_path(connection, project_id) @@ -132,14 +128,7 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: params = await get_surface_confirmation_params(connection, str(project_id)) confirmed = await _confirmed_model(connection, project_id) models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR - sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir) - for source in [s for s in sources if s != design_terrain(params)]: - try: - await find_surface_model_for_selection( - connection, project_id, analysis_terrain_params(params, source) - ) - except LookupError: - sources.remove(source) + sources = await choosable_terrains(connection, project_id, params, models_dir) return { "status": "success", "project_id": str(project_id), @@ -149,6 +138,24 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: } +async def choosable_terrains( + connection: aiomysql.Connection, project_id: UUID, params: dict, models_dir: Path +) -> list[str]: + """고를 수 있는 분석 지형 — 설계 지형 아닌 쪽은 DB 완료 모델이 있어야 둠(505-2 5d) · 추천 창 + 고르기 · 지형마다 추천 미리 셈(509-5)이 같이 씀.""" + from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection + + sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir) + for source in [s for s in sources if s != design_terrain(params)]: + try: + await find_surface_model_for_selection( + connection, project_id, analysis_terrain_params(params, source) + ) + except LookupError: + sources.remove(source) + return sources + + async def _confirmed_model( connection: aiomysql.Connection, project_id: UUID ) -> tuple[int, str, str] | None: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index f3a802301..31e605047 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -31,11 +31,8 @@ import { import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { - analysisTerrain, fetchRoutePlan, fetchTerrainSources, - setAnalysisTerrain, - setTerrainContext, type AnalysisTerrain, } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; @@ -116,7 +113,7 @@ export async function mountRouteEditor( const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(parent, options.tools); const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome; - const { minorContourButton, terrainSelect, stationSnapButton } = chrome; + const { minorContourButton, terrainName, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -161,11 +158,10 @@ export async function mountRouteEditor( let meta: VWorldMeta | null = null; /** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */ let contours: RouteEditContours | null = null; - /** 지금 설계 지형(PLAN 133-2) — 고른 분석 지형이 이와 다르면 [확인]이 설계 지형을 바꾼다. */ + /** 지금 설계 지형 — 편집 분석이 쓰는 지형 · 바꾸기는 추천 창 [적용](509-5). */ let designTerrain: AnalysisTerrain = "las"; - /** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */ - const pickedTerrain = (): AnalysisTerrain | null => - terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId); + /** 추천 창이 고를 지형 — `/route/terrain-sources` 를 읽은 뒤. */ + let terrainSources: { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null = null; /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ @@ -267,6 +263,7 @@ export async function mountRouteEditor( planned: () => planned, planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), + terrains: () => terrainSources, }); function resize(): void { @@ -447,8 +444,8 @@ export async function mountRouteEditor( historyControls.sync(); } - /** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430 · 433-14 ②). */ - const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed(); + /** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */ + const otherChanged = (): boolean => snap.changed(); function tellDraft(): void { if (!history) return; const dirty = lineChanged(history) || otherChanged(); @@ -587,9 +584,7 @@ export async function mountRouteEditor( const unbindApply = bindRouteApply({ projectId, nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }), - // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). changed: () => (!!history && lineChanged(history)) || otherChanged(), - terrain: pickedTerrain, snap: snap.draft, initial, onApplied, @@ -633,21 +628,16 @@ export async function mountRouteEditor( syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; - // 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3). - // 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억. + // 설계 지형 이름만(509-5) — 고르기는 추천 창 안 · 둘 다 있을 때만 이름을 보임. designTerrain = sources?.design ?? "las"; - const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet"); - terrainSelect.closest("label")!.hidden = !choosable; - setTerrainContext(projectId, choosable, designTerrain); - terrainSelect.value = analysisTerrain(projectId); + terrainSources = sources; + terrainName.hidden = !(sources?.sources.includes("las") && sources.sources.includes("sheet")); + terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`; // 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. // 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽). const loadBackground = () => loadRouteEditBackground(projectId, drainage, { - surfaceModelId: - analysisTerrain(projectId) === "las" && designTerrain === "las" - ? (options.surfaceModelId ?? null) - : null, + surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null, intervalM: options.contourIntervalM ?? 1, smooth: options.smooth ?? false, }); @@ -670,21 +660,6 @@ export async function mountRouteEditor( }; useBackground(await loadBackground()); if (closed) return; - // 고르면 모달 안 분석을 고른 지형으로 다시 그림 — 등고선 · 기울기 · 종단 띠 · 횡단 · 잰 값. - let terrainTicket = 0; - terrainSelect.addEventListener("change", () => { - setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las"); - tellDraft(); // 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3). - const mine = ++terrainTicket; - void loadBackground().then((background) => { - if (closed || mine !== terrainTicket) return; - useBackground(background); - measure.clear(); - slope.refresh(); - showStatus(); - draw(); - }); - }); viewWanted = true; resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때). showStatus(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index cf4e1fdee..effc05e50 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -6,7 +6,8 @@ * `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B). * · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지 * 한 번에 · 다 저장한 뒤 `routeSaved` · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`). - * · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정). + * · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정) · + * 창에서 고른 지형이 설계 지형(509-5 · 손 [저장]은 지형 안 바꿈). * 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나). * 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽). * ========================================================================== */ @@ -30,6 +31,7 @@ import { import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import type { SavePart } from "../A00_Common/design/design_save_api"; import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch"; +import { forgetDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import { reloadSavedPlusStations } from "./B05_Profile_Api_PlusStations"; import { replanRoute, @@ -55,8 +57,6 @@ export interface RouteApplyParams { nodes: () => RouteNodes; /** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */ changed: () => boolean; - /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ - terrain: () => string | null; /** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */ snap: () => SnapSetting; /** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ @@ -95,7 +95,8 @@ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteD /** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂 * (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */ export function bindRouteApply(params: RouteApplyParams): () => void { - const settings = () => ({ ...params.snap(), terrain: params.terrain() }); + // 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈은 추천 [적용]만(509-5) + const settings = () => ({ ...params.snap(), terrain: null }); const source: RouteSource = { pending: async () => { const nodes = params.nodes(); @@ -191,18 +192,26 @@ export async function saveRecommendedRoute( nodes: RouteNodes, initial: unknown, balancedProfile: boolean, + terrain: string | null = null, ): Promise { const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null }; showLoadingOverlay(); try { const saved = await saveRouteDraft(projectId, { // 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장 - draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }), + // 창에서 고른 지형 = 설계 지형(509-5 · 서버 `switch_design_terrain`) + draft: routeDraftOf(nodes, { + ...settings, + stationSnap: true, + stationSnapStep: null, + terrain, + }), initial, balanced_profile: balancedProfile, as_initial: true, }); clearDrafts(projectId); + forgetDesignTerrainGrid(projectId); // 지형이 바뀌었으면 새 지형 격자로(509-2) await reloadSavedPlusStations(projectId); // 페이지를 다시 세워도 남는 옛 저장분(493-5) sources.get(projectId)?.saved(); const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index a59ecb239..dea91d8fc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -58,7 +58,8 @@ export interface RouteEditChrome { /** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */ stationSnapButton: HTMLButtonElement; /** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */ - terrainSelect: HTMLSelectElement; + /** 설계 지형 이름(509-5) — 고르기는 추천 창 안. */ + terrainName: HTMLElement; } /** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */ @@ -119,13 +120,8 @@ export function createRouteEditChrome( - +
@@ -253,7 +249,7 @@ export function createRouteEditChrome( const minorContourButton = overlay.querySelector( '[data-act="contour-minor"]', )!; - const terrainSelect = overlay.querySelector('[data-act="terrain"]')!; + const terrainName = overlay.querySelector('[data-slot="terrain-name"]')!; const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!; bindFootprintToggle(overlay.querySelector('[data-act="footprint"]')!); bindMarkToggle(overlay.querySelector('[data-act="stations"]')!, "stations"); @@ -269,6 +265,6 @@ export function createRouteEditChrome( measureButton, minorContourButton, stationSnapButton, - terrainSelect, + terrainName, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index b1cf1376a..c6dcf7fd7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -17,7 +17,11 @@ * 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다. * ========================================================================== */ -import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; +import { + fetchRoutePlan, + type AnalysisTerrain, + type RoutePlanResponse, +} from "./B05_Profile_Api_Replan"; import { fetchPrecomputeProgress, fetchRouteInitial, @@ -57,6 +61,8 @@ export interface InitialPanelParams { planMinRadiusM: () => number; /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ apply: (snapshot: RouteEditSnapshot, message: string) => void; + /** 추천 창이 고를 지형 · 지금 설계 지형(509-5) — 못 읽었으면 null(설계 지형 하나). */ + terrains: () => { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null; } export interface InitialPanel { @@ -126,6 +132,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { criteria: RouteInitialCriteria | null, withProfile: boolean, recompute: boolean, + terrain: AnalysisTerrain, ): Promise { const response = await fetchRouteInitial(projectId, { mode: logic, @@ -133,8 +140,9 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { ...(criteria ? { criteria } : {}), with_profile: withProfile, ...(recompute ? { recompute: true } : {}), + terrain, }); - return normalizeRouteInitial(response, logic, weights, criteria, withProfile); + return { ...normalizeRouteInitial(response, logic, weights, criteria, withProfile), terrain }; } recommendButton.addEventListener("click", () => { @@ -158,6 +166,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: params.planned().map(([x, y]) => [x, y]), request, + terrains: params.terrains()?.sources ?? [], + terrain: params.terrains()?.design ?? "las", progress: () => fetchPrecomputeProgress(projectId), confirmText: () => applyResetText(projectId), onApply: (picked) => { @@ -179,6 +189,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420) }, choiceWithProfile(picked), + picked.terrain ?? null, ); }, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 0fa86ec1c..8ffaf9250 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -215,6 +215,8 @@ export interface InitialChoice { /** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */ endsGradePct?: number | null; endsOverLimit?: boolean; + /** 추천 창에서 고른 지형(509-5) — [적용]이 설계 지형으로. 옛 캐시면 없음(설계 지형 그대로). */ + terrain?: "las" | "sheet"; } /** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index ce1a93a09..a58cbe090 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -13,6 +13,8 @@ * 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다. * 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달 * 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다. + * 지형 고르기(509-5 · 라이다 · 수치지형도 둘일 때) — 바꾸면 그 지형 미리 셈 저장본 · 없으면 안내 + [재계산] · + * [적용]하면 그 지형이 설계 지형(초기값도 그 지형으로). * 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다 * (끔이어도) · [적용]은 「종단 · 횡단 손 입력이 모두 지워짐」 확인 뒤(요구 277). * ========================================================================== */ @@ -25,6 +27,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { toggleButton } from "./B05_Profile_UI_Panel_Fields"; +import type { AnalysisTerrain } from "./B05_Profile_Api_Replan"; import type { PrecomputeProgress, RouteInitialCriteria, @@ -125,6 +128,8 @@ const CRITERIA_FIELD: Record< }, }; +const TERRAIN_NAME: Record = { las: "라이다", sheet: "수치지형도" }; + export interface RecommendParams { /** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */ criteria: RouteInitialCriteria | null; @@ -148,7 +153,11 @@ export interface RecommendParams { criteria: RouteInitialCriteria | null, withProfile: boolean, recompute: boolean, + terrain: AnalysisTerrain, ) => Promise; + /** 고를 수 있는 지형(509-5) — 둘이면 창에 고르기 · 처음 값 = 지금 설계 지형. */ + terrains: AnalysisTerrain[]; + terrain: AnalysisTerrain; /** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */ onApply: (choice: InitialChoice) => void; /** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */ @@ -169,6 +178,9 @@ export function openRecommendModal(params: RecommendParams): void { let preview: InitialChoice | null = null; /** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */ let profileOn = params.withProfile; + /** 창 지형(509-5) — 바꾸면 그 지형 저장본 · [적용]이 설계 지형으로. */ + let terrain = params.terrain; + const choosable = params.terrains.includes("las") && params.terrains.includes("sheet"); let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ let dirty = false; @@ -211,6 +223,12 @@ export function openRecommendModal(params: RecommendParams): void {
+