diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index ee918e151..7315ea67c 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -126,7 +126,7 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}, ) params = await get_surface_confirmation_params(connection, str(project_id)) - confirmed = await _confirmed_model(connection, project_id) + model = await _design_model(connection, project_id, params) models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR sources = await choosable_terrains(connection, project_id, params, models_dir) return { @@ -134,7 +134,7 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse: "project_id": str(project_id), "sources": sources, "design": design_terrain(params), # 지금 설계 지형(PLAN 133-2) - "grid": await asyncio.to_thread(_design_grid, params, models_dir, confirmed), + "grid": await asyncio.to_thread(_design_grid, params, models_dir, model), } @@ -156,17 +156,21 @@ async def choosable_terrains( return sources -async def _confirmed_model( - connection: aiomysql.Connection, project_id: UUID +async def _design_model( + connection: aiomysql.Connection, project_id: UUID, params: dict ) -> tuple[int, str, str] | None: - """확정 지표면 모델 (id, 표현, 파일 경로) — 없으면 None.""" + """설계 지형 DTM 모델 (id, 표현, 파일 경로) — 서버 표본기처럼 stage 1 params 의 `dtm_{filter}.npz` + (확정 행 먼저). 확정 행이 params 와 어긋나도 서버와 같은 바닥을 받게(578-1) · 없으면 None.""" + name = f"dtm_{params.get('source_filter') or ''}.npz" async with connection.cursor() as cursor: await cursor.execute( "SELECT id, model_type, model_file_path FROM surface_models" - " WHERE project_id = %s AND status = 'CONFIRMED' ORDER BY created_at DESC LIMIT 1", + " WHERE project_id = %s AND model_type = 'dtm' AND status IN ('CONFIRMED', 'COMPLETE')" + " ORDER BY status = 'CONFIRMED' DESC, created_at DESC", (str(project_id),), ) - row = await cursor.fetchone() + rows = await cursor.fetchall() + row = next((r for r in rows if Path(str(r[2] or "")).name == name), None) return (int(row[0]), str(row[1]), str(row[2] or "")) if row else None diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 3a5679da4..85f3c3b92 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -6,6 +6,8 @@ * 만든다. 여기 있는 것은 전부 **읽어서 붙이는 값**이라 카드 상태(줌·선택)를 건드리지 않는다. * ========================================================================== */ +import { currentProjectId } from "../A00_Common/b_project_switch"; +import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { buildDesignControls, sectionModeLabel } from "./B06_Section_UI_Cross_Design"; @@ -108,7 +110,15 @@ export function appendCardHeader( meta.className = "b06-cross-card__meta"; // 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지 // 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정). - if (section.design?.slope_unclosed) { + // 지형 격자가 없으면(DTM 아닌 지형) 브라우저는 받은 표본 끝에서 멈춤 — 값은 [저장] 때 서버가 넓혀 셈(578-1) + if (section.design?.slope_unclosed && !designTerrainGridNow(currentProjectId())) { + const outside = document.createElement("span"); + outside.className = "ui-badge ui-badge--pill ui-badge--warning b06-cross-card__outside"; + outside.setAttribute("data-size", "20"); + outside.textContent = L("B06_Cross_TerrainOutside"); + outside.title = L("B06_Cross_TerrainOutside_Tip"); + meta.append(outside); + } else if (section.design?.slope_unclosed) { const openSlope = document.createElement("span"); openSlope.className = "b06-cross-card__warning"; openSlope.textContent = `⚠ ${L("B06_Cross_SlopeUnclosed")}`; diff --git a/resources/tester/test_133_1_analysis_terrain.py b/resources/tester/test_133_1_analysis_terrain.py index 0c4e97281..87df11b00 100644 --- a/resources/tester/test_133_1_analysis_terrain.py +++ b/resources/tester/test_133_1_analysis_terrain.py @@ -75,10 +75,10 @@ def _patch_db(monkeypatch, project_root: Path, params: dict) -> None: monkeypatch.setattr(terrain_router, "get_surface_confirmation_params", confirmed) monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: project_root) - async def no_model(_connection, _project_id): # 격자 받을 자리(344-1)는 따로 시험 + async def no_model(_connection, _project_id, _params): # 격자 받을 자리(344-1)는 따로 시험 return None - monkeypatch.setattr(terrain_router, "_confirmed_model", no_model) + monkeypatch.setattr(terrain_router, "_design_model", no_model) PID = "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a" diff --git a/resources/tester/test_344_1_terrain_grid_route.py b/resources/tester/test_344_1_terrain_grid_route.py index 05c363bc3..1d4ec8669 100644 --- a/resources/tester/test_344_1_terrain_grid_route.py +++ b/resources/tester/test_344_1_terrain_grid_route.py @@ -141,3 +141,28 @@ def test_지반고_자릿수_기본_3_묶음은_6(tmp_path, monkeypatch): ) assert plain["z"] == [round(5.5 * 0.1234567, 3)] assert fine["z"] == [round(5.5 * 0.1234567, 6)] + + +def test_격자_모델은_params_파일_확정_행이_어긋나도(monkeypatch): + """578-1 — 확정 행(sheet)이 params(csf)와 어긋나도 서버 표본기와 같은 `dtm_{filter}.npz` 행을 고름.""" + rows = [ + (9, "dtm", "B04_PreProcess/models/dtm_sheet_laplace.npz"), # 확정(어긋남) + (7, "dtm", "B04_PreProcess/models/dtm_csf.npz"), + ] + + class _Cursor: + async def execute(self, *_args): + return None + + async def fetchall(self): + return rows + + class _Connection: + @asynccontextmanager + async def cursor(self): + yield _Cursor() + + pick = asyncio.run(terrain_router._design_model(_Connection(), PID, PARAMS)) + assert pick == (7, "dtm", "B04_PreProcess/models/dtm_csf.npz") + other = {**PARAMS, "source_filter": "pmf"} + assert asyncio.run(terrain_router._design_model(_Connection(), PID, other)) is None diff --git a/resources/tester/test_505_2_terrain_sheet.py b/resources/tester/test_505_2_terrain_sheet.py index 2464e7f5b..f739c17f4 100644 --- a/resources/tester/test_505_2_terrain_sheet.py +++ b/resources/tester/test_505_2_terrain_sheet.py @@ -70,7 +70,7 @@ def sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(terrain, "get_db_pool", lambda: _Pool()) monkeypatch.setattr(terrain, "get_project_storage_relative_path", _async("p")) monkeypatch.setattr(terrain, "get_surface_confirmation_params", _async(dict(LAS))) - monkeypatch.setattr(terrain, "_confirmed_model", _async(None)) + monkeypatch.setattr(terrain, "_design_model", _async(None)) monkeypatch.setattr(terrain, "resolve_stored_project_path", lambda stored: tmp_path) monkeypatch.setattr(terrain, "_design_grid", lambda *args: None) diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 288889187..a8cb7c849 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -318,6 +318,12 @@ export const ui_locales_b2 = { "사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.", "The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.", ], + /* 지형 격자를 못 받은 지형(DTM 아님)에서 사면이 받은 지반 표본 밖으로 나간 측점(578-1). */ + B06_Cross_TerrainOutside: ["지형 범위 밖", "Outside terrain range"], + B06_Cross_TerrainOutside_Tip: [ + "사면이 화면이 받은 지반 표본 밖으로 나가 화면 면적은 그 끝에서 잘린 값입니다. [저장] 때 서버가 지형 끝까지 넓혀 다시 셉니다.", + "The slope runs past the ground samples this screen received, so the area shown is cut off there. On [Save] the server extends to the terrain edge and recomputes.", + ], /* 성토사면 경사길이 — 구조물이 사면을 끊기 전 **본래** 길이(2026-09-03 사용자 지시). */ B06_Cross_FillSlope: ["성토사면", "Fill slope"], B06_Cross_FillSlope_Tip: [