From 4c6658cf1c08dd761776890a5a42017172da17d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20326-1=C2=B7326-2=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=8A=A4=EB=83=85=20=EC=84=9C=EB=B2=84=20=E2=80=94?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=AA=A8=EB=8D=B8=20Schema=20=EB=A1=9C?= =?UTF-8?q?=20=EC=AA=BC=EA=B0=AC=20=C2=B7=20=ED=98=B8=20=EC=95=88=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=95=98=EB=82=98=EB=A5=BC=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=A4=91=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EC=A7=9D=20=EC=85=88(snap=5Fcurve=5Fstati?= =?UTF-8?q?ons=20=E2=86=94=20snapCurveStations)=20=C2=B7=20[=ED=99=95?= =?UTF-8?q?=EC=9D=B8]=20=EB=95=8C=20=EA=B3=A1=EC=84=A0=ED=91=9C=20station?= =?UTF-8?q?=5Fsnap=20=EC=A0=80=EC=9E=A5=20=C2=B7=20GET=20=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EC=A4=8C=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=EC=97=90=20=EA=B3=A1=EC=84=A0=ED=91=9C=C2=B7?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=85=88=20?= =?UTF-8?q?=EB=91=98=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_Engine_Sections.py | 25 +- .../B05_Profile_Engine_Sections_Core.py | 42 +- B05_Profile/B05_Profile_Router_Replan.py | 114 ++--- B05_Profile/B05_Profile_Router_Terrain.py | 11 +- B05_Profile/B05_Profile_Schema.py | 75 +++- B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts | 99 +++++ resources/tester/test_224_2_station_snap.py | 388 ++++++++++++++++++ 7 files changed, 662 insertions(+), 92 deletions(-) create mode 100644 resources/tester/test_224_2_station_snap.py diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index b9e354689..5dc47a85a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -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: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 2505e8535..22e567aaa 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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 diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..67c62b68f 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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, diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 559d64724..197578e4b 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -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 diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0578c7334..5221c9d7d 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -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"` 으로 남아 측점 셈이 읽음.""" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts index f7e1f4b56..67bc9aeb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -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 | null; + apex?: ReadonlyArray | null; + end?: ReadonlyArray | null; + radius_m?: number | null; +} + +/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */ +export function stationsAlong( + line: ReadonlyArray, + 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] { + 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, + curves: ReadonlyArray, +): 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, + spans: ReadonlyArray, +): 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); +} diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py new file mode 100644 index 000000000..2e123b818 --- /dev/null +++ b/resources/tester/test_224_2_station_snap.py @@ -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