From 5025313b29e7d7beb971d2fc066b70a8733677b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:03:45 +0900 Subject: [PATCH] =?UTF-8?q?perf(342-2):=20=EB=85=B8=EC=84=A0=20[=ED=99=95?= =?UTF-8?q?=EC=9D=B8]=20=EC=B2=B4=EC=9D=B8=20=EC=A2=85=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B2=B4=EC=9D=B8=20=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=ED=95=9C=20=EB=B2=88=20=C2=B7=203D=20=EA=B8=B8=20?= =?UTF-8?q?=EB=AA=A8=ED=98=95=20=EC=84=9C=EB=B2=84=20=EC=A7=93=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노선 [확인] 체인: solve 안 첫 생성을 건너뛰고 관 정한 뒤 한 번만(건너뛴 생성의 옵션을 넘겨 입력 같음 · 배수유역 실패 때도 종횡단 세움) - 자동 체인: 서버 재계산 · 3D 짓기를 [확정] 끝 한 번씩(재계산 → 짓기 차례) - 3D 길 모형: B06 [저장] · [확정] 끝에 서버 Node 가 지음 · 브라우저 올림(PUT) 길 없앰 · 브라우저는 해시 맞으면 받기만 · 어긋나면 미리보기만 - 시험 test_342_2_chain_once · corridor router 시험 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B03_FileInput/B03_FileInput_Service_Chain.py | 70 ++--- B05_Profile/B05_Profile_Router.py | 102 ++++--- B05_Profile/B05_Profile_Router_Corridor.py | 46 +--- B05_Profile/B05_Profile_UI_Corridor.ts | 76 +---- B06_Section/B06_Section_Router.py | 20 +- B06_Section/B06_Section_Router_Confirm.py | 17 ++ resources/tester/test_342_2_chain_once.py | 276 +++++++++++++++++++ resources/tester/test_b05_corridor_router.py | 43 +-- resources/tester/test_user_values_survive.py | 5 +- 9 files changed, 442 insertions(+), 213 deletions(-) create mode 100644 resources/tester/test_342_2_chain_once.py diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 90791b126..a2f589bea 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -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())) diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 1f0576dca..589a5c862 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -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), diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py index 97d461372..b662451d1 100644 --- a/B05_Profile/B05_Profile_Router_Corridor.py +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -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": "코리도 저장에 실패했습니다."} - ) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index c13401a46..7bf2963c5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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(); @@ -116,26 +113,9 @@ async function fetchStored( } } -async function putStored( - projectId: string, - routeId: number, - envelope: CorridorEnvelope, -): Promise { - 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 { - 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 {} /** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */ function withSections( diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index f80a2a120..2f43d05ca 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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), diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index db16f52ab..3a23ceddf 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -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: diff --git a/resources/tester/test_342_2_chain_once.py b/resources/tester/test_342_2_chain_once.py new file mode 100644 index 000000000..2a98d131d --- /dev/null +++ b/resources/tester/test_342_2_chain_once.py @@ -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 diff --git a/resources/tester/test_b05_corridor_router.py b/resources/tester/test_b05_corridor_router.py index 4914c3848..72bc88649 100644 --- a/resources/tester/test_b05_corridor_router.py +++ b/resources/tester/test_b05_corridor_router.py @@ -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 diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 36a7abf5d..04ef0fefe 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -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, "이월이 측점 재생성보다 앞섰음 — 덮어써서 사라진다"