feat(765-4): 지형 칩 = 벌 맞바꿈 · LAS 나중 올리기 뒤 = 확인창 → 전처리 다시(두 벌 · B05~B09 초기화)

- B05_Profile_Router_TerrainSwitch: 칩 = swap_design_set(떠난 벌 [저장] 고침 살림 · 묶음 없음 · 묵음 = 한 번 지음 · 실패 = 통째 되돌림) · force = switch_terrain → run_surface_change_chain(765-5)
- 확인창 글: 바꿈 = 미리 지어 둔 그 지형 설계로 바꿔 끼움 · 전처리 다시 = 두 벌 새로 · B05~B09 초기화 · LAS 올림 끝 = 늘 전처리 다시
- 시험: test_626_1 칩 시험을 맞바꿈 · 전처리 다시 기준으로(옛 노선 그대로 다시 셈 길 없음) · test_637 force 시험 · 화면 줄 · test_763_3 서피스 바꾸는 자리 목록에 지형 칩

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 15:50:38 +09:00
co-authored by Claude Opus 5.5
parent a44fd98adf
commit 0dbe4d5f31
5 changed files with 82 additions and 110 deletions
+26 -50
View File
@@ -1,15 +1,15 @@
"""B05 설계 지형 바꿈(PLAN 626-1 · 요구 530 · 결정 A④ · D2) — 평면 도구줄 지형 칩.
"""B05 설계 지형 바꿈(PLAN 626-1 · 765-4 · 요구 530 · 605 · 606-1) — 평면 도구줄 지형 칩.
`POST /api/projects/{id}/route/design-terrain` 본문 `{terrain: "las" | "sheet"}` →
설계 지형만 바꾸고(`switch_design_terrain`) 노선 저장 체인을 다시 탐
(종단 · 횡단 · 배수 · 관 · 구조물 따라감 ·
계획고 옮김 없음 = 옛 · 새 계획 폴리라인 같음). 노선 · 노드 · 곡선표 = 지금 저장본 그대로(안 씀).
초기값(`initial_snapshot/`) · 추천 저장본 · 예정노선 안 씀(옛 추천 [적용] 길 `as_initial` 대신).
[저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(`common_util_save_guard`) ·
같은 지형 = 바꿀 것 없음(`force` = 같은 지형도 체인 다시 탐 — LAS 갈음 뒤 · PLAN 637-2).
`POST /api/projects/{id}/route/design-terrain` 본문 `{terrain: "las" | "sheet", force}`
- 칩 = 맞바꿈(`common_util_design_sets.swap_design_set`) — 지금 벌(노선 · 종단 · 횡단 · 배수 ·
토공 · 수량 · 초기값)을 묶음으로 떠 두고 전처리가 미리 지은 고른 지형 벌을 꺼내 끼움 · 떠난 벌의
[저장] 고침은 묶음에 살아 돌아오면 그대로 · 묶음 없음 · 지문 어긋남 = 그 자리에서 한 번 지음 ·
실패 = 통째 되돌림 · 같은 지형 = 바꿀 것 없음.
- `force` = LAS 나중 올리기 뒤 전처리 다시(765-5) — 고른 지형을 설계 지형으로 · B05~B09 초기화 ·
두 벌 다시(`run_surface_change_chain`).
[저장]과 같은 프로젝트 잠금(`common_util_save_guard`). 계산 자리 = ③ 서버 단독.
"""
import asyncio
import logging
from typing import Literal
from uuid import UUID
@@ -18,10 +18,6 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B05_Profile.B05_Profile_Router_Terrain import router
from common_util.common_util_surface_confirmation import (
design_terrain,
get_surface_confirmation_params,
)
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
@@ -29,7 +25,7 @@ logger = logging.getLogger(__name__)
class DesignTerrainRequest(BaseModel):
terrain: Literal["las", "sheet"]
#: 같은 지형도 다시 셈 — LAS 갈음 뒤 설계 지형이 라이다인 B(PLAN 637-2)
#: 전처리 다시(B05~B09 초기화 · 두 벌) — LAS 나중 올리기 뒤(765-5)
force: bool = False
@@ -37,45 +33,25 @@ class DesignTerrainRequest(BaseModel):
async def switch_route_design_terrain(
project_id: UUID, body: DesignTerrainRequest
) -> dict | JSONResponse:
"""설계 지형을 바꾸고 뒤 단계를 모두 다시 셈 — 노선 값 · 초기값은 그대로."""
from B05_Profile.B05_Profile_Router_Replan import (
_PROJECT_PATH_MISSING,
_planned_line,
_project_paths,
_recompute,
)
from B06_Section.B06_Section_Router_DesignSave import _restore
"""설계 지형 벌 맞바꿈 · `force` = 전처리 다시."""
from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain
from B05_Profile.B05_Profile_Router_Replan import _PROJECT_PATH_MISSING, _project_paths
from common_util import common_util_design_sets as sets
from common_util import common_util_save_guard as guard
lock = guard.project_lock(str(project_id))
async with lock:
async with guard.project_lock(str(project_id)):
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
pool = get_db_pool()
async with pool.acquire() as connection:
now = design_terrain(await get_surface_confirmation_params(connection, str(project_id)))
if now == body.terrain and not body.force:
return {"status": "success", "design": now, "changed": False}
rows = await guard.snapshot_rows(connection, str(project_id), surface=True)
files = await asyncio.to_thread(
guard.snapshot_storage, project_root, guard.STORAGE_SCOPE + guard.PIPE_SCOPE
)
try:
line = await asyncio.to_thread(_planned_line, project_root)
result = await _recompute(
project_id, project_root, stored_path, None, body.terrain, (line, line), False
)
if "error" in result:
await _restore(project_id, rows, files)
return JSONResponse(
status_code=409, content={"status": "error", "message": result["error"]}
)
except Exception:
await _restore(project_id, rows, files)
raise
finally:
files.discard()
logger.info("설계 지형 바꿈: project_id=%s %s → %s", project_id, now, body.terrain)
return {"status": "success", "design": body.terrain, "changed": True, **result}
if body.force:
async with get_db_pool().acquire() as connection:
model_id = await sets.switch_terrain(connection, project_id, body.terrain)
await run_surface_change_chain(project_id, model_id)
result = {"changed": True, "design": body.terrain, "preprocessed": True}
else:
result = await sets.swap_design_set(project_id, paths[0], body.terrain)
except (LookupError, RuntimeError) as exc:
return JSONResponse(status_code=409, content={"status": "error", "message": str(exc)})
logger.info("설계 지형 바꿈: project_id=%s %s", project_id, result)
return {"status": "success", **result}
+16 -13
View File
@@ -2,10 +2,11 @@
* B05_Profile_UI_RouteEdit_Terrain.ts
* 평면 설계 지형 칩(PLAN 626-1 · 요구 530 · 결정 A④ · D2) — 공용 지형 칩(`map_terrain_chip`)으로 설계 지형 보기 · 바꾸기.
* 고를 수 있는 지형 = `/route/terrain-sources`(없는 쪽 막힘) · 바꾸면 확인창 → `POST /route/design-terrain`
* (설계 지형 바꿈 · 뒤 단계 모두 다시 셈 · 노선 값 · 초기값 · 추천 저장본 그대로 · 잠금 막) → 초안 비움 → 페이지 다시 세움.
* (765-4 맞바꿈 — 전처리가 미리 지은 그 지형 벌로 바꿔 끼움 · 떠난 벌의 [저장] 고침은 살아 돌아오면 그대로 · 잠금 막)
* → 초안 비움 → 페이지 다시 세움.
* 취소 · 실패 = 칩을 지금 지형으로 되돌림. 옛 길(추천 [적용] 안 지형 고르기 · 509-5) 대신.
* 칩 옆 [LAS 올리기](637-3 · 공용 `map_lidar_upload` · 예정노선과 같은 단추) → `POST /route/lidar` → 진행 서클(1 초 묻기)
* → 끝나면 칩 다시 읽고 확인창 「설계 지형을 라이다로 바꿈?」(이미 라이다 = 갈음 → `force` 다시 셈) ·
* → 끝나면 칩 다시 읽고 확인창 「라이다로 전처리 다시」(`force` — 두 벌 새로 · B05~B09 초기화 · 765-5) ·
* 노선을 다 못 덮음(`covered` 거짓) = 라이다로 바꿈 막고 알림 · 설계 값은 확인창을 눌러야만 바뀜.
* 부르는 곳 = `_Chrome`(도구줄 `terrain` 칸) · `_RouteEdit`(지형 읽은 뒤 `load`).
* ========================================================================== */
@@ -82,7 +83,7 @@ export function createRouteTerrain(projectId: string): RouteTerrain {
note(job);
load({ sources: job.sources, design: job.design === "sheet" ? "sheet" : "las" });
if (uncovered != null) return warnUncovered();
await change("las", design === "las");
await change("las", true); // 전처리 다시 — 두 벌 새로 · B05~B09 초기화(765-5)
}
const note = (info: RouteLidar): void => {
@@ -106,26 +107,28 @@ export function createRouteTerrain(projectId: string): RouteTerrain {
if (info.running && !upload.busy()) void upload.follow();
}
/** 설계 지형 바꿈 — `force` = 같은 지형 다시 셈(라이다 갈음 뒤). */
/** 설계 지형 바꿈(벌 맞바꿈) — `force` = 전처리 다시(LAS 나중 올리기 뒤). */
async function change(next: AnalysisTerrain, force = false): Promise<void> {
if (busy || upload.busy() || (next === design && !force)) return chip.set(toChip(design));
if (next === "las" && uncovered != null) {
warnUncovered();
return chip.set(toChip(design));
}
const name = TERRAIN_NAMES[toChip(next)];
const ok = await askConfirm({
title: force
? "설계 지형 다시 셈"
? "라이다로 전처리 다시"
: next === "las"
? "설계 지형을 라이다로 바꿈?"
: "설계 지형 바꿈",
message:
(force
? "새로 올린 라이다로 설계 지형을 다시 셈합니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. "
: `설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 바꿉니다 — 종단 · 횡단 · 배수 · 토공을 모두 다시 셈합니다. `) +
"평면 노선 · 초기값([초기화])은 그대로 · 저장 안 한 고침은 버립니다." +
(force ? " 안 하면 설계 값은 옛 라이다 셈 그대로입니다." : ""),
okLabel: force ? "다시 셈" : "바꿈",
message: force
? "새로 올린 라이다로 전처리를 다시 합니다 — 라이다 · 수치지형도 설계(노선 · 종단 · 횡단 · 배수 · " +
"토공 · 수량)를 새로 짓고 B05~B09 를 초기화합니다. 저장한 고침 · 초기값([초기화])도 새 값으로 " +
"바뀝니다. 안 하면 설계 값은 옛 지형 셈 그대로입니다."
: `설계 지형을 ${name}(으)로 바꿉니다 — 전처리 때 미리 지어 둔 ${name} 설계(노선 · 종단 · 횡단 · ` +
"배수 · 토공 · 수량)로 바꿔 끼웁니다. 지금 지형에서 [저장]한 고침은 남아 돌아오면 그대로 · " +
"저장 안 한 고침은 버립니다. 초기값([초기화])은 지형마다 따로입니다.",
okLabel: force ? "전처리 다시" : "바꿈",
});
if (!ok) return chip.set(toChip(design));
busy = true;
@@ -140,7 +143,7 @@ export function createRouteTerrain(projectId: string): RouteTerrain {
clearDrafts(projectId);
forgetDesignTerrainGrid(projectId); // 새 지형 격자로(509-2)
showToast(
`설계 지형을 ${TERRAIN_NAMES[toChip(next)]}(으)로 ${force ? "다시 셈했" : "바꿨"}습니다.`,
force ? "라이다로 전처리를 다시 했습니다." : `설계 지형을 ${name}(으)로 바꿨습니다.`,
"success",
);
await rebuildAfterRouteSave(projectId);
@@ -2,19 +2,18 @@
- 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 화면 부름 없음(서버 길 · 시험은 남김).
- 미리보기 = 저장본 넷(영선 · 토공 · 기울기 · 비중) 하나만 · 끔 · 다시 셈(`recompute`) 없음.
- 지형 칩 → `POST /route/design-terrain` — 노선 그대로(옛 = 새 선) · 초기값 폴더 안 씀 · 같은 지형 = 그대로.
- 지형 칩 → `POST /route/design-terrain` — 벌 맞바꿈(765-4 · `swap_design_set`) ·
같은 지형 = 그대로 · `force` = 전처리 다시(765-5).
"""
import asyncio
import re
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_TerrainSwitch as switch
from B06_Section import B06_Section_Router_DesignSave as design_save
from common_util import common_util_save_guard as guard
from common_util import common_util_design_sets as sets
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
@@ -60,22 +59,25 @@ def test_지형_칩_확인창_새_길() -> None:
assert "rebuildAfterRouteSave(projectId)" in src
def _run(monkeypatch, now: str, want: str):
def _run(monkeypatch, now: str, want: str, force: bool = False):
"""칩 요청 — 맞바꿈 · 전처리 다시가 받은 값만 잰다(본체 = test_765_design_sets)."""
calls: dict = {}
line = [[0.0, 0.0], [10.0, 0.0]]
async def paths(_pid):
return Path("root"), "stored"
async def recompute(*args):
calls["args"] = args
return {"route_id": 7}
async def swap(pid, root, target):
calls["swap"] = (root, target)
if target == now:
return {"changed": False, "design": now}
return {"changed": True, "design": target, "built": False}
async def rows(*_a, **_k):
return {}
async def switch_terrain(_c, _pid, terrain):
calls["switch"] = terrain
return 9
async def restore(*_a):
calls["restored"] = True
async def chain(pid, model_id):
calls["chain"] = model_id
class Pool:
def acquire(self):
@@ -88,38 +90,31 @@ def _run(monkeypatch, now: str, want: str):
return Ctx()
async def params(*_a):
return {}
def storage(root, scope):
calls["scope"] = scope
return SimpleNamespace(discard=lambda: None)
import B03_FileInput.B03_FileInput_Service_SurfaceChange as change
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_planned_line", lambda _root: line)
monkeypatch.setattr(replan, "_recompute", recompute)
monkeypatch.setattr(design_save, "_restore", restore)
monkeypatch.setattr(guard, "snapshot_rows", rows)
monkeypatch.setattr(guard, "snapshot_storage", storage)
monkeypatch.setattr(sets, "swap_design_set", swap)
monkeypatch.setattr(sets, "switch_terrain", switch_terrain)
monkeypatch.setattr(change, "run_surface_change_chain", chain)
monkeypatch.setattr(switch, "get_db_pool", lambda: Pool())
monkeypatch.setattr(switch, "get_surface_confirmation_params", params)
monkeypatch.setattr(switch, "design_terrain", lambda _p: now)
body = switch.DesignTerrainRequest(terrain=want)
body = switch.DesignTerrainRequest(terrain=want, force=force)
result = asyncio.run(switch.switch_route_design_terrain(uuid4(), body))
return result, calls, line
return result, calls
def test_지형_바꿈_노선_그대로_초기값_안씀(monkeypatch) -> None:
result, calls, line = _run(monkeypatch, "las", "sheet")
def test_지형_바꿈_벌_맞바꿈(monkeypatch) -> None:
result, calls = _run(monkeypatch, "las", "sheet")
assert result["changed"] is True and result["design"] == "sheet"
_pid, _root, _stored, balanced, terrain, lines, initial = calls["args"]
assert balanced is None and terrain == "sheet" and initial is False
assert lines == (line, line) # 계획고 옮김 없음 · 노선 안 씀
assert guard.INITIAL_SCOPE[0] not in calls["scope"]
assert "restored" not in calls
assert calls == {"swap": (Path("root"), "sheet")} # 전처리 다시 아님
def test_같은_지형이면_그대로(monkeypatch) -> None:
result, calls, _ = _run(monkeypatch, "sheet", "sheet")
result, calls = _run(monkeypatch, "sheet", "sheet")
assert result == {"status": "success", "design": "sheet", "changed": False}
assert "args" not in calls and "scope" not in calls
assert "chain" not in calls
def test_force_는_전처리_다시(monkeypatch) -> None:
result, calls = _run(monkeypatch, "las", "las", force=True)
assert result["preprocessed"] is True and result["design"] == "las"
assert calls == {"switch": "las", "chain": 9} # 맞바꿈 아님 · 두 벌 다시
+6 -10
View File
@@ -347,14 +347,10 @@ def test_지형_바꿈_force_같은_지형도_다시_셈(monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_TerrainSwitch as switch
request = switch.DesignTerrainRequest
assert request(terrain="las").force is False
monkeypatch.setattr(
switch, "DesignTerrainRequest", lambda terrain: request(terrain=terrain, force=True)
)
result, calls, line = base._run(monkeypatch, "las", "las")
assert result["changed"] is True and calls["args"][4] == "las"
assert calls["args"][5] == (line, line) # 노선 그대로
assert switch.DesignTerrainRequest(terrain="las").force is False
result, calls = base._run(monkeypatch, "las", "las", force=True)
# LAS 갈음 뒤 = 전처리 다시(765-5 · 두 벌 · B05~B09 초기화)
assert result["changed"] is True and calls["switch"] == "las" and calls["chain"] == 9
# ── 637-3 B05 화면 ──────────────────────────────────────────────────────────
@@ -367,8 +363,8 @@ def test_B05_칩_옆_올리기_확인창_force_덮임_막음() -> None:
assert "const lidarPath = `/projects/${projectId}/route/lidar`;" in ui
assert "root.append(chip.root, upload.button, circle.root);" in ui # 칩 옆 · 서클
assert "createProgressCircle({ size: 16, inline: true })" in ui
# 끝 = 칩 다시 · 못 덮음 = 막고 알림 · 이미 라이다 = force 다시 셈 · 확인창 거쳐서만
assert 'await change("las", design === "las");' in ui
# 끝 = 칩 다시 · 못 덮음 = 막고 알림 · 전처리 다시(force · 765-5) · 확인창 거쳐서만
assert 'await change("las", true);' in ui
assert "if (uncovered != null) return warnUncovered();" in ui
assert 'if (next === "las" && uncovered != null) {' in ui
assert "JSON.stringify({ terrain: next, force })" in ui
@@ -244,6 +244,8 @@ _ALLOWED = {
"B05_Profile/B05_Profile_Router_Replan.py",
# 라이다 확정값만 적어 둠 · 설계 지형 그대로(637-2)
"B05_Profile/B05_Profile_Router_Lidar.py",
# 지형 칩 = 벌 맞바꿈 · LAS 나중 올리기 뒤 전처리 다시(765-4 · 5 · 요구 605-1 · 606-1)
"B05_Profile/B05_Profile_Router_TerrainSwitch.py",
}