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()))
+57 -45
View File
@@ -131,6 +131,17 @@ async def solve_route(
project_id: UUID, request: RouteSolveRequest
) -> RouteSolveResponse | JSONResponse:
"""경로 탐색을 실행하고 결과를 GeoJSON 저장 + DB 기록한다."""
return await solve_route_record(project_id, request)
async def solve_route_record(
project_id: UUID, request: RouteSolveRequest, *, generate_sections: bool = True
) -> RouteSolveResponse | JSONResponse:
"""`solve_route` 본체 — `generate_sections=False` 면 노선 행만 남기고 종횡단은 안 만든다.
노선 [확인] 체인은 관을 정한 뒤 종횡단을 한 번만 만든다(342-2) — 여기서 만들면 관 없는
종횡단을 지었다가 곧바로 통째로 덮는 헛일(1.5~2 s)이 된다.
"""
pool = get_db_pool()
try:
params = {
@@ -269,55 +280,56 @@ async def solve_route(
longitudinal_length_m: float | None = None
cross_section_count: int | None = None
grade_summary: dict[str, Any] | None = None
try:
crs_epsg = await get_surface_crs_epsg(
connection, project_id, request.surface_model_id
)
stored_options = await get_latest_section_options(connection, project_id)
stored_grade_options = await get_latest_grade_options(connection, project_id)
sections = await asyncio.to_thread(
run_section_generation,
project_root,
design["route_data_path"],
request.filter_key,
request.method,
request.smooth,
options=_section_options(request, stored_options),
grade_options=_grade_options(request, stored_grade_options),
# B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로
# 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀).
grade_overrides=request.grade_options(),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
await connection.begin()
if generate_sections:
try:
await delete_sections_for_route(connection, route_id)
await create_longitudinal_section(
connection,
project_id=project_id,
route_id=route_id,
data=sections["longitudinal"]["data"],
longitudinal_file_path=sections["longitudinal"]["file_path"],
crs_epsg = await get_surface_crs_epsg(
connection, project_id, request.surface_model_id
)
await insert_cross_sections(
connection,
project_id=project_id,
route_id=route_id,
sections=sections["cross_sections"],
stored_options = await get_latest_section_options(connection, project_id)
stored_grade_options = await get_latest_grade_options(connection, project_id)
sections = await asyncio.to_thread(
run_section_generation,
project_root,
design["route_data_path"],
request.filter_key,
request.method,
request.smooth,
options=_section_options(request, stored_options),
grade_options=_grade_options(request, stored_grade_options),
# B05 폼이 계획선 입력의 authoritative 소스이므로 요청 값을 그대로
# 저장한다(사용자가 값을 지우면 저장분도 지워져 법정 기본값으로 복귀).
grade_overrides=request.grade_options(),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
await connection.commit()
await connection.begin()
try:
await delete_sections_for_route(connection, route_id)
await create_longitudinal_section(
connection,
project_id=project_id,
route_id=route_id,
data=sections["longitudinal"]["data"],
longitudinal_file_path=sections["longitudinal"]["file_path"],
)
await insert_cross_sections(
connection,
project_id=project_id,
route_id=route_id,
sections=sections["cross_sections"],
)
await connection.commit()
except Exception:
await connection.rollback()
raise
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
cross_section_count = len(sections["cross_sections"])
grade_summary = sections["longitudinal"]["data"].get("grade_summary")
except Exception:
await connection.rollback()
raise
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
cross_section_count = len(sections["cross_sections"])
grade_summary = sections["longitudinal"]["data"].get("grade_summary")
except Exception:
logger.exception(
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
project_id,
route_id,
)
logger.exception(
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
project_id,
route_id,
)
return RouteSolveResponse(
project_id=str(project_id),
+4 -42
View File
@@ -1,23 +1,21 @@
"""B05 3D 예상형상(코리도) 서피스 영구저장 라우터.
코리도 메쉬는 브라우저(TS 단일 구현)가 종횡단 정본으로 빌드한 **파생 데이터**다.
여기서는 파일 보관·조회만 한다 — 기하 계산 없음(2026-08-23 계획).
저장 흐름: 초기 빌드 직후 1회 + 임시저장·종단/횡단 페이지 이동 시 갱신.
코리도 메쉬는 종횡단 정본에서 나온 **파생 데이터**다. 짓기는 서버 Node 가 같은 TS 로 한다
(`B05_Profile_Corridor_Prebuild` — 자동 체인 · 노선 [확인] 체인 · B06 [저장] · [확정] 끝 · 342-2).
여기서는 조회만 한다 — 브라우저 올림(PUT)은 없앰(서버 검산 없이 받아 적던 규칙 어김).
검증(버전 해시)은 프론트가 종횡단 정본과 대조한다.
"""
import asyncio
import json
import logging
import re
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, Body, Query
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -25,9 +23,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"])
# 코리도 JSON 상한 — 리본 좌표(base64 Float32) 기준 넉넉히. 초과는 비정상 요청.
MAX_CORRIDOR_BYTES = 64 * 1024 * 1024
def corridor_path(project_root: Path, route_id: int) -> Path:
"""코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다."""
@@ -126,36 +121,3 @@ async def get_corridor(
return JSONResponse(
status_code=500, content={"status": "error", "message": "코리도 조회에 실패했습니다."}
)
@router.put("/{project_id}/routes/{route_id}/corridor", response_model=None)
async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(...)) -> JSONResponse:
"""코리도 파일 저장(원자적 덮어쓰기). 내용 검증은 최소 — 파생 데이터 보관용."""
if not isinstance(payload, dict) or "ribbons" not in payload:
return JSONResponse(
status_code=422,
content={"status": "error", "message": "코리도 형식이 올바르지 않습니다."},
)
try:
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
if len(encoded.encode("utf-8")) > MAX_CORRIDOR_BYTES:
return JSONResponse(
status_code=413,
content={"status": "error", "message": "코리도 데이터가 허용 크기를 넘습니다."},
)
project_root = await _resolve_project_root(project_id)
if project_root is None:
return JSONResponse(
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
)
path = corridor_path(project_root, route_id)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(atomic_write_json, path, payload)
return JSONResponse(content={"status": "ok"})
except (OSError, TypeError, ValueError) as exc:
logger.warning(
"B05 코리도 저장 실패: project_id=%s route_id=%s error=%s", project_id, route_id, exc
)
return JSONResponse(
status_code=500, content={"status": "error", "message": "코리도 저장에 실패했습니다."}
)
+13 -63
View File
@@ -1,14 +1,13 @@
/* =============================================================================
* B05_Profile_UI_Corridor.ts
* 코리도(3D 예상형상) 오케스트레이션 — 로드·빌드·저장·캐시.
* 코리도(3D 예상형상) 오케스트레이션 — 받기·미리보기 짓기·캐시.
*
* 흐름(2026-08-23 사용자 확정):
* 1) 초기: 종횡단 정본 로드 시 브라우저가 정밀 빌드 → 표시. 저장본이 없던
* 경우에만 즉시 영구저장(PUT).
* 2) 재접근: 저장본 GET → 버전 해시가 현 정본과 일치하면 그대로 표시,
* 불일치·404면 재빌드(dirty 표시).
* 3) 수정: 재빌드는 메모리에서만 — 임시저장·종단/횡단 페이지 이동 시
* saveCorridorIfDirty()로 영구저장.
* 흐름(342-2 · 사용자 확정 10-16):
* 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 ·
* B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음).
* 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시.
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로
* 지음 — 메모리에만 · 올리지 않음.
* 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이
* 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외).
* ========================================================================== */
@@ -27,7 +26,6 @@ import {
corridorHash,
ENVELOPE_VERSION,
deserialize,
serialize,
type CorridorEnvelope,
} from "./B05_Profile_UI_Corridor_Envelope";
@@ -40,7 +38,6 @@ export interface CorridorStructureHook {
interface CacheEntry {
hash: string;
build: CorridorBuildResult;
dirty: boolean;
}
const cache = new Map<string, CacheEntry>();
@@ -116,26 +113,9 @@ async function fetchStored(
}
}
async function putStored(
projectId: string,
routeId: number,
envelope: CorridorEnvelope,
): Promise<boolean> {
try {
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
method: "PUT",
body: JSON.stringify(envelope),
});
return response.ok;
} catch {
return false;
}
}
/**
* 코리도 준비 — 캐시/저장본/빌드 순으로 확보해 반환.
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는
* saveCorridorIfDirty 시점의 최신 상태를 담는다).
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다.
*/
/** 종단 계획선 샘플(라이브 편집 반영분) — buildCorridor designSamples와 같은 꼴. */
export type ProfileSamples = Array<{
@@ -176,7 +156,7 @@ export async function ensureCorridor(
if (lookup.kind === "envelope" && lookup.envelope.hash === hash) {
try {
const build = deserialize(lookup.envelope);
cache.set(key, { hash, build, dirty: false });
cache.set(key, { hash, build });
markSource("stored", hash);
return build;
} catch {
@@ -186,10 +166,6 @@ export async function ensureCorridor(
// 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아
// 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫).
if (!rebuild) return null;
// 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이
// 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다.
const hasStored = lookup.kind !== "missing";
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
// 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③).
@@ -202,40 +178,14 @@ export async function ensureCorridor(
cache.delete(key);
return null;
}
// 미리보기 — 메모리에만(올리지 않음 · 서버가 [저장]·[확정] 때 정본으로 지음)
markSource("built", hash);
if (!hasStored) {
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
cache.set(key, { hash, build, dirty: false });
void putStored(projectId, routeId, serialize(build, hash)).then((ok) => {
if (!ok) markDirty(projectId, routeId);
});
} else {
// 정본이 저장본보다 새것 — 임시저장·페이지 이동 때 저장한다(사용자 확정 흐름).
cache.set(key, { hash, build, dirty: true });
}
cache.set(key, { hash, build });
return build;
}
function markDirty(projectId: string, routeId: number): void {
const entry = cache.get(keyOf(projectId, routeId));
if (entry) entry.dirty = true;
}
/** 임시저장·B05↔B06 페이지 이동 훅 — 미저장 변경분이 있을 때만 PUT. */
export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise<void> {
const entry = cache.get(keyOf(projectId, routeId));
if (!entry || !entry.dirty) return;
const envelope = serialize(entry.build, entry.hash);
const ok = await putStored(projectId, routeId, envelope);
if (!ok) return;
entry.dirty = false;
// 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가
// 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다.
const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`;
const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer;
await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId));
await writeCachedBytes(projectId, url, bytes);
}
/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */
export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise<void> {}
/** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
function withSections(
+18 -2
View File
@@ -379,6 +379,21 @@ async def regenerate_sections(
project_id: UUID, route_id: int, request: SectionRegenerateRequest
) -> SectionDetailResponse | JSONResponse:
"""표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다."""
return await regenerate_route_sections(project_id, route_id, request.cross_half_width_m)
async def regenerate_route_sections(
project_id: UUID,
route_id: int,
cross_half_width_m: float,
*,
stored_options: dict[str, Any] | None = None,
) -> SectionDetailResponse | JSONResponse:
"""`regenerate_sections` 본체 — `stored_options` 를 주면 DB 최신 종단 옵션 대신 그것을 쓴다.
노선 [확인] 체인은 새 노선의 첫 생성을 건너뛰므로(342-2) DB 최신 종단이 아직 옛 노선 것이다 —
건너뛴 첫 생성이 남겼을 옵션을 체인이 넘겨 결과를 지금과 같게 둔다.
"""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -390,7 +405,8 @@ async def regenerate_sections(
)
surface_params = await get_surface_confirmation_params(connection, str(project_id))
stored_path = await get_project_storage_relative_path(connection, project_id)
stored_options = await get_latest_section_options(connection, project_id)
if stored_options is None:
stored_options = await get_latest_section_options(connection, project_id)
async with connection.cursor(aiomysql.DictCursor) as cursor:
workflow = await get_workflow_state(cursor, str(project_id))
route_stage = next(
@@ -411,7 +427,7 @@ async def regenerate_sections(
options=_regeneration_options(
stored_options,
stage_params,
request.cross_half_width_m,
cross_half_width_m,
),
# 계획선까지 함께 재계산·저장 — 없으면 계획 횡단도선·유토곡선이 사라진다.
grade_options=_regeneration_grade_options(stage_params),
+17
View File
@@ -186,6 +186,19 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
)
async def _prebuild_corridor(project_id: UUID, route_id: int, project_root: Path) -> None:
"""3D 길 모형을 서버 Node 가 지어 남긴다(342-2) — 정본이 다시 선 [저장] · [확정] 끝.
브라우저는 열쇠(해시)가 맞으면 받기만 한다. 실패는 비치명적 — 저장은 그대로 남는다.
"""
try:
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
await prebuild_corridor(project_id, route_id, project_root)
except Exception:
logger.exception("3D 길 모형 서버 짓기 실패 (저장은 유지): route_id=%s", route_id)
async def _stale_route_response(
connection: aiomysql.Connection, project_id: UUID, route_id: int
) -> JSONResponse | None:
@@ -276,6 +289,7 @@ async def save_sections(
raise
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다 — 바뀐 벽·측점이 면적·유토곡선에 실린다.
await _recompute_stored_designs(project_id, route_id)
await _prebuild_corridor(project_id, route_id, project_root)
return SectionConfirmResponse(
project_id=str(project_id), route_id=route_id, confirmed=False
)
@@ -401,6 +415,9 @@ async def confirm_sections(
route_id,
)
marks.append(("측구 방향 B05 역반영", time.perf_counter()))
# 역반영까지 끝난 정본으로 3D 길 모형(자동 · 노선 [확인] 체인도 이 끝을 지남)
await _prebuild_corridor(project_id, route_id, project_root)
marks.append(("3D 길 모형 서버 짓기", time.perf_counter()))
_log_steps("B06 확정", marks)
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
except Exception:
+276
View File
@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""342-2 노선 [확인] 체인 — 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 는 [저장]·[확정] 끝.
① 노선 [확인] 체인(`run_redesign_chain`)이 종횡단을 **한 번만** 만들고, 그 한 번이 옛 흐름의
마지막 생성(4c `regenerate_sections`)과 **같은 입력**(옵션 · 반폭 · 계획선 기준)을 받는다.
옛 흐름은 solve 안 첫 생성이 남긴 옵션을 4c 가 읽었다 — 그 옵션을 체인이 넘겨 같게 둠.
② 배수유역이 실패해도 종횡단은 선다(첫 생성을 건너뛰었으므로 체인이 한 번 세움).
③ 자동 체인은 서버 재계산 · 3D 짓기를 따로 부르지 않음 — [확정](confirm_sections) 끝 한 번씩 ·
재계산 → 짓기 차례 · [저장](save_sections)도 끝에 짓기.
DB · 엔진은 가짜로 바꿔 체인의 차례와 생성 입력만 잰다.
"""
from __future__ import annotations
import asyncio
import re
import sys
from dataclasses import asdict
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B03_FileInput.B03_FileInput_Repository as b03_repo # noqa: E402
import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402
import B04_PreProcess.B04_PreProcess_Router_Basins as basins # noqa: E402
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402
import B05_Profile.B05_Profile_Repository as b05_repo # noqa: E402
import B05_Profile.B05_Profile_Router as b05_router # noqa: E402
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
import B06_Section.B06_Section_Repository_Bulk as b06_bulk # noqa: E402
import B06_Section.B06_Section_Router as b06_router # noqa: E402
import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402
import B06_Section.B06_Section_Router_Design as b06_design # noqa: E402
import common_util.common_util_initial_snapshot as snapshot # noqa: E402
import common_util.common_util_storage as storage # noqa: E402
import common_util.common_util_workflow_state as workflow # noqa: E402
import config.config_db as config_db # noqa: E402
from B05_Profile.B05_Profile_Schema import RouteSolveRequest # noqa: E402
from fastapi.responses import JSONResponse # noqa: E402
# stage 2 params(요청 쪽) · 옛 노선 종단 옵션(DB 쪽)이 **엇갈리게** — 옛 흐름 순서를 못 지키면 값이 갈린다
PARAMS = {
"filter_key": "csf",
"method": "dtm",
"smooth": False,
"points": {"bp": {"x": 0.0, "y": 0.0}, "ep": {"x": 100.0, "y": 0.0}, "cp": []},
"options": {"grade_class": "trunk", "terrain_type": "normal"},
"cross_half_width_m": 20.0,
"cross_sample_interval_m": 0.5,
"station_interval_m": None,
"long_sample_interval_m": None,
}
OLD_STORED = {
"station_interval_m": 25.0,
"cross_half_width_m": 30.0,
"cross_sample_interval_m": 1.0,
"long_sample_interval_m": 2.0,
}
class _Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, *args, **kwargs):
return None
async def fetchone(self):
return None
class _Connection:
def cursor(self, *args):
return _Cursor()
async def begin(self):
return None
async def commit(self):
return None
async def rollback(self):
return None
class _Acquire:
async def __aenter__(self):
return _Connection()
async def __aexit__(self, *exc):
return False
class _Pool:
def acquire(self):
return _Acquire()
def _async(value):
async def fake(*args, **kwargs):
return value
return fake
@pytest.fixture
def world(monkeypatch, tmp_path):
calls: dict = {"generate": [], "order": [], "latest_options": 0}
async def solve(project_id, request, *, generate_sections=True):
calls["order"].append("solve")
calls["solve_generates"] = generate_sections
return SimpleNamespace(route_id=6, total_length_m=100.0)
async def primary_region(project_id, refresh=False):
calls["order"].append("drainage")
return calls.get("region", {})
async def latest_options(connection, project_id):
calls["latest_options"] += 1
return OLD_STORED
def generate(project_root, route_data_path, filter_key, method, smooth, **kwargs):
calls["order"].append("generate")
calls["generate"].append(kwargs)
data = {"length_m": 100.0, "options": {}}
return {
"longitudinal": {"data": data, "file_path": "long.json"},
"cross_sections": [],
"result": {"longitudinal": {}, "cross_sections": []},
}
async def confirm(project_id, route_id, request=None, mark_stage_complete=True):
calls["order"].append("confirm_sections")
return SimpleNamespace(route_id=route_id)
state = {"stages": [{"stage_no": 2, "params": PARAMS}]}
monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(b05_repo, "get_latest_route", _async({"id": 5}))
monkeypatch.setattr(b03_repo, "get_project_storage_relative_path", _async("p"))
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda path: str(tmp_path))
monkeypatch.setattr(snapshot, "mark_designing", lambda root: None)
monkeypatch.setattr(snapshot, "clear_designing", lambda root: None)
monkeypatch.setattr(snapshot, "discard_initial_snapshot", lambda root: False)
monkeypatch.setattr(workflow, "get_workflow_state", _async(state))
monkeypatch.setattr(
b06_repo, "get_longitudinal_section", _async({"data": {}, "longitudinal_file_path": "x"})
)
monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([]))
monkeypatch.setattr(b06_repo, "get_latest_section_options", latest_options)
monkeypatch.setattr(b05_router, "solve_route_record", solve)
monkeypatch.setattr(b05_router, "confirm_latest_route", _async({}))
monkeypatch.setattr(watershed, "get_primary_region", primary_region)
monkeypatch.setattr(basins, "put_pipe_points", _async({"saved_count": 2}))
monkeypatch.setattr(b06_confirm, "_rowless_station_chainages", lambda *a: [])
monkeypatch.setattr(b06_confirm, "confirm_sections", confirm)
monkeypatch.setattr(b06_design, "compute_default_designs", lambda *a: [])
monkeypatch.setattr(b06_bulk, "merge_cross_section_designs", _async(0))
# 재생성 본체(B06_Section_Router) — DB 는 가짜 · 생성 입력만 잰다
monkeypatch.setattr(b06_router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(
b06_router,
"get_route_generation_source",
_async({"route_data_path": "route.geojson", "crs_epsg": 5186}),
)
monkeypatch.setattr(
b06_router,
"get_surface_confirmation_params",
_async({"source_filter": "csf", "method": "dtm", "smooth": False}),
)
monkeypatch.setattr(b06_router, "get_project_storage_relative_path", _async("p"))
monkeypatch.setattr(b06_router, "get_latest_section_options", latest_options)
monkeypatch.setattr(b06_router, "get_workflow_state", _async(state))
monkeypatch.setattr(b06_router, "resolve_stored_project_path", lambda path: str(tmp_path))
monkeypatch.setattr(b06_router, "run_section_generation", generate)
monkeypatch.setattr(b06_router, "delete_sections_for_route", _async(None))
monkeypatch.setattr(b06_router, "create_longitudinal_section", _async(None))
monkeypatch.setattr(b06_router, "insert_cross_sections", _async(None))
monkeypatch.setattr(b06_router, "attach_saved_shapes", lambda *a: None)
return calls
def _old_flow_options():
"""옛 흐름 — solve 첫 생성 옵션(요청 → 옛 저장 → 기본)을 4c 가 DB 에서 읽어 다시 고른 값."""
request = RouteSolveRequest(
filter_key="csf",
method="dtm",
smooth=False,
algorithm="as_planned",
bp=PARAMS["points"]["bp"],
ep=PARAMS["points"]["ep"],
cross_half_width_m=PARAMS["cross_half_width_m"],
cross_sample_interval_m=PARAMS["cross_sample_interval_m"],
)
first = b05_router._section_options(request, OLD_STORED)
stored_by_first = {**asdict(first), "extra_stations": []}
return b06_router._regeneration_options(
stored_by_first, PARAMS, float(stored_by_first["cross_half_width_m"])
)
def test_종횡단은_한_번_관_뒤에(world):
failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {}))
assert failure is None
assert world["solve_generates"] is False
assert len(world["generate"]) == 1
order = world["order"]
assert order.index("solve") < order.index("drainage") < order.index("generate")
assert order.index("generate") < order.index("confirm_sections")
def test_한_번이_옛_마지막_생성과_같은_입력(world):
asyncio.run(chain.run_redesign_chain(uuid4(), 1, {}))
got = world["generate"][0]
expected = _old_flow_options()
assert got["options"] == expected
# 옛 노선 저장 반폭(30)이 아니라 요청 반폭(20) — 첫 생성이 남기던 값
assert got["options"].cross_half_width_m == 20.0
assert got["options"].cross_sample_interval_m == 0.5
assert got["options"].station_interval_m == 25.0
assert got["grade_options"] == b06_router._regeneration_grade_options(PARAMS)
assert "grade_overrides" not in got # 옛 4c 도 안 넘김 — 저장본 grade_overrides 빈 값 그대로
# 생성 쪽은 DB 최신 종단(=옛 노선)을 다시 읽지 않음 — 체인이 넘긴 옵션
assert world["latest_options"] == 1 # 체인이 옛 노선 옵션을 읽은 한 번뿐
def test_배수유역이_실패해도_종횡단은_선다(world):
world["region"] = JSONResponse(status_code=500, content={})
failure = asyncio.run(chain.run_redesign_chain(uuid4(), 1, {}))
assert failure is None
assert len(world["generate"]) == 1
assert world["generate"][0]["options"] == _old_flow_options()
def _body(source: str, name: str) -> str:
start = source.index(f"async def {name}(")
following = re.search(r"\n(async def|def|@router)", source[start + 10 :])
return source[start : start + 10 + following.start()] if following else source[start:]
def test_자동_체인은_재계산_짓기를_따로_안_부름():
source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text("utf-8")
auto = _body(source, "run_auto_design_chain")
assert "recompute_server_side" not in auto
assert "prebuild_corridor" not in auto
assert "confirm_sections(" in auto
def test_확정_저장_끝에서_재계산_뒤_짓기():
source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8")
for name in ("confirm_sections", "save_sections"):
body = _body(source, name)
assert body.count("_recompute_stored_designs(") == 1, name
assert body.count("_prebuild_corridor(") == 1, name
assert body.index("_recompute_stored_designs(") < body.index("_prebuild_corridor("), name
confirm = _body(source, "confirm_sections")
# 측구 방향 역반영(종단 파일 고침)까지 끝난 뒤 짓기
assert confirm.index("_merge_uphill_overrides_into_longitudinal") < confirm.index(
"_prebuild_corridor("
)
def test_브라우저는_3D_를_올리지_않음():
source = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor.ts").read_text("utf-8")
assert '"PUT"' not in source
assert "putStored" not in source
+14 -29
View File
@@ -1,6 +1,6 @@
"""B05 코리도(예상형상) 저장 라우터 검증.
파생 데이터 보관 계약 — 경로 규칙, PUT 검증(형식·크기), 파일 왕복.
파생 데이터 보관 계약 — 경로 규칙 · 브라우저 올림(PUT) 없음(342-2 서버가 지음) · 파일 왕복.
DB 의존(_resolve_project_root)은 스텁으로 대체해 파일 로직만 검증한다.
"""
@@ -24,19 +24,16 @@ def test_corridor_path_layout(tmp_path):
assert path == tmp_path / "B05_Profile" / "corridor" / "corridor_0007.json"
def test_put_rejects_wrong_shape(tmp_path, monkeypatch):
"""ribbons 없는 본문은 422 — 파생 데이터라도 최소 형식은 지킨다."""
async def fake_root(_project_id):
return tmp_path
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
response = asyncio.run(corridor.put_corridor(uuid4(), 1, {"nope": True}))
assert response.status_code == 422
def test_put_removed():
"""브라우저 올림 길 없음 — 3D 길 모형은 서버 Node 가 [저장]·[확정]·체인 끝에 지음(342-2)."""
assert not hasattr(corridor, "put_corridor")
methods = {(route.path, method) for route in corridor.router.routes for method in route.methods}
assert all(method != "PUT" for _, method in methods)
def test_put_then_get_roundtrip(tmp_path, monkeypatch):
"""PUT 저장본을 GET이 그대로 돌려준다(원자 기록·경로 일치)."""
def test_stored_then_get_roundtrip(tmp_path, monkeypatch):
"""서버가 둔 저장본을 GET이 그대로 돌려준다(경로 일치)."""
from common_util.common_util_json import atomic_write_json
async def fake_root(_project_id):
return tmp_path
@@ -48,14 +45,15 @@ def test_put_then_get_roundtrip(tmp_path, monkeypatch):
"ribbons": [{"kind": "cut", "side": "left", "colCount": 2, "chainages": [0, 2]}],
"outline": {"chainages": [0, 2], "left": [[0, 1]], "right": [[0, -1]]},
}
put_response = asyncio.run(corridor.put_corridor(uuid4(), 3, payload))
assert put_response.status_code == 200
stored = tmp_path / "B05_Profile" / "corridor" / "corridor_0003.json"
assert stored.is_file()
stored = corridor.corridor_path(tmp_path, 3)
stored.parent.mkdir(parents=True)
atomic_write_json(stored, payload)
get_response = asyncio.run(corridor.get_corridor(uuid4(), 3))
assert get_response.status_code == 200
assert json.loads(get_response.body) == payload
stale = asyncio.run(corridor.get_corridor(uuid4(), 3, hash="cafe"))
assert json.loads(stale.body) == {"status": "stale", "hash": "deadbeef"}
def test_get_missing_returns_404(tmp_path, monkeypatch):
@@ -67,16 +65,3 @@ def test_get_missing_returns_404(tmp_path, monkeypatch):
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
response = asyncio.run(corridor.get_corridor(uuid4(), 99))
assert response.status_code == 404
def test_put_rejects_oversize(tmp_path, monkeypatch):
"""크기 상한 초과는 413 — 비정상 요청 차단."""
async def fake_root(_project_id):
return tmp_path
monkeypatch.setattr(corridor, "MAX_CORRIDOR_BYTES", 64)
monkeypatch.setattr(corridor, "_resolve_project_root", fake_root)
payload = {"version": 1, "hash": "x", "ribbons": [{"blob": "y" * 200}], "outline": {}}
response = asyncio.run(corridor.put_corridor(uuid4(), 1, payload))
assert response.status_code == 413
+4 -1
View File
@@ -13,6 +13,7 @@
"""
import math
import re
import sys
from pathlib import Path
@@ -189,7 +190,9 @@ def test_이월은_측점_재생성_뒤에_한다():
source = (PROJECT_ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text(
encoding="utf-8"
)
rebuild = source.index("_prepare_drainage_pipes_and_reprofile(project_id, new_route_id")
rebuild = re.search(
r"_prepare_drainage_pipes_and_reprofile\(\s*project_id, new_route_id", source
).start()
carry = source.index("merge_cross_section_designs(")
assert carry > rebuild, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다"