perf(342-2): 노선 [확인] 체인 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 길 모형 서버 짓기

- 노선 [확인] 체인: solve 안 첫 생성을 건너뛰고 관 정한 뒤 한 번만(건너뛴 생성의 옵션을 넘겨 입력 같음 · 배수유역 실패 때도 종횡단 세움)
- 자동 체인: 서버 재계산 · 3D 짓기를 [확정] 끝 한 번씩(재계산 → 짓기 차례)
- 3D 길 모형: B06 [저장] · [확정] 끝에 서버 Node 가 지음 · 브라우저 올림(PUT) 길 없앰 · 브라우저는 해시 맞으면 받기만 · 어긋나면 미리보기만
- 시험 test_342_2_chain_once · corridor router 시험 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-16 11:03:45 +09:00
co-authored by Claude Opus 5.5
parent 2879f9bea9
commit 5025313b29
9 changed files with 442 additions and 213 deletions
+39 -31
View File
@@ -18,6 +18,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
import asyncio
import logging
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -65,7 +66,11 @@ def _planned_route_points_in_project_crs(
async def _prepare_drainage_pipes_and_reprofile(
project_id: UUID, route_id: int, *, refresh: bool = False
project_id: UUID,
route_id: int,
*,
refresh: bool = False,
stored_options: dict[str, Any] | None = None,
) -> bool:
"""배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성.
@@ -75,12 +80,13 @@ async def _prepare_drainage_pipes_and_reprofile(
들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다.
실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다.
`stored_options` = 노선 [확인] 체인이 건너뛴 첫 생성의 옵션(342-2) — 반폭 · 재생성 옵션.
"""
from B04_PreProcess.B04_PreProcess_Router_Basins import put_pipe_points
from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region
from B06_Section.B06_Section_Repository import get_latest_section_options
from B06_Section.B06_Section_Router import regenerate_sections
from B06_Section.B06_Section_Schema import SectionRegenerateRequest
from B06_Section.B06_Section_Router import regenerate_route_sections
from config.config_db import get_db_pool
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
@@ -113,14 +119,16 @@ async def _prepare_drainage_pipes_and_reprofile(
marks.append(("관 지점 확정", time.perf_counter()))
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
pool = get_db_pool()
async with pool.acquire() as connection:
stored_options = await get_latest_section_options(connection, project_id)
seeded = stored_options is not None
if not seeded:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_options = await get_latest_section_options(connection, project_id)
half_width = float(
(stored_options or {}).get("cross_half_width_m") or SECTION_CROSS_HALF_WIDTH_M
)
regenerated = await regenerate_sections(
project_id, route_id, SectionRegenerateRequest(cross_half_width_m=half_width)
regenerated = await regenerate_route_sections(
project_id, route_id, half_width, stored_options=stored_options if seeded else None
)
if isinstance(regenerated, JSONResponse):
logger.error(
@@ -319,26 +327,8 @@ async def run_auto_design_chain(
route_id,
)
# 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다
# (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가
# 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다.
try:
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
await prebuild_corridor(project_id, route_id, project_root)
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
# 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에
# 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는
# 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴
# 기준선에도 보정이 담긴다.
try:
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
await recompute_server_side(project_id, route_id)
except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다
logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id)
# 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서
# 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2).
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
@@ -426,13 +416,19 @@ async def run_redesign_chain(
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
from B05_Profile.B05_Profile_Router import (
_section_options,
confirm_latest_route,
solve_route_record,
)
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
get_longitudinal_section,
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router import regenerate_route_sections
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs
from B06_Section.B06_Section_Schema import SectionConfirmRequest
@@ -476,6 +472,7 @@ async def run_redesign_chain(
state = await get_workflow_state(cursor, str(project_id))
old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id)
old_designs = await get_cross_section_designs(connection, old_route_id)
old_section_options = await get_latest_section_options(connection, project_id)
stage2 = next(
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
)
@@ -528,7 +525,8 @@ async def run_redesign_chain(
start_elevation_offset_m=params.get("start_elevation_offset_m"),
end_elevation_offset_m=params.get("end_elevation_offset_m"),
)
solve_result: Any = await solve_route(project_id, request)
# 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일.
solve_result: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solve_result, JSONResponse):
# 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06
# 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음).
@@ -571,7 +569,17 @@ async def run_redesign_chain(
marks.append(("B05 확정", time.perf_counter()))
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
# 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다.
seed = asdict(_section_options(request, old_section_options))
seed.pop("extra_stations", None)
piped = await _prepare_drainage_pipes_and_reprofile(
project_id, new_route_id, refresh=True, stored_options=seed
)
if not piped:
# 관 없이라도 종횡단은 세운다 — 새 노선엔 아직 종횡단이 없다(첫 생성을 건너뜀).
await regenerate_route_sections(
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
)
marks.append(("배수·관·측점 재생성", time.perf_counter()))