횡단도에 그려지던 노면은 사실 월류부 가장 아래를 자른 자리다 — 구체 위 노면은 월류 높이만큼 낮게 앉아야 한다(2026-08-30 사용자 확정). 계획고를 통째로 내려 잡아 측벽·바닥판·십자선·절성토 면적이 한 번에 따라오게 했다. 전폭 평행 하강이라 횡단경사·측구·사면 규칙은 그대로다. 백엔드 - `_ford_set`이 `ford_height_m`을 `overflow_depth_m`으로 스펙에 싣는다. - `compute_cross_design(surface_drop_m=)` — 계획고를 그만큼 내려 계산하고, 내린 양을 `surface_drop_m`으로 echo한다(프론트가 점선을 되그리는 데 쓴다). - `ford_surface_drops()`·`ford_drop_at()` — 관 지점 정본에서 측점별 하강량을 읽는다 (포장 구간과 같은 방식). 설계 계산 경로 전부(기본값·재계산·프리뷰·종횡단 확정)에 물렸다. - `enforce_ford_surface_drops()` — 저장분이 옛 계획고면 상세 조회에서 다시 계산한다. 월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 프론트 - 십자선을 하강량만큼 내려 찍는다(`cardDesignElevation`). 종단 계획고를 그대로 쓰던 자리다. - `appendFordSurfaceDropPlan()` — "월류가 없었다면" 노면을 점선으로 남긴다. 물넘이포장의 「기존 계획고 점선」과 같은 표기라 한 규칙으로 읽힌다. - 측벽·바닥판은 내려간 설계선(`design_line`)에 그대로 붙으므로 따로 손대지 않았다. 자체검증(공용 브라우저 5174 + 워크트리 백엔드 8001, 측점 7+9.7, 월류 높이 0.39m): - `tmp/tests/test_ford_surface_drop.py` 2건 통과 — 계획고·좌우 노견이 정확히 0.39m 내려가고, 절토는 늘고 성토는 줄며, 하강이 없으면 `surface_drop_m` 키 자체가 없다. - 화면 실측: y축 눈금 1m = 24.375px에서 십자선이 점선보다 9.50px 아래 (= 0.390m, 오차 0.001m). 수정 전에는 두 선이 같은 자리였다. - 세월교 측벽 자동 높이 1.70m → 1.50m. 이론값 1.31m이나 하한(관경 1.0 + 최소 토피 0.5 = 1.5m)에 걸린 값이다 — 기존 규칙 그대로다. - 성토 면적 18.46 → 13.85 ㎡. 이웃 일반 측점에는 점선이 그려지지 않는다. - 검증에 쓴 `pipe_points.json` 임시 옵션은 백업본으로 되돌렸다(정본 무변경). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
9.6 KiB
Python
213 lines
9.6 KiB
Python
"""B05 경로 확정 시 종단 정본 파일에 비정규 측점·상단측 변경을 병합하는 헬퍼.
|
|
|
|
라우터(`B05_Profile_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_Profile.B05_Profile_Engine_Sections import generate_irregular_sections
|
|
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
|
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
|
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
|
|
from B06_Section.B06_Section_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)
|
|
|
|
|
|
async def sync_uphill_overrides_into_designs(
|
|
connection: aiomysql.Connection,
|
|
project_id: UUID,
|
|
route_id: int,
|
|
project_root: Path,
|
|
longitudinal_file_path: str,
|
|
overrides: list[dict[str, Any]],
|
|
) -> None:
|
|
"""상단측 변경 측점의 **저장된 횡단 설계**를 새 방향으로 재계산해 저장한다.
|
|
|
|
종단 정본(stations.uphill_side)만 바꾸면 저장 설계(design.section_mode/ditch_side)가
|
|
옛 방향으로 남아 B06이 그것을 계속 표시하고, 종횡단 확정의 역반영
|
|
(ditch_side→uphill_side)이 사용자 지정을 다시 옛 방향으로 되돌린다
|
|
(2026-08-06 13측점 보고: uphill=right vs design=left_cut 순환 덮어쓰기).
|
|
편절은 절토측을, 양절은 측구측을 새 방향으로 맞추고 기하를 즉시 재계산한다.
|
|
양성(both_fill)은 측구가 없으므로 건드리지 않는다.
|
|
"""
|
|
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
|
|
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
|
from B06_Section.B06_Section_Repository import (
|
|
get_cross_section_designs,
|
|
update_cross_section_design,
|
|
)
|
|
from B06_Section.B06_Section_Router import _read_cross_design_inputs
|
|
from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops
|
|
|
|
if not overrides:
|
|
return
|
|
by_chainage = {round(float(item["chainage_m"]), 3): str(item["side"]) for item in overrides}
|
|
designs = await get_cross_section_designs(connection, route_id)
|
|
# 표준단면 수치는 종단 정본 options에 병합 저장돼 있다(종횡단 확정 시) — 없으면 기본값.
|
|
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
|
stored_standard = None
|
|
if longitudinal and isinstance(longitudinal.get("data"), dict):
|
|
options = longitudinal["data"].get("options")
|
|
if isinstance(options, dict):
|
|
stored_standard = options.get("standard_cross_section")
|
|
ford_drops = ford_surface_drops(Path(project_root))
|
|
for record in designs:
|
|
chainage = round(float(record["chainage_m"]), 3)
|
|
side = by_chainage.get(chainage)
|
|
design = record.get("design")
|
|
if side is None or not isinstance(design, dict):
|
|
continue
|
|
mode = design.get("section_mode")
|
|
if mode == "both_fill":
|
|
continue
|
|
next_mode = f"{side}_cut" if mode in ("left_cut", "right_cut") else mode
|
|
next_ditch = side
|
|
if next_mode == mode and design.get("ditch_side") == next_ditch:
|
|
continue
|
|
samples, design_elevation, pavement_suggested = await asyncio.to_thread(
|
|
_read_cross_design_inputs, project_root, longitudinal_file_path, float(chainage)
|
|
)
|
|
next_design = compute_cross_design(
|
|
samples,
|
|
design_elevation,
|
|
ground_type=str(design.get("ground_type", "soil")),
|
|
section_mode=str(next_mode),
|
|
ditch_side=next_ditch,
|
|
ditch_type=str(design.get("ditch_type", "standard")),
|
|
paved=bool(design.get("paved", False)),
|
|
standard=stored_standard,
|
|
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
|
|
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
|
ditch_enabled=design.get("ditch_enabled"),
|
|
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
|
|
)
|
|
next_design["status"] = design.get("status", "provisional")
|
|
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
|
# 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다.
|
|
if design.get("display_half_width_m") is not None:
|
|
next_design["display_half_width_m"] = design["display_half_width_m"]
|
|
await update_cross_section_design(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=float(chainage),
|
|
design=next_design,
|
|
project_id=project_id,
|
|
)
|
|
|
|
|
|
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,
|
|
)
|