"""B03 업로드 이후 자동 설계 체인 — WF1 확정 다음을 잇는다. WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어도 서버가 이어서 ① B05 기본 경로 계산·확정(계획노선 정본 기반) ② B06 기본 횡단 설계 확정까지 기본값으로 진행해 영구저장소에 남긴다(2026-08-04 사용자 확정). 이후 사용자가 대시보드에서 B05/B06에 들어오면 저장본을 바로 로딩해 검토·수정만 하면 된다. 원칙: - **수동 이력 보호**: 프로젝트에 경로가 하나라도 있으면 체인을 건너뛴다 — 사용자가 이미 작업한 것을 자동 계산이 덮어쓰면 안 된다. - **단계별 실패 격리**: 각 단계는 해당 라우터가 자기 workflow stage 전이(실패 기록)를 책임진다. 체인은 실패한 단계에서 멈추고 뒤 단계로 오류를 전파하지 않는다 — 사용자는 그 페이지에서 수동으로 이어서 진행할 수 있다. - 라우터 함수를 직접 호출한다(HTTP 재진입 없음). solve/confirm 엔드포인트는 인증 의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다. - **지형마다 한 벌**(765-3): 전처리 체인 = `run_preprocess_chain` — 다른 지형 벌을 먼저 지어 `design_sets/` 묶음으로 떠 두고 설계 지형 벌을 지음(`common_util_design_sets`). """ import asyncio import logging import time from pathlib import Path from typing import Any from uuid import UUID from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) def _log_steps(title: str, marks: list[tuple[str, float]]) -> None: """단계별 경과시간을 한 줄로 남긴다 — 어디서 시간을 쓰는지 보려는 계측용.""" total = (marks[-1][1] - marks[0][1]) or 1e-9 parts = [ f"{name} {marks[i][1] - marks[i - 1][1]:.1f}s" f"({(marks[i][1] - marks[i - 1][1]) / total * 100:.0f}%)" for i, (name, _) in enumerate(marks) if i ] logger.info("[계측] %s 총 %.1fs = %s", title, total, " | ".join(parts)) def _planned_route_points_in_project_crs( project_root: Path, surface: dict[str, Any] | None = None, route_range: tuple[float | None, float | None] | None = None, ) -> list[dict[str, float]] | None: """설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. 읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도 같은 함수를 쓰므로 여기만 트림되는 일이 없다. 사용자가 정한 사용 범위(`route_range`)는 서피스 트림보다 먼저 적용된다 (2026-09-04 사용자 지시). """ from common_util.common_util_route_geometry import load_design_route planned = load_design_route(project_root, surface, route_range) if planned is None: if surface: logger.warning( "자동 설계 체인 중단(설계 노선 없음): 계획노선을 읽지 못했거나 라이다 측량" " 범위와 겹치지 않습니다 — %s", project_root.name, ) return None return [{"x": v.x, "y": v.y} for v in planned.vertices] async def _prepare_drainage_pipes_and_reprofile( project_id: UUID, route_id: int, *, refresh: bool = False, stored_options: dict[str, Any] | None = None, ) -> bool: """배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성. 계획선 1차 로직(`design_pipe_anchored_profile`)은 **저장된 관 지점**을 변화점으로 쓴다. 자동 체인이 이 파일을 만들어 주지 않으면 초기 계획선이 2차 폴백(지반 추종 직선 분할)으로 산출돼 관 자리 틸팅·R이 통째로 빠진다(2026-08-08 사용자 보고). 같은 이유로 B05·B06에 들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다. 실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다. `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_route_sections from config.config_db import get_db_pool from config.config_system import SECTION_CROSS_HALF_WIDTH_M marks = [("시작", time.perf_counter())] try: # 1) 배수유역 분석(30초 내외). 저장분이 있으면 그대로 쓴다. # 저장분은 노선이 바뀌었는지 보지 않으므로, 노선을 다시 푼 뒤에는 refresh로 부른다. region = await get_primary_region(project_id, refresh=refresh) if isinstance(region, JSONResponse): logger.error( "자동 설계 체인 배수유역 분석 실패(계획선 폴백 유지): project_id=%s status=%s", project_id, region.status_code, ) return False marks.append(("배수유역 분석", time.perf_counter())) # 2) 기본 관(도로 × 상류 세류선 교차)만 정본으로 저장한다 — 간격 보충은 [자동 제안](369). pipes = await put_pipe_points(project_id, None) if isinstance(pipes, JSONResponse): logger.error( "자동 설계 체인 관 지점 확정 실패: project_id=%s status=%s", project_id, pipes.status_code, ) return False pipe_count = int(pipes.get("saved_count") or 0) marks.append(("관 지점 확정", time.perf_counter())) # 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다. 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_route_sections( project_id, route_id, half_width, stored_options=stored_options if seeded else None ) if isinstance(regenerated, JSONResponse): logger.error( "자동 설계 체인 배관 정착 계획선 재산출 실패: project_id=%s status=%s", project_id, regenerated.status_code, ) return False marks.append(("종횡단 측점 재생성", time.perf_counter())) _log_steps("배수·관·측점", marks) logger.info( "자동 설계 체인 배수유역·배관 정착 계획선 완료: project_id=%s route_id=%s 관=%d개", project_id, route_id, pipe_count, ) return True except Exception: logger.exception( "자동 설계 체인 배수유역·관 지점 단계 실패(계획선은 폴백으로 유지): project_id=%s", project_id, ) return False async def run_auto_design_chain( project_id: UUID, surface_model_id: int | None = None, *, inner: bool = False ) -> dict[str, Any] | None: """B05 기본 경로 계산·확정 → B06 기본 횡단 설계 확정을 기본값으로 이어 실행한다. `inner` = 두 벌 짓기 · 지형 칩 안에서 부름(765) — 진입 막음 마커 · 추천 미리 셈은 부르는 쪽. WF1 자동 확정 직후 같은 백그라운드 태스크에서 호출된다. 어떤 단계가 실패해도 예외를 밖으로 던지지 않는다 — 로그와 각 단계의 workflow 상태 기록으로 남긴다. 성공하면 초기 분석 완료 메일에 실을 요약(노선 id·연장·측점 수)을 돌려주고, 건너뛰거나 실패하면 None을 돌려준다. """ 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_Schema import RoutePoint, RouteSolveRequest from B06_Section.B06_Section_Router_Confirm import confirm_sections from common_util.common_util_initial_snapshot import ( clear_design_failed, clear_designing, mark_design_failed, mark_designing, save_initial_snapshot, ) from common_util.common_util_route_geometry import ( expected_route_csv_path, write_route_csv, ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from config.config_db import get_db_pool pool = get_db_pool() project_root: Path | None = None try: # 1) 수동 이력 보호 — 경로가 이미 있으면(사용자 작업 또는 이전 자동 실행) 건너뛴다. async with pool.acquire() as connection: existing = await get_latest_route(connection, project_id) stored_path = await get_project_storage_relative_path(connection, project_id) if existing: logger.info( "자동 설계 체인 건너뜀(기존 경로 있음): project_id=%s route_id=%s", project_id, existing.get("id"), ) return None # 2) 계획노선 정본 → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. project_root = Path(resolve_stored_project_path(stored_path)) # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). mark_designing(project_root) # 옛 실패 마커는 여기서 지운다 — 이번 체인의 결과로 다시 판정한다. clear_design_failed(project_root) # WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을 # 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이 # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. async with pool.acquire() as connection: defaults = await get_surface_confirmation_params(connection, str(project_id)) # 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시). # 비어 있으면 전 구간 — 지금까지와 같다. async with connection.cursor() as cursor: # 설계속도 = 단계 2 params 에 먼저 들어온 값(예정노선 연결 · 531-4) — 없으면 None. await cursor.execute( "SELECT route_start_m, route_end_m," " (SELECT JSON_VALUE(s.params, '$.options.design_speed_kph')" " FROM project_workflow_stages s" " WHERE s.project_id = projects.id AND s.stage_no = 2)" " FROM projects WHERE id = %s", (str(project_id),), ) range_row = await cursor.fetchone() route_range = (range_row[0], range_row[1]) if range_row else None seeded_speed = range_row[2] if range_row else None # 벌 예정노선 = 받은 노선을 이 벌 지형으로 자름(765-8 · 요구 612) — 벌마다 길이 · 곡선 다름 points = await asyncio.to_thread(_terrain_route_points, project_root, defaults, route_range) if not points: logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id) mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.") return None # 2.5) 예상노선(원본) 정본을 남긴다 — 초기값 스냅샷과 달리 재확정 체인이 지우지 # 않는 자리라, 노선 초기화가 언제나 이 값으로 돌아갈 수 있다(2026-09-06 PLAN 0-7). expected_path = expected_route_csv_path(project_root) if not expected_path.is_file(): write_route_csv(expected_path, points) # 2.6) 계획노선 **초기 폴리라인**도 여기서 세운다(2026-09-06 PLAN 0-10). # 예상노선은 점 묶음이라 그대로는 설계선이 못 된다 — 지식DB 의 R 기준으로 # 곡선을 끼운 폴리라인이 불변 초기 데이터다. 화면이 처음 열릴 때 만들면 # 「화면을 안 열면 값이 없다」가 되므로 초기값은 서버가 낸다(0-4 원칙). await _ensure_initial_polyline(project_id, project_root, points) # 2.7) 노선의 기준을 **초기 폴리라인**으로 갈아 끼운다(2026-09-06 사용자 지시). # 다시 읽으면 `load_design_route` 가 방금 만든 초기 폴리라인을 집는다 # (읽는 순서: 수정본 → 초기 폴리라인 → 예상노선). 종횡단 측점·유토곡선· # 3D 코리도가 전부 이 노선에서 나오므로, 여기서 갈아 끼우지 않으면 화면에는 # 곡선 없는 원본 점군(용화: 3.3m 간격 331점)이 그대로 선다. polyline_points = _planned_route_points_in_project_crs(project_root, defaults, route_range) if polyline_points and len(polyline_points) >= 2: points = polyline_points # 3) B05 경로 계산 request = RouteSolveRequest( filter_key=str(defaults["source_filter"]), method=str(defaults["method"]), smooth=bool(defaults["smooth"]), surface_model_id=surface_model_id, # 계획노선을 **그대로** 쓴다 — 격자 재탐색은 이 선을 자기 제약으로 다시 풀어 # 곡선을 뭉개거나 통째로 막는다(재확정 체인과 같은 이유, 0-10). algorithm="as_planned", bp=RoutePoint(**points[0]), ep=RoutePoint(**points[-1]), cp=[ RoutePoint(**point, order=index) for index, point in enumerate(points[1:-1], start=1) ], design_speed_kph=int(seeded_speed) if seeded_speed else None, ) solve_result: Any = await solve_route(project_id, request) if isinstance(solve_result, JSONResponse): logger.error( "자동 설계 체인 중단(B05 경로 계산 실패): project_id=%s status=%s", project_id, solve_result.status_code, ) mark_design_failed( project_root, f"B05 초기 노선 계산 실패 (status={solve_result.status_code}).", ) return None route_id = int(solve_result.route_id) logger.info( "자동 설계 체인 B05 경로 계산 완료: project_id=%s route_id=%s length=%.1fm", project_id, route_id, float(solve_result.total_length_m or 0.0), ) # 4) B05 경로 확정 — 데이터만 CONFIRMED, stage 2는 IN_PROGRESS(사용자 검토 대기)로 # 남긴다. 완료 전이는 B06 [확정]이 stage 2·3을 함께 처리한다(2026-08-08 재정의). confirm_result = await confirm_latest_route(project_id, None, mark_stage_complete=False) if isinstance(confirm_result, JSONResponse): logger.error( "자동 설계 체인 중단(B05 경로 확정 실패): project_id=%s status=%s", project_id, confirm_result.status_code, ) mark_design_failed( project_root, f"B05 초기 노선 확정 실패 (status={confirm_result.status_code}).", ) return None # 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출. # B06 확정 전에 끝내야 확정 스냅샷에 배관 정착 계획선이 담긴다. # 배수는 늘 다시 셈(765-3) — 저장분은 노선이 바뀐 줄 모름(두 벌 · 서피스 바꿈 뒤 묵음). await _prepare_drainage_pipes_and_reprofile(project_id, route_id, refresh=True) # 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장. stage 3은 # IN_PROGRESS(스텝바 노란 표시)로 남겨 사용자 검토·확정을 기다린다. sections_result = await confirm_sections( project_id, route_id, None, mark_stage_complete=False ) if isinstance(sections_result, JSONResponse): logger.error( "자동 설계 체인 중단(B06 횡단 확정 실패): project_id=%s route_id=%s status=%s", project_id, route_id, sections_result.status_code, ) mark_design_failed( project_root, f"B06 초기 횡단 확정 실패 (status={sections_result.status_code}).", ) return None logger.info( "자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s", project_id, route_id, ) # 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서 # 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2). # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). try: async with pool.acquire() as connection: await save_initial_snapshot(connection, project_root, route_id, points) except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), "cross_section_count": solve_result.cross_section_count, } except Exception as exc: # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. logger.exception("자동 설계 체인 실패: project_id=%s", project_id) if project_root is not None: mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}") finally: # 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다. if project_root is not None and not inner: clear_designing(project_root) # 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀 if not inner: from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute schedule_precompute(project_id) async def run_preprocess_chain( project_id: UUID, surface_model_id: int | None = None ) -> dict[str, Any] | None: """전처리 체인 = 지형마다 설계 한 벌(765-3 · 요구 605 · 606-1) — 결과 = 설계 지형 요약. 다른 지형 먼저 자동 설계 → 묶음으로 떠 둠(B05~B09 비움) → 설계 지형 자동 설계 → 추천 미리 셈 한 번(실패 길에도). 다른 지형이 없으면(LAS 만 · 수치지형도 못 받음 · 수치지형도만) 한 벌 · 묶음 meta.json 에 까닭. 부르는 곳 = WF1 자동 확정 · 서피스 바꿈 체인(B04 다른 서피스 [확정] · LAS 나중 올리기 뒤 전처리 다시). 묶음은 처음부터 다시(옛 벌 버림). """ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute from common_util import common_util_design_sets as sets from common_util.common_util_initial_snapshot import ( clear_designing, mark_designing, read_design_failure, ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( design_terrain, get_surface_confirmation_params, ) from config.config_db import get_db_pool pool = get_db_pool() root: Path | None = None marks = [("시작", time.perf_counter())] try: async with pool.acquire() as connection: stored_path = await get_project_storage_relative_path(connection, project_id) params = await get_surface_confirmation_params(connection, str(project_id)) if not stored_path: return None root = Path(resolve_stored_project_path(stored_path)) mark_designing(root) sets.discard_sets(root) design = design_terrain(params) other = sets.other_terrain(params, root) if other is None: missing = "sheet" if design == "las" else "las" sets.note_unbuilt(root, missing, f"{missing} 지표면 모델 없음 — 한 벌만") else: # 예상노선(공유 · 추천 열쇠)은 설계 지형으로 자른 것 하나 — 벌 평면노선은 벌마다 그 지형으로 # 자른 벌 예정노선(`terrain_route.csv`)을 따라감(765-8 · 요구 612) await _seed_expected_route(project_id, root, params) try: async with pool.acquire() as connection: model_id = await sets.switch_terrain(connection, project_id, other) built = await run_auto_design_chain(project_id, model_id, inner=True) async with pool.acquire() as connection: fingerprint = await sets.set_fingerprint( connection, str(project_id), root, other ) if built and fingerprint: await sets.park_active_set(connection, root, str(project_id), fingerprint) else: await sets.clear_active_set(connection, root, str(project_id)) sets.note_unbuilt(root, other, read_design_failure(root) or "짓기 실패") except Exception as exc: # noqa: BLE001 — 다른 벌 실패가 설계 지형 벌을 막지 않음 logger.exception("두 벌 짓기 — %s 벌 실패: project_id=%s", other, project_id) async with pool.acquire() as connection: await sets.clear_active_set(connection, root, str(project_id)) sets.note_unbuilt(root, other, f"짓기 오류: {exc}") finally: async with pool.acquire() as connection: await sets.switch_terrain(connection, project_id, design) marks.append((f"{other} 벌", time.perf_counter())) summary = await run_auto_design_chain(project_id, surface_model_id, inner=True) marks.append((f"{design} 벌", time.perf_counter())) _log_steps("전처리 체인 두 벌", marks) return summary except Exception: logger.exception("전처리 체인 실패: project_id=%s", project_id) return None finally: if root is not None: clear_designing(root) schedule_precompute(project_id) # 추천은 지형 · 예정노선만 씀 — 실패 길에도(763-3) def _terrain_route_points( root: Path, surface: dict[str, Any], route_range: tuple[Any, Any] | None ) -> list[dict[str, float]] | None: """벌 예정노선(`terrain_route.csv` · 벌 몫) — 있으면 그것 · 없으면 받은 노선을 이 지형으로 잘라 적음. 받은 노선 파일이 없는 옛 프로젝트 = 정본 차례(`load_design_route`) 그대로.""" from common_util.common_util_route_geometry import ( load_design_route, read_planned_route_csv, terrain_route_csv_path, write_route_csv, ) path = terrain_route_csv_path(root) stored = read_planned_route_csv(path) if path.is_file() else None if stored is not None and len(stored.vertices) >= 2: return [{"x": v.x, "y": v.y} for v in stored.vertices] planned = load_design_route(root, surface, route_range, from_source=True) points = ( [{"x": v.x, "y": v.y} for v in planned.vertices] if planned is not None else _planned_route_points_in_project_crs(root, surface, route_range) ) if points: write_route_csv(path, points) return points async def _seed_expected_route(project_id: UUID, root: Path, surface: dict[str, Any]) -> None: """예상노선 정본을 설계 지형으로 잘라 적음(없을 때만 · 자동 체인 2.5 와 같은 값).""" from common_util.common_util_route_geometry import expected_route_csv_path, write_route_csv from config.config_db import get_db_pool path = expected_route_csv_path(root) if path.is_file(): return async with get_db_pool().acquire() as connection, connection.cursor() as cursor: await cursor.execute( "SELECT route_start_m, route_end_m FROM projects WHERE id = %s", (str(project_id),) ) row = await cursor.fetchone() points = await asyncio.to_thread( _planned_route_points_in_project_crs, root, surface, (row[0], row[1]) if row else None ) if points: write_route_csv(path, points) async def _ensure_initial_polyline( project_id: UUID, project_root: Path, points: list[dict[str, float]] ) -> None: """계획노선 초기 폴리라인을 세운다 — 이미 있으면 그대로 둔다. 실패는 비치명적.""" from B05_Profile.B05_Profile_Router_FollowLand import initial_land from B05_Profile.B05_Profile_Router_Replan import _ensure_planned_initial, _min_plan_radius_m try: radius_m = await _min_plan_radius_m(project_id) land = await initial_land(project_id, project_root) summary = await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m, land) if summary: logger.info( "초기 계획노선 폴리라인: project_id=%s 노드 %d · 곡선 %d · 위반 %d (R %.1fm)", project_id, summary["nodes"], summary["curves"], summary["violations"], radius_m, ) except Exception: # noqa: BLE001 — 없으면 화면이 처음 열릴 때 만든다 logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id) def redesign_solve_request( params: dict[str, Any], selection: dict[str, Any], surface_model_id: int ) -> Any: """stage 2 params(사용자 저장분) + 새 확정 지표면 → as_planned 풀기 요청(노선 저장도 씀).""" from B05_Profile.B05_Profile_Schema import RouteSolveRequest points = params.get("points") or {} options = params.get("options") or {} return RouteSolveRequest( filter_key=str(selection.get("source_filter") or params.get("filter_key")), method=str(selection.get("method") or params.get("method") or "dtm"), smooth=bool(selection.get("smooth", params.get("smooth", False))), surface_model_id=surface_model_id, # 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사· # 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측: # 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음). # PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다. algorithm="as_planned", bp=points["bp"], ep=points["ep"], cp=points.get("cp") or [], ap=points.get("ap") or [], fp=points.get("fp") or [], station_interval_m=params.get("station_interval_m"), cross_half_width_m=params.get("cross_half_width_m"), cross_sample_interval_m=params.get("cross_sample_interval_m"), long_sample_interval_m=params.get("long_sample_interval_m"), **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, paved=bool(options.get("paved", False)), terrain_type=str(options.get("terrain_type") or "special"), # 설계속도 · 최소고 강제 · 시작 측점도 옮김 — 빠지면 노선 저장마다 20 · 꺼짐으로(448 E1) design_speed_kph=options.get("design_speed_kph"), enforce_pipe_clearance=params.get("enforce_pipe_clearance"), station_display=params.get("station_display"), main_direction=str(options.get("main_direction") or "auto"), min_curve_radius_m=options.get("min_curve_radius_m"), max_uphill_grade=options.get("max_uphill_grade"), max_downhill_grade=options.get("max_downhill_grade"), min_uphill_grade=options.get("min_uphill_grade"), min_downhill_grade=options.get("min_downhill_grade"), weights=options.get("weights"), allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)), max_grade_pct=params.get("max_grade_pct"), min_vertical_radius_m=params.get("min_vertical_radius_m"), min_tangent_length_m=params.get("min_tangent_length_m"), balance_segment_length_m=params.get("balance_segment_length_m"), start_elevation_offset_m=params.get("start_elevation_offset_m"), end_elevation_offset_m=params.get("end_elevation_offset_m"), )