절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야 정리되므로, 계획용 유토곡선과 부지 선정을 B05로 옮겼다. B06은 실측 단면적으로 낸 정식 곡선과 확정·B08 인계를 맡는다. B05 계획 유토곡선 - _UI_Profile_MassHaul.ts: 종단면 패널 안 2차 하단 슬라이드. 펼치면 12행 도면 테이블 자리를 곡선이 대신 차지한다. 곡선 SVG를 종단 그래프와 같은 가로 스크롤러 안 형제로 넣고 MassHaulAxis에 LONG_PAD + originOffset을 넘겨 X축을 종단 chainageMapper와 일치시켰다. - computeLongitudinalMassHaul(): 횡단 설계가 없는 계획 단계용 개략 엔진. 표준횡단 노반폭을 전 구간 공통으로 물린다. 종단 기반 토량은 국내 기준상 노선계획 개산용 이므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지 않는다. 사토장·토취장 자동 선정 - B05_wf2_Route_Engine_Disposal.py + POST /route/disposal-sites: 노선 corridor DEM 격자를 훑어 후보 부지를 추리고 옵션별 점수로 정렬한다. 기준 4가지 — 비용(최단거리+하향 운반), 지형안정성(완경사·계곡 이격), 계곡부(계곡 축 매립), 임내 공간(라이다 수고 기반 공터). 기준값 정의처는 DISPOSAL_SITE_CRITERIA. - _UI_Profile_Disposal.ts: 범례 줄의 [토량 분배]와 [도형 위치 초기화] 사이에 구분기호와 함께 라디오 토글 4개. 부지 카드에 위치·용량·운반거리와 기준별 근거값, 측점·용량 사용자 정의 편집과 복원. 후보 부족으로 남은 토량은 경고로 알린다. - 확정 시 종단 정본의 disposal_sites에 심는다(기준 미선택이면 저장분 삭제). B06 연동 - common_util_mass_haul_sites.ts: 저장된 부지를 정식 곡선의 잔량 위치 기준으로 운반거리를 다시 재 표시하고 mass_haul.disposal_sites로 B08에 넘긴다. 미구현(계획서에 남김): 토취장 토질 판정(지반유형이 B06 산출물), 계곡부 암거 연장. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
6.6 KiB
Python
154 lines
6.6 KiB
Python
"""B05 경로 확정 시 종단 정본 파일에 비정규 측점·상단측 변경을 병합하는 헬퍼.
|
|
|
|
라우터(`B05_wf2_Route_Router.py`)의 confirm 엔드포인트가 호출한다. 파일 기반 병합이라
|
|
재확정해도 중복이 생기지 않으며, 모든 호출은 비치명적(실패해도 경로 확정은 진행)이다.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import aiomysql
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import generate_irregular_sections
|
|
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
|
|
from B05_wf2_Route.B05_wf2_Route_Repository import get_surface_crs_epsg
|
|
from B05_wf2_Route.B05_wf2_Route_Schema import RouteConfirmRequest
|
|
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
|
get_latest_section_options,
|
|
get_longitudinal_section,
|
|
)
|
|
from common_util.common_util_json import atomic_write_json
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
|
|
|
|
def _section_options_from_stored(stored: dict[str, Any] | None) -> SectionGenerationOptions:
|
|
"""저장된 종횡단 옵션(단일 소스)만으로 옵션을 재구성한다(확정 시 비정규 측점 샘플링용)."""
|
|
defaults = SectionGenerationOptions()
|
|
stored = stored or {}
|
|
return SectionGenerationOptions(
|
|
station_interval_m=stored.get("station_interval_m") or defaults.station_interval_m,
|
|
cross_half_width_m=stored.get("cross_half_width_m") or defaults.cross_half_width_m,
|
|
cross_sample_interval_m=stored.get("cross_sample_interval_m")
|
|
or defaults.cross_sample_interval_m,
|
|
long_sample_interval_m=stored.get("long_sample_interval_m")
|
|
or defaults.long_sample_interval_m,
|
|
include_endpoint=defaults.include_endpoint,
|
|
)
|
|
|
|
|
|
def _merge_uphill_overrides_into_longitudinal(
|
|
project_root: Path, longitudinal_file_path: str, overrides: list[dict[str, Any]]
|
|
) -> None:
|
|
"""종단 정본 파일 stations의 uphill_side를 사용자 변경값으로 덮어쓴다.
|
|
|
|
solve가 자동 판정한 상단측(=측구 방향)을 3D 램프 클릭으로 바꾼 경우, 확정 시점에
|
|
정본에 반영해 B06이 값만 읽으면 되게 한다. 사용자 지정임을 소스 필드로 남긴다.
|
|
"""
|
|
if not overrides:
|
|
return
|
|
root = project_root.resolve()
|
|
path = (root / longitudinal_file_path).resolve()
|
|
if root not in path.parents or not path.is_file():
|
|
return
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
stations = data.get("stations")
|
|
if not isinstance(stations, list):
|
|
return
|
|
by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides}
|
|
changed = False
|
|
for station in stations:
|
|
side = by_chainage.get(round(float(station.get("chainage_m", -1.0)), 3))
|
|
if side is None:
|
|
continue
|
|
station["uphill_side"] = side
|
|
station["uphill_side_source"] = "user"
|
|
changed = True
|
|
if changed:
|
|
data["stations"] = stations
|
|
atomic_write_json(path, data)
|
|
|
|
|
|
def _merge_disposal_sites_into_longitudinal(
|
|
project_root: Path, longitudinal_file_path: str, disposal: dict[str, Any] | None
|
|
) -> None:
|
|
"""사토장·토취장 선정 결과를 종단 정본 파일에 심는다(확정 = 영구저장 시점).
|
|
|
|
B06 정식 유토곡선이 이 값을 읽어 운반거리를 다시 재고, B08 내역서가 운반 항목으로
|
|
받는다. `None`이면(기준 미선택) 기존에 저장된 값을 **지운다** — 사용자가 선정을
|
|
풀었는데 옛 부지가 남아 있으면 다음 단계가 없는 계획을 있다고 읽는다.
|
|
"""
|
|
root = project_root.resolve()
|
|
path = (root / longitudinal_file_path).resolve()
|
|
if root not in path.parents or not path.is_file():
|
|
return
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if disposal:
|
|
data["disposal_sites"] = disposal
|
|
elif "disposal_sites" in data:
|
|
data.pop("disposal_sites")
|
|
else:
|
|
return
|
|
atomic_write_json(path, data)
|
|
|
|
|
|
def _merge_irregular_into_longitudinal(
|
|
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
|
|
) -> None:
|
|
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
|
|
|
|
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
|
|
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 계획선·규칙 측점·표고
|
|
샘플은 손대지 않는다. 파일 기반이라 재확정해도 중복이 생기지 않는다.
|
|
"""
|
|
root = project_root.resolve()
|
|
path = (root / longitudinal_file_path).resolve()
|
|
if root not in path.parents or not path.is_file():
|
|
return
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
stations = data.get("stations")
|
|
if not isinstance(stations, list):
|
|
return
|
|
regular = [station for station in stations if station.get("kind") != "irregular"]
|
|
merged = regular + list(irregular_stations)
|
|
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
|
|
data["stations"] = merged
|
|
atomic_write_json(path, data)
|
|
|
|
|
|
async def _append_irregular_cross_sections(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
route: dict[str, Any],
|
|
request: RouteConfirmRequest,
|
|
) -> None:
|
|
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
stored_options = await get_latest_section_options(connection, project_id)
|
|
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
|
|
irregular_stations = await asyncio.to_thread(
|
|
generate_irregular_sections,
|
|
project_root,
|
|
str(route["route_data_path"]),
|
|
request.filter_key,
|
|
request.method,
|
|
request.smooth,
|
|
extra_stations=request.extra_stations(),
|
|
options=_section_options_from_stored(stored_options),
|
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
|
)
|
|
if not irregular_stations:
|
|
return
|
|
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
|
if longitudinal:
|
|
await asyncio.to_thread(
|
|
_merge_irregular_into_longitudinal,
|
|
project_root,
|
|
str(longitudinal["longitudinal_file_path"]),
|
|
irregular_stations,
|
|
)
|