무슨 일이 있었나 랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운 커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`· `CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴. 잃었던 것 - 공용 — `common_util_provenance.py` · `ui_template_provenance.ts` - B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸 - B09 — 근거 사전 셋 - B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈 - 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등) 어떻게 되살렸나 `611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림. 이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난 옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛 상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 — `9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑). 위키 여덟은 코덱스 몫이라 손대지 않음. 자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 · `B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의 `attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped** (되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
222 lines
9.5 KiB
Python
222 lines
9.5 KiB
Python
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
|
|
|
|
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어
|
|
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가
|
|
있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
|
|
않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
|
|
|
|
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
|
|
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
|
|
|
|
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
|
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
|
SectionGenerationOptions,
|
|
generate_sections,
|
|
)
|
|
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
|
from common_util.common_util_route_polyline import build_planned_polyline
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
|
from common_util.common_util_surface_sampler import build_surface_sampler
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
|
|
|
|
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
|
|
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
|
|
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
|
|
MAX_POINTS = 4000
|
|
|
|
|
|
class ElevationRequest(BaseModel):
|
|
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
|
|
|
|
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
|
|
|
|
|
|
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
|
|
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
|
|
try:
|
|
sampler = build_surface_sampler(
|
|
project_root / _MODELS_SUBDIR,
|
|
str(params["source_filter"]),
|
|
str(params["method"]),
|
|
bool(params["smooth"]),
|
|
)
|
|
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
|
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
|
|
return None
|
|
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
|
|
return z, valid
|
|
|
|
|
|
@router.post("/{project_id}/route/elevations", response_model=None)
|
|
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
|
|
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
|
|
|
|
지표면 밖이거나 자료가 없는 자리는 `valid=false` 로 나가고 표고는 `null` 이다 —
|
|
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 그 자리를 「모름」으로 낸다.
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored = await get_project_storage_relative_path(connection, project_id)
|
|
if not stored:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
|
)
|
|
params = await get_surface_confirmation_params(connection, str(project_id))
|
|
|
|
project_root = Path(resolve_stored_project_path(stored))
|
|
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
|
|
if sampled is None:
|
|
return JSONResponse(
|
|
status_code=409,
|
|
content={
|
|
"status": "error",
|
|
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
|
|
},
|
|
)
|
|
z, valid = sampled
|
|
return {
|
|
"status": "success",
|
|
"project_id": str(project_id),
|
|
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
|
|
"valid": [bool(ok) for ok in valid],
|
|
}
|
|
|
|
|
|
class PreviewVertex(BaseModel):
|
|
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
|
|
|
|
x: float
|
|
y: float
|
|
curve: bool = True
|
|
radius_m: float | None = None
|
|
|
|
|
|
class CrossPreviewRequest(BaseModel):
|
|
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
|
|
|
|
vertices: list[PreviewVertex] = Field(..., min_length=2)
|
|
chainage_m: float = Field(..., ge=0)
|
|
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
|
|
min_radius_m: float = Field(12.0, gt=0)
|
|
station_interval_m: float | None = None
|
|
|
|
|
|
def _cross_preview(
|
|
project_root: Path,
|
|
params: dict,
|
|
request: CrossPreviewRequest,
|
|
) -> dict | None:
|
|
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
|
|
|
|
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
|
|
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
|
|
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
|
|
|
|
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
|
|
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
|
|
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
|
|
"""
|
|
try:
|
|
sampler = build_surface_sampler(
|
|
project_root / _MODELS_SUBDIR,
|
|
str(params["source_filter"]),
|
|
str(params["method"]),
|
|
bool(params["smooth"]),
|
|
)
|
|
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
|
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
|
|
return None
|
|
|
|
built = build_planned_polyline(
|
|
[(vertex.x, vertex.y) for vertex in request.vertices],
|
|
min_radius_m=request.min_radius_m,
|
|
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
|
|
simplify=False,
|
|
curve_flags=[vertex.curve for vertex in request.vertices],
|
|
radii=[vertex.radius_m for vertex in request.vertices],
|
|
)
|
|
interval = request.station_interval_m
|
|
options = (
|
|
SectionGenerationOptions(station_interval_m=float(interval))
|
|
if interval and interval > 0
|
|
else SectionGenerationOptions()
|
|
)
|
|
result = generate_sections(built.vertices, sampler, options)
|
|
sections = result["cross_sections"]
|
|
if not sections:
|
|
return None
|
|
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
|
|
|
|
design = None
|
|
center_z = section.get("center_z")
|
|
if center_z is not None:
|
|
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
|
|
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
|
|
design = compute_cross_design(
|
|
section["samples"],
|
|
float(center_z),
|
|
ground_type="soil",
|
|
section_mode=section_mode,
|
|
**curve_widening_args(section),
|
|
)
|
|
return {
|
|
"chainage_m": round(float(section["chainage_m"]), 3),
|
|
"label": section.get("label"),
|
|
"uphill_side": section.get("uphill_side"),
|
|
"plan_radius_m": section.get("plan_radius_m"),
|
|
"curve_widening_m": section.get("curve_widening_m"),
|
|
"samples": section["samples"],
|
|
"design": design,
|
|
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
|
|
if result.get("longitudinal", {}).get("total_length_m") is not None
|
|
else None,
|
|
}
|
|
|
|
|
|
@router.post("/{project_id}/route/cross-preview", response_model=None)
|
|
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
|
|
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
|
|
|
|
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
|
|
"""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored = await get_project_storage_relative_path(connection, project_id)
|
|
if not stored:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
|
)
|
|
params = await get_surface_confirmation_params(connection, str(project_id))
|
|
|
|
project_root = Path(resolve_stored_project_path(stored))
|
|
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
|
|
if preview is None:
|
|
return JSONResponse(
|
|
status_code=409,
|
|
content={
|
|
"status": "error",
|
|
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
|
|
},
|
|
)
|
|
return {"status": "success", "project_id": str(project_id), **preview}
|