feat(B05): 326-1·326-2 측점 스냅 서버 — 요청 모델 Schema 로 쪼갬 · 호 안 측점 하나를 곡선 중점으로 옮기는 짝 셈(snap_curve_stations ↔ snapCurveStations) · [확인] 때 곡선표 station_snap 저장 · GET 돌려줌 · 실패 되돌림에 곡선표·노드 · 측점 셈 둘 · 횡단 미리보기 반영

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-15 12:32:36 +09:00
co-authored by Claude Opus 5.5
parent b5b6264016
commit 4c6658cf1c
7 changed files with 662 additions and 92 deletions
+19 -6
View File
@@ -79,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
"""
curves = _read_design_curve_table(project_root).get("curves")
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def load_station_snap(project_root: Path) -> bool:
"""[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔."""
return _read_design_curve_table(project_root).get("station_snap") is True
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
route_path = planned_route_working_path(project_root)
if not route_path.is_file():
route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file():
return []
return {}
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
return {}
return data if isinstance(data, dict) else {}
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
@@ -435,6 +445,7 @@ def run_section_generation(
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
station_snap=load_station_snap(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -548,6 +559,8 @@ def sample_structure_sections(
crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
station_snap=load_station_snap(project_root),
)
def structural(entry: dict[str, Any]) -> bool:
@@ -6,6 +6,9 @@
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
`resources/tester/test_224_2_station_snap.py`.
"""
import math
@@ -275,6 +278,35 @@ def _design_curve_spans(
return sorted(spans)
#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m).
CURVE_STATION_EDGE_M = 0.005
def snap_curve_stations(
stations: np.ndarray | list[float],
spans: list[tuple[float, ...]],
) -> np.ndarray:
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
"""
values = [float(value) for value in stations]
for span in spans:
start, end = float(span[0]), float(span[1])
middle = (start + end) / 2.0
inside = [
index
for index, value in enumerate(values)
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
]
if not inside:
continue
nearest = min(inside, key=lambda index: abs(values[index] - middle))
values[nearest] = float(math.floor(middle + 0.5))
return np.unique(np.asarray(values, dtype=np.float64))
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
@@ -319,8 +351,12 @@ def generate_sections(
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
station_snap: bool = False,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2).
"""
options = options or SectionGenerationOptions()
options.validate()
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
@@ -331,6 +367,9 @@ def generate_sections(
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if station_snap:
station_chainage = snap_curve_stations(station_chainage, spans)
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
regular_rounded = {round(float(value), 6) for value in station_chainage}
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
@@ -372,7 +411,6 @@ def generate_sections(
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans
+32 -82
View File
@@ -41,10 +41,17 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import AliasChoices, BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
RouteInitialRequest,
RouteInitialWeights,
RouteReplanRequest,
RouteVertexInput,
)
from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import (
densify_route,
@@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
pool = get_db_pool()
@@ -305,9 +244,12 @@ def _write_planned_polyline(
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
@@ -321,13 +263,13 @@ def _write_planned_polyline(
)
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
_curves_path(path).write_text(
json.dumps(
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
ensure_ascii=False,
),
encoding="utf-8",
)
table: dict[str, Any] = {
"min_radius_m": round(radius_m, 3),
"curves": [c.as_dict() for c in result.curves],
}
if station_snap is not None:
table["station_snap"] = bool(station_snap)
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
@@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
}
@@ -604,11 +547,17 @@ async def replan_route(
radii = [vertex.radius_m for vertex in request.vertices]
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
working_path = planned_route_working_path(project_root)
previous = working_path.read_bytes() if working_path.is_file() else None
keep = (working_path, _curves_path(working_path), _nodes_path(working_path))
previous = {path: path.read_bytes() if path.is_file() else None for path in keep}
summary = await asyncio.to_thread(
functools.partial(
_write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii
_write_planned_polyline,
simplify=False,
curve_flags=curve_flags,
radii=radii,
station_snap=request.station_snap,
),
working_path,
nodes,
@@ -633,10 +582,11 @@ async def replan_route(
)
if "error" in result:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
if previous is None:
working_path.unlink(missing_ok=True)
else:
working_path.write_bytes(previous)
for path, saved in previous.items():
if saved is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(saved)
logger.warning(
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
project_id,
+10 -1
View File
@@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel):
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
cross_half_width_m: float | None = Field(None, gt=0)
terrain: AnalysisTerrain | None = None
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
station_snap: bool = False
def _cross_preview(
@@ -177,7 +179,14 @@ def _cross_preview(
else defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
)
result = generate_sections(built.vertices, sampler, options)
# 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
result = generate_sections(
built.vertices,
sampler,
options,
design_curves=[curve.as_dict() for curve in built.curves],
station_snap=request.station_snap,
)
sections = result["cross_sections"]
if not sections:
return None
+74 -1
View File
@@ -2,7 +2,7 @@
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
from config.config_system import (
@@ -309,3 +309,76 @@ class RouteLatestResponse(BaseModel):
route_points: list[dict[str, Any]] = Field(default_factory=list)
surface_params: dict[str, Any]
route_params: dict[str, Any] | None = None
# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ─────────────────────────────
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteInitialWeights(BaseModel):
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
expected: float = Field(50.0, ge=0, le=100)
earthwork: float = Field(25.0, ge=0, le=100)
grade: float = Field(25.0, ge=0, le=100)
class RouteInitialCriteria(BaseModel):
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
"""
max_grade_pct: float | None = Field(None, ge=0, le=100)
min_radius_m: float | None = Field(None, ge=0)
stream_offset_m: float | None = Field(None, ge=0)
grade_smooth_m: float | None = Field(None, ge=0)
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
target_grade_pct: float | None = Field(None, ge=0, le=100)
class RouteInitialRequest(BaseModel):
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
mode: Literal[
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
weights: RouteInitialWeights | None = None
criteria: RouteInitialCriteria | None = None
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
initial: RouteInitialRequest | None = None
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
balanced_profile: bool = False
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
terrain: Literal["las", "sheet"] | None = None
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
station_snap: bool = False
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
@@ -5,6 +5,10 @@
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at`
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
*
* ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations`
* ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` ·
* `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`.
* ========================================================================== */
type Vertex = [number, number];
@@ -75,3 +79,98 @@ export function stationFrameAt(
const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] };
}
/** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */
export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0;
/** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */
export const CURVE_STATION_EDGE_M = 0.005;
/** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */
export interface CurveEnds {
start?: ReadonlyArray<number> | null;
apex?: ReadonlyArray<number> | null;
end?: ReadonlyArray<number> | null;
radius_m?: number | null;
}
/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */
export function stationsAlong(
line: ReadonlyArray<Vertex>,
intervalM: number,
includeEndpoint: boolean,
): number[] {
const { chainage } = cleanPolyline(line);
const total = chainage.length ? chainage[chainage.length - 1] : 0;
const values: number[] = [];
if (intervalM > 0)
for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM);
if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0);
if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total);
return values;
}
/** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */
function projectChainage(
points: Vertex[],
chainage: number[],
xy: ReadonlyArray<number>,
): [number, number] {
let bestGap = Infinity;
let bestAt = 0;
for (let index = 0; index < points.length - 1; index += 1) {
const [sx, sy] = points[index];
const vx = points[index + 1][0] - sx;
const vy = points[index + 1][1] - sy;
const length2 = vx * vx + vy * vy;
const safe = length2 > 1e-12 ? length2 : 1;
const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe));
const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]);
if (gap < bestGap) {
bestGap = gap;
bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio;
}
}
return [bestGap, bestAt];
}
/** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림.
* `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */
export function curveSpans(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<CurveEnds>,
): Array<[number, number]> {
const { points, chainage } = cleanPolyline(line);
if (points.length < 2) return [];
const spans: Array<[number, number]> = [];
for (const { start, apex, end, radius_m } of curves) {
if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue;
const [startGap, startAt] = projectChainage(points, chainage, start);
const [endGap, endAt] = projectChainage(points, chainage, end);
if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue;
const cross =
(apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]);
if (Math.abs(cross) < 1e-12) continue;
spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]);
}
return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 ·
* 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */
export function snapCurveStations(
stations: ReadonlyArray<number>,
spans: ReadonlyArray<readonly [number, number]>,
): number[] {
const values = [...stations];
for (const [start, end] of spans) {
const middle = (start + end) / 2;
let nearest = -1;
values.forEach((value, index) => {
if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return;
if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle))
nearest = index;
});
if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5);
}
return [...new Set(values)].sort((a, b) => a - b);
}
+388
View File
@@ -0,0 +1,388 @@
"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅].
짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` ·
`snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` ·
`curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운
하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 m 하나로.
"""
from __future__ import annotations
import json
import math
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
GRID = [float(value) for value in range(0, 1001, 20)]
def _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]:
return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)]
# ── 파이썬 규칙 ────────────────────────────────────────────────────────────────
def test_곡선_없음_그대로() -> None:
assert _snap([]) == GRID
def test_측점_없는_호는_안_심음() -> None:
assert _snap([(621.0, 639.0)]) == GRID
def test_호_안_하나를_정수_MC_로() -> None:
out = _snap([(612.0, 634.0)]) # MC 623
assert 623.0 in out and 620.0 not in out and len(out) == len(GRID)
def test_호_안_둘이면_MC_에_가까운_하나만() -> None:
out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움
assert 631.0 in out and 640.0 not in out and 620.0 in out
def test_BC_위_측점은_호_밖() -> None:
assert _snap([(620.0, 630.0)]) == GRID
assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖
def test_반_올림() -> None:
out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖
assert 626.0 in out and 620.0 not in out and 640.0 in out
def test_같은_m_는_하나로() -> None:
# 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음
assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0]
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────────────
def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list:
return [
[cx + radius * math.cos(angle), cy + radius * math.sin(angle)]
for angle in np.linspace(start, end, count)
]
def _bend_route() -> tuple[list, list]:
"""직선 300 m → R 40 호 → 직선 — 곡선표 한 줄 + 노선 밖 곡선 · 방향 없는 곡선."""
arc = _arc(300.0, 40.0, 40.0, -math.pi / 2, 0.3, 60)
line = [[0.0, 0.0], *arc, [arc[-1][0] - 200 * math.sin(0.3), arc[-1][1] + 200 * math.cos(0.3)]]
curves = [
{"start": arc[0], "apex": [340.0, 0.0], "end": arc[-1], "radius_m": 40.0},
{"start": [0.0, 500.0], "apex": [10.0, 510.0], "end": [20.0, 500.0], "radius_m": 30.0},
{"start": [50.0, 0.0], "apex": [60.0, 0.0], "end": [70.0, 0.0], "radius_m": 30.0},
{"start": [80.0, 0.0], "apex": [90.0, 1.0], "end": [100.0, 0.0], "radius_m": None},
]
return line, curves
CASES = [
{"line": [[0, 0], [1000, 0]], "interval": 20, "end": True, "curves": []},
{
"line": [[0, 0], [1000.3, 0]],
"interval": 20,
"end": True,
"curves": [{"start": [616.1, 0], "apex": [631.3, 3], "end": [646.5, 0], "radius_m": 40}],
},
{
"line": [[0, 0], [500, 0], [500, 0], [700, 10]],
"interval": 20,
"end": False,
"curves": [
{"start": [611, 0], "apex": [625.5, -2], "end": [640, 0], "radius_m": 25},
{"start": [621, 0], "apex": [630, 2], "end": [639, 0], "radius_m": 25},
{"start": [100, 0], "apex": [110, 2], "end": [112, 0], "radius_m": 25},
],
},
{"line": _bend_route()[0], "interval": 20, "end": True, "curves": _bend_route()[1]},
{"line": _bend_route()[0], "interval": 7.5, "end": True, "curves": _bend_route()[1]},
]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as frame from "./B05_Profile_UI_RouteEdit_Frame.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = {
tolerance: frame.DESIGN_CURVE_MATCH_TOLERANCE_M,
edge: frame.CURVE_STATION_EDGE_M,
rows: cases.map((item) => {
const grid = frame.stationsAlong(item.line, item.interval, item.end);
const spans = frame.curveSpans(item.line, item.curves);
return { grid, spans, snapped: frame.snapCurveStations(grid, spans) };
}),
};
writeFileSync(process.argv[3], JSON.stringify(out));
"""
def _python_row(case: dict) -> dict:
points, route_chainage = core._clean_polyline(np.asarray(case["line"], dtype=np.float64))
grid = core._chainages(float(route_chainage[-1]), float(case["interval"]), case["end"])
spans = core._design_curve_spans(points, route_chainage, case["curves"])
return {
"grid": [float(v) for v in grid],
"spans": [[span[0], span[1]] for span in spans],
"snapped": [float(v) for v in core.snap_curve_stations(grid, spans)],
}
def _ts_rows(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.glob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(json.dumps(CASES), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_스냅_셈이_두_쪽에서_같다(tmp_path: Path) -> None:
actual = _ts_rows(tmp_path)
assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M
assert actual["edge"] == core.CURVE_STATION_EDGE_M
moved = 0
for index, case in enumerate(CASES):
expected = _python_row(case)
row = actual["rows"][index]
assert row["grid"] == pytest.approx(expected["grid"], abs=1e-9), f"사례 {index} 격자"
assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수"
for got, want in zip(row["spans"], expected["spans"], strict=True):
assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선"
assert row["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅"
moved += row["snapped"] != row["grid"]
assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌
def test_짝_머리글이_서로를_가리킨다() -> None:
text = TS_FILE.read_text(encoding="utf-8")
assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text
assert "snapCurveStations" in (core.__doc__ or "")
# ── generate_sections ────────────────────────────────────────────────────────
class _Flat:
def sample_xy(self, xy):
count = len(np.asarray(xy, dtype=np.float64))
return np.full(count, 100.0), np.ones(count, dtype=bool)
_LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]]
_CURVES = [{"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}]
def _stations(snap: bool | None, extras: tuple = ()) -> list[dict]:
kwargs = {} if snap is None else {"station_snap": snap}
result = core.generate_sections(
_LINE,
_Flat(),
core.SectionGenerationOptions(extra_stations=extras),
design_curves=_CURVES,
**kwargs,
)
return result["longitudinal"]["stations"]
def test_끔은_지금과_같음() -> None:
assert _stations(False) == _stations(None)
assert 640.0 in [s["chainage_m"] for s in _stations(False)]
def test_켬은_640_없고_631_있음() -> None:
by_chainage = {s["chainage_m"]: s for s in _stations(True)}
assert 640.0 not in by_chainage and 631.0 in by_chainage
assert by_chainage[631.0]["kind"] == "regular"
assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경
assert len(by_chainage) == len(_stations(False))
def test_631_에_관이면_grid() -> None:
by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))}
assert by_chainage[631.0]["kind"] == "irregular"
assert by_chainage[631.0]["grid"] is True
# 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점
off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))}
assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0]
# ── 서버 저장 · 정본 (326-2) ─────────────────────────────────────────────────
_NODES = [(0.0, 0.0), (300.0, 0.0), (300.0, 300.0)]
def _working(tmp_path: Path) -> Path:
from common_util.common_util_route_geometry import planned_route_working_path
path = planned_route_working_path(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
return path
def test_확인_때_곡선표에_적고_읽음(tmp_path: Path) -> None:
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
path = _working(tmp_path)
assert load_station_snap(tmp_path) is False # 파일 없음
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
assert "station_snap" not in json.loads(replan._curves_path(path).read_text("utf-8"))
assert load_station_snap(tmp_path) is False # 칸 없음 = 끔(옛 프로젝트)
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True)
assert load_station_snap(tmp_path) is True
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=False)
assert load_station_snap(tmp_path) is False
def _patch_replan(monkeypatch, tmp_path: Path):
from B05_Profile import B05_Profile_Router_Replan as replan
async def paths(_project_id):
return tmp_path, "stored"
async def criteria(_project_id):
return 30.0, 0.0, 0.0
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_plan_criteria", criteria)
return replan
def test_실패하면_곡선표_노드도_되돌림(tmp_path: Path, monkeypatch) -> None:
import asyncio
from uuid import uuid4
replan = _patch_replan(monkeypatch, tmp_path)
async def broken(*_args, **_kwargs):
return {"error": "체인 끊김"}
monkeypatch.setattr(replan, "_recompute", broken)
path = _working(tmp_path)
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
files = (path, replan._curves_path(path), replan._nodes_path(path))
before = {file: file.read_bytes() for file in files}
request = replan.RouteReplanRequest(
vertices=[{"x": 0, "y": 0}, {"x": 320, "y": 40}, {"x": 300, "y": 300}],
station_snap=True,
)
response = asyncio.run(replan.replan_route(uuid4(), request))
assert response.status_code == 409
assert {file: file.read_bytes() for file in files} == before
def test_GET_계획노선이_스냅_값을_돌려줌(tmp_path: Path, monkeypatch) -> None:
import asyncio
from uuid import uuid4
replan = _patch_replan(monkeypatch, tmp_path)
path = _working(tmp_path)
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is False
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True)
assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is True
def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Engine_Sections as sections
from B05_Profile import B05_Profile_Router_Replan as replan
path = _working(tmp_path)
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
replan._curves_path(path).write_text(
json.dumps({"curves": _CURVES, "station_snap": True}), encoding="utf-8"
)
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE)
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat())
# [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리
stations, _ = sections.sample_structure_sections(
tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),)
)
assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True
seen: dict = {}
def stop(*_args, **kwargs):
seen.update(kwargs)
raise RuntimeError("여기까지")
monkeypatch.setattr(sections, "generate_sections", stop)
monkeypatch.setattr(sections, "_load_pipe_points", lambda *_args: [])
monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_args: ())
with pytest.raises(RuntimeError):
sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False)
assert seen["station_snap"] is True
def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Terrain as terrain
from common_util.common_util_route_polyline import build_planned_polyline
monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat())
built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False)
points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64))
spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves])
assert len(spans) == 1
target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5)
assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음
def ask(snap: bool) -> float:
request = terrain.CrossPreviewRequest(
vertices=[{"x": x, "y": y} for x, y in _NODES],
chainage_m=target,
min_radius_m=40.0,
station_interval_m=20.0,
station_snap=snap,
)
params = {"source_filter": "f", "method": "m", "smooth": False}
return terrain._cross_preview(tmp_path, params, request)["chainage_m"]
assert ask(True) == target
assert ask(False) % 20 == 0